diff --git a/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md b/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md new file mode 100644 index 0000000..19bbbd3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md @@ -0,0 +1,47 @@ +# 面贴合装配辅助设计 + +## 背景 + +CAD 用户在 FreeCAD 中摆放导轨、线槽和设备时,需要让两个接触面刚好贴合,避免穿模或悬空。FreeCAD 原生 `变换` 可以移动旋转对象,但不会自动判断面接触;`Assembly` 工作台可以做装配约束,但对当前 QET 演示流程偏重。 + +## 目标 + +在 `QET模板 -> 3D手动布线` 面板增加一个轻量装配辅助按钮:`贴合到选中面`。 + +用户先选择目标承载面,再选择要移动对象的接触面,点击按钮后: + +- 沿第一个目标面的法向移动第二个对象,使第二个选择面落到目标面的同一平面上。 +- 尽量让第二个选择面的法向与第一个选择面的反向对齐。 +- 保持第二个对象原来的切向位置,不把对象横向拉到目标面的拾取点。 +- 操作完成后恢复当前 QET 工程为活动文档。 +- 不写数据库,不改 2D/3D 绑定表,不影响导线任务。 + +## 适用场景 + +- 导轨背面贴合机柜安装板。 +- 线槽背面或底面贴合机柜安装板。 +- 设备背面或卡扣接触面贴合导轨安装面。 + +## 交互 + +1. 在 3D 视图中选择机柜、导轨或线槽上的目标面。 +2. 按住 Ctrl,再选择要移动对象上的接触面。 +3. 点击 `贴合到选中面`。 +4. 如果方向不理想,用户可以先用 FreeCAD `变换` 粗调姿态,再重新执行贴合。 + +第一版只接受两个面。多选多个设备、多个面时,系统不能唯一判断哪个对象应该移动、哪个面是目标、是否要同时满足多个约束,因此会直接提示错误。多面贴合属于完整 Assembly 约束求解范围,不放进这个轻量按钮。 + +第一版不做多约束求解,不自动识别“哪个面是背面”,也不保存永久装配约束。它只执行一次几何位姿调整。 + +## 错误处理 + +- 少于两个面:提示用户先选目标面,再选移动对象接触面。 +- 多于两个面:提示只能选择两个面。 +- 第二个选择对象没有可移动 `Placement`:提示对象不能移动。 +- 无法读取面中心或法向:提示请选择有效模型面。 + +## 测试 + +- 选择两个面后,移动对象应只沿目标面法向平移,消除法向间距。 +- 多选三个或更多面时应报错。 +- 导入类操作或贴合操作后,`App.ActiveDocument` 仍应是当前 QET 工程。 diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 020ba62..f1520fa 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -23,6 +23,19 @@ set(FreeCADExchange_Scripts ManualWiringPanel.py ) +set(FreeCADExchange_CabinetAssetDir + ${CMAKE_CURRENT_SOURCE_DIR}/../../../data/examples/qet_cabinet_assets +) + +set(FreeCADExchange_CabinetAssets + ${FreeCADExchange_CabinetAssetDir}/README.md + ${FreeCADExchange_CabinetAssetDir}/qet_cabinet_assets_report.json + ${FreeCADExchange_CabinetAssetDir}/qet_din_rail.FCStd + ${FreeCADExchange_CabinetAssetDir}/qet_din_rail.step + ${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.FCStd + ${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.step +) + add_custom_target(FreeCADExchangeScripts ALL SOURCES ${FreeCADExchange_Scripts} ) @@ -39,3 +52,10 @@ install( DESTINATION Mod/FreeCADExchange ) + +install( + FILES + ${FreeCADExchange_CabinetAssets} + DESTINATION + data/examples/qet_cabinet_assets +) diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index d4fc078..646c3e3 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -791,25 +791,28 @@ def _import_model_into_group(doc, device_group, model_path, merge=False, use_lin before_names = _existing_object_names(doc) try: - ImportGui.insert( - name=model_path, - docName=doc.Name, - merge=bool(merge), - useLinkGroup=bool(use_link_group), - ) - except Exception: - for obj in _new_objects_since(doc, before_names): - _remove_object_tree(doc, obj) - raise - - imported_objects = _new_objects_since(doc, before_names) - top_level_objects = _top_level_imported_objects(imported_objects) - for obj in top_level_objects: - if obj not in getattr(device_group, "Group", []): - device_group.addObject(obj) - TemplateSemantics.clear_stored_template_slot_hints(device_group) - TerminalObjects.hide_template_terminal_hints(device_group) - return top_level_objects + try: + ImportGui.insert( + name=model_path, + docName=doc.Name, + merge=bool(merge), + useLinkGroup=bool(use_link_group), + ) + except Exception: + for obj in _new_objects_since(doc, before_names): + _remove_object_tree(doc, obj) + raise + + imported_objects = _new_objects_since(doc, before_names) + top_level_objects = _top_level_imported_objects(imported_objects) + for obj in top_level_objects: + if obj not in getattr(device_group, "Group", []): + device_group.addObject(obj) + TemplateSemantics.clear_stored_template_slot_hints(device_group) + TerminalObjects.hide_template_terminal_hints(device_group) + return top_level_objects + finally: + _activate_document(doc) def _open_fcstd_source_document(model_path): diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index 55c7634..3da71e3 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -1,6 +1,7 @@ # FreeCADExchange GUI panel for guided manual 3D wiring. import json +import math from pathlib import Path import FreeCAD as App @@ -68,15 +69,32 @@ def _console_error(message): pass -def _repo_root(): - return Path(__file__).resolve().parents[3] - - def _builtin_carrier_asset_path(carrier_kind): file_name = BUILTIN_CARRIER_ASSETS.get((carrier_kind or "").strip()) if not file_name: return "" - return str(_repo_root() / "data" / "examples" / "qet_cabinet_assets" / file_name) + module_path = Path(__file__).resolve() + candidate_roots = [] + for index in (3, 2): + try: + candidate_roots.append(module_path.parents[index]) + except IndexError: + pass + try: + app_home = App.ConfigGet("AppHomePath") + if app_home: + candidate_roots.append(Path(app_home)) + except Exception: + pass + + for root in candidate_roots: + candidate = root / "data" / "examples" / "qet_cabinet_assets" / file_name + if candidate.is_file(): + return str(candidate) + + if candidate_roots: + return str(candidate_roots[0] / "data" / "examples" / "qet_cabinet_assets" / file_name) + return str(module_path.parent / file_name) def _supported_carrier_asset(path): @@ -91,6 +109,29 @@ def _active_document(): return doc +def _activate_document(doc): + if doc is None: + return + + setter = getattr(App, "setActiveDocument", None) + if callable(setter): + try: + setter(doc.Name) + except Exception: + pass + + try: + App.ActiveDocument = doc + except Exception: + pass + + try: + if Gui is not None: + Gui.ActiveDocument = Gui.getDocument(doc.Name) + except Exception: + pass + + def _selection(): if Gui is None: return [] @@ -120,6 +161,195 @@ def _shape_center(shape): ) +def _vector(x=0.0, y=0.0, z=0.0): + return App.Vector(float(x), float(y), float(z)) + + +def _vector_add(left, right): + return _vector(left.x + right.x, left.y + right.y, left.z + right.z) + + +def _vector_sub(left, right): + return _vector(left.x - right.x, left.y - right.y, left.z - right.z) + + +def _vector_scale(vector, factor): + return _vector(vector.x * float(factor), vector.y * float(factor), vector.z * float(factor)) + + +def _vector_length(vector): + return math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z) + + +def _normalize_vector(vector): + if vector is None: + return None + length = _vector_length(vector) + if length <= 1e-9: + return None + return _vector_scale(vector, 1.0 / length) + + +def _vector_dot(left, right): + return left.x * right.x + left.y * right.y + left.z * right.z + + +def _vectors_close(left, right, tolerance=1e-6): + return _vector_length(_vector_sub(left, right)) <= tolerance + + +def _face_anchor_point(picked, sub_object): + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + center = getattr(sub_object, "CenterOfMass", None) + if center is not None: + return center + return _shape_center(sub_object) + + +def _face_normal(sub_object): + if not hasattr(sub_object, "normalAt"): + return None + try: + return _normalize_vector(sub_object.normalAt(0.5, 0.5)) + except Exception: + try: + return _normalize_vector(sub_object.normalAt(0.5)) + except Exception: + return None + + +def _selected_contact_face_refs(): + refs = [] + for picked in _selection_ex(): + obj = getattr(picked, "Object", None) + if obj is None: + continue + subelement_names = list(getattr(picked, "SubElementNames", []) or []) + for index, sub_object in enumerate(list(getattr(picked, "SubObjects", []) or [])): + shape_type = (getattr(sub_object, "ShapeType", "") or "").strip().lower() + if shape_type != "face": + continue + point = _face_anchor_point(picked, sub_object) + normal = _face_normal(sub_object) + if point is None or normal is None: + continue + refs.append( + { + "object": obj, + "face": sub_object, + "point": point, + "normal": normal, + "subelement_name": subelement_names[index] if index < len(subelement_names) else "", + } + ) + break + return refs + + +def _has_placement(obj): + return getattr(obj, "Placement", None) is not None + + +def _contact_transform_object(obj): + carrier = _carrier_object_from_object(obj) + if carrier is not None and _has_placement(carrier): + return carrier + + best = obj if _has_placement(obj) else None + current = obj + visited = set() + while current is not None and id(current) not in visited: + visited.add(id(current)) + parents = list(getattr(current, "InList", []) or []) + parent = parents[0] if parents else None + if parent is None: + break + if _has_placement(parent): + name = getattr(parent, "Name", "") or "" + if ( + name.startswith("QETDevice_") + or (getattr(parent, "QetInstanceId", "") or "").strip() + or (getattr(parent, "QetCarrierKind", "") or "").strip() + ): + best = parent + current = parent + return best + + +def _rotation_for_face_contact(moving_normal, target_normal): + desired = _vector_scale(target_normal, -1.0) + moving = _normalize_vector(moving_normal) + desired = _normalize_vector(desired) + if moving is None or desired is None or _vectors_close(moving, desired): + return None + try: + return App.Rotation(moving, desired) + except Exception: + return None + + +def _normal_contact_translation(target_point, target_normal, moving_point): + 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) + + +def _rotate_object_about_point(obj, rotation, pivot): + if rotation is None: + return False + placement = getattr(obj, "Placement", None) + if placement is None or not hasattr(rotation, "multVec"): + return False + + base = getattr(placement, "Base", None) + if base is None: + return False + + try: + rotated_base = _vector_add(pivot, rotation.multVec(_vector_sub(base, pivot))) + except Exception: + return False + + old_rotation = getattr(placement, "Rotation", None) + new_rotation = old_rotation + try: + if hasattr(rotation, "multiply"): + new_rotation = rotation.multiply(old_rotation) + except Exception: + new_rotation = old_rotation + + try: + obj.Placement = App.Placement(rotated_base, new_rotation) + return True + except Exception: + try: + placement.Base = rotated_base + placement.Rotation = new_rotation + obj.Placement = placement + return True + except Exception: + return False + + +def _translate_object(obj, translation): + placement = getattr(obj, "Placement", None) + base = getattr(placement, "Base", None) + if placement is None or base is None: + raise ManualWiringPanelError("所选对象没有可移动的 Placement,不能执行贴合。") + + new_base = _vector_add(base, translation) + try: + placement.Base = new_base + obj.Placement = placement + except Exception: + obj.Placement = App.Placement(new_base, getattr(placement, "Rotation", App.Rotation())) + return new_base + + def _selected_point(): for picked in _selection_ex(): picked_points = list(getattr(picked, "PickedPoints", []) or []) @@ -377,32 +607,36 @@ def _import_carrier_objects_from_path(doc, path): if not _supported_carrier_asset(path): raise ManualWiringPanelError("请选择 STEP/STP/FCStd 线槽或导轨模型。") - suffix = Path(path).suffix.lower() - if suffix == ".fcstd": - source_doc = None - try: - source_doc = _open_fcstd_source(path) - copied = [] - for source_obj in _top_level_objects(list(getattr(source_doc, "Objects", []) or [])): - copied.append(doc.copyObject(source_obj, True)) - return copied - finally: - if source_doc is not None: - try: - App.closeDocument(source_doc.Name) - except Exception: - pass - - if ImportGui is None: - raise ManualWiringPanelError("当前 FreeCAD 无法导入 STEP/STP 文件。") - before = _existing_object_names(doc) - ImportGui.insert( - name=path, - docName=doc.Name, - merge=False, - useLinkGroup=True, - ) - return _top_level_objects(_new_objects_since(doc, before)) + try: + suffix = Path(path).suffix.lower() + if suffix == ".fcstd": + source_doc = None + try: + source_doc = _open_fcstd_source(path) + copied = [] + for source_obj in _top_level_objects(list(getattr(source_doc, "Objects", []) or [])): + copied.append(doc.copyObject(source_obj, True)) + return copied + finally: + if source_doc is not None: + try: + App.closeDocument(source_doc.Name) + except Exception: + pass + + if ImportGui is None: + raise ManualWiringPanelError("当前 FreeCAD 无法导入 STEP/STP 文件。") + before = _existing_object_names(doc) + ImportGui.insert( + name=path, + docName=doc.Name, + merge=False, + useLinkGroup=True, + ) + return _top_level_objects(_new_objects_since(doc, before)) + finally: + _activate_document(doc) + def _ensure_float_property(obj, prop_name, value, description): @@ -776,30 +1010,33 @@ class ManualWiringController: raise ManualWiringPanelError("找不到内置线槽/导轨资产。") doc = _active_document() - project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() - carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid) - imported = list((importer or _import_carrier_objects_from_path)(doc, path) or []) - if not imported: - raise ManualWiringPanelError("没有从模型文件导入任何对象。") - - role_label = _carrier_role_label(carrier_kind) - container_name = "QETCarrier_{0}".format(TerminalObjects.safe_token(carrier_kind)) - container = doc.addObject("App::DocumentObjectGroup", container_name) - container.Label = "QET {0}".format(role_label or carrier_kind) - carrier_group.addObject(container) - for obj in imported: - if obj not in getattr(container, "Group", []): - container.addObject(obj) - - _set_carrier_properties(container, carrier_kind, source_path=path) - for obj in imported: - _set_carrier_properties(obj, carrier_kind, source_path=path) - _apply_carrier_length(container, length_mm) try: - doc.recompute() - except Exception: - pass - return container + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid) + imported = list((importer or _import_carrier_objects_from_path)(doc, path) or []) + if not imported: + raise ManualWiringPanelError("没有从模型文件导入任何对象。") + + role_label = _carrier_role_label(carrier_kind) + container_name = "QETCarrier_{0}".format(TerminalObjects.safe_token(carrier_kind)) + container = doc.addObject("App::DocumentObjectGroup", container_name) + container.Label = "QET {0}".format(role_label or carrier_kind) + carrier_group.addObject(container) + for obj in imported: + if obj not in getattr(container, "Group", []): + container.addObject(obj) + + _set_carrier_properties(container, carrier_kind, source_path=path) + for obj in imported: + _set_carrier_properties(obj, carrier_kind, source_path=path) + _apply_carrier_length(container, length_mm) + try: + doc.recompute() + except Exception: + pass + return container + finally: + _activate_document(doc) def apply_length_to_selected_carriers(self, length_mm): selected = _selected_carrier_objects() @@ -818,6 +1055,44 @@ class ManualWiringController: pass return updated + def align_selected_contact_faces(self): + refs = _selected_contact_face_refs() + if len(refs) < 2: + raise ManualWiringPanelError("请先选择目标面,再按 Ctrl 选择要移动对象的接触面。") + if len(refs) > 2: + raise ManualWiringPanelError("只能选择两个面:第一个是目标面,第二个是要移动对象的接触面。") + + target = refs[0] + moving = refs[1] + moving_object = _contact_transform_object(moving["object"]) + if moving_object is None: + raise ManualWiringPanelError("没有找到可移动的对象。") + + rotation = _rotation_for_face_contact(moving["normal"], target["normal"]) + rotated = _rotate_object_about_point(moving_object, rotation, moving["point"]) + translation = _normal_contact_translation( + target["point"], + target["normal"], + moving["point"], + ) + if translation is None: + raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。") + _translate_object(moving_object, translation) + try: + _active_document().recompute() + except Exception: + pass + _activate_document(_active_document()) + return { + "target_object": target["object"], + "moving_object": moving_object, + "target_point": target["point"], + "moving_point": moving["point"], + "translation": translation, + "translation_mode": "normal", + "rotated": rotated, + } + def _clear_preview_objects(self): doc = getattr(App, "ActiveDocument", None) if doc is None: @@ -1108,6 +1383,7 @@ class ManualWiringTaskPanel: self.mark_duct_button = QtWidgets.QPushButton("标记为线槽") self.mark_cabinet_button = QtWidgets.QPushButton("标记为柜面") self.mark_rail_button = QtWidgets.QPushButton("标记为导轨") + self.align_faces_button = QtWidgets.QPushButton("贴合到选中面") self.waypoint_button = QtWidgets.QPushButton("添加折点") self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点") self.end_button = QtWidgets.QPushButton("设为终点并生成") @@ -1140,6 +1416,7 @@ class ManualWiringTaskPanel: carrier_layout.addWidget(self.mark_cabinet_button) carrier_layout.addWidget(self.mark_rail_button) layout.addLayout(carrier_layout) + layout.addWidget(self.align_faces_button) layout.addWidget(self.start_button) layout.addWidget(self.waypoint_button) layout.addWidget(self.delete_waypoint_button) @@ -1171,6 +1448,7 @@ class ManualWiringTaskPanel: self.mark_duct_button.clicked.connect(self.mark_wire_duct) self.mark_cabinet_button.clicked.connect(self.mark_cabinet) self.mark_rail_button.clicked.connect(self.mark_rail) + self.align_faces_button.clicked.connect(self.align_selected_contact_faces) 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) @@ -1323,6 +1601,14 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def align_selected_contact_faces(self): + try: + result = self.controller.align_selected_contact_faces() + moving_label = getattr(result.get("moving_object"), "Label", "") or getattr(result.get("moving_object"), "Name", "") + self._set_status("已贴合对象:{0}".format(moving_label or "选中对象")) + except Exception as exc: + self._set_error(str(exc)) + def set_start(self): try: terminal = self.controller.set_start_from_selection() diff --git a/tests/python/freecad_exchange_device_import_fcstd_test.py b/tests/python/freecad_exchange_device_import_fcstd_test.py index 5e53b91..ec567d5 100644 --- a/tests/python/freecad_exchange_device_import_fcstd_test.py +++ b/tests/python/freecad_exchange_device_import_fcstd_test.py @@ -486,6 +486,32 @@ class FcstdDeviceImportTest(unittest.TestCase): self.assertEqual("", device_group.QetTemplateSlotsJson) self.assertEqual([], template_semantics.collect_terminal_hints(device_group)) + def test_non_fcstd_import_restores_target_document_after_insert_changes_active_document(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + doc = FakeDocument("QETScene") + app.ActiveDocument = doc + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + def insert_step_body(name, docName, merge, useLinkGroup): + doc.addObject("Part::Feature", "StepBody") + app.ActiveDocument = None + + device_import.ImportGui.insert = insert_step_body + + device_import._import_model_into_group( + doc, + device_group, + r"D:\models\breaker.step", + ) + + self.assertIs(doc, app.ActiveDocument) + self.assertIn("QETScene", app.set_active_document_calls) + def test_fcstd_import_detaches_removed_template_lcs_from_parent_group(self): source = FakeDocument("Source", r"D:\models\breaker.FCStd") _install_fake_freecad(source) diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 6fde382..3c1e003 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -1,6 +1,7 @@ import importlib import json import sys +import tempfile import types import unittest from pathlib import Path @@ -198,6 +199,29 @@ def _reload_modules(): class ManualWiringPanelTest(unittest.TestCase): + def test_builtin_carrier_asset_path_supports_installed_freecad_layout(self): + _selection_state = _install_fake_freecad() + _terminal_objects, panel = _reload_modules() + + with tempfile.TemporaryDirectory() as temp_dir: + app_home = Path(temp_dir) / "run-FreeCAD" + module_dir = app_home / "Mod" / "FreeCADExchange" + asset_dir = app_home / "data" / "examples" / "qet_cabinet_assets" + module_dir.mkdir(parents=True) + asset_dir.mkdir(parents=True) + asset = asset_dir / "qet_din_rail.FCStd" + asset.write_text("fake rail", encoding="utf-8") + + original_file = panel.__file__ + try: + panel.__file__ = str(module_dir / "ManualWiringPanel.py") + + resolved = panel._builtin_carrier_asset_path("rail") + finally: + panel.__file__ = original_file + + self.assertEqual(str(asset), resolved) + def test_controller_rejects_local_terminal_as_manual_wiring_start(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -415,6 +439,29 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertIn(carrier, carrier_group.Group) self.assertEqual(3.0, getattr(carrier, "QetCarrierScaleX", None)) + def test_controller_restores_active_document_after_carrier_import_changes_it(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") + + def importer(doc, path): + obj = doc.addObject("Part::Feature", "ImportedRail") + app.ActiveDocument = None + return [obj] + + panel.ManualWiringController().import_carrier_asset( + r"D:\assets\rail.FCStd", + "rail", + length_mm=300.0, + importer=importer, + ) + + self.assertIs(doc, app.ActiveDocument) + def test_controller_applies_length_to_selected_carrier(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -441,6 +488,123 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual(500.0, getattr(carrier, "QetCarrierLength", None)) self.assertEqual(2.5, getattr(carrier, "QetCarrierScaleX", None)) + def test_controller_aligns_second_selected_face_to_first_selected_face(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().align_selected_contact_faces() + + self.assertIs(rail, result["moving_object"]) + self.assertEqual( + (0.0, 0.0, 1.0), + (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z), + ) + self.assertEqual( + (-0.0, -0.0, -9.0), + ( + result["translation"].x, + result["translation"].y, + result["translation"].z, + ), + ) + self.assertEqual("normal", result["translation_mode"]) + + def test_controller_requires_two_faces_for_contact_alignment(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") + 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=[face], + SubElementNames=["Face1"], + Object=cabinet, + ) + ] + + with self.assertRaisesRegex(panel.ManualWiringPanelError, "目标面"): + panel.ManualWiringController().align_selected_contact_faces() + + def test_controller_rejects_more_than_two_faces_for_contact_alignment(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") + breaker = doc.addObject("Part::Feature", "Breaker") + + 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=[face], + SubElementNames=["Face1"], + Object=cabinet, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 10)], + SubObjects=[face], + SubElementNames=["Face2"], + Object=rail, + ), + types.SimpleNamespace( + PickedPoints=[app.Vector(0, 0, 20)], + SubObjects=[face], + SubElementNames=["Face3"], + Object=breaker, + ), + ] + + with self.assertRaisesRegex(panel.ManualWiringPanelError, "只能选择两个面"): + panel.ManualWiringController().align_selected_contact_faces() + def test_controller_deletes_last_waypoint_and_preview_point(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules()