From e8b4f9b9eb2df3cd018e426f02630397881796b4 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Thu, 28 May 2026 11:34:45 +0800 Subject: [PATCH] feat: support editing manual 3d wires --- src/Mod/FreeCADExchange/ManualWiring.py | 81 +++++++ src/Mod/FreeCADExchange/ManualWiringPanel.py | 219 +++++++++++++++++- ...eecad_exchange_manual_wiring_panel_test.py | 144 ++++++++++++ 3 files changed, 433 insertions(+), 11 deletions(-) diff --git a/src/Mod/FreeCADExchange/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py index 4f17bd5..999732e 100644 --- a/src/Mod/FreeCADExchange/ManualWiring.py +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -34,6 +34,12 @@ def _append_debug_log(message): def _vector_from_point(point): if isinstance(point, App.Vector): return point + if isinstance(point, dict) and {"x", "y", "z"}.issubset(set(point.keys())): + return App.Vector( + float(point.get("x", 0.0)), + float(point.get("y", 0.0)), + float(point.get("z", 0.0)), + ) if isinstance(point, (list, tuple)) and len(point) >= 3: return App.Vector(float(point[0]), float(point[1]), float(point[2])) return None @@ -684,6 +690,15 @@ def _set_wire_points(obj, points): pass +def _refresh_wire_shape(obj, points): + try: + import Part + + obj.Shape = Part.makePolygon(points) + except Exception: + pass + + def _create_wire_geometry(doc, wire_name, points): if getattr(App, "ActiveDocument", None) is doc: try: @@ -711,6 +726,72 @@ def _create_wire_geometry(doc, wire_name, points): return wire_obj +def update_manual_wire( + doc, + wire_obj, + start_terminal, + end_terminal, + waypoints=None, + terminal_exit_length=None, +): + if doc is None: + raise ManualWiringError("No active FreeCAD document is available.") + if not WiringObjects.is_routed_wire_object(wire_obj): + raise ManualWiringError("请选择一条已布好的 QET 导线。") + if not TerminalObjects.is_terminal_object(start_terminal): + raise ManualWiringError("The start terminal is not valid.") + if not TerminalObjects.is_terminal_object(end_terminal): + raise ManualWiringError("The end terminal is not valid.") + + if terminal_exit_length is None: + terminal_exit_length = float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0) + + points, normalized_waypoints = _terminal_points( + start_terminal, + end_terminal, + waypoints=waypoints, + terminal_exit_length=terminal_exit_length, + ) + route_nodes = _manual_route_nodes( + start_terminal, + end_terminal, + normalized_waypoints=normalized_waypoints, + terminal_exit_length=terminal_exit_length, + ) + if len(points) < 2: + raise ManualWiringError("A wire requires at least two points.") + + _set_wire_points(wire_obj, points) + _refresh_wire_shape(wire_obj, points) + _set_wire_properties( + wire_obj, + getattr(wire_obj, "QetProjectUuid", "").strip() + or getattr(start_terminal, "QetProjectUuid", "").strip() + or getattr(end_terminal, "QetProjectUuid", "").strip(), + start_terminal, + end_terminal, + wire_uuid=getattr(wire_obj, "QetWireUuid", "").strip(), + wire_label=getattr(wire_obj, "QetWireLabel", "").strip(), + net_uuid=getattr(wire_obj, "QetNetUuid", "").strip(), + group_uuid=getattr(wire_obj, "QetGroupUuid", "").strip(), + wire_mark=getattr(wire_obj, "QetWireMark", "").strip(), + wire_mark_is_manual=bool(getattr(wire_obj, "QetWireMarkIsManual", False)), + manual_waypoints=normalized_waypoints, + route_nodes=route_nodes, + terminal_exit_length=terminal_exit_length, + ) + + try: + wire_obj.ViewObject.Visibility = True + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + return wire_obj + + def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None): for terminal in (start_terminal, end_terminal): element_uuid = getattr(terminal, "QetElementUuid", "").strip() diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index 53ed966..a9ab6a7 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -1,5 +1,6 @@ # FreeCADExchange GUI panel for guided manual 3D wiring. +import json from pathlib import Path import FreeCAD as App @@ -183,7 +184,9 @@ def _iter_terminal_objects(doc): except Exception: root = None if root is not None: - return TerminalObjects.collect_terminal_objects(root) + terminals = TerminalObjects.collect_terminal_objects(root) + if terminals: + return terminals return [ obj for obj in list(getattr(doc, "Objects", []) or []) @@ -619,6 +622,74 @@ def _task_wire_kwargs(task): } +def _json_array_value(text): + if not text: + return [] + try: + value = json.loads(text) + except Exception: + return [] + if isinstance(value, list): + return value + return [] + + +def _selected_routed_wire(): + for obj in _selection(): + if WiringObjects.is_routed_wire_object(obj): + return obj + for picked in _selection_ex(): + obj = getattr(picked, "Object", None) + if WiringObjects.is_routed_wire_object(obj): + return obj + return None + + +def _editable_waypoints_from_wire(wire_obj): + payload = _json_array_value(getattr(wire_obj, "QetManualWaypointsJson", "")) + if not payload: + payload = [ + item + for item in _json_array_value(getattr(wire_obj, "QetRouteNodesJson", "")) + if isinstance(item, dict) and item.get("role") == "waypoint" + ] + + waypoints = [] + for item in payload: + waypoint = ManualWiring._coerce_waypoint(item) + if waypoint is not None: + waypoints.append(waypoint) + return waypoints + + +def _open_transaction(doc, name): + try: + if hasattr(doc, "openTransaction"): + doc.openTransaction(name) + return True + except Exception: + pass + return False + + +def _commit_transaction(doc, opened): + if not opened: + return + try: + doc.commitTransaction() + except Exception: + pass + + +def _abort_transaction(doc, opened): + if not opened: + return + try: + doc.abortTransaction() + except Exception: + pass + + class ManualWiringController: def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH): self.terminal_exit_length = float(terminal_exit_length or 0.0) @@ -627,6 +698,7 @@ class ManualWiringController: self.waypoints = [] self.preview_objects = [] self.last_wire = None + self.editing_wire = None def set_terminal_exit_length(self, value): self.terminal_exit_length = max(float(value or 0.0), 0.0) @@ -740,8 +812,17 @@ class ManualWiringController: self.start_terminal = None self.waypoints = [] self.last_wire = None + self.editing_wire = None self._clear_preview_objects() + def _create_waypoint_previews(self): + self._clear_preview_objects() + doc = _active_document() + for index, waypoint in enumerate(self.waypoints, start=1): + preview = _create_preview_point(doc, waypoint, index) + if preview is not None: + self.preview_objects.append(preview) + def available_tasks(self): doc = _active_document() try: @@ -805,6 +886,71 @@ class ManualWiringController: _remove_preview_object(getattr(App, "ActiveDocument", None), preview) return waypoint + def load_selected_wire_for_edit(self): + doc = _active_document() + wire = _selected_routed_wire() + if wire is None: + raise ManualWiringPanelError("请先选择一条已布好的 QET 导线。") + + start_terminal = _find_terminal_by_uuid( + doc, + getattr(wire, "QetStartTerminalUuid", ""), + ) + end_terminal = _find_terminal_by_uuid( + doc, + getattr(wire, "QetEndTerminalUuid", ""), + ) + if start_terminal is None or end_terminal is None: + raise ManualWiringPanelError("已布导线的起点或终点工程端子未找到。") + + self._reset_route_state() + self.editing_wire = wire + self.last_wire = wire + self.start_terminal = start_terminal + self.waypoints = _editable_waypoints_from_wire(wire) + self.terminal_exit_length = float(getattr(wire, "QetTerminalExitLength", 0.0) or 0.0) + self._create_waypoint_previews() + return wire + + def update_loaded_wire(self): + doc = _active_document() + if self.editing_wire is None: + raise ManualWiringPanelError("请先选择已布导线并点击“载入选中导线”。") + + end_terminal = _find_terminal_by_uuid( + doc, + getattr(self.editing_wire, "QetEndTerminalUuid", ""), + ) + if self.start_terminal is None or end_terminal is None: + raise ManualWiringPanelError("已布导线的起点或终点工程端子未找到。") + + opened = _open_transaction(doc, "修改手动导线") + try: + wire = ManualWiring.update_manual_wire( + doc, + self.editing_wire, + self.start_terminal, + end_terminal, + waypoints=list(self.waypoints), + terminal_exit_length=self.terminal_exit_length, + ) + _commit_transaction(doc, opened) + except Exception: + _abort_transaction(doc, opened) + raise + self.last_wire = wire + return wire + + def undo_last_change(self): + doc = _active_document() + if hasattr(doc, "undo"): + doc.undo() + return True + if Gui is not None and hasattr(Gui, "runCommand"): + Gui.runCommand("Std_Undo") + return True + raise ManualWiringPanelError("当前 FreeCAD 文档不支持撤销。") + def set_end_from_selection_and_generate(self): doc = _active_document() if self.start_terminal is None: @@ -823,17 +969,24 @@ class ManualWiringController: if end_terminal is None: raise ManualWiringPanelError("请先选择一个工程端子,再点击“设为终点并生成”。") - wire = ManualWiring.create_manual_wire( - doc, - self.start_terminal, - end_terminal, - waypoints=list(self.waypoints), - terminal_exit_length=self.terminal_exit_length, - **wire_kwargs, - ) + opened = _open_transaction(doc, "生成手动导线") + try: + wire = ManualWiring.create_manual_wire( + doc, + self.start_terminal, + end_terminal, + waypoints=list(self.waypoints), + terminal_exit_length=self.terminal_exit_length, + **wire_kwargs, + ) + self.last_wire = wire + if self.current_task is not None: + _set_task_route_status(self.current_task, "Routed") + _commit_transaction(doc, opened) + except Exception: + _abort_transaction(doc, opened) + raise self.last_wire = wire - if self.current_task is not None: - _set_task_route_status(self.current_task, "Routed") return wire def diagnose_last_wire(self): @@ -870,6 +1023,11 @@ class ManualWiringController: wire_text = "未生成" if self.last_wire is not None: wire_text = getattr(self.last_wire, "Label", "") or getattr(self.last_wire, "Name", "") + if self.editing_wire is not None: + wire_text = "编辑中:" + ( + getattr(self.editing_wire, "Label", "") + or getattr(self.editing_wire, "Name", "") + ) waypoint_text = "无" if self.waypoints: waypoint_text = ";".join( @@ -924,6 +1082,9 @@ class ManualWiringTaskPanel: self.waypoint_button = QtWidgets.QPushButton("添加折点") self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点") self.end_button = QtWidgets.QPushButton("设为终点并生成") + self.load_wire_button = QtWidgets.QPushButton("载入选中导线") + self.update_wire_button = QtWidgets.QPushButton("更新已布导线") + self.undo_button = QtWidgets.QPushButton("撤销上次修改") self.diagnose_button = QtWidgets.QPushButton("检查最近导线") self.diagnose_all_button = QtWidgets.QPushButton("检查全部导线") self.clear_button = QtWidgets.QPushButton("清除草稿") @@ -954,6 +1115,11 @@ class ManualWiringTaskPanel: layout.addWidget(self.waypoint_button) layout.addWidget(self.delete_waypoint_button) layout.addWidget(self.end_button) + edit_layout = QtWidgets.QHBoxLayout() + edit_layout.addWidget(self.load_wire_button) + edit_layout.addWidget(self.update_wire_button) + edit_layout.addWidget(self.undo_button) + layout.addLayout(edit_layout) layout.addWidget(self.diagnose_button) layout.addWidget(self.diagnose_all_button) layout.addWidget(self.clear_button) @@ -980,6 +1146,9 @@ class ManualWiringTaskPanel: self.waypoint_button.clicked.connect(self.add_waypoint) self.delete_waypoint_button.clicked.connect(self.delete_last_waypoint) self.end_button.clicked.connect(self.set_end_and_generate) + self.load_wire_button.clicked.connect(self.load_selected_wire) + self.update_wire_button.clicked.connect(self.update_loaded_wire) + self.undo_button.clicked.connect(self.undo_last_change) self.diagnose_button.clicked.connect(self.diagnose_last_wire) self.diagnose_all_button.clicked.connect(self.diagnose_all_wires) self.clear_button.clicked.connect(self.clear) @@ -1169,6 +1338,34 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def load_selected_wire(self): + try: + wire = self.controller.load_selected_wire_for_edit() + self.exit_length_input.setValue(self.controller.terminal_exit_length) + self._refresh_waypoint_list() + self._set_status("已载入导线:{0}".format(getattr(wire, "Label", "") or getattr(wire, "Name", ""))) + except Exception as exc: + self._set_error(str(exc)) + + def update_loaded_wire(self): + try: + try: + self.controller.set_terminal_exit_length(self.exit_length_input.value()) + except Exception: + pass + wire = self.controller.update_loaded_wire() + self._refresh_waypoint_list() + self._set_status("已更新导线:{0}".format(getattr(wire, "Label", "") or getattr(wire, "Name", ""))) + except Exception as exc: + self._set_error(str(exc)) + + def undo_last_change(self): + try: + self.controller.undo_last_change() + self._set_status("已撤销上次布线修改。") + except Exception as exc: + self._set_error(str(exc)) + def diagnose_last_wire(self): try: diagnostics = self.controller.diagnose_last_wire() diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index ef41f95..e3341b3 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -1,4 +1,5 @@ import importlib +import json import sys import types import unittest @@ -149,6 +150,7 @@ class FakeDocument: def __init__(self): self.Objects = [] self.Name = "FakeDoc" + self.transactions = [] def addObject(self, type_name, name): obj = FakeObject(name, type_name) @@ -167,6 +169,18 @@ class FakeDocument: def recompute(self): return None + def openTransaction(self, name): + self.transactions.append(("open", name)) + + def commitTransaction(self): + self.transactions.append(("commit", "")) + + def abortTransaction(self): + self.transactions.append(("abort", "")) + + def undo(self): + self.transactions.append(("undo", "")) + def _reload_modules(): for name in [ @@ -767,6 +781,136 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertIs(correct_start, controller.start_terminal) self.assertIs(correct_end, panel._find_terminal_by_uuid(doc, "terminal-reused", element_uuid="device-c")) + def test_controller_loads_selected_routed_wire_for_edit(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + manual_wiring = importlib.import_module("ManualWiring") + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + 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(100, 0, 0), app.Rotation()) + 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, + waypoints=[{"point": app.Vector(40, 20, 0), "support_axis": "z"}], + terminal_exit_length=5.0, + wire_uuid="wire-1", + wire_label="W001", + ) + + selection_state["selection"] = [wire] + controller = panel.ManualWiringController() + + loaded = controller.load_selected_wire_for_edit() + + self.assertIs(wire, loaded) + self.assertIs(wire, controller.editing_wire) + self.assertIs(start_terminal, controller.start_terminal) + self.assertEqual(5.0, controller.terminal_exit_length) + self.assertEqual(1, len(controller.waypoints)) + self.assertEqual( + (40.0, 20.0, 0.0), + ( + controller.waypoints[0]["point"].x, + controller.waypoints[0]["point"].y, + controller.waypoints[0]["point"].z, + ), + ) + self.assertEqual(1, len(controller.preview_objects)) + + def test_controller_updates_loaded_wire_in_transaction(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + manual_wiring = importlib.import_module("ManualWiring") + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + 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(100, 0, 0), app.Rotation()) + 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, + waypoints=[{"point": app.Vector(40, 20, 0)}], + wire_uuid="wire-1", + wire_label="W001", + ) + + selection_state["selection"] = [wire] + controller = panel.ManualWiringController() + controller.load_selected_wire_for_edit() + controller.waypoints = [{"point": app.Vector(70, 30, 10), "support_axis": "x"}] + + updated = controller.update_loaded_wire() + + routed_group = doc.getObject("QETWiring_04_Routed") + self.assertIs(wire, updated) + self.assertEqual([wire], routed_group.Group) + self.assertTrue( + any(point.x == 70.0 and point.y == 30.0 and point.z == 10.0 for point in wire.Points) + ) + waypoints = json.loads(getattr(wire, "QetManualWaypointsJson", "[]")) + self.assertEqual("x", waypoints[0]["support_axis"]) + self.assertEqual( + [("open", "修改手动导线"), ("commit", "")], + doc.transactions[-2:], + ) + + def test_controller_undo_last_change_uses_document_undo(self): + _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") + + panel.ManualWiringController().undo_last_change() + + self.assertEqual(("undo", ""), doc.transactions[-1]) + if __name__ == "__main__": unittest.main()