From 0f31b9cd86a71732137f6f559be2cda484491c28 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Tue, 26 May 2026 16:57:53 +0800 Subject: [PATCH] feat: enhance manual 3d wiring diagnostics --- src/Mod/FreeCADExchange/ManualWiring.py | 360 ++++++++++++++++- src/Mod/FreeCADExchange/ManualWiringPanel.py | 184 +++++++++ src/Mod/FreeCADExchange/WiringObjects.py | 19 + ...eecad_exchange_manual_wiring_panel_test.py | 198 ++++++++++ .../freecad_exchange_manual_wiring_test.py | 370 ++++++++++++++++++ tests/python/freecad_exchange_wiring_test.py | 8 + 6 files changed, 1133 insertions(+), 6 deletions(-) diff --git a/src/Mod/FreeCADExchange/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py index 38a3b20..4f17bd5 100644 --- a/src/Mod/FreeCADExchange/ManualWiring.py +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -100,6 +100,14 @@ def _dominant_axis(vector): return axis +def _point_payload(point): + return { + "x": float(getattr(point, "x", 0.0)), + "y": float(getattr(point, "y", 0.0)), + "z": float(getattr(point, "z", 0.0)), + } + + def _coerce_waypoint(point_like): if isinstance(point_like, dict): point = _vector_from_point( @@ -129,7 +137,10 @@ def _coerce_waypoint(point_like): "point": point, "support_axis": support_axis, "anchor_kind": (point_like.get("anchor_kind", "") or "").strip(), + "carrier_kind": (point_like.get("carrier_kind", "") or "").strip(), + "carrier_axis": (point_like.get("carrier_axis", "") or "").strip().lower(), "source_label": (point_like.get("source_label", "") or "").strip(), + "source_object_name": (point_like.get("source_object_name", "") or "").strip(), "subelement_name": (point_like.get("subelement_name", "") or "").strip(), } @@ -140,7 +151,10 @@ def _coerce_waypoint(point_like): "point": point, "support_axis": None, "anchor_kind": "", + "carrier_kind": "", + "carrier_axis": "", "source_label": "", + "source_object_name": "", "subelement_name": "", } @@ -209,31 +223,329 @@ def _manual_waypoints_payload(waypoints): }, "support_axis": waypoint.get("support_axis", ""), "anchor_kind": waypoint.get("anchor_kind", ""), + "carrier_kind": waypoint.get("carrier_kind", ""), + "carrier_axis": waypoint.get("carrier_axis", ""), "source_label": waypoint.get("source_label", ""), + "source_object_name": waypoint.get("source_object_name", ""), "subelement_name": waypoint.get("subelement_name", ""), } ) return payload +def _route_node_payload( + role, + point, + terminal=None, + waypoint=None, + waypoint_index=None, +): + payload = { + "role": role, + "point": _point_payload(point), + } + if terminal is not None: + payload["terminal_uuid"] = getattr(terminal, "QetTerminalUuid", "").strip() + payload["instance_id"] = getattr(terminal, "QetInstanceId", "").strip() + if waypoint is not None: + payload["waypoint_index"] = int(waypoint_index or 0) + payload["support_axis"] = waypoint.get("support_axis", "") or "" + payload["anchor_kind"] = waypoint.get("anchor_kind", "") or "" + payload["carrier_kind"] = waypoint.get("carrier_kind", "") or "" + payload["carrier_axis"] = waypoint.get("carrier_axis", "") or "" + payload["source_label"] = waypoint.get("source_label", "") or "" + payload["source_object_name"] = waypoint.get("source_object_name", "") or "" + payload["subelement_name"] = waypoint.get("subelement_name", "") or "" + return payload + + +def _manual_route_nodes( + start_terminal, + end_terminal, + normalized_waypoints=None, + terminal_exit_length=0.0, +): + start_origin = TerminalObjects.terminal_origin(start_terminal) + end_origin = TerminalObjects.terminal_origin(end_terminal) + exit_length = max(float(terminal_exit_length or 0.0), 0.0) + + nodes = [ + _route_node_payload("start_terminal", start_origin, terminal=start_terminal) + ] + if exit_length > 0: + nodes.append( + _route_node_payload( + "start_exit", + _offset_point( + start_origin, + _terminal_exit_direction(start_terminal), + exit_length, + ), + terminal=start_terminal, + ) + ) + + for index, waypoint in enumerate(normalized_waypoints or [], start=1): + nodes.append( + _route_node_payload( + "waypoint", + waypoint["point"], + waypoint=waypoint, + waypoint_index=index, + ) + ) + + if exit_length > 0: + nodes.append( + _route_node_payload( + "end_exit", + _offset_point( + end_origin, + _terminal_exit_direction(end_terminal), + exit_length, + ), + terminal=end_terminal, + ) + ) + nodes.append(_route_node_payload("end_terminal", end_origin, terminal=end_terminal)) + return nodes + + +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 _diagnostic(severity, code, message): + return { + "severity": severity, + "code": code, + "message": message, + } + + +def diagnose_manual_wire(wire_obj): + diagnostics = [] + points = WiringObjects.wire_shape_points(wire_obj) + if len(points) < 2: + diagnostics.append( + _diagnostic("error", "wire_points_missing", "导线至少需要两个几何点。") + ) + + route_nodes = _json_array_value(getattr(wire_obj, "QetRouteNodesJson", "")) + if not route_nodes: + diagnostics.append( + _diagnostic("warning", "route_nodes_missing", "导线缺少语义路线节点。") + ) + return diagnostics + + roles = [str(node.get("role", "")) for node in route_nodes if isinstance(node, dict)] + if not roles or roles[0] != "start_terminal": + diagnostics.append( + _diagnostic("warning", "start_route_node_missing", "导线缺少起点端子路线节点。") + ) + if not roles or roles[-1] != "end_terminal": + diagnostics.append( + _diagnostic("warning", "end_route_node_missing", "导线缺少终点端子路线节点。") + ) + + for node in route_nodes: + if not isinstance(node, dict): + continue + if node.get("role") != "waypoint": + continue + if node.get("carrier_kind") != "wire_duct": + continue + if not (node.get("source_object_name") or "").strip(): + diagnostics.append( + _diagnostic( + "warning", + "wire_duct_source_missing", + "线槽折点缺少载体对象,不能可靠判断是否同一线槽。", + ) + ) + if not (node.get("carrier_axis") or "").strip(): + diagnostics.append( + _diagnostic( + "warning", + "wire_duct_axis_missing", + "线槽折点缺少轴向信息,不能可靠沿线槽方向走线。", + ) + ) + return diagnostics + + +def _remove_from_group(group, obj): + if group is None or obj is None: + return + try: + if hasattr(group, "removeObject"): + group.removeObject(obj) + else: + group.Group = [candidate for candidate in group.Group if candidate is not obj] + except Exception: + pass + + +def _clear_manual_diagnostics(doc, diagnostic_group): + for obj in list(getattr(diagnostic_group, "Group", []) or []): + if (getattr(obj, "QetDiagnosticSource", "") or "").strip() != "ManualWiring": + continue + _remove_from_group(diagnostic_group, obj) + try: + if doc.getObject(getattr(obj, "Name", "")) is not None: + doc.removeObject(obj.Name) + except Exception: + pass + + +def _set_diagnostic_property(obj, prop_name, value, description): + TerminalObjects.ensure_string_property( + obj, + prop_name, + "QET Wiring Diagnostics", + description, + value, + ) + + +def _create_diagnostic_object(doc, diagnostic_group, wire_obj, diagnostic, index): + name = "QETWireDiagnostic_{0}".format(index) + suffix = 1 + base_name = name + while doc.getObject(name) is not None: + name = "{0}_{1}".format(base_name, suffix) + suffix += 1 + + obj = doc.addObject("App::FeaturePython", name) + message = diagnostic.get("message", "") + obj.Label = "{0}: {1}".format(diagnostic.get("severity", "warning"), message) + _set_diagnostic_property(obj, "QetDiagnosticSource", "ManualWiring", "Diagnostic source") + _set_diagnostic_property(obj, "QetDiagnosticSeverity", diagnostic.get("severity", ""), "Diagnostic severity") + _set_diagnostic_property(obj, "QetDiagnosticCode", diagnostic.get("code", ""), "Diagnostic code") + _set_diagnostic_property(obj, "QetDiagnosticMessage", message, "Diagnostic message") + _set_diagnostic_property(obj, "QetWireObjectName", getattr(wire_obj, "Name", ""), "Wire object name") + _set_diagnostic_property(obj, "QetWireLabel", getattr(wire_obj, "Label", ""), "Wire label") + diagnostic_group.addObject(obj) + return obj + + +def write_document_wire_diagnostics(doc, wires=None, project_uuid=""): + if doc is None: + raise ManualWiringError("No active FreeCAD document is available.") + + if wires is None: + wires = WiringObjects.iter_routed_wire_objects(doc) + + project_uuid = ( + (project_uuid or "").strip() + or getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + ) + diagnostic_group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) + _clear_manual_diagnostics(doc, diagnostic_group) + + created = [] + issue_count = 0 + for wire_obj in list(wires or []): + for diagnostic in diagnose_manual_wire(wire_obj): + issue_count += 1 + created.append( + _create_diagnostic_object( + doc, + diagnostic_group, + wire_obj, + diagnostic, + issue_count, + ) + ) + + try: + doc.recompute() + except Exception: + pass + return { + "wire_count": len(list(wires or [])), + "issue_count": issue_count, + "diagnostic_objects": created, + } + + def _append_unique_point(points, point): if not points or not _vector_close(points[-1], point): points.append(point) -def _append_orthogonal_segment(points, target_point, preferred_axis=None): +def _append_orthogonal_segment(points, target_point, preferred_axis=None, leading_axis=None): if not points: points.append(target_point) return - segment = _orthogonal_segment_points( - points[-1], - target_point, - preferred_axis=preferred_axis, - ) + if leading_axis in {"x", "y", "z"}: + axis_order = [leading_axis] + [ + axis for axis in ("x", "y", "z") if axis != leading_axis + ] + segment = _orthogonal_segment_points_for_axis_order( + points[-1], + target_point, + axis_order, + ) + else: + segment = _orthogonal_segment_points( + points[-1], + target_point, + preferred_axis=preferred_axis, + ) for point in segment[1:]: _append_unique_point(points, point) +def _orthogonal_segment_points_for_axis_order(start_point, end_point, axis_order): + if _vector_close(start_point, end_point): + return [start_point] + + points = [start_point] + current = start_point + for axis in axis_order: + if axis not in {"x", "y", "z"}: + continue + target = _axis_value(end_point, axis) + if abs(_axis_value(current, axis) - target) <= 0.000001: + continue + current = _vector_with_axis(current, axis, target) + if not _vector_close(current, points[-1]): + points.append(current) + + if not _vector_close(points[-1], end_point): + points.append(end_point) + return points + + +def _same_carrier_run(left, right): + if not left or not right: + return None + if (left.get("carrier_kind") or "") != "wire_duct": + return None + if (right.get("carrier_kind") or "") != "wire_duct": + return None + left_source = (left.get("source_object_name") or "").strip() + right_source = (right.get("source_object_name") or "").strip() + if not left_source or not right_source: + return None + if left_source != right_source: + return None + left_axis = (left.get("carrier_axis") or "").strip().lower() + right_axis = (right.get("carrier_axis") or "").strip().lower() + if left_axis and left_axis == right_axis and left_axis in {"x", "y", "z"}: + return left_axis + return None + + def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit_length=0.0): start_origin = TerminalObjects.terminal_origin(start_terminal) end_origin = TerminalObjects.terminal_origin(end_terminal) @@ -251,16 +563,20 @@ def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit ) ) + previous_waypoint = None for point_like in waypoints or []: waypoint = _coerce_waypoint(point_like) if waypoint is None: continue normalized_waypoints.append(waypoint) + leading_axis = _same_carrier_run(previous_waypoint, waypoint) _append_orthogonal_segment( points, waypoint["point"], preferred_axis=waypoint.get("support_axis"), + leading_axis=leading_axis, ) + previous_waypoint = waypoint if exit_length > 0: end_exit = _offset_point( @@ -293,6 +609,8 @@ def _set_wire_properties( wire_mark="", wire_mark_is_manual=False, manual_waypoints=None, + route_nodes=None, + terminal_exit_length=0.0, ): WiringObjects.set_routed_wire_semantics( obj, @@ -325,6 +643,28 @@ def _set_wire_properties( ) except Exception: pass + try: + if "QetRouteNodesJson" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyString", + "QetRouteNodesJson", + "QET Wiring", + "Manual route semantic nodes", + ) + obj.QetRouteNodesJson = json.dumps(route_nodes or [], ensure_ascii=False) + except Exception: + pass + try: + if "QetTerminalExitLength" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyFloat", + "QetTerminalExitLength", + "QET Wiring", + "Terminal exit length in millimeters", + ) + obj.QetTerminalExitLength = max(float(terminal_exit_length or 0.0), 0.0) + except Exception: + pass def _set_wire_points(obj, points): @@ -447,6 +787,12 @@ def create_manual_wire( 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.") @@ -464,6 +810,8 @@ def create_manual_wire( wire_mark=wire_mark, wire_mark_is_manual=wire_mark_is_manual, manual_waypoints=normalized_waypoints, + route_nodes=route_nodes, + terminal_exit_length=terminal_exit_length, ) if parent_group is None: diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index a1eaf5e..6b99025 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -30,6 +30,11 @@ except Exception: COMMAND_NAME = "QET_Exchange_OpenManualWiringPanel" DEFAULT_TERMINAL_EXIT_LENGTH = 20.0 +CARRIER_ROLE_LABELS = { + "wire_duct": "线槽", + "cabinet": "柜面", + "rail": "导轨", +} class ManualWiringPanelError(RuntimeError): @@ -184,7 +189,10 @@ def _point_label(point_like, index): label = (point_like.get("source_label", "") or "").strip() subelement = (point_like.get("subelement_name", "") or "").strip() anchor_kind = (point_like.get("anchor_kind", "") or "").strip() + carrier_label = _carrier_role_label(point_like.get("carrier_kind", "")) parts = [] + if carrier_label: + parts.append(carrier_label) if label: parts.append(label) if subelement: @@ -216,6 +224,60 @@ def _dominant_axis(vector): return axis +def _carrier_kind_from_object(obj): + candidates = [] + current = obj + if current is not None: + candidates.append(current) + candidates.extend(list(getattr(current, "InList", []) or [])) + + for candidate in candidates: + carrier_kind = (getattr(candidate, "QetCarrierKind", "") or "").strip() + if carrier_kind: + return carrier_kind + + text_parts = [] + for candidate in candidates: + text_parts.append(getattr(candidate, "Name", "") or "") + text_parts.append(getattr(candidate, "Label", "") or "") + text = " ".join(text_parts).lower() + if "线槽" in text or "duct" in text or "trunking" in text: + return "wire_duct" + if "导轨" in text or "rail" in text: + return "rail" + if "机柜" in text or "柜体" in text or "cabinet" in text or "panel" in text: + return "cabinet" + return "" + + +def _edge_carrier_axis(edge): + vertexes = list(getattr(edge, "Vertexes", []) or []) + if len(vertexes) >= 2: + start = getattr(vertexes[0], "Point", None) + end = getattr(vertexes[-1], "Point", None) + if start is not None and end is not None: + return _dominant_axis( + App.Vector( + float(getattr(end, "x", 0.0)) - float(getattr(start, "x", 0.0)), + float(getattr(end, "y", 0.0)) - float(getattr(start, "y", 0.0)), + float(getattr(end, "z", 0.0)) - float(getattr(start, "z", 0.0)), + ) + ) + return None + + +def _carrier_role_label(carrier_kind): + return CARRIER_ROLE_LABELS.get((carrier_kind or "").strip(), "") + + +def _selected_carrier_objects(): + return [ + obj + for obj in _selection() + if obj is not None and not TerminalObjects.is_terminal_object(obj) + ] + + def _selected_waypoint(): for picked in _selection_ex(): picked_points = list(getattr(picked, "PickedPoints", []) or []) @@ -226,6 +288,7 @@ def _selected_waypoint(): obj = getattr(picked, "Object", None) support_axis = None anchor_kind = "" + carrier_axis = None if point is None and sub_objects: point = _shape_center(sub_objects[0]) @@ -248,6 +311,9 @@ def _selected_waypoint(): support_axis = _dominant_axis(sub_object.normalAt(0.5)) except Exception: support_axis = None + carrier_axis = _edge_carrier_axis(sub_object) + elif anchor_kind == "edge": + carrier_axis = _edge_carrier_axis(sub_object) elif anchor_kind == "vertex": support_axis = None @@ -255,7 +321,10 @@ def _selected_waypoint(): "point": point, "support_axis": support_axis, "anchor_kind": anchor_kind, + "carrier_kind": _carrier_kind_from_object(obj), + "carrier_axis": carrier_axis, "source_label": getattr(obj, "Label", "") if obj is not None else "", + "source_object_name": getattr(obj, "Name", "") if obj is not None else "", "subelement_name": subelement_names[0] if subelement_names else "", } return None @@ -385,6 +454,40 @@ class ManualWiringController: self.terminal_exit_length = max(float(value or 0.0), 0.0) return self.terminal_exit_length + def mark_selected_carriers(self, carrier_kind): + carrier_kind = (carrier_kind or "").strip() + if carrier_kind not in CARRIER_ROLE_LABELS: + raise ManualWiringPanelError("未知的布线载体类型:{0}".format(carrier_kind)) + + doc = _active_document() + selected = _selected_carrier_objects() + if not selected: + raise ManualWiringPanelError("请先选择线槽、柜面或导轨对象。") + + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid) + role_label = _carrier_role_label(carrier_kind) + marked = [] + for obj in selected: + TerminalObjects.ensure_string_property( + obj, + "QetCarrierKind", + "QET Wiring", + "3D wiring carrier kind", + carrier_kind, + ) + TerminalObjects.ensure_string_property( + obj, + "QetCarrierRoleLabel", + "QET Wiring", + "3D wiring carrier role label", + role_label, + ) + if obj not in getattr(carrier_group, "Group", []): + carrier_group.addObject(obj) + marked.append(obj) + return marked + def _clear_preview_objects(self): doc = getattr(App, "ActiveDocument", None) if doc is None: @@ -493,6 +596,14 @@ class ManualWiringController: _set_task_route_status(self.current_task, "Routed") return wire + def diagnose_last_wire(self): + if self.last_wire is None: + raise ManualWiringPanelError("当前还没有生成导线。") + return ManualWiring.diagnose_manual_wire(self.last_wire) + + def diagnose_all_wires(self): + return ManualWiring.write_document_wire_diagnostics(_active_document()) + def clear(self): self._reset_route_state() @@ -558,9 +669,14 @@ class ManualWiringTaskPanel: self.exit_length_input.setSuffix(" mm") self.exit_length_input.setValue(self.controller.terminal_exit_length) self.start_button = QtWidgets.QPushButton("设为起点") + self.mark_duct_button = QtWidgets.QPushButton("标记为线槽") + self.mark_cabinet_button = QtWidgets.QPushButton("标记为柜面") + self.mark_rail_button = QtWidgets.QPushButton("标记为导轨") self.waypoint_button = QtWidgets.QPushButton("添加折点") self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点") self.end_button = QtWidgets.QPushButton("设为终点并生成") + self.diagnose_button = QtWidgets.QPushButton("检查最近导线") + self.diagnose_all_button = QtWidgets.QPushButton("检查全部导线") self.clear_button = QtWidgets.QPushButton("清除草稿") self.save_button = QtWidgets.QPushButton("保存并回写") @@ -571,10 +687,17 @@ class ManualWiringTaskPanel: exit_layout.addWidget(QtWidgets.QLabel("端子出线长度")) exit_layout.addWidget(self.exit_length_input) layout.addLayout(exit_layout) + carrier_layout = QtWidgets.QHBoxLayout() + carrier_layout.addWidget(self.mark_duct_button) + carrier_layout.addWidget(self.mark_cabinet_button) + carrier_layout.addWidget(self.mark_rail_button) + layout.addLayout(carrier_layout) layout.addWidget(self.start_button) layout.addWidget(self.waypoint_button) layout.addWidget(self.delete_waypoint_button) layout.addWidget(self.end_button) + layout.addWidget(self.diagnose_button) + layout.addWidget(self.diagnose_all_button) layout.addWidget(self.clear_button) layout.addWidget(self.save_button) @@ -589,10 +712,15 @@ class ManualWiringTaskPanel: self.use_task_button.clicked.connect(self.use_selected_task) self.reload_tasks_button.clicked.connect(self._refresh_task_list) self.exit_length_input.valueChanged.connect(self.set_exit_length) + self.mark_duct_button.clicked.connect(self.mark_wire_duct) + self.mark_cabinet_button.clicked.connect(self.mark_cabinet) + self.mark_rail_button.clicked.connect(self.mark_rail) self.start_button.clicked.connect(self.set_start) 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.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) self.save_button.clicked.connect(self.save_and_write_back) @@ -659,6 +787,29 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def mark_carrier(self, carrier_kind): + marked = self.controller.mark_selected_carriers(carrier_kind) + role_label = _carrier_role_label(carrier_kind) or carrier_kind + self._set_status("已将 {0} 个对象标记为{1}。".format(len(marked), role_label)) + + def mark_wire_duct(self): + try: + self.mark_carrier("wire_duct") + except Exception as exc: + self._set_error(str(exc)) + + def mark_cabinet(self): + try: + self.mark_carrier("cabinet") + except Exception as exc: + self._set_error(str(exc)) + + def mark_rail(self): + try: + self.mark_carrier("rail") + except Exception as exc: + self._set_error(str(exc)) + def set_start(self): try: terminal = self.controller.set_start_from_selection() @@ -703,6 +854,39 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def diagnose_last_wire(self): + try: + diagnostics = self.controller.diagnose_last_wire() + if not diagnostics: + self._set_status("最近导线检查通过。") + return + text = ";".join( + "{0}:{1}".format(item.get("severity", ""), item.get("message", "")) + for item in diagnostics[:3] + ) + if len(diagnostics) > 3: + text += ";..." + self._set_status("最近导线检查发现 {0} 个问题:{1}".format(len(diagnostics), text)) + except Exception as exc: + self._set_error(str(exc)) + + def diagnose_all_wires(self): + try: + report = self.controller.diagnose_all_wires() + issue_count = int(report.get("issue_count", 0)) + wire_count = int(report.get("wire_count", 0)) + if issue_count <= 0: + self._set_status("全部导线检查通过:共 {0} 条导线。".format(wire_count)) + return + self._set_status( + "全部导线检查完成:{0} 条导线,发现 {1} 个问题,已写入 Diagnostics。".format( + wire_count, + issue_count, + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def clear(self): self.controller.clear() self._refresh_waypoint_list() diff --git a/src/Mod/FreeCADExchange/WiringObjects.py b/src/Mod/FreeCADExchange/WiringObjects.py index 102c489..17cb256 100644 --- a/src/Mod/FreeCADExchange/WiringObjects.py +++ b/src/Mod/FreeCADExchange/WiringObjects.py @@ -291,6 +291,19 @@ def _point_from_vector(vector): } +def _json_array_property(obj, prop_name): + text = getattr(obj, prop_name, "") + if not text: + return [] + try: + value = json.loads(text) + except Exception: + return [] + if isinstance(value, list): + return value + return [] + + def wire_shape_points(wire_obj): if wire_obj is None: return [] @@ -347,6 +360,9 @@ def wire_payload_from_object(wire_obj): "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), "points": [], + "manual_waypoints": [], + "route_nodes": [], + "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), } points = [_point_from_vector(point) for point in wire_shape_points(wire_obj)] return { @@ -364,6 +380,9 @@ def wire_payload_from_object(wire_obj): "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), "points": points, + "manual_waypoints": _json_array_property(wire_obj, "QetManualWaypointsJson"), + "route_nodes": _json_array_property(wire_obj, "QetRouteNodesJson"), + "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), } diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 0b84880..10c1009 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -247,6 +247,106 @@ class ManualWiringPanelTest(unittest.TestCase): ), ) + def test_controller_records_selected_wire_duct_waypoint_as_carrier_anchor(self): + selection_state = _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") + + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(100, 20, 30)], + SubObjects=[ + types.SimpleNamespace( + ShapeType="Edge", + normalAt=lambda u: app.Vector(0, 1, 0), + ) + ], + SubElementNames=["Edge1"], + Object=carrier, + ) + ] + + waypoint = panel.ManualWiringController().add_waypoint_from_selection() + + self.assertEqual("edge", waypoint["anchor_kind"]) + self.assertEqual("wire_duct", waypoint["carrier_kind"]) + self.assertEqual("线槽A", waypoint["source_label"]) + self.assertEqual("WireDuct_A", waypoint["source_object_name"]) + + def test_controller_records_wire_duct_edge_axis_for_waypoint(self): + selection_state = _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") + + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + edge = types.SimpleNamespace( + ShapeType="Edge", + Vertexes=[ + types.SimpleNamespace(Point=app.Vector(0, 10, 20)), + types.SimpleNamespace(Point=app.Vector(100, 10, 20)), + ], + ) + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(40, 10, 20)], + SubObjects=[edge], + SubElementNames=["Edge1"], + Object=carrier, + ) + ] + + waypoint = panel.ManualWiringController().add_waypoint_from_selection() + + self.assertEqual("wire_duct", waypoint["carrier_kind"]) + self.assertEqual("x", waypoint["carrier_axis"]) + + def test_controller_marks_selected_object_as_wire_duct_carrier(self): + selection_state = _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") + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + selection_state["selection"] = [carrier] + + marked = panel.ManualWiringController().mark_selected_carriers("wire_duct") + + carrier_group = doc.getObject("QETWiring_02_Carriers") + self.assertEqual([carrier], marked) + self.assertEqual("wire_duct", getattr(carrier, "QetCarrierKind", "")) + self.assertEqual("线槽", getattr(carrier, "QetCarrierRoleLabel", "")) + self.assertIn(carrier, carrier_group.Group) + def test_controller_deletes_last_waypoint_and_preview_point(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -366,6 +466,104 @@ class ManualWiringPanelTest(unittest.TestCase): [(point.x, point.y, point.z) for point in getattr(wire, "Points", [])], ) + def test_controller_diagnoses_last_generated_wire(self): + selection_state = _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") + + 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(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + controller = panel.ManualWiringController() + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(10, 10, 10)], + SubObjects=[], + SubElementNames=[], + Object=types.SimpleNamespace(Name="线槽无对象名", Label="线槽"), + ) + ] + controller.add_waypoint_from_selection() + controller.waypoints[0]["source_object_name"] = "" + selection_state["selection"] = [end_terminal] + controller.set_end_from_selection_and_generate() + + diagnostics = controller.diagnose_last_wire() + + self.assertTrue( + any(item["code"] == "wire_duct_source_missing" for item in diagnostics) + ) + + def test_controller_writes_all_wire_diagnostics(self): + selection_state = _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") + + 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(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + controller = panel.ManualWiringController() + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + controller.waypoints = [ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ] + selection_state["selection"] = [end_terminal] + controller.set_end_from_selection_and_generate() + + report = controller.diagnose_all_wires() + + self.assertEqual(1, report["issue_count"]) + self.assertEqual(1, len(doc.getObject("QETWiring_05_Diagnostics").Group)) + def test_controller_generates_wire_from_selected_task(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() diff --git a/tests/python/freecad_exchange_manual_wiring_test.py b/tests/python/freecad_exchange_manual_wiring_test.py index 521af3c..09eaf42 100644 --- a/tests/python/freecad_exchange_manual_wiring_test.py +++ b/tests/python/freecad_exchange_manual_wiring_test.py @@ -1,6 +1,7 @@ import sys import types import unittest +import json from pathlib import Path @@ -306,6 +307,375 @@ class ManualWiringGroupTest(unittest.TestCase): points[:5], ) + def test_manual_wire_records_semantic_route_nodes_for_later_carrier_routing(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + 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, 20, 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(50, 10, 30), + "support_axis": "z", + "anchor_kind": "edge", + "carrier_kind": "wire_duct", + "source_label": "线槽A", + "subelement_name": "Edge1", + } + ], + terminal_exit_length=15.0, + ) + + self.assertEqual(15.0, getattr(wire, "QetTerminalExitLength", None)) + route_nodes = json.loads(getattr(wire, "QetRouteNodesJson", "[]")) + self.assertEqual( + [ + "start_terminal", + "start_exit", + "waypoint", + "end_exit", + "end_terminal", + ], + [node["role"] for node in route_nodes], + ) + self.assertEqual("wire_duct", route_nodes[2]["carrier_kind"]) + self.assertEqual("edge", route_nodes[2]["anchor_kind"]) + self.assertEqual("terminal-start", route_nodes[0]["terminal_uuid"]) + self.assertEqual("terminal-end", route_nodes[-1]["terminal_uuid"]) + + def test_manual_wire_routes_along_same_wire_duct_axis_between_waypoints(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + 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(30, 120, 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(10, 0, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + }, + { + "point": app.Vector(20, 100, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + }, + ], + terminal_exit_length=0.0, + ) + + points = [(point.x, point.y, point.z) for point in wire.Shape] + self.assertEqual( + [ + (10.0, 0.0, 20.0), + (20.0, 0.0, 20.0), + (20.0, 100.0, 20.0), + ], + points[2:5], + ) + route_nodes = json.loads(getattr(wire, "QetRouteNodesJson", "[]")) + self.assertEqual("x", route_nodes[1]["carrier_axis"]) + self.assertEqual("x", route_nodes[2]["carrier_axis"]) + + def test_manual_wire_does_not_treat_unknown_wire_duct_sources_as_same_carrier(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + 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(30, 120, 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(10, 0, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + }, + { + "point": app.Vector(20, 100, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + }, + ], + terminal_exit_length=0.0, + ) + + points = [(point.x, point.y, point.z) for point in wire.Shape] + self.assertEqual((10.0, 100.0, 20.0), points[3]) + + def test_manual_wire_diagnostics_warn_for_wire_duct_waypoint_without_source(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + 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(20, 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(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + diagnostics = manual_wiring.diagnose_manual_wire(wire) + + self.assertTrue( + any(item["code"] == "wire_duct_source_missing" for item in diagnostics) + ) + + def test_manual_wire_diagnostics_pass_for_complete_manual_route(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + 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(20, 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(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + } + ], + terminal_exit_length=20.0, + ) + + self.assertEqual([], manual_wiring.diagnose_manual_wire(wire)) + + def test_write_document_wire_diagnostics_creates_diagnostic_objects(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + 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(20, 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(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + report = manual_wiring.write_document_wire_diagnostics(doc) + + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertEqual(1, report["issue_count"]) + self.assertEqual(1, len(diagnostic_group.Group)) + diagnostic = diagnostic_group.Group[0] + self.assertEqual("wire_duct_source_missing", diagnostic.QetDiagnosticCode) + self.assertEqual(wire.Name, diagnostic.QetWireObjectName) + self.assertIn("线槽折点", diagnostic.QetDiagnosticMessage) + + def test_write_document_wire_diagnostics_replaces_previous_manual_results(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + 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(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + manual_wiring.write_document_wire_diagnostics(doc) + manual_wiring.write_document_wire_diagnostics(doc) + + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertEqual(1, len(diagnostic_group.Group)) + def test_manual_wire_is_visible_in_routed_group_not_hidden_legacy_group(self): _install_fake_freecad() _device_import, manual_wiring, terminal_objects = _reload_modules() diff --git a/tests/python/freecad_exchange_wiring_test.py b/tests/python/freecad_exchange_wiring_test.py index 85a73ba..b9ffc2e 100644 --- a/tests/python/freecad_exchange_wiring_test.py +++ b/tests/python/freecad_exchange_wiring_test.py @@ -330,6 +330,14 @@ class WiringTest(unittest.TestCase): self.assertTrue(any(point.x == 4.0 and point.y == 5.0 and point.z == 6.0 for point in wire.Points)) self.assertIn("QetManualWaypointsJson", getattr(wire, "PropertiesList", [])) self.assertIn('"support_axis": "x"', getattr(wire, "QetManualWaypointsJson", "")) + payload = wiring_objects.wire_payload_from_object(wire) + self.assertEqual(20.0, payload["terminal_exit_length"]) + self.assertEqual("Manual", payload["route_mode"]) + self.assertEqual( + ["start_terminal", "start_exit", "waypoint", "end_exit", "end_terminal"], + [node["role"] for node in payload["route_nodes"]], + ) + self.assertEqual("face", payload["route_nodes"][2]["anchor_kind"]) def test_wire_writeback_omits_scene_routed_wire_payload(self): _install_fake_freecad()