feat: support editing manual 3d wires

dev
Zhaowenlong 4 weeks ago
parent 8b01a4504b
commit e8b4f9b9eb

@ -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()

@ -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()

@ -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()

Loading…
Cancel
Save