# FreeCADExchange 3D automatic wiring. # # 第一版不碰 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, # 没有线槽网络时,退回到这个方向抬高/偏移后做正交折线。 "clearance_axis": "z", "clearance": 80.0, "lane_axis": "y", "lane_spacing": 10.0, # 线槽网络相关参数。 "use_routing_network": True, "network_entry_max_distance": 1000.0, "bend_penalty": 25.0, # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 "carrier_kind_cost_factors": { "WireDuct": 1.0, "RoutingPath": 1.0, "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, "RoutingRange": 8.0, "SurfaceGrid": 8.0, }, # 默认不再生成长距离悬空 fallback;主干必须走 carrier/贴面网络。 "allow_floating_fallback": False, # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 "obstacle_clearance": 5.0, # 防止坐标异常或端子离路由网络过远时生成超长接入线,把 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 _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 _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 "QETAutoWire_{0}".format(TerminalObjects.safe_token(wire_uuid)) return "QETAutoWire_{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", "Auto route points") obj.Points = list(points) except Exception: pass def _set_string(obj, name, value, description="Auto-routing property"): TerminalObjects.ensure_string_property(obj, name, "QET Routing", description, value) def _route_payload(route_data, collisions, wire_style_id=""): points = route_data.get("points", []) return { "algorithm": route_data.get("algorithm", ""), "length_mm": _route_length(points), "wire_style_id": str(wire_style_id or "").strip(), "points": [_point_payload(point) for point in points], "collision_count": len(collisions), "collisions": collisions, "network": route_data.get("network", {}), } def _set_auto_metadata(wire, route_data, collisions, wire_style_id=""): length_mm = _route_length(route_data.get("points", [])) _set_string( wire, "QetAutoRouteAlgorithm", route_data.get("algorithm", ""), "Auto-routing algorithm used for this wire", ) _set_string( wire, "QetAutoRouteLengthMm", "{0:.3f}".format(length_mm), "Auto route length in millimeters", ) _set_string( wire, "QetWireStyleId", str(wire_style_id or "").strip(), "QET wire style ID", ) _set_string( wire, "QetAutoRouteDiagnosticsJson", json.dumps(_route_payload(route_data, collisions, wire_style_id=wire_style_id), ensure_ascii=False), "Auto-routing diagnostics", ) if route_data.get("network"): _set_string( wire, "QetAutoRouteNetworkJson", json.dumps(route_data.get("network", {}), ensure_ascii=False), "Route network metadata used by this wire", ) def build_orthogonal_route(start_terminal, end_terminal, route_index=0, options=None): opts = _merged_options(options) start_origin = _terminal_origin(start_terminal) end_origin = _terminal_origin(end_terminal) exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) clearance_axis = (opts.get("clearance_axis") or "z").lower() if clearance_axis not in {"x", "y", "z"}: clearance_axis = "z" lane_axis = (opts.get("lane_axis") or "y").lower() if lane_axis not in {"x", "y", "z"}: lane_axis = "y" clearance_value = max( _axis_value(start_exit, clearance_axis), _axis_value(end_exit, clearance_axis), ) + float(opts.get("clearance", 0.0) or 0.0) lane_offset = float(route_index or 0) * float(opts.get("lane_spacing", 0.0) or 0.0) lane_point = _with_axis(start_exit, clearance_axis, clearance_value) lane_point = _with_axis(lane_point, lane_axis, _axis_value(lane_point, lane_axis) + lane_offset) end_lane = _with_axis(end_exit, clearance_axis, clearance_value) end_lane = _with_axis(end_lane, lane_axis, _axis_value(end_lane, lane_axis) + lane_offset) points = [] _append_unique(points, start_origin) _append_unique(points, start_exit) _append_orthogonal(points, lane_point, preferred_axis=clearance_axis) _append_orthogonal(points, end_lane) _append_orthogonal(points, end_exit, preferred_axis=clearance_axis) _append_unique(points, end_origin) return { "algorithm": "orthogonal-v1", "points": points, "network": {}, } 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 = RoutingNetwork.nearest_node(network, start_exit) end_key, end_distance = RoutingNetwork.nearest_node(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_keys = RoutingNetwork.shortest_path( 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", {}), ) if not path_keys: return None carrier_points = RoutingNetwork.path_points(network, path_keys) if not carrier_points: return None 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) return { "algorithm": "network-dijkstra-v1", "points": points, "network": { "carriers": int(network.get("carrier_count", 0)), "segments": int(network.get("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), "obstacle_aware": bool(obstacle_aware), }, } 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) 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) 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_obstacles(doc, exclude=None, options=None): opts = _merged_options(options) excluded = set(id(obj) for obj in (exclude or []) if obj is not None) clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) 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 bbox = _bbox_payload(obj, clearance=clearance) if bbox is None: continue obstacles.append( { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), "bbox": 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): collisions = [] for index in range(max(len(points) - 1, 0)): start = points[index] end = points[index + 1] for obstacle in obstacles: if _segment_intersects_bbox(start, end, obstacle["bbox"]): collisions.append( { "segment_index": index, "obstacle_name": obstacle.get("name", ""), "obstacle_label": obstacle.get("label", ""), } ) return collisions def _remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=""): removed = 0 for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested": continue if wire_uuid and (getattr(obj, "QetWireUuid", "") or "").strip() != wire_uuid: continue 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 try: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed 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_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="", ): 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 auto-routing.") if opts.get("replace_existing", True): _remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=wire_uuid) route_data = build_network_route( start_terminal, end_terminal, route_index=route_index, options=opts, doc=doc, ) if route_data is None: if not opts.get("allow_floating_fallback", False): raise AutoRoutingError( "没有可用的线槽/路由路径网络;请先自动识别线槽生成路径,或选择线槽实体生成中心路径。" ) route_data = build_orthogonal_route( start_terminal, end_terminal, route_index=route_index, options=opts, ) points = route_data.get("points", []) if len(points) < 2: raise AutoRoutingError("Auto-routing produced fewer than two points.") obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) collisions = detect_collisions(points, obstacles) status = "CollisionWarning" if collisions else "Routed" wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid)) wire = _create_wire_geometry(doc, wire_name, points) wire.Label = wire_label or wire_mark or wire_uuid or "QET Auto Wire" 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="AutoSuggested", route_status=status, route_mode="Auto", net_uuid=net_uuid, group_uuid=group_uuid, wire_mark=wire_mark, wire_mark_is_manual=wire_mark_is_manual, ) _set_auto_metadata(wire, route_data, collisions, wire_style_id=effective_wire_style_id) 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) try: doc.recompute() except Exception: pass return { "wire": wire, "route_status": status, "algorithm": route_data.get("algorithm", ""), "network": route_data.get("network", {}), "points": 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 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_all_from_payload(doc, payload, options=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, "skipped_missing_terminal": 0, "skipped_invalid": 0, "missing_endpoint_uuids": [], "missing_endpoint_samples": [], "errors": [], "routes": [], } missing_endpoint_uuids = set() for index, item in enumerate(wires): if not isinstance(item, dict): report["skipped_invalid"] += 1 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 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 try: result = route_between_terminals( doc, start_terminal, end_terminal, route_index=index, options=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"), ) except Exception as exc: report["errors"].append(str(exc)) continue if result["route_status"] == "CollisionWarning": report["collision_warnings"] += 1 report["routed"] += 1 report["routes"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "algorithm": result["algorithm"], "route_status": result["route_status"], "collision_count": result["collision_count"], } ) report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) _write_auto_route_batch_diagnostic(doc, report) return report def format_route_all_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), ) 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), ) errors = report.get("errors", []) or [] if errors: message += "\n首个错误:{0}".format(str(errors[0])) 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}".format( sample.get("start_terminal_uuid", ""), sample.get("end_terminal_uuid", ""), ) return message def _clear_auto_route_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() != "AutoRouteBatch": 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_auto_route_batch_diagnostic(doc, report): if doc is None or not isinstance(report, dict): return None if not report.get("errors") and not report.get("missing_endpoint_uuids") and report.get("collision_warnings", 0) <= 0: return None project_uuid = _project_uuid(doc) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_auto_route_batch_diagnostics(doc) diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETAutoRouteDiagnostic")) diagnostic.Label = "QET Auto Route Diagnostic" _set_string(diagnostic, "QetDiagnosticKind", "AutoRouteBatch", "QET diagnostic kind") _set_string( diagnostic, "QetDiagnosticJson", json.dumps(report, ensure_ascii=False), "QET auto-routing 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(), "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(), } ) return payload def bind_wire_task_terminals_from_tasks(doc): return bind_wire_task_terminals_from_payload(doc, _wire_tasks_payload(doc)) def route_all_tasks(doc, options=None): payload = _wire_tasks_payload(doc) return route_all_from_payload(doc, payload, options=options) def prepare_eplan_style_layout(doc, project_uuid="", options=None): """Prepare the whole document for production auto-routing. EPLAN/SW 的操作语义是“对布线布局空间执行布线”,不是要求用户先点面、 画草图或手工补每个端子的接入线。这里统一生成:线槽中心路径、柜内 可布线面,以及端子到路由网络的自动接入 carrier。 """ 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_layout_space_from_document( 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), ) def wire_task_count(doc): return len(_iter_wire_tasks(doc)) def clear_auto_routes(doc): removed = 0 for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested": continue try: 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 CommandAutoRouteAll: 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: prepared_layout = prepare_eplan_style_layout(doc) payload = getattr(App, "_qet_exchange_payload", None) if isinstance(payload, dict) and payload.get("wires"): report = route_all_from_payload(doc, payload) else: report = route_all_tasks(doc) report["prepared_layout"] = prepared_layout if report.get("total_wires", 0) <= 0: _console_error("没有导线任务。一键自动布线需要 QET wires[] 或 QETWiring_01_Tasks。") return _console_message(format_route_all_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_AutoRouteAll", CommandAutoRouteAll()) _COMMANDS_REGISTERED = True register_commands()