From 58c9eae33c3f8d49531b1bdfb1f61cbb899aab61 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Mon, 25 May 2026 18:46:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20FCStd=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=90=8E=E5=8F=98=E6=8D=A2=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mod/FreeCADExchange/DeviceImport.py | 274 +++++++++- src/Mod/FreeCADExchange/TemplateSemantics.py | 132 ++++- ...eecad_exchange_device_import_fcstd_test.py | 482 ++++++++++++++++++ 3 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 tests/python/freecad_exchange_device_import_fcstd_test.py diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index 6cc64fb..b4ea5f9 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -6,6 +6,7 @@ import FreeCAD as App import FreeCADGui as Gui import ImportGui import DevicePreview +import TemplateSemantics import TerminalObjects @@ -54,6 +55,17 @@ def _safe_token(value): return "".join(chars) +def _unique_object_name(doc, base_name): + base = _safe_token(base_name) or "QETObject" + if doc.getObject(base) is None: + return base + + suffix = 1 + while doc.getObject("{0}_{1}".format(base, suffix)) is not None: + suffix += 1 + return "{0}_{1}".format(base, suffix) + + def _native_path(value): text = (value or "").strip() if not text: @@ -156,9 +168,34 @@ def _ensure_document(scene_path): preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene" existing_doc = DevicePreview.find_main_exchange_document(preferred_name) if existing_doc is not None: + _activate_document(existing_doc) return existing_doc - return App.newDocument(preferred_name) + doc = App.newDocument(preferred_name) + _activate_document(doc) + 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: + Gui.ActiveDocument = Gui.getDocument(doc.Name) + except Exception: + pass def _cabinet_label_text(cabinet): @@ -387,12 +424,103 @@ def _remove_object_tree(doc, obj): if obj is None: return + obj_name = _object_name(obj) + if not obj_name or doc.getObject(obj_name) is None: + _detach_from_parent_groups(obj) + return + children = list(getattr(obj, "Group", []) or []) for child in children: _remove_object_tree(doc, child) - if doc.getObject(obj.Name) is not None: - doc.removeObject(obj.Name) + _detach_from_parent_groups(obj) + if doc.getObject(obj_name) is not None: + doc.removeObject(obj_name) + + +def _object_name(obj): + try: + return getattr(obj, "Name", "") + except Exception: + return "" + + +def _object_exists(doc, obj): + obj_name = _object_name(obj) + return bool(obj_name and doc.getObject(obj_name) is not None) + + +def _detach_from_parent_groups(obj): + try: + parents = list(getattr(obj, "InList", []) or []) + except Exception: + return + + for parent in parents: + try: + group_children = list(getattr(parent, "Group", []) or []) + except Exception: + continue + if obj not in group_children: + continue + + remover = getattr(parent, "removeObject", None) + if callable(remover): + try: + remover(obj) + continue + except Exception: + pass + + try: + while obj in getattr(parent, "Group", []): + parent.Group.remove(obj) + except Exception: + pass + + +def _linked_document_objects(value): + if value is None: + return [] + if hasattr(value, "Name") and hasattr(value, "TypeId"): + return [value] + if isinstance(value, dict): + result = [] + for item in value.values(): + result.extend(_linked_document_objects(item)) + return result + if isinstance(value, (list, tuple, set)): + result = [] + for item in value: + result.extend(_linked_document_objects(item)) + return result + return [] + + +def _remove_template_terminal_hint_object(doc, obj): + linked_objects = [] + for prop_name in ("OriginFeatures",): + linked_objects.extend(_linked_document_objects(getattr(obj, prop_name, None))) + + _remove_object_tree(doc, obj) + for linked_obj in linked_objects: + if linked_obj is not obj: + _remove_object_tree(doc, linked_obj) + + +def _remove_template_terminal_hints(doc, container): + removed = 0 + if container is None: + return removed + + for child in list(getattr(container, "Group", []) or []): + if TerminalObjects.is_template_terminal_object(child): + _remove_template_terminal_hint_object(doc, child) + removed += 1 + continue + if hasattr(child, "Group"): + removed += _remove_template_terminal_hints(doc, child) + return removed def _clear_group_contents(doc, group): @@ -405,6 +533,126 @@ def _clear_group_contents(doc, group): _remove_object_tree(doc, child) +def _is_exchange_sidecar_group(obj): + child_name = _object_name(obj) + if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX): + return True + return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES} + + +def _keep_only_direct_model_children(device_group, direct_model_objects): + allowed_ids = {id(obj) for obj in direct_model_objects if obj is not None} + kept_children = [] + for child in list(getattr(device_group, "Group", []) or []): + if id(child) in allowed_ids: + kept_children.append(child) + continue + if _is_exchange_sidecar_group(child): + kept_children.append(child) + + try: + device_group.Group = kept_children + except Exception: + try: + device_group.Group[:] = kept_children + except Exception: + pass + + +def _document_object_links(obj): + linked_objects = [] + for prop_name in ("Links", "OutList"): + linked_objects.extend(_linked_document_objects(getattr(obj, prop_name, None))) + return linked_objects + + +def _shape_copy(shape): + copier = getattr(shape, "copy", None) + if callable(copier): + try: + return copier() + except Exception: + pass + return shape + + +def _can_materialize_shape(obj): + if getattr(obj, "TypeId", "") == "Part::Feature": + return False + try: + shape = getattr(obj, "Shape", None) + except Exception: + return False + return shape is not None + + +def _materialize_shape_object(doc, source_obj): + source_name = _object_name(source_obj) or "Model" + target = doc.addObject( + "Part::Feature", + _unique_object_name(doc, source_name + "_Shape"), + ) + target.Label = getattr(source_obj, "Label", source_name) + target.Shape = _shape_copy(getattr(source_obj, "Shape", None)) + + try: + target.Placement = getattr(source_obj, "Placement") + except Exception: + pass + + try: + target.ViewObject.Visibility = getattr(source_obj.ViewObject, "Visibility", True) + except Exception: + pass + + try: + target.ViewObject.ShapeColor = getattr(source_obj.ViewObject, "ShapeColor") + except Exception: + pass + + return target + + +def _materialize_direct_model_objects(doc, device_group, model_objects): + direct_objects = [] + obsolete_objects = [] + materialized_labels = [] + + for obj in model_objects: + if not _object_exists(doc, obj): + continue + + if not _can_materialize_shape(obj): + direct_objects.append(obj) + continue + + static_obj = _materialize_shape_object(doc, obj) + if static_obj not in getattr(device_group, "Group", []): + device_group.addObject(static_obj) + + direct_objects.append(static_obj) + materialized_labels.append((static_obj, getattr(obj, "Label", _object_name(obj)))) + obsolete_objects.append(obj) + obsolete_objects.extend(_document_object_links(obj)) + + direct_names = {_object_name(obj) for obj in direct_objects} + removed_names = set() + for obsolete in obsolete_objects: + obsolete_name = _object_name(obsolete) + if not obsolete_name or obsolete_name in direct_names or obsolete_name in removed_names: + continue + _remove_object_tree(doc, obsolete) + removed_names.add(obsolete_name) + + for obj, label in materialized_labels: + try: + obj.Label = label + except Exception: + pass + + return direct_objects + + def _generate_instance_id(project_uuid, element_uuid): seed = "QET:{0}:{1}".format((project_uuid or "").strip(), (element_uuid or "").strip()) return str(uuid.uuid5(uuid.NAMESPACE_URL, seed)) @@ -445,6 +693,7 @@ def _import_model_into_group(doc, device_group, model_path, merge=False, use_lin 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 @@ -467,6 +716,7 @@ def _import_fcstd_into_group(doc, device_group, model_path): if source_doc is None: raise DeviceImportError("Cannot open FCStd file") + TemplateSemantics.clear_stored_template_slot_hints(device_group) top_level_objects = _top_level_document_objects(source_doc) copied_objects = [] for source_obj in top_level_objects: @@ -475,14 +725,28 @@ def _import_fcstd_into_group(doc, device_group, model_path): device_group.addObject(copied_obj) copied_objects.append(copied_obj) - TerminalObjects.hide_template_terminal_hints(device_group) - return copied_objects + template_slots = TemplateSemantics.collect_live_terminal_hints(device_group) + TemplateSemantics.store_template_slot_hints(device_group, template_slots) + _remove_template_terminal_hints(doc, device_group) + copied_model_objects = [ + obj + for obj in copied_objects + if _object_exists(doc, obj) + ] + direct_model_objects = _materialize_direct_model_objects( + doc, + device_group, + copied_model_objects, + ) + _keep_only_direct_model_children(device_group, direct_model_objects) + return direct_model_objects finally: if should_close and source_doc is not None: try: App.closeDocument(source_doc.Name) except Exception: pass + _activate_document(doc) def _model_index(payload): diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py index 8cdeff8..9eb7c03 100644 --- a/src/Mod/FreeCADExchange/TemplateSemantics.py +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -8,6 +8,9 @@ import FreeCAD as App import TerminalObjects as TerminalObjects +STORED_TEMPLATE_SLOTS_PROPERTY = "QetTemplateSlotsJson" + + def _sidecar_candidates(model_path): native = TerminalObjects.native_path(model_path) if not native: @@ -113,6 +116,111 @@ def _rotation_from_object(source_object): } +def _vector_to_payload(value): + vector = _vector_from_value(value) + if vector is None: + return None + return [float(vector.x), float(vector.y), float(vector.z)] + + +def _rotation_to_payload(value): + if not isinstance(value, dict): + return None + + axis = _vector_to_payload(value.get("axis")) + angle = value.get("angle") + if axis is None or angle is None: + return None + + try: + angle = float(angle) + except (TypeError, ValueError): + return None + + return { + "axis": axis, + "angle": angle, + } + + +def _slot_to_payload(slot): + if not isinstance(slot, dict): + return None + + base = _vector_to_payload(slot.get("base")) + if base is None: + return None + + name = (slot.get("name") or slot.get("label") or "").strip() + label = (slot.get("label") or name).strip() + payload = { + "name": name, + "label": label, + "base": base, + } + rotation = _rotation_to_payload(slot.get("rotation")) + if rotation is not None: + payload["rotation"] = rotation + return payload + + +def _stored_template_slot_payloads(container): + raw_text = getattr(container, STORED_TEMPLATE_SLOTS_PROPERTY, "") if container is not None else "" + if not isinstance(raw_text, str) or not raw_text.strip(): + return [] + + try: + payload = json.loads(raw_text) + except (TypeError, ValueError): + return [] + + if isinstance(payload, dict): + entries = payload.get("terminal_slots") + elif isinstance(payload, list): + entries = payload + else: + entries = None + + if not isinstance(entries, list): + return [] + return entries + + +def clear_stored_template_slot_hints(container): + if container is None: + return + if STORED_TEMPLATE_SLOTS_PROPERTY in getattr(container, "PropertiesList", []): + setattr(container, STORED_TEMPLATE_SLOTS_PROPERTY, "") + + +def store_template_slot_hints(container, slots): + if container is None: + return 0 + + payloads = [] + for slot in slots or []: + payload = _slot_to_payload(slot) + if payload is not None: + payloads.append(payload) + + raw_text = "" + if payloads: + raw_text = json.dumps( + {"terminal_slots": payloads}, + ensure_ascii=False, + sort_keys=True, + ) + + TerminalObjects.ensure_string_property( + container, + STORED_TEMPLATE_SLOTS_PROPERTY, + "QET Exchange", + "Serialized FCStd template terminal slots", + raw_text, + ) + return len(payloads) + + def _slot_from_payload(item, source, index, source_object=None): if not isinstance(item, dict): return None @@ -179,11 +287,23 @@ def _is_child_group(obj, group_kind): return False -def collect_terminal_hints(container): +def _collect_stored_terminal_hints(container): + hints = [] + for index, item in enumerate(_stored_template_slot_payloads(container)): + hint = _slot_from_payload(item, "model", index, source_object=None) + if hint is not None: + hints.append(hint) + return hints + + +def _collect_terminal_hints(container, include_stored): hints = [] if container is None: return hints + if include_stored: + hints.extend(_collect_stored_terminal_hints(container)) + for child in list(getattr(container, "Group", []) or []): if TerminalObjects.is_terminal_hint_object(child) and not TerminalObjects.is_terminal_object(child): slot_name = ( @@ -214,12 +334,20 @@ def collect_terminal_hints(container): continue if hasattr(child, "Group"): - hints.extend(collect_terminal_hints(child)) + hints.extend(_collect_terminal_hints(child, include_stored)) hints.sort(key=lambda item: (item.get("label", ""), item.get("name", ""))) return hints +def collect_terminal_hints(container): + return _collect_terminal_hints(container, include_stored=True) + + +def collect_live_terminal_hints(container): + return _collect_terminal_hints(container, include_stored=False) + + def collect_model_bounding_box(container): boxes = [] diff --git a/tests/python/freecad_exchange_device_import_fcstd_test.py b/tests/python/freecad_exchange_device_import_fcstd_test.py new file mode 100644 index 0000000..fd238a0 --- /dev/null +++ b/tests/python/freecad_exchange_device_import_fcstd_test.py @@ -0,0 +1,482 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange" +if str(MODULE_DIR) not in sys.path: + sys.path.insert(0, str(MODULE_DIR)) + + +def _install_fake_freecad(source_doc): + class Vector: + def __init__(self, x=0.0, y=0.0, z=0.0): + self.x = float(x) + self.y = float(y) + self.z = float(z) + + class Rotation: + def __init__(self, axis=None, angle=None): + self.Axis = axis + self.Angle = angle + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or Rotation() + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + fake_freecad.ActiveDocument = None + fake_freecad.set_active_document_calls = [] + + def set_active_document(name): + fake_freecad.set_active_document_calls.append(name) + + def close_document(*args, **kwargs): + fake_freecad.ActiveDocument = None + + fake_freecad.setActiveDocument = set_active_document + fake_freecad.listDocuments = lambda: {} + fake_freecad.openDocument = lambda *args, **kwargs: source_doc + fake_freecad.closeDocument = close_document + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.getDocument = lambda *args, **kwargs: object() + fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: []) + sys.modules["FreeCADGui"] = fake_freecadgui + + fake_importgui = types.ModuleType("ImportGui") + fake_importgui.insert = lambda *args, **kwargs: None + sys.modules["ImportGui"] = fake_importgui + + fake_device_preview = types.ModuleType("DevicePreview") + fake_device_preview.find_main_exchange_document = lambda *args, **kwargs: None + sys.modules["DevicePreview"] = fake_device_preview + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = None + + +class FakeShape: + def copy(self): + return FakeShape() + + +class FakeObject: + def __init__(self, name, type_id): + self.Name = name + self.Label = name + self.TypeId = type_id + self._deleted = False + self._raise_name_when_deleted = False + self._raise_group_when_deleted = False + self._raise_inlist_when_deleted = False + self.PropertiesList = [] + self.Group = [] + self.InList = [] + self.Links = [] + self.ViewObject = FakeViewObject() + self.Placement = sys.modules["FreeCAD"].Placement() + self.Shape = FakeShape() + + def __getattribute__(self, name): + if name == "Name": + data = object.__getattribute__(self, "__dict__") + if data.get("_deleted") and data.get("_raise_name_when_deleted"): + raise RuntimeError("Cannot access attribute 'Name' of deleted object") + if name == "Group": + data = object.__getattribute__(self, "__dict__") + if data.get("_deleted") and data.get("_raise_group_when_deleted"): + raise RuntimeError("Cannot access attribute 'Group' of deleted object") + if name == "InList": + data = object.__getattribute__(self, "__dict__") + if data.get("_deleted") and data.get("_raise_inlist_when_deleted"): + raise RuntimeError("Cannot access attribute 'InList' of deleted object") + return object.__getattribute__(self, name) + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId in {"App::DocumentObjectGroup", "App::Part"} + if type_name == "App::LocalCoordinateSystem": + return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"} + return False + + def addProperty(self, prop_type, prop_name, group_name, description): + if prop_name not in self.PropertiesList: + self.PropertiesList.append(prop_name) + + def addObject(self, child): + if child not in self.Group: + self.Group.append(child) + if self not in child.InList: + child.InList.append(self) + for dependency in list(getattr(child, "Links", []) or []): + if dependency not in self.Group: + self.Group.append(dependency) + if self not in dependency.InList: + dependency.InList.append(self) + + +class FakeDocument: + def __init__( + self, + name, + filename="", + detach_on_remove=True, + raise_name_when_deleted=False, + raise_group_when_deleted=False, + raise_inlist_when_deleted=False, + ): + self.Name = name + self.FileName = filename + self.Objects = [] + self.detach_on_remove = detach_on_remove + self.raise_name_when_deleted = raise_name_when_deleted + self.raise_group_when_deleted = raise_group_when_deleted + self.raise_inlist_when_deleted = raise_inlist_when_deleted + + def addObject(self, type_name, name): + obj = FakeObject(name, type_name) + obj._raise_name_when_deleted = self.raise_name_when_deleted + obj._raise_group_when_deleted = self.raise_group_when_deleted + obj._raise_inlist_when_deleted = self.raise_inlist_when_deleted + self.Objects.append(obj) + return obj + + def getObject(self, name): + for obj in self.Objects: + if obj.Name == name: + return obj + return None + + def removeObject(self, name): + target = self.getObject(name) + if target is None: + return + self.Objects = [obj for obj in self.Objects if obj is not target] + target._deleted = True + if self.detach_on_remove: + for obj in self.Objects: + if target in getattr(obj, "Group", []): + obj.Group.remove(target) + if target in getattr(obj, "InList", []): + obj.InList.remove(target) + + def copyObject(self, source_obj, recursive): + copies = {} + + def clone(obj): + if obj in copies: + return copies[obj] + copied = FakeObject(obj.Name, obj.TypeId) + copied._raise_name_when_deleted = self.raise_name_when_deleted + copied._raise_group_when_deleted = self.raise_group_when_deleted + copied._raise_inlist_when_deleted = self.raise_inlist_when_deleted + copied.Label = obj.Label + copied.PropertiesList = list(getattr(obj, "PropertiesList", []) or []) + copied.Placement = obj.Placement + for prop in copied.PropertiesList: + setattr(copied, prop, getattr(obj, prop, None)) + self.Objects.append(copied) + copies[obj] = copied + if recursive: + copied.Links = [ + clone(child) + for child in list(getattr(obj, "Links", []) or []) + ] + for child in copied.Links: + if copied not in child.InList: + child.InList.append(copied) + for child in list(getattr(obj, "Group", []) or []): + copied.addObject(clone(child)) + copied.OriginFeatures = [ + clone(child) + for child in list(getattr(obj, "OriginFeatures", []) or []) + ] + return copied + + return clone(source_obj) + + +def _reload_modules(): + for name in [ + "DeviceImport", + "TemplateSemantics", + "TerminalObjects", + ]: + sys.modules.pop(name, None) + device_import = importlib.import_module("DeviceImport") + template_semantics = importlib.import_module("TemplateSemantics") + return device_import, template_semantics + + +class FcstdDeviceImportTest(unittest.TestCase): + def test_fcstd_import_preserves_template_slots_without_live_template_lcs(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + body = source.addObject("Part::Feature", "Body") + terminal = source.addObject("Part::LocalCoordinateSystem", "Terminal_D1") + terminal.Label = "D1" + terminal.Placement = app.Placement( + app.Vector(11, 22, 33), + app.Rotation(app.Vector(0, 0, 1), 90), + ) + terminal.addProperty("App::PropertyString", "Role", "QET Template", "") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + terminal.QetTemplateSlotName = "D1" + + x_axis = source.addObject("App::Line", "Terminal_D1_XAxis") + terminal.OriginFeatures = [x_axis] + terminal.addObject(x_axis) + + doc = FakeDocument("QETScene") + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, template_semantics = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual([body.Name], [obj.Name for obj in copied_objects]) + self.assertEqual(["Body"], [obj.Name for obj in device_group.Group]) + self.assertNotIn("Terminal_D1", [obj.Name for obj in doc.Objects]) + self.assertNotIn("Terminal_D1_XAxis", [obj.Name for obj in doc.Objects]) + self.assertIn("QetTemplateSlotsJson", device_group.PropertiesList) + + slots = template_semantics.collect_terminal_hints(device_group) + + self.assertEqual(1, len(slots)) + self.assertEqual("D1", slots[0]["name"]) + self.assertEqual(11.0, slots[0]["base"].x) + self.assertEqual(90.0, slots[0]["rotation"]["angle"]) + self.assertIsNone(slots[0]["source_object"]) + + def test_fcstd_import_keeps_link_dependencies_out_of_device_group(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + + solid = source.addObject("Part::Feature", "Solid") + solid_001 = source.addObject("Part::Feature", "Solid001") + compound = source.addObject("Part::Compound2", "Compound") + compound.Links = [solid, solid_001] + solid.InList.append(compound) + solid_001.InList.append(compound) + + doc = FakeDocument("QETScene") + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual(1, len(copied_objects)) + self.assertEqual("Part::Feature", copied_objects[0].TypeId) + self.assertEqual("Compound", copied_objects[0].Label) + self.assertEqual(copied_objects, device_group.Group) + self.assertEqual([], copied_objects[0].Links) + self.assertIsNone(doc.getObject("Compound")) + self.assertIsNone(doc.getObject("Solid")) + self.assertIsNone(doc.getObject("Solid001")) + + def test_fcstd_import_restores_target_document_after_closing_source_doc(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + source.addObject("Part::Feature", "Body") + + doc = FakeDocument("QETScene") + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertIs(app.ActiveDocument, doc) + self.assertIn("QETScene", app.set_active_document_calls) + + def test_non_fcstd_import_clears_stored_template_slots_from_previous_fcstd(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + + doc = FakeDocument("QETScene") + device_group = doc.addObject("App::Part", "QETDevice_breaker") + device_group.addProperty( + "App::PropertyString", + "QetTemplateSlotsJson", + "QET Exchange", + "", + ) + device_group.QetTemplateSlotsJson = ( + '{"terminal_slots":[{"name":"D1","label":"D1","base":[1,2,3]}]}' + ) + + device_import, template_semantics = _reload_modules() + + def insert_step_body(name, docName, merge, useLinkGroup): + doc.addObject("Part::Feature", "StepBody") + + device_import.ImportGui.insert = insert_step_body + + copied_objects = device_import._import_model_into_group( + doc, + device_group, + r"D:\models\breaker.step", + ) + + self.assertEqual(["StepBody"], [obj.Name for obj in copied_objects]) + self.assertEqual("", device_group.QetTemplateSlotsJson) + self.assertEqual([], template_semantics.collect_terminal_hints(device_group)) + + def test_fcstd_import_detaches_removed_template_lcs_from_parent_group(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + body = source.addObject("Part::Feature", "Body") + terminal = source.addObject("Part::LocalCoordinateSystem", "Terminal_D1") + terminal.Placement = app.Placement(app.Vector(1, 2, 3), app.Rotation()) + terminal.addProperty("App::PropertyString", "Role", "QET Template", "") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + terminal.QetTemplateSlotName = "D1" + + doc = FakeDocument("QETScene", detach_on_remove=False) + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual([body.Name], [obj.Name for obj in copied_objects]) + self.assertEqual(["Body"], [obj.Name for obj in device_group.Group]) + + def test_fcstd_import_does_not_revisit_deleted_origin_features(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + body = source.addObject("Part::Feature", "Body") + terminal = source.addObject("Part::LocalCoordinateSystem", "Terminal_D1") + terminal.Placement = app.Placement(app.Vector(1, 2, 3), app.Rotation()) + terminal.addProperty("App::PropertyString", "Role", "QET Template", "") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + terminal.QetTemplateSlotName = "D1" + + x_axis = source.addObject("App::Line", "Terminal_D1_XAxis") + terminal.OriginFeatures = [x_axis] + terminal.addObject(x_axis) + + doc = FakeDocument("QETScene", raise_group_when_deleted=True) + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual([body.Name], [obj.Name for obj in copied_objects]) + self.assertEqual(["Body"], [obj.Name for obj in device_group.Group]) + + def test_fcstd_import_does_not_touch_inlist_after_origin_feature_deleted(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + body = source.addObject("Part::Feature", "Body") + terminal = source.addObject("Part::LocalCoordinateSystem", "Terminal_D1") + terminal.Placement = app.Placement(app.Vector(1, 2, 3), app.Rotation()) + terminal.addProperty("App::PropertyString", "Role", "QET Template", "") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + terminal.QetTemplateSlotName = "D1" + + x_axis = source.addObject("App::Line", "Terminal_D1_XAxis") + terminal.OriginFeatures = [x_axis] + terminal.addObject(x_axis) + + doc = FakeDocument("QETScene", raise_inlist_when_deleted=True) + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual([body.Name], [obj.Name for obj in copied_objects]) + self.assertEqual(["Body"], [obj.Name for obj in device_group.Group]) + + def test_fcstd_import_does_not_read_name_from_deleted_template_lcs_when_returning_objects(self): + source = FakeDocument("Source", r"D:\models\breaker.FCStd") + _install_fake_freecad(source) + app = sys.modules["FreeCAD"] + + body = source.addObject("Part::Feature", "Body") + terminal = source.addObject("Part::LocalCoordinateSystem", "Terminal_D1") + terminal.Placement = app.Placement(app.Vector(1, 2, 3), app.Rotation()) + terminal.addProperty("App::PropertyString", "Role", "QET Template", "") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + terminal.QetTemplateSlotName = "D1" + + doc = FakeDocument("QETScene", raise_name_when_deleted=True) + device_group = doc.addObject("App::Part", "QETDevice_breaker") + + device_import, _ = _reload_modules() + + copied_objects = device_import._import_fcstd_into_group( + doc, + device_group, + source.FileName, + ) + + self.assertEqual([body.Name], [obj.Name for obj in copied_objects]) + self.assertEqual(["Body"], [obj.Name for obj in device_group.Group]) + + +if __name__ == "__main__": + unittest.main()