diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 5423e9f..d8ee2e0 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -598,4 +598,5 @@ ManualWiring.py - 2026-05-20:修复 `TemplateAuthoring.py` 在 `FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。 - 2026-05-20:确定方案 2 开发目标:新增“设备模板端子制作”任务面板,让 CAD 工作人员通过输入端子名、选择模型位置、点击按钮完成添加端子、校验端子和保存 FCStd,不再依赖 Python 控制台。 - 2026-05-20:新增 `TemplateAuthoringPanel.py`,提供“设备模板端子制作”任务面板和 `QET_Template_OpenAuthoringPanel` 命令;面板支持输入端子名、添加端子、校验端子、保存 FCStd,并已同步到运行目录验证模块可导入。 +- 2026-05-21:新增手动布线结果回写能力,`3d_to_2d.json` 现在会输出 `manual_wires`,包含起止端子 UUID、起止设备实例 ID、路线类型和 3D 路径点;已用单元测试验证,待 QET 侧决定是否消费该可选字段。 ``` diff --git a/src/Mod/FreeCADExchange/ExchangeWriteBack.py b/src/Mod/FreeCADExchange/ExchangeWriteBack.py index 2e2f28d..26bb80b 100644 --- a/src/Mod/FreeCADExchange/ExchangeWriteBack.py +++ b/src/Mod/FreeCADExchange/ExchangeWriteBack.py @@ -79,6 +79,44 @@ def _iter_terminal_objects(device_group): return TerminalObjects.collect_terminal_objects(terminal_container) +def _iter_wire_objects(device_group): + wire_container = TerminalObjects.find_child_group_by_kind( + device_group, + TerminalObjects.WIRE_GROUP_KIND, + ) + if wire_container is None: + return [] + + result = [] + + def walk(obj): + if obj is None: + return + for child in list(getattr(obj, "Group", []) or []): + if _is_manual_wire_object(child): + result.append(child) + continue + try: + if child.isDerivedFrom("App::DocumentObjectGroup"): + walk(child) + except Exception: + pass + + walk(wire_container) + return result + + +def _is_manual_wire_object(obj): + if obj is None: + return False + properties = getattr(obj, "PropertiesList", []) + if "QetStartTerminalUuid" not in properties: + return False + if "QetEndTerminalUuid" not in properties: + return False + return (getattr(obj, "RouteType", "") or "").strip() == "Manual" + + def _scene_path_from_doc(doc, scene_path=""): candidate = (scene_path or "").strip() if candidate: @@ -158,6 +196,70 @@ def _collect_terminal_bindings(doc): return bindings +def _point_from_vector(vector): + return { + "x": float(getattr(vector, "x", 0.0)), + "y": float(getattr(vector, "y", 0.0)), + "z": float(getattr(vector, "z", 0.0)), + } + + +def _wire_shape_points(wire_obj): + shape = getattr(wire_obj, "Shape", None) + if shape is None: + return [] + + if isinstance(shape, (list, tuple)): + return [_point_from_vector(point) for point in shape] + + vertices = getattr(shape, "Vertexes", None) + if vertices: + points = [] + for vertex in vertices: + point = getattr(vertex, "Point", None) + if point is not None: + points.append(_point_from_vector(point)) + return points + + return [] + + +def _collect_manual_wires(doc): + wires = [] + seen = set() + for device_group in _iter_device_groups(doc): + for wire_obj in _iter_wire_objects(device_group): + start_terminal_uuid = getattr(wire_obj, "QetStartTerminalUuid", "").strip() + end_terminal_uuid = getattr(wire_obj, "QetEndTerminalUuid", "").strip() + start_instance_id = getattr(wire_obj, "QetStartInstanceId", "").strip() + end_instance_id = getattr(wire_obj, "QetEndInstanceId", "").strip() + route_type = (getattr(wire_obj, "RouteType", "") or "").strip() or "Manual" + if not start_terminal_uuid or not end_terminal_uuid: + continue + + key = ( + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + getattr(wire_obj, "Name", ""), + ) + if key in seen: + continue + seen.add(key) + wires.append( + { + "start_terminal_uuid": start_terminal_uuid, + "end_terminal_uuid": end_terminal_uuid, + "start_instance_id": start_instance_id, + "end_instance_id": end_instance_id, + "route_type": route_type, + "points": _wire_shape_points(wire_obj), + } + ) + return wires + + def _project_uuid_from_doc(doc, payload=None): root = _root_group(doc) if root is not None: @@ -192,6 +294,7 @@ def write_back_document(doc=None, scene_path="", payload=None): "generated_at": _format_timestamp(), "instances": _collect_instance_bindings(doc), "terminals": _collect_terminal_bindings(doc), + "manual_wires": _collect_manual_wires(doc), "output_path": output_path, } @@ -205,6 +308,7 @@ def write_back_document(doc=None, scene_path="", payload=None): "generated_at": report["generated_at"], "instances": report["instances"], "terminals": report["terminals"], + "manual_wires": report["manual_wires"], }, ensure_ascii=False, indent=2, @@ -213,9 +317,10 @@ def write_back_document(doc=None, scene_path="", payload=None): ) _append_debug_log( - "write_back_document completed: instances={0}, terminals={1}, path={2}".format( + "write_back_document completed: instances={0}, terminals={1}, manual_wires={2}, path={3}".format( len(report["instances"]), len(report["terminals"]), + len(report["manual_wires"]), output_path, ) ) @@ -287,9 +392,10 @@ class CommandWriteBack: report = write_back_document(App.ActiveDocument) try: App.Console.PrintMessage( - "[FreeCADExchange] Write-back completed: {0} instances, {1} terminals\n".format( + "[FreeCADExchange] Write-back completed: {0} instances, {1} terminals, {2} manual wires\n".format( len(report["instances"]), len(report["terminals"]), + len(report["manual_wires"]), ) ) except Exception: diff --git a/tests/python/freecad_exchange_writeback_test.py b/tests/python/freecad_exchange_writeback_test.py new file mode 100644 index 0000000..20b0466 --- /dev/null +++ b/tests/python/freecad_exchange_writeback_test.py @@ -0,0 +1,158 @@ +import importlib +import json +import sys +import tempfile +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) + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + fake_freecad.addDocumentObserver = lambda observer: None + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + 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 preferred_name="": None + sys.modules["DevicePreview"] = fake_device_preview + + +class FakeObject: + def __init__(self, name, type_id="App::DocumentObjectGroup"): + self.Name = name + self.Label = name + self.TypeId = type_id + self.PropertiesList = [] + self.Group = [] + self.InList = [] + + 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"} + 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.FileName = "" + 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 _reload_writeback(): + for name in ["DeviceImport", "TerminalObjects", "ExchangeWriteBack"]: + sys.modules.pop(name, None) + return importlib.import_module("ExchangeWriteBack"), importlib.import_module("TerminalObjects") + + +class ExchangeWriteBackManualWireTest(unittest.TestCase): + def test_write_back_includes_manual_wires_with_route_points(self): + _install_fake_freecad() + exchange_writeback, terminal_objects = _reload_writeback() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_device_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-1") + + wire_group = terminal_objects.ensure_wire_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-1", + ) + wire = doc.addObject("Part::Feature", "QETWire_terminal_1_terminal_2") + wire_group.addObject(wire) + terminal_objects.ensure_string_property(wire, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_objects.ensure_string_property(wire, "QetStartTerminalUuid", "QET Exchange", "", "terminal-1") + terminal_objects.ensure_string_property(wire, "QetEndTerminalUuid", "QET Exchange", "", "terminal-2") + terminal_objects.ensure_string_property(wire, "QetStartInstanceId", "QET Exchange", "", "instance-1") + terminal_objects.ensure_string_property(wire, "QetEndInstanceId", "QET Exchange", "", "instance-2") + terminal_objects.ensure_string_property(wire, "RouteType", "QET Exchange", "", "Manual") + wire.Shape = ( + app.Vector(1, 2, 3), + app.Vector(4, 5, 6), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + scene_path = str(Path(temp_dir) / "scene.FCStd") + report = exchange_writeback.write_back_document( + doc, + scene_path=scene_path, + payload={"project_uuid": "project-1"}, + ) + payload = json.loads(Path(report["output_path"]).read_text(encoding="utf-8")) + + self.assertEqual(1, len(report["manual_wires"])) + self.assertEqual(1, len(payload["manual_wires"])) + self.assertEqual( + { + "start_terminal_uuid": "terminal-1", + "end_terminal_uuid": "terminal-2", + "start_instance_id": "instance-1", + "end_instance_id": "instance-2", + "route_type": "Manual", + "points": [ + {"x": 1.0, "y": 2.0, "z": 3.0}, + {"x": 4.0, "y": 5.0, "z": 6.0}, + ], + }, + payload["manual_wires"][0], + ) + + +if __name__ == "__main__": + unittest.main()