From 9d31edff70d69bab9e6474a62c261e0cd58572e7 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Thu, 4 Jun 2026 17:56:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(freecad):=20=E4=BC=98=E5=8C=963D=E8=A3=85?= =?UTF-8?q?=E9=85=8D=E8=B4=B4=E5=90=88=E4=B8=8E=E6=A0=91=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-02-batch-din-device-placement-design.md | 144 ++++++++ src/Mod/FreeCADExchange/DeviceImport.py | 1 + src/Mod/FreeCADExchange/ManualWiringPanel.py | 199 +++++++++- src/Mod/FreeCADExchange/TerminalImport.py | 3 + src/Mod/FreeCADExchange/TerminalObjects.py | 40 +++ ...eecad_exchange_manual_wiring_panel_test.py | 340 +++++++++++++++++- .../freecad_exchange_terminal_objects_test.py | 20 ++ 7 files changed, 736 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md index d094f53..4cb2eed 100644 --- a/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md +++ b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md @@ -142,6 +142,144 @@ FreeCAD 导入工程端子时按下面顺序读取: 当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版。 +## 装配视频复盘 + +本节作为后续 3D 装配优化的对比基准。装配相关需求、问题复盘和验收差异优先沉淀到本文档,再拆分为具体实现计划。 + +### 用户装配视频提炼 + +用户视频前半段体现的目标流程: + +1. 先按真实设备和实物安装关系确认 3D 模型是否匹配。 +2. 对设备补充可复用的装配脚点、连接点或接线点。 +3. 设备脚点制作完成后,保存为可复用 `.FCStd` 模块。 +4. 后续工程中再次插入该设备时,自动带出脚点、端子和装配语义。 +5. 装配时可使用 FreeCAD 原生 `切换透明度`、`显示/隐藏所选`,便于选中柜板、导轨、线槽和设备背面。 +6. 按步骤导入设备并完成贴合,避免只靠人工拖拽。 + +当前 FreeCAD 二开需要重点解决的问题: + +- 面不容易选中,尤其是柜内导轨、线槽、设备背面被遮挡时。 +- 旋转模型后再贴合,容易出现一部分贴合、一部分穿模或悬空。 +- 贴合时如果只移动可视子对象,父对象 `Placement` 没同步,后续使用 `变换` 会回到旧位置。 +- 多选多个设备面参与贴合不合理,约束语义不清,会导致算法不知道哪个面是移动面。 +- 线槽、导轨贴合后仍需要能二次修改长度,并保持与柜板的贴合关系。 +- FreeCAD 任务面板和原生 `变换` 任务框会冲突,普通用户需要一键关闭当前面板并进入原生变换。 + +### 甲方视频参考能力 + +甲方视频中可参考的装配体验: + +- 柜体、导轨、线槽、安装板可透明显示,便于从柜内选择目标面;透明化优先复用 FreeCAD 原生右键菜单能力。 +- 对象树、属性面板和三维操纵器联动,用户能明确看到当前选中的对象和坐标。 +- 装配过程使用面、边、点作为参考,而不是单纯输入绝对坐标。 +- 设备沿导轨或安装板成组排列,位置规则清晰,适合端子排、断路器、继电器等电气元件。 +- 贴合后仍能继续微调距离、方向和局部偏移。 +- 电气装配关注柜板、导轨、线槽、设备安装面,不需要第一阶段实现完整机械 CAD 装配约束。 + +## 后续装配优化方向 + +后续装配能力优先向 SolidWorks Electrical / EPLAN 的电气柜装配体验靠拢,但第一阶段只做电气常用能力,不做完整机械装配工作台。 + +### 1. 面贴合可靠性 + +目标: + +- 目标面和移动面只允许一对一贴合。 +- 如果用户已点击 `设为贴合目标面`,后续只能再选一个移动面。 +- 如果用户一次选择两个面,按选择顺序解释为:第一个目标面,第二个移动面。 +- 如果选择超过两个面,直接提示重新选择,不执行贴合。 +- 贴合时同时更新父级可移动对象的 `Placement`,避免可视位置和对象坐标脱节。 + +贴合计算原则: + +```text +移动面法向 -> 目标面反向法向 +移动面参考点 -> 目标面参考点所在平面 +最终位姿写入可移动父对象 Placement +``` + +### 2. 旋转模型后的贴合 + +目标: + +- 用户为了选面临时旋转设备后,贴合仍能根据真实面法向计算旋转和位移。 +- 不再只做单轴平移。 +- 贴合完成后设备安装面应整体与目标面共面,不允许局部穿模。 +- 用户可设置 `贴合间距`,0 mm 表示完全贴合,正值表示沿目标面法向预留距离。 +- 已贴合对象保存 `QetMountHostNormalJson` 和 `QetMountOffsetMm`,后续选择对象后可点击 `应用贴合间距` 做二次调节。 +- 如果模型法向与现场直觉相反,选择已贴合对象后点击 `反转贴合方向`,再应用贴合间距。 + +验收: + +- 电流互感器、小型断路器、端子片旋转后,仍可贴到导轨或柜板。 +- 贴合后使用 FreeCAD 原生 `变换`,对象从当前贴合位置继续移动,不跳回旧位置。 +- 在 `3D手动布线` 面板中选择对象后点击 `关闭面板并变换`,系统先关闭当前任务面板,再调用 FreeCAD 原生 `Std_TransformManip`。 + +### 3. 导轨、线槽、柜板宿主语义 + +装配宿主分为: + +- `cabinet`:柜板、安装板、门板等。 +- `rail`:DIN 导轨。 +- `wire_duct`:线槽。 +- `device`:已经装配好的设备,可作为局部参考。 + +宿主对象应保存: + +- `QetCarrierKind` +- `QetCarrierAxis` +- `QetCarrierBaseLength` +- `QetMountMode` +- `QetMountHostName` +- `QetMountHostKind` +- `QetMountContactSubElement` +- `QetMountHostSubElement` +- `QetMountLocalBaseJson` +- `QetMountHostBaseJson` + +这些属性用于保存重开、刷新宿主装配和后续自动布线。 + +### 4. 长度二次调节 + +导轨和线槽长度调整规则: + +- 导入时可设置初始长度。 +- 贴合到柜板后仍可修改长度。 +- 修改长度时保持宿主贴合面不变。 +- 长度变化应优先沿 `QetCarrierAxis` 扩展。 +- 如果对象是导入的 FCStd/STEP 组合体,优先修改带 `QetCarrierBaseLength` 的父级载体对象,不应误选内部子零件。 + +### 5. 设备模板化与复用 + +设备模板应包含: + +- 真实几何模型。 +- 安装接触面或装配脚点。 +- 工程端子 LCS。 +- 端子出线方向。 +- 可选局部出线路径。 + +保存为 `.FCStd` 后,QET 再次导入同型号设备时,应复用这些模板语义。正式导线匹配仍以 QET 传入的 `terminal_uuid` 为准,不使用 `local:*` 作为正式端子身份。 + +### 6. 电气装配优先级 + +优先实现: + +1. 导轨贴柜板。 +2. 线槽贴柜板。 +3. 端子排沿导轨排列。 +4. 小型断路器沿导轨排列。 +5. 电流互感器、继电器等设备贴导轨或柜板。 +6. 贴合后的长度调节和刷新宿主装配。 + +暂不优先实现: + +- 完整机械装配 Joint。 +- 螺钉、孔、螺纹的精确机械配合。 +- 复杂运动学约束。 +- 完整 SW Mechanical 级别的 Mate 系统。 + ## UI 入口位于: @@ -154,6 +292,12 @@ QET模板 -> 3D手动布线 - `批量端子排` - `批量断路器` +- `设为贴合目标面` +- `贴合到选中面` +- `应用贴合间距` +- `反转贴合方向` +- `刷新宿主装配` +- `贴合间距` 参数窗口说明: diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index f9cbb4f..dc62731 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -1109,6 +1109,7 @@ def import_devices_from_payload(payload, scene_path=""): if not instance_id: report["imported_without_instance_id"] += 1 + TerminalObjects.sort_group_children(root_group) doc.recompute() try: Gui.SendMsgToActiveView("ViewFit") diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index fc5598e..55e15d7 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -409,7 +409,12 @@ def _selected_contact_face_refs(): shape_type = (getattr(sub_object, "ShapeType", "") or "").strip().lower() if shape_type != "face": continue - point = _face_anchor_point(picked, sub_object) + point = None + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if index < len(picked_points): + point = picked_points[index] + if point is None: + point = _face_anchor_point(picked if not picked_points else None, sub_object) normal = _face_normal(sub_object) if point is None or normal is None: continue @@ -422,7 +427,6 @@ def _selected_contact_face_refs(): "subelement_name": subelement_names[index] if index < len(subelement_names) else "", } ) - break if refs: return refs @@ -464,6 +468,13 @@ def _has_placement(obj): return getattr(obj, "Placement", None) is not None +def _is_app_part_object(obj): + try: + return bool(obj is not None and obj.isDerivedFrom("App::Part")) + except Exception: + return (getattr(obj, "TypeId", "") or "") == "App::Part" + + def _contact_transform_object(obj): carrier = _carrier_object_from_object(obj) if carrier is not None and _has_placement(carrier): @@ -484,6 +495,7 @@ def _contact_transform_object(obj): name.startswith("QETDevice_") or (getattr(parent, "QetInstanceId", "") or "").strip() or (getattr(parent, "QetCarrierKind", "") or "").strip() + or _is_app_part_object(parent) ): best = parent current = parent @@ -502,12 +514,12 @@ def _rotation_for_face_contact(moving_normal, target_normal): return None -def _normal_contact_translation(target_point, target_normal, moving_point): +def _normal_contact_translation(target_point, target_normal, moving_point, offset_mm=0.0): normal = _normalize_vector(target_normal) if normal is None: return None signed_distance = _vector_dot(_vector_sub(moving_point, target_point), normal) - return _vector_scale(normal, -signed_distance) + return _vector_scale(normal, float(offset_mm or 0.0) - signed_distance) def _rotate_object_about_point(obj, rotation, pivot): @@ -645,7 +657,7 @@ def _mount_kind(obj): return "object" -def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref): +def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref, offset_mm=0.0): if moving_obj is None or not isinstance(target_ref, dict) or not isinstance(moving_ref, dict): return target_obj = target_ref.get("object") @@ -672,6 +684,12 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref): "QET cabinet assembly mount metadata", value, ) + _ensure_float_property( + moving_obj, + "QetMountOffsetMm", + float(offset_mm or 0.0), + "QET cabinet assembly contact offset in target normal direction", + ) if target_base is not None: _set_vector_json_property( moving_obj, @@ -679,6 +697,14 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref): target_base, "QET cabinet assembly host base at bind time", ) + host_normal = _normalize_vector(target_ref.get("normal")) + if host_normal is not None: + _set_vector_json_property( + moving_obj, + "QetMountHostNormalJson", + host_normal, + "QET cabinet assembly host face normal at bind time", + ) if target_base is not None and moving_base is not None: _set_vector_json_property( moving_obj, @@ -1381,8 +1407,9 @@ def _abort_transaction(doc, opened): class ManualWiringController: - def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH): + def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH, contact_offset_mm=0.0): self.terminal_exit_length = float(terminal_exit_length or 0.0) + self.contact_offset_mm = float(contact_offset_mm or 0.0) self.current_task = None self.contact_target_ref = None self.start_terminal = None @@ -1395,6 +1422,10 @@ class ManualWiringController: self.terminal_exit_length = max(float(value or 0.0), 0.0) return self.terminal_exit_length + def set_contact_offset(self, value): + self.contact_offset_mm = float(value or 0.0) + return self.contact_offset_mm + def mark_selected_carriers(self, carrier_kind): carrier_kind = (carrier_kind or "").strip() if carrier_kind not in CARRIER_ROLE_LABELS: @@ -1475,6 +1506,7 @@ class ManualWiringController: _activate_document(doc) def apply_length_to_selected_carriers(self, length_mm): + doc = _active_document() selected = _selected_carrier_objects() if not selected: raise ManualWiringPanelError("请先选择线槽或导轨对象。") @@ -1485,8 +1517,83 @@ class ManualWiringController: updated.append(_apply_carrier_length(carrier, length_mm)) if not updated: raise ManualWiringPanelError("所选对象不是已标记的线槽或导轨。") + refresh_mount_hosted_objects(doc) try: - _active_document().recompute() + doc.recompute() + except Exception: + pass + return updated + + def apply_contact_offset_to_selected_mounts(self, offset_mm): + doc = _active_document() + selected = _selection() + if not selected: + raise ManualWiringPanelError("请先选择已贴合的对象。") + + new_offset = float(offset_mm or 0.0) + updated = [] + for selected_obj in selected: + obj = _contact_transform_object(selected_obj) + if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact": + continue + host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson")) + if host_normal is None: + raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再调节间距。") + old_offset = float(getattr(obj, "QetMountOffsetMm", 0.0) or 0.0) + delta = _vector_scale(host_normal, new_offset - old_offset) + _translate_object(obj, delta) + _ensure_float_property( + obj, + "QetMountOffsetMm", + new_offset, + "QET cabinet assembly contact offset in target normal direction", + ) + host_name = (getattr(obj, "QetMountHostName", "") or "").strip() + host = doc.getObject(host_name) if host_name and hasattr(doc, "getObject") else None + host_base = _placement_base(host) + obj_base = _placement_base(obj) + if host_base is not None and obj_base is not None: + _set_vector_json_property( + obj, + "QetMountLocalBaseJson", + _vector_sub(obj_base, host_base), + "QET cabinet assembly local base offset from host", + ) + updated.append(obj) + if not updated: + raise ManualWiringPanelError("所选对象不是已贴合的装配对象。") + self.contact_offset_mm = new_offset + try: + doc.recompute() + except Exception: + pass + return updated + + def reverse_contact_normal_for_selected_mounts(self): + doc = _active_document() + selected = _selection() + if not selected: + raise ManualWiringPanelError("请先选择已贴合的对象。") + + updated = [] + for selected_obj in selected: + obj = _contact_transform_object(selected_obj) + if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact": + continue + host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson")) + if host_normal is None: + raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再反转方向。") + _set_vector_json_property( + obj, + "QetMountHostNormalJson", + _vector_scale(host_normal, -1.0), + "QET cabinet assembly host face normal at bind time", + ) + updated.append(obj) + if not updated: + raise ManualWiringPanelError("所选对象不是已贴合的装配对象。") + try: + doc.recompute() except Exception: pass return updated @@ -1523,11 +1630,12 @@ class ManualWiringController: target["point"], target["normal"], moving["point"], + self.contact_offset_mm, ) if translation is None: raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。") _translate_object(moving_object, translation) - _set_face_contact_mount_metadata(moving_object, target, moving) + _set_face_contact_mount_metadata(moving_object, target, moving, self.contact_offset_mm) try: _active_document().recompute() except Exception: @@ -1541,6 +1649,7 @@ class ManualWiringController: "moving_point": moving["point"], "translation": translation, "translation_mode": "normal", + "contact_offset_mm": self.contact_offset_mm, "rotated": rotated, } @@ -1770,6 +1879,23 @@ class ManualWiringController: return True raise ManualWiringPanelError("当前 FreeCAD 文档不支持撤销。") + def launch_native_transform_for_selection(self): + _active_document() + if not _selection(): + raise ManualWiringPanelError("请选择要变换的对象。") + if Gui is None or not hasattr(Gui, "runCommand"): + raise ManualWiringPanelError("当前 FreeCAD 界面不支持原生变换命令。") + + # FreeCAD 原生变换也是任务面板;先关闭本面板,避免 TaskDialog 互相占用。 + control = getattr(Gui, "Control", None) + if control is not None and hasattr(control, "closeDialog"): + try: + control.closeDialog() + except Exception: + pass + Gui.runCommand("Std_TransformManip") + return True + def set_end_from_selection_and_generate(self): doc = _active_document() if self.start_terminal is None: @@ -1859,10 +1985,11 @@ class ManualWiringController: ) if len(self.waypoints) > 3: waypoint_text += ";..." - return "任务:{0};起点:{1};出线:{2:.1f} mm;折点:{3} 个;最近导线:{4};折点明细:{5}".format( + return "任务:{0};起点:{1};出线:{2:.1f} mm;贴合间距:{3:.1f} mm;折点:{4} 个;最近导线:{5};折点明细:{6}".format( task_text, start_text, self.terminal_exit_length, + self.contact_offset_mm, len(self.waypoints), wire_text, waypoint_text, @@ -1889,6 +2016,12 @@ class ManualWiringTaskPanel: self.exit_length_input.setSingleStep(5.0) self.exit_length_input.setSuffix(" mm") self.exit_length_input.setValue(self.controller.terminal_exit_length) + self.contact_offset_input = QtWidgets.QDoubleSpinBox() + self.contact_offset_input.setRange(-1000.0, 1000.0) + self.contact_offset_input.setDecimals(1) + self.contact_offset_input.setSingleStep(1.0) + self.contact_offset_input.setSuffix(" mm") + self.contact_offset_input.setValue(self.controller.contact_offset_mm) self.carrier_length_input = QtWidgets.QDoubleSpinBox() self.carrier_length_input.setRange(1.0, 10000.0) self.carrier_length_input.setDecimals(1) @@ -1906,7 +2039,10 @@ class ManualWiringTaskPanel: self.batch_breaker_button = QtWidgets.QPushButton("批量断路器") self.set_contact_target_button = QtWidgets.QPushButton("设为贴合目标面") self.align_faces_button = QtWidgets.QPushButton("贴合到选中面") + self.apply_contact_offset_button = QtWidgets.QPushButton("应用贴合间距") + self.reverse_contact_normal_button = QtWidgets.QPushButton("反转贴合方向") self.refresh_mount_hosts_button = QtWidgets.QPushButton("刷新宿主装配") + self.native_transform_button = QtWidgets.QPushButton("关闭面板并变换") self.waypoint_button = QtWidgets.QPushButton("添加折点") self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点") self.end_button = QtWidgets.QPushButton("设为终点并生成") @@ -1925,6 +2061,10 @@ class ManualWiringTaskPanel: exit_layout.addWidget(QtWidgets.QLabel("手动端子出线长度")) exit_layout.addWidget(self.exit_length_input) layout.addLayout(exit_layout) + contact_offset_layout = QtWidgets.QHBoxLayout() + contact_offset_layout.addWidget(QtWidgets.QLabel("贴合间距")) + contact_offset_layout.addWidget(self.contact_offset_input) + layout.addLayout(contact_offset_layout) carrier_length_layout = QtWidgets.QHBoxLayout() carrier_length_layout.addWidget(QtWidgets.QLabel("载体长度")) carrier_length_layout.addWidget(self.carrier_length_input) @@ -1945,7 +2085,10 @@ class ManualWiringTaskPanel: layout.addLayout(batch_layout) layout.addWidget(self.set_contact_target_button) layout.addWidget(self.align_faces_button) + layout.addWidget(self.apply_contact_offset_button) + layout.addWidget(self.reverse_contact_normal_button) layout.addWidget(self.refresh_mount_hosts_button) + layout.addWidget(self.native_transform_button) layout.addWidget(self.start_button) layout.addWidget(self.waypoint_button) layout.addWidget(self.delete_waypoint_button) @@ -1971,6 +2114,7 @@ class ManualWiringTaskPanel: self.use_task_button.clicked.connect(self.use_selected_task) self.reload_tasks_button.clicked.connect(self._refresh_task_list) self.exit_length_input.valueChanged.connect(self.set_exit_length) + self.contact_offset_input.valueChanged.connect(self.set_contact_offset) self.import_duct_button.clicked.connect(self.import_wire_duct) self.import_rail_button.clicked.connect(self.import_din_rail) self.apply_carrier_length_button.clicked.connect(self.apply_carrier_length) @@ -1981,7 +2125,10 @@ class ManualWiringTaskPanel: self.batch_breaker_button.clicked.connect(self.create_breakers) self.set_contact_target_button.clicked.connect(self.set_contact_target_face) self.align_faces_button.clicked.connect(self.align_selected_contact_faces) + self.apply_contact_offset_button.clicked.connect(self.apply_contact_offset) + self.reverse_contact_normal_button.clicked.connect(self.reverse_contact_normal) self.refresh_mount_hosts_button.clicked.connect(self.refresh_mount_hosts) + self.native_transform_button.clicked.connect(self.launch_native_transform) self.start_button.clicked.connect(self.set_start) self.waypoint_button.clicked.connect(self.add_waypoint) self.delete_waypoint_button.clicked.connect(self.delete_last_waypoint) @@ -2057,6 +2204,13 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def set_contact_offset(self, value): + try: + self.controller.set_contact_offset(value) + self._set_status(self.controller.state_text()) + except Exception as exc: + self._set_error(str(exc)) + def _select_carrier_asset_path(self, carrier_kind): default_path = _builtin_carrier_asset_path(carrier_kind) default_dir = str(Path(default_path).parent) if default_path else "" @@ -2188,6 +2342,27 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def apply_contact_offset(self): + try: + updated = self.controller.apply_contact_offset_to_selected_mounts( + self.contact_offset_input.value() + ) + self._set_status( + "已对 {0} 个贴合对象应用间距 {1:.1f} mm。".format( + len(updated), + self.contact_offset_input.value(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def reverse_contact_normal(self): + try: + updated = self.controller.reverse_contact_normal_for_selected_mounts() + self._set_status("已反转 {0} 个贴合对象的间距方向。".format(len(updated))) + except Exception as exc: + self._set_error(str(exc)) + def set_contact_target_face(self): try: target = self.controller.set_contact_target_from_selection() @@ -2203,6 +2378,12 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def launch_native_transform(self): + try: + self.controller.launch_native_transform_for_selection() + except Exception as exc: + self._set_error(str(exc)) + def set_start(self): try: terminal = self.controller.set_start_from_selection() diff --git a/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py index 8b922a2..e603f6e 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -629,6 +629,9 @@ def import_terminals_from_payload(payload, scene_path=""): _hide_object(source_obj) report["reused_template_hints"] += 1 + TerminalObjects.sort_group_children(terminal_group) + + TerminalObjects.sort_group_children(root_group) doc.recompute() if Gui is not None: try: diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py index f30d5f5..5f59081 100644 --- a/src/Mod/FreeCADExchange/TerminalObjects.py +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -3,6 +3,7 @@ import json import math import os +import re from pathlib import Path import FreeCAD as App @@ -45,6 +46,45 @@ def is_local_terminal_uuid(value): return (value or "").strip().lower().startswith("local:") +def natural_sort_key(value): + text = str(value or "").strip().casefold() + parts = re.split(r"(\d+)", text) + key = [] + for part in parts: + if not part: + continue + if part.isdigit(): + key.append((0, int(part))) + else: + key.append((1, part)) + return tuple(key) + + +def object_display_sort_key(obj): + label = (getattr(obj, "Label", "") or "").strip() + name = (getattr(obj, "Name", "") or "").strip() + return (natural_sort_key(label or name), natural_sort_key(name)) + + +def sort_group_children(group): + children = list(getattr(group, "Group", []) or []) + if len(children) < 2: + return children + sorted_children = sorted( + enumerate(children), + key=lambda item: (object_display_sort_key(item[1]), item[0]), + ) + ordered = [child for _index, child in sorted_children] + try: + group.Group = ordered + except Exception: + try: + group.Group[:] = ordered + except Exception: + return children + return ordered + + def ensure_string_property(obj, prop_name, group_name, description, value): if prop_name not in getattr(obj, "PropertiesList", []): obj.addProperty("App::PropertyString", prop_name, group_name, description) diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 5ec5884..f483a6e 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -57,9 +57,15 @@ def _install_fake_freecad(): ) sys.modules["FreeCAD"] = fake_freecad - selection_state = {"selection": [], "selection_ex": []} + selection_state = { + "selection": [], + "selection_ex": [], + "commands": [], + "control_events": [], + } fake_freecadgui = types.ModuleType("FreeCADGui") fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.runCommand = lambda command: selection_state["commands"].append(command) fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None def clear_selection(): selection_state["selection"] = [] @@ -76,7 +82,7 @@ def _install_fake_freecad(): fake_freecadgui.Control = types.SimpleNamespace( activeDialog=lambda: False, showDialog=lambda panel: panel, - closeDialog=lambda: None, + closeDialog=lambda: selection_state["control_events"].append("closeDialog"), ) sys.modules["FreeCADGui"] = fake_freecadgui @@ -643,6 +649,58 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual(400.0, getattr(carrier, "QetCarrierLength", None)) self.assertEqual(2.0, getattr(carrier, "QetCarrierScaleX", None)) + def test_controller_refreshes_face_contact_mount_when_changing_carrier_length(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + carrier = doc.addObject("App::DocumentObjectGroup", "WireDuctCarrier") + cabinet.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) + carrier.Placement = app.Placement(app.Vector(120, 0, 5), app.Rotation()) + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + carrier.addProperty("App::PropertyFloat", "QetCarrierBaseLength", "QET Wiring", "Base length") + carrier.QetCarrierBaseLength = 200.0 + terminal_objects.ensure_string_property( + carrier, + "QetMountMode", + "QET Assembly", + "QET cabinet assembly mount metadata", + "face_contact", + ) + terminal_objects.ensure_string_property( + carrier, + "QetMountHostName", + "QET Assembly", + "QET cabinet assembly mount metadata", + "CabinetPanel", + ) + terminal_objects.ensure_string_property( + carrier, + "QetMountLocalBaseJson", + "QET Assembly", + "QET cabinet assembly local base offset", + json.dumps({"x": 20.0, "y": 0.0, "z": 5.0}, ensure_ascii=False), + ) + cabinet.Placement = app.Placement(app.Vector(130, 0, 0), app.Rotation()) + selection_state["selection"] = [carrier] + + updated = panel.ManualWiringController().apply_length_to_selected_carriers(500.0) + + self.assertEqual([carrier], updated) + self.assertEqual(500.0, carrier.QetCarrierLength) + self.assertEqual((150.0, 0.0, 5.0), (carrier.Placement.Base.x, carrier.Placement.Base.y, carrier.Placement.Base.z)) + self.assertEqual({"x": 130.0, "y": 0.0, "z": 0.0}, json.loads(carrier.QetMountHostBaseJson)) + def test_controller_auto_marks_selected_wire_duct_by_name_before_length_change(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -797,6 +855,160 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual("Face2", rail.QetMountContactSubElement) self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, json.loads(rail.QetMountHostBaseJson)) self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountLocalBaseJson)) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountHostNormalJson)) + + def test_controller_aligns_contact_faces_with_configured_normal_offset(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + rail = doc.addObject("Part::Feature", "DINRail") + rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation()) + target_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, 1), + ) + moving_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(100, 20, 0)], + SubObjects=[target_face], + SubElementNames=["Face1"], + Object=cabinet, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(5, 6, 9)], + SubObjects=[moving_face], + SubElementNames=["Face2"], + Object=rail, + ), + ] + + result = panel.ManualWiringController(contact_offset_mm=2.0).align_selected_contact_faces() + + self.assertIs(rail, result["moving_object"]) + self.assertEqual((0.0, 0.0, 3.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z)) + self.assertEqual((-0.0, -0.0, -7.0), (result["translation"].x, result["translation"].y, result["translation"].z)) + self.assertEqual(2.0, result["contact_offset_mm"]) + self.assertEqual(2.0, rail.QetMountOffsetMm) + + def test_controller_applies_contact_offset_to_selected_mounted_object(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + rail = doc.addObject("Part::Feature", "DINRail") + rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation()) + target_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, 1), + ) + moving_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(100, 20, 0)], + SubObjects=[target_face], + SubElementNames=["Face1"], + Object=cabinet, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(5, 6, 9)], + SubObjects=[moving_face], + SubElementNames=["Face2"], + Object=rail, + ), + ] + controller = panel.ManualWiringController() + controller.align_selected_contact_faces() + selection_state["selection"] = [rail] + + updated = controller.apply_contact_offset_to_selected_mounts(5.0) + + self.assertEqual([rail], updated) + self.assertEqual((0.0, 0.0, 6.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z)) + self.assertEqual(5.0, rail.QetMountOffsetMm) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 6.0}, json.loads(rail.QetMountLocalBaseJson)) + + def test_controller_reverses_contact_normal_for_selected_mounted_object(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + rail = doc.addObject("Part::Feature", "DINRail") + rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation()) + target_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, 1), + ) + moving_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(100, 20, 0)], + SubObjects=[target_face], + SubElementNames=["Face1"], + Object=cabinet, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(5, 6, 9)], + SubObjects=[moving_face], + SubElementNames=["Face2"], + Object=rail, + ), + ] + controller = panel.ManualWiringController() + controller.align_selected_contact_faces() + selection_state["selection"] = [rail] + + reversed_objects = controller.reverse_contact_normal_for_selected_mounts() + updated = controller.apply_contact_offset_to_selected_mounts(5.0) + + self.assertEqual([rail], reversed_objects) + self.assertEqual([rail], updated) + self.assertEqual({"x": -0.0, "y": -0.0, "z": -1.0}, json.loads(rail.QetMountHostNormalJson)) + self.assertEqual((0.0, 0.0, -4.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z)) + + def test_controller_requires_saved_contact_normal_before_applying_offset(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + rail = doc.addObject("Part::Feature", "DINRail") + rail.Placement = app.Placement(app.Vector(0, 0, 1), app.Rotation()) + terminal_objects.ensure_string_property( + rail, + "QetMountMode", + "QET Assembly", + "QET cabinet assembly mount metadata", + "face_contact", + ) + selection_state["selection"] = [rail] + + with self.assertRaisesRegex(panel.ManualWiringPanelError, "贴合法向"): + panel.ManualWiringController().apply_contact_offset_to_selected_mounts(3.0) def test_refresh_mount_hosted_objects_moves_child_by_host_delta(self): _install_fake_freecad() @@ -999,6 +1211,97 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual((0.0, 0.0, 0.0), (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z)) self.assertEqual([device], selection_state["selection"]) + def test_controller_rejects_multiple_moving_faces_after_target_face_is_set(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + rail = doc.addObject("Part::Feature", "DINRail") + + target_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, 1), + ) + moving_face_a = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + moving_face_b = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + controller = panel.ManualWiringController() + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 0)], + SubObjects=[target_face], + SubElementNames=["Face1"], + Object=cabinet, + ) + ] + controller.set_contact_target_from_selection() + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 9), app.Vector(0, 1, 9)], + SubObjects=[moving_face_a, moving_face_b], + SubElementNames=["Face2", "Face3"], + Object=rail, + ) + ] + + with self.assertRaisesRegex(panel.ManualWiringPanelError, "只选择一个"): + controller.align_selected_contact_faces() + + def test_controller_moves_plain_app_part_parent_when_selected_face_belongs_to_child_shape(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cabinet = doc.addObject("Part::Feature", "CabinetPanel") + assembly = doc.addObject("App::Part", "ImportedFcstdDevice") + child = doc.addObject("Part::Feature", "ImportedFcstdBody") + assembly.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation()) + child.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + assembly.addObject(child) + + target_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, 1), + ) + moving_face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(0, 0, -1), + ) + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 0)], + SubObjects=[target_face], + SubElementNames=["Face1"], + Object=cabinet, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 9)], + SubObjects=[moving_face], + SubElementNames=["Face2"], + Object=child, + ), + ] + + result = panel.ManualWiringController().align_selected_contact_faces() + + self.assertIs(assembly, result["moving_object"]) + self.assertEqual((0.0, 0.0, 1.0), (assembly.Placement.Base.x, assembly.Placement.Base.y, assembly.Placement.Base.z)) + self.assertEqual((0.0, 0.0, 0.0), (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z)) + self.assertEqual([assembly], selection_state["selection"]) + def test_controller_requires_two_faces_for_contact_alignment(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -1558,6 +1861,39 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual(("undo", ""), doc.transactions[-1]) + def test_controller_closes_panel_and_launches_native_transform(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("Part::Feature", "Device") + selection_state["selection"] = [device] + + result = panel.ManualWiringController().launch_native_transform_for_selection() + + self.assertTrue(result) + self.assertEqual(["closeDialog"], selection_state["control_events"]) + self.assertEqual(["Std_TransformManip"], selection_state["commands"]) + + def test_controller_requires_selection_before_native_transform(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + selection_state["selection"] = [] + + with self.assertRaisesRegex(panel.ManualWiringPanelError, "请选择要变换的对象"): + panel.ManualWiringController().launch_native_transform_for_selection() + + self.assertEqual([], selection_state["control_events"]) + self.assertEqual([], selection_state["commands"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/python/freecad_exchange_terminal_objects_test.py b/tests/python/freecad_exchange_terminal_objects_test.py index dcce4af..83504fd 100644 --- a/tests/python/freecad_exchange_terminal_objects_test.py +++ b/tests/python/freecad_exchange_terminal_objects_test.py @@ -42,6 +42,7 @@ def _install_fake_freecad(): class FakeObject: def __init__(self, name, type_id="App::DocumentObjectGroup"): self.Name = name + self.Label = name self.TypeId = type_id self.Group = [] self.InList = [] @@ -131,5 +132,24 @@ class TemplateTerminalVisibilityTest(unittest.TestCase): self.assertTrue(engineering_terminal.ViewObject.Visibility) +class GroupSortingTest(unittest.TestCase): + def test_sort_group_children_uses_case_insensitive_natural_label_order(self): + _install_fake_freecad() + terminal_objects = _reload_module() + + root = FakeObject("QETExchangeDevices") + for label in ["ID:10", "TAa", "id:2", "TAb", "C - 电容柜001", "ID:7"]: + child = FakeObject("QETDevice_" + label) + child.Label = label + root.addObject(child) + + sorted_children = terminal_objects.sort_group_children(root) + + self.assertEqual( + ["C - 电容柜001", "id:2", "ID:7", "ID:10", "TAa", "TAb"], + [child.Label for child in sorted_children], + ) + + if __name__ == "__main__": unittest.main()