# FreeCADExchange 3D routing connections. # # 第一版不碰 C++,也不把 3D 走线结果写进数据库。 # 它只读取 FreeCAD 文档里的端子、走线网络和几何障碍, # 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。 import json import math import FreeCAD as App try: import FreeCADGui as Gui except ImportError: Gui = None import RoutingNetwork import TerminalObjects import TemplateSemantics import WiringObjects DEFAULT_OPTIONS = { # 端子出来先走一小段,避免导线贴着设备外壳起步。 "terminal_exit_length": 20.0, "lane_axis": "auto", "lane_spacing": 10.0, "segment_reuse_penalty": 200.0, # 线槽网络相关参数。 "use_routing_network": True, "network_entry_max_distance": 1000.0, "adjoining_duct_tolerance": RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, "bend_penalty": 25.0, # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 "carrier_kind_cost_factors": { "WireDuct": 1.0, "WireDuctOpenEnd": 1.0, "WiringCutOut": 1.0, "RoutingPath": 1.0, "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, "RoutingRange": 8.0, }, # 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。 # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 "obstacle_clearance": 5.0, # 端子出线/入线段通常会贴近端子塑壳或设备外壳,不作为主路径碰撞判定依据。 "ignore_endpoint_collision_segments": True, # 防止坐标异常或端子离路由网络过远时生成超长接入线,把 FreeCAD # 视图包围盒拉得过大,导致旋转时模型被裁剪到看不见。 "terminal_access_max_distance": 1000.0, # 先把穿过障碍包围盒的路由网络边从 Dijkstra 图中移除;如果没有安全 # 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。 "avoid_obstacles": True, "replace_existing": True, } class AutoRoutingError(RuntimeError): pass def _merged_options(options): merged = dict(DEFAULT_OPTIONS) if isinstance(options, dict): merged.update(options) return merged def _vector(point): if isinstance(point, App.Vector): return App.Vector(point.x, point.y, point.z) if isinstance(point, (list, tuple)) and len(point) >= 3: return App.Vector(float(point[0]), float(point[1]), float(point[2])) if isinstance(point, dict): return App.Vector( float(point.get("x", 0.0)), float(point.get("y", 0.0)), float(point.get("z", 0.0)), ) if all(hasattr(point, name) for name in ("x", "y", "z")): return App.Vector(float(point.x), float(point.y), float(point.z)) return App.Vector(0, 0, 0) def _distance(left, right): dx = float(left.x) - float(right.x) dy = float(left.y) - float(right.y) dz = float(left.z) - float(right.z) return (dx * dx + dy * dy + dz * dz) ** 0.5 def _vector_close(left, right, tolerance=0.000001): return _distance(left, right) <= tolerance def _point_payload(point): return { "x": float(point.x), "y": float(point.y), "z": float(point.z), } def _route_length(points): total = 0.0 normalized = [_vector(point) for point in points or []] for index in range(len(normalized) - 1): total += _distance(normalized[index], normalized[index + 1]) return total def _is_finite_point(point): try: return all( math.isfinite(float(getattr(point, axis, 0.0))) for axis in ("x", "y", "z") ) except Exception: return False def _append_unique(points, point): vector = _vector(point) if not _is_finite_point(vector): return if not points or not _vector_close(points[-1], vector): points.append(vector) def _axis_value(point, axis): return float(getattr(point, axis, 0.0)) def _with_axis(point, axis, value): return App.Vector( float(value) if axis == "x" else float(point.x), float(value) if axis == "y" else float(point.y), float(value) if axis == "z" else float(point.z), ) def _auto_lane_axis(route_points): points = [_vector(point) for point in route_points or []] if len(points) < 2: return "y" extents = {"x": 0.0, "y": 0.0, "z": 0.0} for index in range(len(points) - 1): start = points[index] end = points[index + 1] extents["x"] += abs(float(end.x) - float(start.x)) extents["y"] += abs(float(end.y) - float(start.y)) extents["z"] += abs(float(end.z) - float(start.z)) dominant_axis = max(extents, key=lambda axis: extents[axis]) if dominant_axis == "y": return "x" if dominant_axis == "x": return "y" return "x" def _lane_payload(route_index, options, route_points=None): opts = options or {} lane_axis = (opts.get("lane_axis") or "y").lower() if lane_axis == "auto": lane_axis = _auto_lane_axis(route_points) if lane_axis not in {"x", "y", "z"}: lane_axis = "y" lane_index = max(int(route_index or 0), 0) lane_spacing = float(opts.get("lane_spacing", 0.0) or 0.0) if lane_index <= 0: lane_offset = 0.0 else: lane_order = (lane_index + 1) // 2 lane_direction = 1.0 if lane_index % 2 == 1 else -1.0 lane_offset = float(lane_order) * lane_spacing * lane_direction return { "index": lane_index, "axis": lane_axis, "spacing_mm": lane_spacing, "offset_mm": lane_offset, } def _apply_lane_offset(points, lane): offset = float((lane or {}).get("offset_mm", 0.0) or 0.0) if abs(offset) <= 0.000001: return list(points or []) axis = (lane or {}).get("axis", "y") return [ _with_axis(point, axis, _axis_value(point, axis) + offset) for point in list(points or []) ] def _orthogonal_points(start_point, end_point, preferred_axis=None): if _vector_close(start_point, end_point): return [start_point] # 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。 axis_order = sorted( ("x", "y", "z"), key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)), reverse=True, ) if preferred_axis in {"x", "y", "z"}: axis_order = [axis for axis in axis_order if axis != preferred_axis] axis_order.append(preferred_axis) points = [start_point] current = start_point for axis in axis_order: target = _axis_value(end_point, axis) if abs(_axis_value(current, axis) - target) <= 0.000001: continue current = _with_axis(current, axis, target) _append_unique(points, current) _append_unique(points, end_point) return points def _append_orthogonal(points, target_point, preferred_axis=None): if not points: _append_unique(points, target_point) return segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis) for point in segment[1:]: _append_unique(points, point) def _collinear_points(first, middle, last): ax = float(middle.x) - float(first.x) ay = float(middle.y) - float(first.y) az = float(middle.z) - float(first.z) bx = float(last.x) - float(middle.x) by = float(last.y) - float(middle.y) bz = float(last.z) - float(middle.z) cross_x = ay * bz - az * by cross_y = az * bx - ax * bz cross_z = ax * by - ay * bx dot = ax * bx + ay * by + az * bz return ( abs(cross_x) <= 0.000001 and abs(cross_y) <= 0.000001 and abs(cross_z) <= 0.000001 and dot >= -0.000001 ) def _route_point_key(point, tolerance=0.001): scale = 1.0 / float(tolerance or 0.001) return ( int(round(float(point.x) * scale)), int(round(float(point.y) * scale)), int(round(float(point.z) * scale)), ) def _simplify_collinear_points(points, preserved_point_keys=None): normalized = [_vector(point) for point in points or [] if _is_finite_point(_vector(point))] if len(normalized) <= 2: return normalized preserved_indices = {0, 1, len(normalized) - 2, len(normalized) - 1} preserved_point_keys = set(preserved_point_keys or []) simplified = [normalized[0]] simplified_indices = [0] for index, point in enumerate(normalized[1:], start=1): _append_unique(simplified, point) if len(simplified_indices) < len(simplified): simplified_indices.append(index) while len(simplified) >= 3 and _collinear_points( simplified[-3], simplified[-2], simplified[-1], ): if ( simplified_indices[-2] in preserved_indices or _route_point_key(simplified[-2]) in preserved_point_keys ): break simplified.pop(-2) simplified_indices.pop(-2) return simplified def _important_route_node_keys(network, path_keys, path_result): edges = network.get("edges", {}) if isinstance(network, dict) else {} important = { key for key in path_keys or [] if len(edges.get(key, []) or []) != 2 } segments = path_result.get("segments", []) if isinstance(path_result, dict) else [] for index in range(1, len(path_keys or []) - 1): previous_segment = segments[index - 1] if index - 1 < len(segments) else {} next_segment = segments[index] if index < len(segments) else {} previous_carrier = (previous_segment.get("carrier") or {}).get("name", "") next_carrier = (next_segment.get("carrier") or {}).get("name", "") if previous_carrier != next_carrier: important.add(path_keys[index]) return important def _offset(point, direction, distance): return App.Vector( float(point.x) + float(direction.x) * float(distance), float(point.y) + float(direction.y) * float(distance), float(point.z) + float(direction.z) * float(distance), ) def _terminal_origin(terminal): return _vector(TerminalObjects.terminal_origin(terminal)) def _terminal_direction(terminal): try: return _vector(TerminalObjects.terminal_direction(terminal)) except Exception: return App.Vector(0, 0, 1) def _project_uuid(doc, start_terminal=None, end_terminal=None): for obj in (start_terminal, end_terminal): value = (getattr(obj, "QetProjectUuid", "") or "").strip() if value: return value try: return (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: return "" def index_terminals(doc): """Return {terminal_uuid: terminal_object} for routable engineering terminals.""" if doc is None: return {} terminals = [] root = None try: root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME) except Exception: root = None if root is not None: terminals.extend(TerminalObjects.collect_terminal_objects(root)) terminals.extend( obj for obj in list(getattr(doc, "Objects", []) or []) if TerminalObjects.is_terminal_object(obj) ) indexed = {} for terminal in terminals: terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() if terminal_uuid and terminal_uuid not in indexed: indexed[terminal_uuid] = terminal return indexed def _normalized_match_token(value): return (value or "").strip().lower().replace(" ", "") def _device_group_for_wire_endpoint(doc, instance_id, element_uuid): device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id) if device_group is None: device_group = TerminalObjects.find_device_group(doc, element_uuid) return device_group def _terminal_match_tokens(obj): tokens = [] for value in ( getattr(obj, "QetTemplateSlotName", ""), getattr(obj, "QetTerminalLabel", ""), getattr(obj, "Label", ""), getattr(obj, "Name", ""), ): token = _normalized_match_token(value) if token and token not in tokens: tokens.append(token) return tokens def _slot_match_tokens(slot): tokens = [] for value in ( slot.get("name", ""), slot.get("label", ""), ): token = _normalized_match_token(value) if token and token not in tokens: tokens.append(token) return tokens def _matching_local_terminal(terminal_group, terminal_display, used_objects): local_terminals = [] display_token = _normalized_match_token(terminal_display) for terminal in TerminalObjects.collect_terminal_objects(terminal_group): if terminal in used_objects: continue terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() if not TerminalObjects.is_local_terminal_uuid(terminal_uuid): continue local_terminals.append(terminal) if display_token and display_token in _terminal_match_tokens(terminal): return terminal if not display_token and len(local_terminals) == 1: return local_terminals[0] return None def _matching_template_slot(device_group, terminal_display, used_slot_tokens): display_token = _normalized_match_token(terminal_display) if not display_token: return None for slot in TemplateSemantics.collect_terminal_hints(device_group): slot_tokens = _slot_match_tokens(slot) if display_token not in slot_tokens: continue slot_token = slot_tokens[0] if slot_tokens else "" if slot_token and slot_token in used_slot_tokens: continue return slot return None 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 _wire_endpoint_entries(payload): entries = [] seen = set() for item in payload.get("wires", []) or []: if not isinstance(item, dict): continue for prefix in ("start", "end"): terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(prefix)) if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid): continue if terminal_uuid in seen: continue seen.add(terminal_uuid) entries.append( { "terminal_uuid": terminal_uuid, "element_uuid": _wire_item_value(item, "{0}_element_uuid".format(prefix)), "instance_id": _wire_item_value(item, "{0}_instance_id".format(prefix)), "terminal_display": _wire_item_value( item, "{0}_terminal_display".format(prefix), "{0}_terminal_label".format(prefix), ), } ) return entries def _bind_wire_task_terminals(doc, payload): """Promote matching local template terminals to QET terminal UUIDs before routing.""" report = { "bound": 0, "created": 0, "skipped": 0, "warnings": [], } if doc is None or not isinstance(payload, dict): return report project_uuid = (payload.get("project_uuid") or "").strip() if not project_uuid: try: project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: project_uuid = "" indexed = index_terminals(doc) used_objects = set() used_slot_tokens = set() for entry in _wire_endpoint_entries(payload): terminal_uuid = entry["terminal_uuid"] if terminal_uuid in indexed: continue device_group = _device_group_for_wire_endpoint( doc, entry.get("instance_id", ""), entry.get("element_uuid", ""), ) if device_group is None: report["skipped"] += 1 report["warnings"].append( "端子 {0} 找不到所属 3D 设备实例。".format(terminal_uuid) ) continue instance_id = (getattr(device_group, "QetInstanceId", "") or "").strip() element_uuid = (getattr(device_group, "QetElementUuid", "") or "").strip() terminal_group = TerminalObjects.ensure_terminal_group( doc, device_group, project_uuid=project_uuid, instance_id=instance_id, ) terminal_display = entry.get("terminal_display", "") terminal_obj = _matching_local_terminal(terminal_group, terminal_display, used_objects) if terminal_obj is None: slot = _matching_template_slot(device_group, terminal_display, used_slot_tokens) if slot is None: report["skipped"] += 1 report["warnings"].append( "端子 {0} 没有匹配到模板槽位 {1}。".format( terminal_uuid, terminal_display or "", ) ) continue slot_name = (slot.get("name") or terminal_display or terminal_uuid).strip() terminal_obj = TerminalObjects.create_lcs_object( doc, "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid)), placement=_slot_placement(slot), label=terminal_display or terminal_uuid, ) terminal_group.addObject(terminal_obj) source_obj = slot.get("source_object") if source_obj is not None: try: source_obj.ViewObject.Visibility = False except Exception: pass report["created"] += 1 else: slot_name = (getattr(terminal_obj, "QetTemplateSlotName", "") or terminal_display).strip() report["bound"] += 1 TerminalObjects.set_terminal_semantics( terminal_obj, project_uuid, element_uuid, terminal_uuid, instance_id, label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid, slot_name=slot_name, ) used_objects.add(terminal_obj) slot_token = _normalized_match_token(slot_name) if slot_token: used_slot_tokens.add(slot_token) if report["bound"] or report["created"]: try: doc.recompute() except Exception: pass return report def _wire_object_name(start_terminal, end_terminal, wire_uuid=""): if wire_uuid: return "QETRoutedConnection_{0}".format(TerminalObjects.safe_token(wire_uuid)) return "QETRoutedConnection_{0}_{1}".format( TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", "")), TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", "")), ) def _unique_name(doc, base_name): name = TerminalObjects.safe_token(base_name) if doc.getObject(name) is None: return name suffix = 1 while doc.getObject("{0}_{1}".format(name, suffix)) is not None: suffix += 1 return "{0}_{1}".format(name, suffix) def _create_wire_geometry(doc, name, points): # Use a plain Part edge for generated auto wires. Draft wires are convenient for # editing, but in large imported assemblies they can trigger view-provider redraw # glitches while rotating the 3D scene. try: import Part obj = doc.addObject("Part::Feature", name) obj.Shape = Part.makePolygon(points) _set_points(obj, points) return obj except Exception: pass if getattr(App, "ActiveDocument", None) is doc: try: import Draft obj = Draft.make_wire( points, closed=False, placement=None, face=False, support=None, bs2wire=False, ) if obj is not None: try: obj.MakeFace = False except Exception: pass _set_points(obj, points) return obj except Exception: pass obj = doc.addObject("App::FeaturePython", name) _set_points(obj, points) return obj def _set_points(obj, points): try: if "Points" not in getattr(obj, "PropertiesList", []): obj.addProperty("App::PropertyVectorList", "Points", "QET Wiring", "Route points") obj.Points = list(points) except Exception: pass def _set_string(obj, name, value, description="Routing connection property"): TerminalObjects.ensure_string_property(obj, name, "QET Routing", description, value) def _clean_endpoint_metadata(endpoint_metadata): if not isinstance(endpoint_metadata, dict): return {} allowed = ( "start_element_uuid", "start_terminal_display", "start_device_label", "end_element_uuid", "end_terminal_display", "end_device_label", "endpoint_label", ) cleaned = {} for key in allowed: value = str(endpoint_metadata.get(key, "") or "").strip() if value: cleaned[key] = value return cleaned def _set_endpoint_metadata(wire, endpoint_metadata): metadata = _clean_endpoint_metadata(endpoint_metadata) property_names = { "start_element_uuid": "QetStartElementUuid", "start_terminal_display": "QetStartTerminalDisplay", "start_device_label": "QetStartDeviceLabel", "end_element_uuid": "QetEndElementUuid", "end_terminal_display": "QetEndTerminalDisplay", "end_device_label": "QetEndDeviceLabel", "endpoint_label": "QetEndpointLabel", } for key, prop_name in property_names.items(): if key in metadata: _set_string(wire, prop_name, metadata[key], "QET routed wire endpoint metadata") return metadata def _route_payload(route_data, collisions, wire_style_id="", endpoint_metadata=None): points = route_data.get("points", []) payload = { "algorithm": route_data.get("algorithm", ""), "length_mm": _route_length(points), "wire_style_id": str(wire_style_id or "").strip(), "lane": route_data.get("lane", {}), "points": [_point_payload(point) for point in points], "collision_count": len(collisions), "collisions": collisions, "network": route_data.get("network", {}), "route_track": route_data.get("route_track", {}), } metadata = _clean_endpoint_metadata(endpoint_metadata) if metadata: payload["endpoint_metadata"] = metadata return payload def _set_routing_connection_metadata(wire, route_data, collisions, wire_style_id="", endpoint_metadata=None): length_mm = _route_length(route_data.get("points", [])) cleaned_endpoint_metadata = _set_endpoint_metadata(wire, endpoint_metadata) _set_string( wire, "QetRouteAlgorithm", route_data.get("algorithm", ""), "Routing connection algorithm used for this wire", ) _set_string( wire, "QetRouteLengthMm", "{0:.3f}".format(length_mm), "Routing connection length in millimeters", ) _set_string( wire, "QetWireStyleId", str(wire_style_id or "").strip(), "QET wire style ID", ) _set_string( wire, "QetRouteDiagnosticsJson", json.dumps( _route_payload( route_data, collisions, wire_style_id=wire_style_id, endpoint_metadata=cleaned_endpoint_metadata, ), ensure_ascii=False, ), "Routing connection diagnostics", ) if route_data.get("network"): _set_string( wire, "QetRouteNetworkJson", json.dumps(route_data.get("network", {}), ensure_ascii=False), "Route network metadata used by this wire", ) if route_data.get("route_track"): _set_string( wire, "QetRouteTrackJson", json.dumps(route_data.get("route_track", {}), ensure_ascii=False), "Routing carriers passed through by this wire", ) def build_network_route(start_terminal, end_terminal, route_index=0, options=None, doc=None): opts = _merged_options(options) if not opts.get("use_routing_network", True): return None if doc is None: doc = getattr(start_terminal, "Document", None) or getattr(App, "ActiveDocument", None) if doc is None: return None exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) start_origin = _terminal_origin(start_terminal) end_origin = _terminal_origin(end_terminal) start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) def route_on_network(network, obstacle_aware=False): if network.get("segment_count", 0) <= 0: return None start_key, start_distance, start_mode = RoutingNetwork.connect_point_to_network(network, start_exit) end_key, end_distance, end_mode = RoutingNetwork.connect_point_to_network(network, end_exit) if start_key is None or end_key is None: return None max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) if max_distance > 0.0 and ( float(start_distance or 0.0) > max_distance or float(end_distance or 0.0) > max_distance ): return None path_result = RoutingNetwork.shortest_path_with_carriers( network, start_key, end_key, bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0), kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), segment_usage_costs=opts.get("segment_usage_costs", {}), segment_reuse_penalty=float(opts.get("segment_reuse_penalty", 0.0) or 0.0), ) path_keys = path_result.get("path", []) if isinstance(path_result, dict) else [] if not path_keys: return None carrier_points = RoutingNetwork.path_points(network, path_keys) if not carrier_points: return None lane = _lane_payload(route_index, opts, route_points=carrier_points) carrier_points = _apply_lane_offset(carrier_points, lane) points = [] _append_unique(points, start_origin) _append_unique(points, start_exit) _append_orthogonal(points, carrier_points[0]) for point in carrier_points[1:]: _append_unique(points, point) _append_orthogonal(points, end_exit) _append_unique(points, end_origin) points = _simplify_collinear_points( points, preserved_point_keys=_important_route_node_keys(network, path_keys, path_result), ) return { "algorithm": "network-dijkstra-v1", "points": points, "network": { "carriers": int(network.get("carrier_count", 0)), "segments": int(network.get("segment_count", 0)), "bridged_segments": int(network.get("bridged_segment_count", 0)), "blocked_segments": int(network.get("blocked_segment_count", 0)), "nodes": len(network.get("nodes", {})), "entry_distance": float(start_distance or 0.0), "exit_distance": float(end_distance or 0.0), "entry_point_mode": start_mode, "exit_point_mode": end_mode, "obstacle_aware": bool(obstacle_aware), }, "route_track": path_result, "lane": lane, } use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True)) obstacles = [] if use_obstacle_avoidance: obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) blocked_bboxes = [obstacle["bbox"] for obstacle in obstacles if obstacle.get("bbox")] if blocked_bboxes: obstacle_aware_network = RoutingNetwork.build_route_graph( doc, blocked_bboxes=blocked_bboxes, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) route_data = route_on_network(obstacle_aware_network, obstacle_aware=True) if route_data is not None: return route_data network = RoutingNetwork.build_route_graph( doc, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) return route_on_network(network, obstacle_aware=False) def _is_group(obj): try: return bool(obj.isDerivedFrom("App::DocumentObjectGroup")) except Exception: return False def _is_origin_helper(obj): type_id = (getattr(obj, "TypeId", "") or "").lower() name = (getattr(obj, "Name", "") or "").lower() label = (getattr(obj, "Label", "") or "").lower() compact_name = "".join(ch for ch in name if not ch.isdigit()).replace("-", "_") compact_label = "".join(ch for ch in label if not ch.isdigit()).replace("-", "_") helper_names = { "origin", "xy_plane", "xz_plane", "yz_plane", "x_axis", "y_axis", "z_axis", } if "origin" in type_id or compact_name in helper_names: return True return compact_label in helper_names or compact_label.replace(" ", "_") in helper_names def _bbox_payload(obj, clearance=0.0): shape = getattr(obj, "Shape", None) bbox = getattr(shape, "BoundBox", None) if bbox is None: return None return { "xmin": float(bbox.XMin) - clearance, "xmax": float(bbox.XMax) + clearance, "ymin": float(bbox.YMin) - clearance, "ymax": float(bbox.YMax) + clearance, "zmin": float(bbox.ZMin) - clearance, "zmax": float(bbox.ZMax) + clearance, } def _collect_group_tree_ids(root): excluded = set() stack = [root] while stack: obj = stack.pop() if obj is None or id(obj) in excluded: continue excluded.add(id(obj)) stack.extend(list(getattr(obj, "Group", []) or [])) return excluded def _expanded_obstacle_exclusion_ids(doc, exclude): excluded = set(id(obj) for obj in (exclude or []) if obj is not None) endpoint_instance_ids = { (getattr(obj, "QetInstanceId", "") or "").strip() for obj in (exclude or []) if obj is not None and (getattr(obj, "QetInstanceId", "") or "").strip() } if not endpoint_instance_ids: return excluded for obj in list(getattr(doc, "Objects", []) or []): instance_id = (getattr(obj, "QetInstanceId", "") or "").strip() parent_instance_ids = { (getattr(parent, "QetInstanceId", "") or "").strip() for parent in list(getattr(obj, "InList", []) or []) if (getattr(parent, "QetInstanceId", "") or "").strip() } if instance_id in endpoint_instance_ids or parent_instance_ids.intersection(endpoint_instance_ids): excluded.update(_collect_group_tree_ids(obj)) excluded.add(id(obj)) return excluded def _distance_point_to_bbox(point, bbox): squared = 0.0 for axis, min_key, max_key in ( ("x", "xmin", "xmax"), ("y", "ymin", "ymax"), ("z", "zmin", "zmax"), ): value = _axis_value(point, axis) low = float(bbox[min_key]) high = float(bbox[max_key]) if value < low: squared += (low - value) * (low - value) elif value > high: squared += (value - high) * (value - high) return math.sqrt(squared) def collect_obstacles(doc, exclude=None, options=None): opts = _merged_options(options) excluded = _expanded_obstacle_exclusion_ids(doc, exclude) clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) endpoint_clearance = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + clearance endpoint_points = [] for obj in exclude or []: if obj is not None and TerminalObjects.is_terminal_object(obj): endpoint_points.append(_terminal_origin(obj)) obstacles = [] for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in excluded: continue obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip() if obstacle_mode in {"PassThrough", "WireDuctPassThrough", "SupportSurface"}: continue if _is_group(obj) or _is_origin_helper(obj): continue if TerminalObjects.is_lcs_like(obj) or TerminalObjects.is_terminal_object(obj): continue if RoutingNetwork.is_route_carrier(obj) or WiringObjects.is_routed_wire_object(obj): continue raw_bbox = _bbox_payload(obj, clearance=0.0) bbox = _bbox_payload(obj, clearance=clearance) if bbox is None: continue if endpoint_points and any( _distance_point_to_bbox(point, bbox) <= endpoint_clearance for point in endpoint_points ): continue obstacles.append( { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), "bbox": bbox, "raw_bbox": raw_bbox or bbox, } ) return obstacles def _segment_intersects_bbox(start, end, bbox): # Slab intersection: 把线段参数化为 start + t*(end-start),t 在 [0, 1] 内相交即命中。 t_min = 0.0 t_max = 1.0 for axis, min_key, max_key in ( ("x", "xmin", "xmax"), ("y", "ymin", "ymax"), ("z", "zmin", "zmax"), ): start_value = _axis_value(start, axis) end_value = _axis_value(end, axis) delta = end_value - start_value low = float(bbox[min_key]) high = float(bbox[max_key]) if abs(delta) <= 0.000001: if start_value < low or start_value > high: return False continue inv = 1.0 / delta near = (low - start_value) * inv far = (high - start_value) * inv if near > far: near, far = far, near t_min = max(t_min, near) t_max = min(t_max, far) if t_min > t_max: return False return True def detect_collisions(points, obstacles, ignored_segment_indices=None): ignored = set(ignored_segment_indices or []) collisions = [] for index in range(max(len(points) - 1, 0)): if index in ignored: continue start = points[index] end = points[index + 1] for obstacle in obstacles: if _segment_intersects_bbox(start, end, obstacle["bbox"]): raw_bbox = obstacle.get("raw_bbox") or obstacle.get("bbox") or {} collision_kind = "HardIntersection" if raw_bbox and not _segment_intersects_bbox(start, end, raw_bbox): collision_kind = "ClearanceWarning" collisions.append( { "segment_index": index, "segment_start": _point_payload(start), "segment_end": _point_payload(end), "collision_kind": collision_kind, "obstacle_name": obstacle.get("name", ""), "obstacle_label": obstacle.get("label", ""), "obstacle_bbox": dict(raw_bbox), "collision_bbox": dict(obstacle.get("bbox", {}) or {}), } ) return collisions def _endpoint_collision_segment_indices(points): segment_count = max(len(points or []) - 1, 0) if segment_count <= 0: return set() ignored = {0} if segment_count > 1: ignored.add(segment_count - 1) return ignored def _detach_object_from_groups(doc, obj): parents = list(getattr(obj, "InList", []) or []) parents.extend(list(getattr(doc, "Objects", []) or [])) for parent in parents: group = list(getattr(parent, "Group", []) or []) if obj not in group: continue try: if hasattr(parent, "removeObject"): parent.removeObject(obj) else: parent.Group = [child for child in group if child is not obj] except Exception: try: parent.Group = [child for child in group if child is not obj] except Exception: pass def _matching_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=""): matches = [] for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue if wire_uuid: if (getattr(obj, "QetWireUuid", "") or "").strip() != wire_uuid: continue else: same_direction = ( (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == start_uuid and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == end_uuid ) reverse_direction = ( (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == end_uuid and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == start_uuid ) if not same_direction and not reverse_direction: continue matches.append(obj) return matches def _remove_routing_connection_objects(doc, objects): removed = 0 for obj in list(objects or []): try: _detach_object_from_groups(doc, obj) doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _remove_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=""): return _remove_routing_connection_objects( doc, _matching_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=wire_uuid), ) def _find_task_by_wire_uuid(doc, wire_uuid): if not wire_uuid: return None try: task_group = doc.getObject("QETWiring_01_Tasks") except Exception: task_group = None if task_group is None: return None for task in list(getattr(task_group, "Group", []) or []): if (getattr(task, "QetWireUuid", "") or "").strip() == wire_uuid: return task return None def _set_task_status(task, status): if task is None: return TerminalObjects.ensure_string_property( task, "RouteStatus", "QET Wiring", "Wire task route status", status, ) def _style_wire(wire, collision_count=0): try: wire.ViewObject.Visibility = True wire.ViewObject.LineWidth = 5.0 if hasattr(wire.ViewObject, "DrawStyle"): wire.ViewObject.DrawStyle = "Solid" if hasattr(wire.ViewObject, "DisplayMode"): wire.ViewObject.DisplayMode = "Wireframe" if collision_count: wire.ViewObject.LineColor = (1.0, 0.1, 0.0) else: wire.ViewObject.LineColor = (0.0, 0.35, 1.0) except Exception: pass def route_eplan_connection_between_terminals( doc, start_terminal, end_terminal, route_index=0, options=None, wire_uuid="", wire_label="", net_uuid="", group_uuid="", wire_mark="", wire_mark_is_manual=False, wire_style_id="", endpoint_metadata=None, ): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not TerminalObjects.is_terminal_object(start_terminal): raise AutoRoutingError("Start object is not a routable terminal.") if not TerminalObjects.is_terminal_object(end_terminal): raise AutoRoutingError("End object is not a routable terminal.") if start_terminal == end_terminal: raise AutoRoutingError("Start and end terminal must be different.") opts = _merged_options(options) effective_wire_style_id = str(wire_style_id or opts.get("wire_style_id", "") or "").strip() start_uuid = (getattr(start_terminal, "QetTerminalUuid", "") or "").strip() end_uuid = (getattr(end_terminal, "QetTerminalUuid", "") or "").strip() project_uuid = _project_uuid(doc, start_terminal, end_terminal) if not project_uuid: raise AutoRoutingError("Project UUID is required for routing connections.") route_data = build_network_route( start_terminal, end_terminal, route_index=route_index, options=opts, doc=doc, ) if route_data is None: raise AutoRoutingError( "没有可用的布线路径网络;请先生成布线布局空间和布线路径网络。" ) points = route_data.get("points", []) if len(points) < 2: raise AutoRoutingError("Routing connection produced fewer than two points.") obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) ignored_collision_segments = set() if opts.get("ignore_endpoint_collision_segments", True): ignored_collision_segments = _endpoint_collision_segment_indices(points) collisions = detect_collisions(points, obstacles, ignored_segment_indices=ignored_collision_segments) status = "CollisionWarning" if collisions else "Routed" existing_replacements = [] if opts.get("replace_existing", True): existing_replacements = _matching_existing_routing_connections( doc, start_uuid, end_uuid, wire_uuid=wire_uuid, ) wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid)) wire = None try: wire = _create_wire_geometry(doc, wire_name, points) wire.Label = wire_label or wire_mark or wire_uuid or "QET Routed Connection" WiringObjects.set_routed_wire_semantics( wire, project_uuid, wire_uuid, wire_label or wire_mark or wire_uuid, start_uuid, end_uuid, (getattr(start_terminal, "QetInstanceId", "") or "").strip(), (getattr(end_terminal, "QetInstanceId", "") or "").strip(), route_type="RoutedConnection", route_status=status, route_mode="EplanRoute", net_uuid=net_uuid, group_uuid=group_uuid, wire_mark=wire_mark, wire_mark_is_manual=wire_mark_is_manual, ) _set_routing_connection_metadata( wire, route_data, collisions, wire_style_id=effective_wire_style_id, endpoint_metadata=endpoint_metadata, ) routed_group = WiringObjects.ensure_routed_group(doc, project_uuid) if wire not in getattr(routed_group, "Group", []): routed_group.addObject(wire) try: routed_group.ViewObject.Visibility = True except Exception: pass _style_wire(wire, collision_count=len(collisions)) task = _find_task_by_wire_uuid(doc, wire_uuid) _set_task_status(task, status) except Exception: if wire is not None: _remove_routing_connection_objects(doc, [wire]) raise if existing_replacements: removed_existing = _remove_routing_connection_objects(doc, existing_replacements) if removed_existing != len(existing_replacements): if wire is not None: _remove_routing_connection_objects(doc, [wire]) raise AutoRoutingError("Failed to replace existing routed connection.") try: doc.recompute() except Exception: pass return { "wire": wire, "route_status": status, "algorithm": route_data.get("algorithm", ""), "network": route_data.get("network", {}), "route_track": route_data.get("route_track", {}), "points": points, "lane": route_data.get("lane", {}), "length_mm": _route_length(points), "collision_count": len(collisions), "collisions": collisions, } def _wire_item_value(item, *names): if not isinstance(item, dict): return "" for name in names: value = item.get(name, "") if value is not None and str(value).strip(): return str(value).strip() return "" def _route_lane_key(start_uuid, end_uuid): endpoints = sorted( value for value in ( str(start_uuid or "").strip(), str(end_uuid or "").strip(), ) if value ) return tuple(endpoints) def _route_segment_key(segment): if not isinstance(segment, dict): return None carrier = segment.get("carrier") or {} carrier_name = str(carrier.get("name", "") or "").strip() from_key = tuple(segment.get("from_key", []) or []) to_key = tuple(segment.get("to_key", []) or []) if not from_key or not to_key: return None return ( carrier_name, tuple(sorted((from_key, to_key))), ) def _route_segment_keys(result): route_track = result.get("route_track", {}) if isinstance(result, dict) else {} return _route_track_segment_keys(route_track) def _route_track_segment_keys(route_track): segments = route_track.get("segments", []) if isinstance(route_track, dict) else [] keys = [] for segment in segments or []: key = _route_segment_key(segment) if key is not None: keys.append(key) return keys def _incoming_wire_uuids(wires): wire_uuids = set() for item in wires or []: if not isinstance(item, dict): continue wire_uuid = _wire_item_value(item, "wire_id", "wire_uuid", "id") if wire_uuid: wire_uuids.add(wire_uuid) return wire_uuids def _existing_routed_segment_usage(doc, excluded_wire_uuids=None): excluded_wire_uuids = set(excluded_wire_uuids or []) usage = {} for wire in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(wire, "RouteType", "") or "").strip() != "RoutedConnection": continue wire_uuid = (getattr(wire, "QetWireUuid", "") or "").strip() if wire_uuid and wire_uuid in excluded_wire_uuids: continue try: route_track = json.loads((getattr(wire, "QetRouteTrackJson", "") or "").strip() or "{}") except Exception: route_track = {} for segment_key in _route_track_segment_keys(route_track): usage[segment_key] = usage.get(segment_key, 0) + 1 return usage def bind_wire_task_terminals_from_payload(doc, payload): """Bind local template terminals to QET terminal UUIDs without creating wires.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") binding_report = _bind_wire_task_terminals(doc, payload) terminals = index_terminals(doc) wires = payload.get("wires", []) or [] endpoints = _wire_endpoint_entries(payload) binding_report.update( { "total_wires": len(wires), "endpoint_terminals": len(endpoints), "available_terminals": len(terminals), "local_terminals": sum( 1 for terminal_uuid in terminals if TerminalObjects.is_local_terminal_uuid(terminal_uuid) ), } ) return binding_report def format_terminal_binding_report(report): message = "工程端子检查/绑定完成:更新 {0} 个,新建 {1} 个,跳过 {2} 个;当前端子 {3} 个,本地端子 {4} 个。".format( report.get("bound", 0), report.get("created", 0), report.get("skipped", 0), report.get("available_terminals", 0), report.get("local_terminals", 0), ) warnings = report.get("warnings", []) or [] if warnings: message += "\n首个问题:{0}".format(warnings[0]) if report.get("total_wires", 0) <= 0: message += "\n没有导线任务,无法按 QET terminal_uuid 绑定工程端子。" return message def route_eplan_connections_from_payload(doc, payload, options=None, prepared_layout=None): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) terminals = index_terminals(doc) local_terminal_count = sum( 1 for terminal_uuid in terminals if TerminalObjects.is_local_terminal_uuid(terminal_uuid) ) wires = payload.get("wires", []) or [] report = { "total_wires": len(wires), "available_terminals": len(terminals), "local_terminals": local_terminal_count, "auto_bound_terminals": terminal_binding_report["bound"], "auto_created_terminals": terminal_binding_report["created"], "auto_terminal_binding_warnings": terminal_binding_report["warnings"], "routed": 0, "collision_warnings": 0, "total_length_mm": 0.0, "skipped_missing_terminal": 0, "skipped_invalid": 0, "missing_endpoint_uuids": [], "missing_endpoint_samples": [], "collision_samples": [], "errors": [], "error_samples": [], "route_status_counts": {}, "routes": [], } if isinstance(prepared_layout, dict): report["prepared_layout"] = prepared_layout missing_endpoint_uuids = set() lane_indexes_by_pair = {} lane_indexes_by_segment = {} segment_usage_costs = _existing_routed_segment_usage( doc, excluded_wire_uuids=_incoming_wire_uuids(wires), ) def add_status(status): key = str(status or "").strip() or "Unknown" report["route_status_counts"][key] = report["route_status_counts"].get(key, 0) + 1 def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata): route_options = dict(options or {}) if isinstance(item, dict) and "__segment_usage_costs" in item: route_options["segment_usage_costs"] = item.get("__segment_usage_costs", {}) return route_eplan_connection_between_terminals( doc, start_terminal, end_terminal, route_index=route_lane_index, options=route_options, wire_uuid=_wire_item_value(item, "wire_id", "wire_uuid", "id"), wire_label=_wire_item_value(item, "wire_label", "wire_mark"), net_uuid=_wire_item_value(item, "net_uuid"), group_uuid=_wire_item_value(item, "group_uuid"), wire_mark=_wire_item_value(item, "wire_mark"), wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)), wire_style_id=_wire_item_value(item, "wire_style_id"), endpoint_metadata=endpoint_metadata, ) for item in wires: if not isinstance(item, dict): report["skipped_invalid"] += 1 add_status("Invalid") continue start_uuid = _wire_item_value(item, "start_terminal_uuid") end_uuid = _wire_item_value(item, "end_terminal_uuid") start_terminal = terminals.get(start_uuid) end_terminal = terminals.get(end_uuid) if start_terminal is None or end_terminal is None: report["skipped_missing_terminal"] += 1 add_status("MissingTerminal") for terminal_uuid in (start_uuid, end_uuid): if terminal_uuid and terminal_uuid not in terminals: missing_endpoint_uuids.add(terminal_uuid) # 这里只保留少量样例,避免面板状态被大量导线任务刷屏。 if len(report["missing_endpoint_samples"]) < 8: report["missing_endpoint_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "start_terminal_uuid": start_uuid, "start_found": start_terminal is not None, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "end_terminal_uuid": end_uuid, "end_found": end_terminal is not None, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), } ) continue lane_key = _route_lane_key(start_uuid, end_uuid) route_lane_index = lane_indexes_by_pair.get(lane_key, 0) try: endpoint_metadata = { "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label"), "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label"), "endpoint_label": _wire_item_value(item, "endpoint_label"), } result = create_route( route_lane_index, dict(item, __segment_usage_costs=segment_usage_costs), start_terminal, end_terminal, endpoint_metadata, ) route_segment_keys = _route_segment_keys(result) shared_lane_index = max( [lane_indexes_by_segment.get(key, 0) for key in route_segment_keys] or [0] ) final_lane_index = max(route_lane_index, shared_lane_index) if final_lane_index != route_lane_index: initial_wire = result.get("wire") if isinstance(result, dict) else None try: result = create_route( final_lane_index, dict(item, __segment_usage_costs=segment_usage_costs), start_terminal, end_terminal, endpoint_metadata, ) except Exception: if initial_wire is not None: _remove_routing_connection_objects(doc, [initial_wire]) raise route_segment_keys = _route_segment_keys(result) except Exception as exc: error_text = str(exc) report["errors"].append(error_text) add_status("Error") if len(report["error_samples"]) < 8: report["error_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label"), "endpoint_label": _wire_item_value(item, "endpoint_label"), "error": error_text, } ) continue lane_indexes_by_pair[lane_key] = max( lane_indexes_by_pair.get(lane_key, 0), int(result.get("lane", {}).get("index", 0) or 0) + 1, ) for segment_key in route_segment_keys: lane_indexes_by_segment[segment_key] = max( lane_indexes_by_segment.get(segment_key, 0), int(result.get("lane", {}).get("index", 0) or 0) + 1, ) segment_usage_costs[segment_key] = segment_usage_costs.get(segment_key, 0) + 1 if result["route_status"] == "CollisionWarning": report["collision_warnings"] += 1 add_status(result["route_status"]) route_collision_samples = [] for collision in list(result.get("collisions", []) or [])[:3]: sample = dict(collision) sample.update( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "start_terminal_uuid": start_uuid, "end_terminal_uuid": end_uuid, } ) route_collision_samples.append(sample) if len(report["collision_samples"]) < 8: report["collision_samples"].append(sample) report["routed"] += 1 route_length = float(result.get("length_mm", 0.0) or 0.0) report["total_length_mm"] += route_length report["routes"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_style_id": _wire_item_value(item, "wire_style_id"), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label"), "endpoint_label": _wire_item_value(item, "endpoint_label"), "algorithm": result["algorithm"], "route_status": result["route_status"], "length_mm": route_length, "lane": result.get("lane", {}), "network": result.get("network", {}), "route_track": result.get("route_track", {}), "collision_count": result["collision_count"], "collision_samples": route_collision_samples, } ) report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) _write_routing_connection_batch_diagnostic(doc, report) return report def _missing_endpoint_label(sample, side): terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() element_uuid = str(sample.get("{0}_element_uuid".format(side), "") or "").strip() terminal_display = str(sample.get("{0}_terminal_display".format(side), "") or "").strip() if element_uuid and terminal_display: label = "{0}/{1}".format(element_uuid, terminal_display) elif terminal_display: label = terminal_display elif element_uuid: label = element_uuid else: return terminal_uuid if terminal_uuid and terminal_uuid != label: return "{0} ({1})".format(label, terminal_uuid) return label def _missing_endpoint_side_summary(sample): missing_sides = [] if sample.get("start_found") is False: missing_sides.append("起点") if sample.get("end_found") is False: missing_sides.append("终点") if not missing_sides: return "" if len(missing_sides) == 2: return "(缺失:两端)" return "(缺失:{0})".format(missing_sides[0]) def _wire_sample_text(sample): return ( str(sample.get("wire_label", "") or "").strip() or str(sample.get("wire_uuid", "") or "").strip() or "未知导线" ) def _endpoint_pair_text(sample): endpoint_label = str(sample.get("endpoint_label", "") or "").strip() if endpoint_label: return endpoint_label return "{0} -> {1}".format( _missing_endpoint_label(sample, "start"), _missing_endpoint_label(sample, "end"), ) def _route_source_labels(route_track, limit=5): labels = [] seen = set() if not isinstance(route_track, dict): return labels for segment in route_track.get("segments", []) or []: carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue label = ( str(carrier.get("source_label", "") or "").strip() or str(carrier.get("source_name", "") or "").strip() ) if not label or label in seen: continue seen.add(label) labels.append(label) if len(labels) >= int(limit or 0): break return labels def _route_source_sample_text(report): for route in report.get("routes", []) or []: if not isinstance(route, dict): continue labels = _route_source_labels(route.get("route_track", {})) if not labels: continue return "路径示例:导线 {0} 经过 {1}。".format( _wire_sample_text(route), "、".join(labels), ) return "" def _route_network_metric_max(report, key): maximum = 0 for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue try: maximum = max(maximum, int(network.get(key, 0) or 0)) except Exception: continue return maximum def _route_lane_summary(report): max_lane_index = 0 lane_spacing = 0.0 for route in report.get("routes", []) or []: if not isinstance(route, dict): continue lane = route.get("lane", {}) if not isinstance(lane, dict): continue try: lane_index = int(lane.get("index", 0) or 0) except Exception: lane_index = 0 if lane_index <= max_lane_index: continue max_lane_index = lane_index try: lane_spacing = float(lane.get("spacing_mm", 0.0) or 0.0) except Exception: lane_spacing = 0.0 if max_lane_index <= 0: return {} return { "max_lane_index": max_lane_index, "spacing_mm": lane_spacing, } def format_eplan_connection_route_report(report): message = "批量生成布线连接完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( report.get("routed", 0), report.get("collision_warnings", 0), report.get("skipped_missing_terminal", 0), ) status_counts = report.get("route_status_counts", {}) if isinstance(status_counts, dict) and status_counts: status_labels = { "Routed": "正常", "CollisionWarning": "碰撞告警", "Error": "错误", "MissingTerminal": "缺失端子", "Invalid": "无效任务", } def status_count_value(value): try: return int(value or 0) except Exception: return 0 status_parts = [] for key in ("Routed", "CollisionWarning", "Error", "MissingTerminal", "Invalid"): value = status_count_value(status_counts.get(key, 0)) if value > 0: status_parts.append("{0} {1} 条".format(status_labels[key], value)) for key, value in sorted(status_counts.items()): value = status_count_value(value) if key in status_labels or value <= 0: continue status_parts.append("{0} {1} 条".format(key, value)) if status_parts: message += "\n结果状态:{0}。".format(",".join(status_parts)) prepared_layout = report.get("prepared_layout") if isinstance(prepared_layout, dict): message += "\n布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。".format( prepared_layout.get("wire_duct_carriers", 0), prepared_layout.get("surface_carriers", 0), prepared_layout.get("terminal_access_carriers", 0), ) total_length_mm = float(report.get("total_length_mm", 0.0) or 0.0) if total_length_mm > 0.0: message += "\n布线连接总长度:{0:.1f} mm。".format(total_length_mm) bridged_segments = _route_network_metric_max(report, "bridged_segments") blocked_segments = _route_network_metric_max(report, "blocked_segments") network_parts = [] if bridged_segments > 0: network_parts.append("自动桥接 {0} 段相邻线槽".format(bridged_segments)) if blocked_segments > 0: network_parts.append("避障屏蔽 {0} 段".format(blocked_segments)) if network_parts: message += "\n路径网络:{0}。".format(",".join(network_parts)) lane_summary = _route_lane_summary(report) if lane_summary: message += "\n并行错位:最大 lane {0},间距 {1:.1f} mm。".format( lane_summary.get("max_lane_index", 0), float(lane_summary.get("spacing_mm", 0.0) or 0.0), ) route_source_sample = _route_source_sample_text(report) if route_source_sample: message += "\n{0}".format(route_source_sample) errors = report.get("errors", []) or [] if errors: message += "\n首个错误:{0}".format(str(errors[0])) error_sample = (report.get("error_samples") or [None])[0] if error_sample: message += "\n错误示例:导线 {0},{1}:{2}".format( _wire_sample_text(error_sample), _endpoint_pair_text(error_sample), error_sample.get("error", ""), ) collision_sample = (report.get("collision_samples") or [None])[0] if collision_sample: obstacle_text = ( collision_sample.get("obstacle_label") or collision_sample.get("obstacle_name") or "未知对象" ) wire_text = ( collision_sample.get("wire_label") or collision_sample.get("wire_uuid") or "未知导线" ) if collision_sample.get("collision_kind") == "ClearanceWarning": message += "\n碰撞示例:导线 {0} 进入 {1} 的安全间隙。".format( wire_text, obstacle_text, ) else: message += "\n碰撞示例:导线 {0} 碰到 {1}。".format( wire_text, obstacle_text, ) auto_bound = report.get("auto_bound_terminals", 0) auto_created = report.get("auto_created_terminals", 0) if auto_bound or auto_created: message += "\n已按导线任务绑定 3D 工程端子:更新 {0} 个,新建 {1} 个。".format( auto_bound, auto_created, ) if report.get("routed", 0) == 0 and report.get("skipped_missing_terminal", 0) > 0: message += ( "\n端子匹配失败:当前 3D 可布线端子 {0} 个,其中本地模板端子 {1} 个;" "导线任务引用的 QET terminal_uuid 没有绑定到这些 3D 工程端子。" ).format( report.get("available_terminals", 0), report.get("local_terminals", 0), ) if report.get("local_terminals", 0) > 0: message += " 请先从 QET 重新导入/更新工程端子,使端子 UUID 不再是 local:...。" sample = (report.get("missing_endpoint_samples") or [None])[0] if sample: message += "\n缺失示例:{0} -> {1}{2}".format( _missing_endpoint_label(sample, "start"), _missing_endpoint_label(sample, "end"), _missing_endpoint_side_summary(sample), ) return message def _clear_routing_connection_batch_diagnostics(doc): group = WiringObjects.ensure_diagnostic_group(doc, _project_uuid(doc)) removed = 0 for obj in list(getattr(group, "Group", []) or []): if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "RoutingConnectionBatch": continue try: group.removeObject(obj) except Exception: try: group.Group = [ candidate for candidate in list(getattr(group, "Group", []) or []) if candidate is not obj ] except Exception: pass try: if doc.getObject(getattr(obj, "Name", "")) is not None: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _write_routing_connection_batch_diagnostic(doc, report): if doc is None or not isinstance(report, dict): return None project_uuid = _project_uuid(doc) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_connection_batch_diagnostics(doc) if ( report.get("total_wires", 0) <= 0 and not report.get("routes") and not report.get("errors") and not report.get("missing_endpoint_uuids") and report.get("collision_warnings", 0) <= 0 ): return None diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETRoutingConnectionDiagnostic")) diagnostic.Label = "QET Routing Connection Diagnostic" _set_string(diagnostic, "QetDiagnosticKind", "RoutingConnectionBatch", "QET diagnostic kind") _set_string( diagnostic, "QetDiagnosticJson", json.dumps(report, ensure_ascii=False), "QET routing connection batch diagnostic payload", ) group.addObject(diagnostic) return diagnostic def _iter_wire_tasks(doc): try: task_group = doc.getObject("QETWiring_01_Tasks") except Exception: task_group = None if task_group is None: return [] return [ task for task in list(getattr(task_group, "Group", []) or []) if (getattr(task, "RouteType", "") or "").strip() == "Task" ] def _wire_tasks_payload(doc): payload = {"project_uuid": _project_uuid(doc), "wires": []} for task in _iter_wire_tasks(doc): payload["wires"].append( { "wire_id": (getattr(task, "QetWireUuid", "") or "").strip(), "wire_label": (getattr(task, "QetWireLabel", "") or "").strip(), "wire_mark": (getattr(task, "QetWireMark", "") or "").strip(), "wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)), "wire_style_id": (getattr(task, "QetWireStyleId", "") or "").strip(), "net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(), "group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(), "start_element_uuid": (getattr(task, "QetStartElementUuid", "") or "").strip(), "start_instance_id": (getattr(task, "QetStartInstanceId", "") or "").strip(), "start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(), "start_terminal_display": (getattr(task, "QetStartTerminalDisplay", "") or "").strip(), "start_device_label": (getattr(task, "QetStartDeviceLabel", "") or "").strip(), "end_element_uuid": (getattr(task, "QetEndElementUuid", "") or "").strip(), "end_instance_id": (getattr(task, "QetEndInstanceId", "") or "").strip(), "end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(), "end_terminal_display": (getattr(task, "QetEndTerminalDisplay", "") or "").strip(), "end_device_label": (getattr(task, "QetEndDeviceLabel", "") or "").strip(), "endpoint_label": (getattr(task, "QetEndpointLabel", "") or "").strip(), } ) return payload def bind_wire_task_terminals_from_tasks(doc): return bind_wire_task_terminals_from_payload(doc, _wire_tasks_payload(doc)) def route_eplan_connection_tasks(doc, options=None, prepared_layout=None): payload = _wire_tasks_payload(doc) return route_eplan_connections_from_payload(doc, payload, options=options, prepared_layout=prepared_layout) def prepare_eplan_layout_space(doc, project_uuid=""): """Prepare the FreeCAD document as an EPLAN-style layout space. This step marks layout-space source objects and wiring buckets, but does not generate the routing path network. In EPLAN terms, the layout space is the 3D installation context in which the network is later generated. """ if doc is None: raise AutoRoutingError("No FreeCAD document is available.") target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" return RoutingNetwork.prepare_layout_space_sources_from_document( doc, project_uuid=target_project_uuid, ) def generate_eplan_routing_path_network(doc, project_uuid="", options=None, selection_ex=None): """Generate the routing path network for the current layout space.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" return RoutingNetwork.create_routing_path_network_from_document( doc, project_uuid=target_project_uuid, selection_ex=selection_ex, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) def check_eplan_routing_path_network(doc, project_uuid="", options=None): """Write and return routing path network diagnostics for the layout space.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" result = RoutingNetwork.write_routing_path_network_diagnostic( doc, project_uuid=target_project_uuid, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) diagnostic = result.get("diagnostic", {}) if isinstance(result, dict) else {} return { "diagnostic": diagnostic, "diagnostic_object": result.get("diagnostic_object") if isinstance(result, dict) else None, "ok": bool(diagnostic.get("ok", False)) if isinstance(diagnostic, dict) else False, "issue_count": len(diagnostic.get("issues", []) or []) if isinstance(diagnostic, dict) else 0, } def _format_distance_mm(value): try: return "{0:.1f} mm".format(float(value)) except Exception: return "未知距离" def _format_point_text(point): if not isinstance(point, dict): return "未知位置" try: return "({0:.1f}, {1:.1f}, {2:.1f})".format( float(point.get("x", 0.0)), float(point.get("y", 0.0)), float(point.get("z", 0.0)), ) except Exception: return "未知位置" def _diagnostic_terminal_text(item): if not isinstance(item, dict): return "未知端子" return ( item.get("terminal_uuid") or item.get("label") or item.get("name") or "未知端子" ) def _dict_items(value): if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def format_routing_path_network_report(diagnostic): """Return an actionable Chinese summary for routing path network diagnostics.""" if not isinstance(diagnostic, dict): return "布线路径网络检查失败:诊断结果无效。" summary = diagnostic.get("summary", {}) if isinstance(diagnostic.get("summary", {}), dict) else {} issues = _dict_items(diagnostic.get("issues", []) or []) if not issues: message = "布线路径网络检查通过:{0} 条 carrier / {1} 段 / {2} 个节点。".format( summary.get("carriers", 0), summary.get("segments", 0), summary.get("nodes", 0), ) bridged_segments = int(summary.get("bridged_segments", 0) or 0) if bridged_segments > 0: message += " 自动桥接 {0} 段相邻线槽。".format(bridged_segments) return message message = "布线路径网络检查发现 {0} 类问题。".format(len(issues)) unconnected = _dict_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: sample = unconnected[0] message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}。请重新生成布线路径网络,或补一段线槽/辅助路径到该端子。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("nearest_network_distance_mm")), _format_distance_mm(sample.get("terminal_access_max_distance_mm")), ) possible_breaks = _dict_items(diagnostic.get("possible_breaks", []) or []) if possible_breaks: sample = possible_breaks[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} carrier_text = carrier.get("label") or carrier.get("name") or "未知线槽" message += "\n线槽端点疑似断开:{0} @ {1}。请补齐相邻线槽、开口或辅助路径。".format( carrier_text, _format_point_text(sample.get("point")), ) isolated = _dict_items(diagnostic.get("isolated_components", []) or []) if isolated: sample = isolated[0] carriers = sample.get("carrier_labels") or sample.get("carrier_names") or [] carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知 carrier" message += "\n存在孤立路径网络:{0}。请用线槽/辅助路径把孤立网络接入主网络。".format(carrier_text) if not (unconnected or possible_breaks or isolated): first_issue = issues[0] message += "\n首个问题:{0} ({1})。".format( first_issue.get("code", "unknown"), first_issue.get("count", 0), ) return message def update_eplan_routing_path_network(doc, project_uuid="", options=None, selection_ex=None): """Update the routing path network before EPLAN-style Route.""" return generate_eplan_routing_path_network( doc, project_uuid=project_uuid, options=options, selection_ex=selection_ex, ) def route_eplan_connections( doc, payload=None, options=None, project_uuid="", selection_ex=None, update_network=True, ): """Route QET wire tasks through the EPLAN-style routing path network.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") prepared_network = None if update_network: prepared_network = update_eplan_routing_path_network( doc, project_uuid=project_uuid, options=options, selection_ex=selection_ex, ) target_payload = payload if target_payload is None: target_payload = getattr(App, "_qet_exchange_payload", None) if isinstance(target_payload, dict) and target_payload.get("wires"): report = route_eplan_connections_from_payload( doc, target_payload, options=options, prepared_layout=prepared_network, ) else: report = route_eplan_connection_tasks( doc, options=options, prepared_layout=prepared_network, ) report["routing_method"] = "eplan-route-v1" report["routing_path_network_updated"] = bool(update_network) if isinstance(prepared_network, dict): report["routing_path_network"] = prepared_network return report def wire_task_count(doc): return len(_iter_wire_tasks(doc)) def clear_routing_connections(doc): removed = 0 for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue try: _detach_object_from_groups(doc, obj) doc.removeObject(obj.Name) removed += 1 except Exception: pass try: doc.recompute() except Exception: pass return removed def _console_message(message): try: App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) except Exception: pass def _console_error(message): try: App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) except Exception: pass class CommandRouteEplanConnections: def GetResources(self): return { "MenuText": "生成布线连接(全部导线)", "ToolTip": "按布线路径网络生成全部 3D 布线连接", } def IsActive(self): return getattr(App, "ActiveDocument", None) is not None def Activated(self): doc = getattr(App, "ActiveDocument", None) try: payload = getattr(App, "_qet_exchange_payload", None) report = route_eplan_connections( doc, payload=payload if isinstance(payload, dict) and payload.get("wires") else None, update_network=True, ) if report.get("total_wires", 0) <= 0: _console_error("没有导线任务。生成布线连接需要 QET wires[] 或 QETWiring_01_Tasks。") return _console_message(format_eplan_connection_route_report(report)) except Exception as exc: _console_error("批量生成布线连接失败:{0}".format(exc)) _COMMANDS_REGISTERED = False def register_commands(): global _COMMANDS_REGISTERED if _COMMANDS_REGISTERED: return if Gui is None or not hasattr(Gui, "addCommand"): return Gui.addCommand("QET_Exchange_RouteEplanConnections", CommandRouteEplanConnections()) _COMMANDS_REGISTERED = True register_commands()