From 14f7bae6bd9bc339f4e4b02ec27700bd1364fdc0 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Thu, 21 May 2026 11:19:05 +0800 Subject: [PATCH] =?UTF-8?q?feature/FreeCAD=E5=B7=A5=E7=A8=8B=E7=AB=AF?= =?UTF-8?q?=E5=AD=90=E4=B8=8E=E6=89=8B=E5=8A=A8=E5=B8=83=E7=BA=BF-zwl-0521?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/InitGui.py | 14 + src/Mod/FreeCADExchange/ManualWiring.py | 4 +- .../FreeCADExchange/TemplateAuthoringPanel.py | 3 + .../FreeCADExchange/TemplateInstantiation.py | 370 ++++++++++++++++++ src/Mod/FreeCADExchange/TemplateSemantics.py | 6 +- src/Mod/FreeCADExchange/TerminalObjects.py | 45 ++- .../freecad_exchange_manual_wiring_test.py | 75 +++- ..._exchange_template_authoring_panel_test.py | 10 +- ...ad_exchange_template_instantiation_test.py | 168 ++++++++ ...reecad_exchange_template_semantics_test.py | 21 + 11 files changed, 707 insertions(+), 10 deletions(-) create mode 100644 src/Mod/FreeCADExchange/TemplateInstantiation.py create mode 100644 tests/python/freecad_exchange_template_instantiation_test.py diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index f9684bd..bdcf78b 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -9,6 +9,7 @@ set(FreeCADExchange_Scripts TemplateSemantics.py TemplateAuthoring.py TemplateAuthoringPanel.py + TemplateInstantiation.py TerminalImport.py ExchangeWriteBack.py ManualWiring.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 67e9b7f..3517ea1 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -12,6 +12,9 @@ COMMANDS = [ "QET_Template_AddTerminal", "QET_Template_ValidateTerminals", "QET_Template_SaveAsFCStd", + "QET_Template_ImportInstance", + "QET_Template_CreateEngineeringTerminals", + "QET_Exchange_CreateManualWire", ] @@ -62,6 +65,7 @@ def _register_exchange_commands( manual_wiring = safe_import("ManualWiring") template_authoring = safe_import("TemplateAuthoring") template_authoring_panel = safe_import("TemplateAuthoringPanel") + template_instantiation = safe_import("TemplateInstantiation") try: if exchange_write_back is not None: @@ -113,6 +117,16 @@ def _register_exchange_commands( ) ) + try: + if template_instantiation is not None: + template_instantiation.register_commands() + except Exception: + append_init_log( + "InitGui failed to register template instantiation commands:\n{0}".format( + traceback_module.format_exc() + ) + ) + def _bootstrap_if_requested( safe_import=_safe_import, diff --git a/src/Mod/FreeCADExchange/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py index 7860965..10a0f3d 100644 --- a/src/Mod/FreeCADExchange/ManualWiring.py +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -177,8 +177,8 @@ def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent class CommandCreateManualWire: def GetResources(self): return { - "MenuText": "Create Manual Wire", - "ToolTip": "Create a manual wire between two selected terminals", + "MenuText": "连接选中端子", + "ToolTip": "在两个选中的工程端子之间创建手动导线", } def IsActive(self): diff --git a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py index 81efca9..f8805bf 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py @@ -235,6 +235,7 @@ class CommandOpenTemplateAuthoringPanel: _COMMANDS_REGISTERED = False _MENU_ACTION_INSTALLED = False _TOOLBAR_ACTION_INSTALLED = False +INSTALL_GLOBAL_ACTIONS = False def _tools_menu(): @@ -320,6 +321,8 @@ def register_commands(): if not _COMMANDS_REGISTERED: Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel()) _COMMANDS_REGISTERED = True + if not INSTALL_GLOBAL_ACTIONS: + return try: install_menu_action() except RuntimeError as exc: diff --git a/src/Mod/FreeCADExchange/TemplateInstantiation.py b/src/Mod/FreeCADExchange/TemplateInstantiation.py new file mode 100644 index 0000000..de4a86b --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateInstantiation.py @@ -0,0 +1,370 @@ +# FreeCADExchange FCStd template instantiation helpers. + +import os +from pathlib import Path +import uuid + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +try: + from PySide6 import QtWidgets +except ImportError: + try: + from PySide2 import QtWidgets + except ImportError: + try: + from PySide import QtGui as QtWidgets + except ImportError: + QtWidgets = None + +import DeviceImport +import TemplateSemantics +import TerminalObjects + + +COMMAND_IMPORT_TEMPLATE_INSTANCE = "QET_Template_ImportInstance" +COMMAND_CREATE_ENGINEERING_TERMINALS = "QET_Template_CreateEngineeringTerminals" + + +class TemplateInstantiationError(RuntimeError): + pass + + +def _debug(message): + try: + DeviceImport._append_debug_log(message) + except Exception: + pass + + +def _project_uuid_for_document(doc): + root = TerminalObjects.ensure_root_group(doc) + project_uuid = getattr(root, "QetProjectUuid", "").strip() + if project_uuid: + return project_uuid + project_uuid = "local-project" + TerminalObjects.ensure_string_property( + root, + "QetProjectUuid", + "QET Exchange", + "Project UUID for the exchange document", + project_uuid, + ) + return project_uuid + + +def _local_terminal_uuid(instance_id, element_uuid, slot_name): + owner = (instance_id or "").strip() or (element_uuid or "").strip() or "device" + slot = (slot_name or "").strip() or "slot" + return "local:{0}:{1}".format(owner, slot) + + +def _existing_terminal_by_slot(terminal_group): + result = {} + for obj in TerminalObjects.collect_terminal_objects(terminal_group): + slot_name = getattr(obj, "QetTemplateSlotName", "").strip() + if slot_name and slot_name not in result: + result[slot_name] = obj + return result + + +def _terminal_group_for_device(doc, device_group, project_uuid): + instance_id = getattr(device_group, "QetInstanceId", "").strip() + return TerminalObjects.ensure_terminal_group( + doc, + device_group, + project_uuid=project_uuid, + instance_id=instance_id, + ) + + +def _slot_label(slot, fallback): + return (slot.get("label") or slot.get("name") or fallback or "QET Terminal").strip() + + +def _slot_placement(slot): + base = slot.get("base") + if not isinstance(base, App.Vector): + base = App.Vector(0, 0, 0) + + rotation = App.Rotation() + rotation_value = slot.get("rotation") + if isinstance(rotation_value, dict): + axis = rotation_value.get("axis") + angle = rotation_value.get("angle") + if isinstance(axis, App.Vector) and angle is not None: + try: + rotation = App.Rotation(axis, float(angle)) + except Exception: + rotation = App.Rotation() + return App.Placement(base, rotation) + + +def _iter_device_groups(doc): + result = [] + for obj in list(getattr(doc, "Objects", []) or []): + if getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX): + if "QetElementUuid" in getattr(obj, "PropertiesList", []): + result.append(obj) + return result + + +def ensure_engineering_terminals_for_device(doc, device_group): + if doc is None: + raise TemplateInstantiationError("A FreeCAD document is required.") + if device_group is None: + raise TemplateInstantiationError("A device group is required.") + + project_uuid = ( + getattr(device_group, "QetProjectUuid", "").strip() + or _project_uuid_for_document(doc) + ) + element_uuid = getattr(device_group, "QetElementUuid", "").strip() + instance_id = getattr(device_group, "QetInstanceId", "").strip() + model_path = getattr(device_group, "QetResolvedModelPath", "").strip() + + slots = TemplateSemantics.collect_terminal_hints(device_group) + terminal_group = _terminal_group_for_device(doc, device_group, project_uuid) + existing_by_slot = _existing_terminal_by_slot(terminal_group) + + report = { + "device": getattr(device_group, "Name", ""), + "slots": len(slots), + "created_terminals": 0, + "updated_terminals": 0, + "skipped_slots": 0, + } + + for index, slot in enumerate(slots): + slot_name = (slot.get("name") or "SLOT_{0}".format(index + 1)).strip() + if not slot_name: + report["skipped_slots"] += 1 + continue + + terminal_obj = existing_by_slot.get(slot_name) + if terminal_obj is None: + terminal_obj = TerminalObjects.create_lcs_object( + doc, + "QETTerminal_{0}_{1}".format( + TerminalObjects.safe_token(instance_id or element_uuid), + TerminalObjects.safe_token(slot_name), + ), + placement=_slot_placement(slot), + label=_slot_label(slot, slot_name), + ) + terminal_group.addObject(terminal_obj) + report["created_terminals"] += 1 + else: + try: + terminal_obj.Placement = _slot_placement(slot) + except Exception: + pass + report["updated_terminals"] += 1 + + terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip() + if not terminal_uuid: + terminal_uuid = _local_terminal_uuid(instance_id, element_uuid, slot_name) + + TerminalObjects.set_terminal_semantics( + terminal_obj, + project_uuid, + element_uuid, + terminal_uuid, + instance_id, + label=_slot_label(slot, slot_name), + slot_name=slot_name, + ) + try: + terminal_obj.ViewObject.Visibility = True + terminal_obj.ViewObject.ShapeColor = (0.0, 0.75, 1.0) + except Exception: + pass + + source_obj = slot.get("source_object") + if source_obj is not None and source_obj is not terminal_obj: + try: + source_obj.ViewObject.Visibility = False + except Exception: + pass + + try: + doc.recompute() + except Exception: + pass + _debug( + "TemplateInstantiation terminals: device={0}, model={1}, slots={2}, created={3}, updated={4}".format( + getattr(device_group, "Name", ""), + model_path, + report["slots"], + report["created_terminals"], + report["updated_terminals"], + ) + ) + return report + + +def ensure_engineering_terminals_for_selection_or_all(doc=None): + doc = doc or getattr(App, "ActiveDocument", None) + if doc is None: + raise TemplateInstantiationError("请先打开 FreeCAD 工程。") + + selected = [] + if Gui is not None and hasattr(Gui, "Selection"): + try: + selected = list(Gui.Selection.getSelection()) or [] + except Exception: + selected = [] + + devices = [ + obj + for obj in selected + if getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX) + ] + if not devices: + devices = _iter_device_groups(doc) + + if not devices: + raise TemplateInstantiationError("没有找到 QET 设备实例。") + + reports = [ensure_engineering_terminals_for_device(doc, device) for device in devices] + return { + "devices": len(reports), + "created_terminals": sum(item["created_terminals"] for item in reports), + "updated_terminals": sum(item["updated_terminals"] for item in reports), + "reports": reports, + } + + +def create_device_instance_from_template(doc, model_path, label=""): + if doc is None: + raise TemplateInstantiationError("A FreeCAD document is required.") + model_path = os.path.normpath(os.path.expanduser(os.path.expandvars(model_path or ""))) + if not model_path: + raise TemplateInstantiationError("请选择 FCStd 设备模板。") + if not os.path.isfile(model_path): + raise TemplateInstantiationError("FCStd 设备模板不存在:{0}".format(model_path)) + if Path(model_path).suffix.lower() != ".fcstd": + raise TemplateInstantiationError("请选择 .FCStd 设备模板。") + + project_uuid = _project_uuid_for_document(doc) + root = TerminalObjects.ensure_root_group(doc, project_uuid) + element_uuid = str(uuid.uuid4()) + instance_id = str(uuid.uuid4()) + display_label = (label or "").strip() or Path(model_path).stem + device_group, _created = DeviceImport._ensure_device_group( + doc, + root, + element_uuid, + instance_id, + model_path, + display_label, + 0, + ) + DeviceImport._clear_group_contents(doc, device_group) + DeviceImport._import_model_into_group(doc, device_group, model_path) + report = ensure_engineering_terminals_for_device(doc, device_group) + try: + doc.recompute() + except Exception: + pass + return { + "device": device_group, + "element_uuid": element_uuid, + "instance_id": instance_id, + "terminal_report": report, + } + + +class CommandImportTemplateInstance: + def GetResources(self): + return { + "MenuText": "导入模板实例", + "ToolTip": "把 FCStd 设备模板作为工程设备实例导入当前场景", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + if QtWidgets is None: + try: + App.Console.PrintError("[FreeCADExchange] Qt file dialog is not available.\n") + except Exception: + pass + return + file_path, _selected_filter = QtWidgets.QFileDialog.getOpenFileName( + None, + "选择 FCStd 设备模板", + "", + "FreeCAD template (*.FCStd *.fcstd);;All files (*.*)", + ) + if not file_path: + return + try: + result = create_device_instance_from_template(App.ActiveDocument, file_path) + try: + App.Console.PrintMessage( + "[FreeCADExchange] 已导入设备实例:{0},端子新增 {1} 个。\n".format( + getattr(result["device"], "Label", ""), + result["terminal_report"]["created_terminals"], + ) + ) + except Exception: + pass + except Exception as exc: + try: + App.Console.PrintError("[FreeCADExchange] 导入模板实例失败:{0}\n".format(exc)) + except Exception: + pass + + +class CommandCreateEngineeringTerminals: + def GetResources(self): + return { + "MenuText": "生成工程端子", + "ToolTip": "把设备模板 LCS 槽位转换为可布线工程端子", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + try: + report = ensure_engineering_terminals_for_selection_or_all(App.ActiveDocument) + try: + App.Console.PrintMessage( + "[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2} 个。\n".format( + report["devices"], + report["created_terminals"], + report["updated_terminals"], + ) + ) + except Exception: + pass + except Exception as exc: + try: + App.Console.PrintError("[FreeCADExchange] 工程端子生成失败:{0}\n".format(exc)) + except Exception: + pass + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + try: + Gui.addCommand(COMMAND_IMPORT_TEMPLATE_INSTANCE, CommandImportTemplateInstance()) + Gui.addCommand(COMMAND_CREATE_ENGINEERING_TERMINALS, CommandCreateEngineeringTerminals()) + _COMMANDS_REGISTERED = True + except Exception as exc: + _debug("failed to register template instantiation commands: {0}".format(exc)) diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py index 1e0d3b7..37b5faf 100644 --- a/src/Mod/FreeCADExchange/TemplateSemantics.py +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -186,9 +186,13 @@ def collect_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 = ( + getattr(child, "QetTemplateSlotName", "") + or getattr(child, "Name", "") + ) hint = _slot_from_payload( { - "name": getattr(child, "Name", ""), + "name": slot_name, "label": getattr(child, "Label", "") or getattr(child, "Name", ""), "base": [ getattr(child.Placement.Base, "x", 0.0), diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py index c35cf94..868f598 100644 --- a/src/Mod/FreeCADExchange/TerminalObjects.py +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -259,10 +259,53 @@ def is_terminal_object(obj): def terminal_origin(obj): + try: + if hasattr(obj, "getGlobalPlacement"): + placement = obj.getGlobalPlacement() + base = getattr(placement, "Base", None) + if base is not None: + return App.Vector(base.x, base.y, base.z) + except Exception: + pass + try: placement = getattr(obj, "Placement", None) if placement is not None: - return App.Vector(placement.Base.x, placement.Base.y, placement.Base.z) + local_point = App.Vector(placement.Base.x, placement.Base.y, placement.Base.z) + parent_chain = [] + current = obj + visited = set() + while True: + parents = list(getattr(current, "InList", []) or []) + parent = None + for candidate in parents: + if id(candidate) in visited: + continue + if getattr(candidate, "Placement", None) is not None: + parent = candidate + break + if parent is None: + break + visited.add(id(parent)) + parent_chain.append(parent) + current = parent + + point = local_point + for parent in parent_chain: + parent_placement = getattr(parent, "Placement", None) + if parent_placement is None: + continue + try: + point = parent_placement.multVec(point) + except Exception: + base = getattr(parent_placement, "Base", None) + if base is not None: + point = App.Vector( + point.x + base.x, + point.y + base.y, + point.z + base.z, + ) + return point except Exception: pass return App.Vector(0, 0, 0) diff --git a/tests/python/freecad_exchange_manual_wiring_test.py b/tests/python/freecad_exchange_manual_wiring_test.py index 082752b..1311f69 100644 --- a/tests/python/freecad_exchange_manual_wiring_test.py +++ b/tests/python/freecad_exchange_manual_wiring_test.py @@ -24,9 +24,16 @@ def _install_fake_freecad(): class Placement: def __init__(self, base=None, rotation=None): - self.Base = base + self.Base = base or Vector() self.Rotation = rotation + def multVec(self, vector): + return Vector( + self.Base.x + vector.x, + self.Base.y + vector.y, + self.Base.z + vector.z, + ) + fake_freecad = types.ModuleType("FreeCAD") fake_freecad.Vector = Vector fake_freecad.Rotation = Rotation @@ -70,6 +77,8 @@ class FakeObject: self.Group = [] self.ViewObject = FakeViewObject() self.Shape = None + self.Placement = sys.modules["FreeCAD"].Placement() + self.InList = [] def isDerivedFrom(self, type_name): if self.TypeId == type_name: @@ -87,6 +96,8 @@ class FakeObject: def addObject(self, child): if child not in self.Group: self.Group.append(child) + if self not in child.InList: + child.InList.append(self) class FakeDocument: @@ -127,6 +138,68 @@ def _reload_modules(): class ManualWiringGroupTest(unittest.TestCase): + def test_manual_wire_uses_terminal_global_points_after_device_move(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + start_device = doc.addObject("App::DocumentObjectGroup", "QETDevice_start") + start_device.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) + root.addObject(start_device) + end_device = doc.addObject("App::DocumentObjectGroup", "QETDevice_end") + end_device.Placement = app.Placement(app.Vector(300, 0, 0), app.Rotation()) + root.addObject(end_device) + + for device, element_uuid, instance_id in [ + (start_device, "device-start", "instance-start"), + (end_device, "device-end", "instance-end"), + ]: + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + element_uuid, + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + instance_id, + ) + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation()) + start_device.addObject(start_terminal) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + end_device.addObject(end_terminal) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire(doc, start_terminal, end_terminal) + + self.assertEqual(110.0, wire.Shape[0].x) + self.assertEqual(320.0, wire.Shape[-1].x) + def test_manual_wire_is_added_to_device_wire_group(self): _install_fake_freecad() device_import, manual_wiring, terminal_objects = _reload_modules() diff --git a/tests/python/freecad_exchange_template_authoring_panel_test.py b/tests/python/freecad_exchange_template_authoring_panel_test.py index 4dd9b06..3e1d997 100644 --- a/tests/python/freecad_exchange_template_authoring_panel_test.py +++ b/tests/python/freecad_exchange_template_authoring_panel_test.py @@ -49,16 +49,16 @@ def _reload_panel_module(): class TemplateAuthoringPanelTest(unittest.TestCase): - def test_register_commands_ignores_menu_install_runtime_errors(self): + def test_register_commands_does_not_touch_global_menu_or_toolbar(self): _install_fake_modules() panel_module = _reload_panel_module() panel_module._COMMANDS_REGISTERED = False - def raise_deleted_menu_error(): - raise RuntimeError("Internal C++ object already deleted") + def fail_if_called(): + raise AssertionError("global menu/toolbar installation should not run") - panel_module.install_menu_action = raise_deleted_menu_error - panel_module.install_toolbar_action = raise_deleted_menu_error + panel_module.install_menu_action = fail_if_called + panel_module.install_toolbar_action = fail_if_called panel_module.register_commands() diff --git a/tests/python/freecad_exchange_template_instantiation_test.py b/tests/python/freecad_exchange_template_instantiation_test.py new file mode 100644 index 0000000..d31aaf4 --- /dev/null +++ b/tests/python/freecad_exchange_template_instantiation_test.py @@ -0,0 +1,168 @@ +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(): + 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 + 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.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 + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = None + + +class FakeObject: + def __init__(self, name, type_id): + self.Name = name + self.Label = name + self.TypeId = type_id + self.PropertiesList = [] + self.Group = [] + self.InList = [] + self.ViewObject = FakeViewObject() + self.Placement = sys.modules["FreeCAD"].Placement() + + 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) + + +class FakeDocument: + def __init__(self): + self.Name = "QETScene" + self.Objects = [] + + def addObject(self, type_name, name): + obj = FakeObject(name, type_name) + 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): + self.Objects = [obj for obj in self.Objects if obj.Name != name] + + def recompute(self): + return None + + +def _reload_modules(): + for name in [ + "TerminalObjects", + "TemplateSemantics", + "DeviceImport", + "TemplateInstantiation", + ]: + sys.modules.pop(name, None) + return importlib.import_module("TemplateInstantiation"), importlib.import_module("TerminalObjects") + + +class TemplateInstantiationTest(unittest.TestCase): + def test_template_lcs_slots_become_engineering_terminals(self): + _install_fake_freecad() + template_instantiation, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_ct_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "ct-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "inst-1") + terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1") + + p1 = doc.addObject("Part::LocalCoordinateSystem", "P1") + p1.Placement = app.Placement(app.Vector(10, 20, 30), app.Rotation()) + p1.addProperty("App::PropertyString", "Role", "QET Template", "") + p1.Role = "Terminal" + p1.addProperty("App::PropertyBool", "CanWire", "QET Template", "") + p1.CanWire = True + p1.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + p1.QetTemplateSlotName = "P1" + device.addObject(p1) + + report = template_instantiation.ensure_engineering_terminals_for_device(doc, device) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["created_terminals"]) + self.assertEqual(1, len(terminals)) + self.assertEqual("local:inst-1:P1", terminals[0].QetTerminalUuid) + self.assertEqual("inst-1", terminals[0].QetInstanceId) + self.assertEqual("ct-1", terminals[0].QetElementUuid) + self.assertEqual("P1", terminals[0].QetTemplateSlotName) + self.assertTrue(terminals[0].CanWire) + self.assertFalse(p1.ViewObject.Visibility) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_template_semantics_test.py b/tests/python/freecad_exchange_template_semantics_test.py index bfedecf..14a34bc 100644 --- a/tests/python/freecad_exchange_template_semantics_test.py +++ b/tests/python/freecad_exchange_template_semantics_test.py @@ -68,6 +68,27 @@ def _reload_exchange_modules(): class TemplateSemanticsRotationTest(unittest.TestCase): + def test_terminal_hint_uses_template_slot_name_before_object_name(self): + _install_fake_freecad() + template_semantics, _ = _reload_exchange_modules() + + fake_lcs = types.SimpleNamespace( + Name="Terminal_P1", + Label="端子一", + TypeId="Part::LocalCoordinateSystem", + Role="Terminal", + QetTemplateSlotName="P1", + Placement=types.SimpleNamespace( + Base=sys.modules["FreeCAD"].Vector(1, 2, 3), + Rotation=sys.modules["FreeCAD"].Rotation(), + ), + ) + container = types.SimpleNamespace(Group=[fake_lcs]) + + hints = template_semantics.collect_terminal_hints(container) + + self.assertEqual("P1", hints[0]["name"]) + def test_terminal_hint_keeps_source_object_rotation(self): _install_fake_freecad() template_semantics, _ = _reload_exchange_modules()