# FreeCADExchange route carrier network helpers. # # 这个模块只管理 FreeCAD 文档里的走线网络,不写数据库。 # 第一版的思路是:用户或模板把线槽/导轨中心线标成 carrier, # 布线连接算法再沿这些 carrier 做最短路搜索。 import heapq import json import math import FreeCAD as App import TerminalObjects import WiringObjects ROUTING_ROLE = "RoutingCarrier" ROUTE_CARRIER_KIND = "RoutingPath" ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct" ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END = "WireDuctOpenEnd" ROUTE_CARRIER_KIND_WIRING_CUT_OUT = "WiringCutOut" ROUTE_CARRIER_KIND_USER_PATH = "UserPath" ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess" ROUTING_BOUNDARY_ROLE = "RoutingBoundary" ROUTING_BOUNDARY_KIND_CABINET_INTERIOR = "CabinetInterior" ROUTE_CONSTRAINT_MODE_REQUIRED = "Required" ROUTE_CONSTRAINT_MODE_FORBIDDEN = "Forbidden" BRIDGEABLE_ENDPOINT_CARRIER_KINDS = { ROUTE_CARRIER_KIND, ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_USER_PATH, ROUTE_CARRIER_KIND_AUXILIARY_PATH, } MANAGED_ROUTE_SOURCE_KINDS = { ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_ROUTING_RANGE, ROUTE_CARRIER_KIND_USER_PATH, } PROPERTY_GROUP = "QET Routing" DEFAULT_NODE_TOLERANCE = 0.001 DEFAULT_SURFACE_LANE_SPACING = 100.0 DEFAULT_SURFACE_OFFSET = 5.0 DEFAULT_SURFACE_MARGIN = 20.0 DEFAULT_WIRE_DUCT_MARGIN = 20.0 DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH = 20.0 DEFAULT_ROUTE_PATH_FACE_OFFSET = 2.0 DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION = 5.0 DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT = 2.5 DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE = 1000.0 DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE = 500.0 DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY = 25.0 DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY = 1000.0 DEFAULT_TERMINAL_ACCESS_FALLBACK_CARRIER_PENALTY = 5000.0 DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY = 2000.0 DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE = 10.0 DEFAULT_TERMINAL_EXIT_MAX_LENGTH = 80.0 DEFAULT_ADJOINING_DUCT_TOLERANCE = 5.0 DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION = 20.0 WIRE_DUCT_OBSTACLE_MODE = "PassThrough" SUPPORT_SURFACE_OBSTACLE_MODE = "SupportSurface" WIRE_DUCT_NAME_KEYWORDS = ( "wire duct", "wiring duct", "cable duct", "cable tray", "trunking", "wireway", "线槽", "走线槽", "走线", "电缆槽", "配线槽", ) WIRE_DUCT_EXCLUDE_KEYWORDS = ( "cabinet", "door", "panel", "backplate", "base plate", "mounting plate", "机柜", "柜体", "门板", "安装板", "背板", "底板", ) WIRING_CUT_OUT_NAME_KEYWORDS = ( "wiring cut-out", "wiring cutout", "wire cut-out", "wire cutout", "cable cut-out", "cable cutout", "through hole", "pass-through", "passthrough", "穿线孔", "过线孔", "开孔", "过线", ) SUPPORT_SURFACE_NAME_KEYWORDS = ( "mounting plate", "base plate", "back plate", "backplate", "panel", "door panel", "rear door", "front door", "side cover", "side panel", "cabinet face", "cabinet panel", "\u5b89\u88c5\u677f", "\u80cc\u677f", "\u5e95\u677f", "\u95e8\u677f", "\u4fa7\u76d6", "\u4fa7\u677f", "\u67dc\u9762", ) SUPPORT_SURFACE_CARRIER_KINDS = { "cabinet", "panel", "cabinet_face", "mounting_plate", "routing_range", } DEFAULT_KIND_COST_FACTORS = { ROUTE_CARRIER_KIND_WIRE_DUCT: 1.0, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END: 1.0, ROUTE_CARRIER_KIND_WIRING_CUT_OUT: 1.0, ROUTE_CARRIER_KIND: 1.0, ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0, ROUTE_CARRIER_KIND_TERMINAL_ACCESS: 2.0, ROUTE_CARRIER_KIND_ROUTING_RANGE: 40.0, ROUTE_CARRIER_KIND_USER_PATH: 1.0, } ROUTE_CARRIER_VIEW_STYLES = { ROUTE_CARRIER_KIND_WIRE_DUCT: { "color": (1.0, 0.55, 0.0), "width": 4.0, }, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END: { "color": (1.0, 0.72, 0.2), "width": 3.0, }, ROUTE_CARRIER_KIND_WIRING_CUT_OUT: { "color": (0.0, 0.72, 0.85), "width": 3.0, }, ROUTE_CARRIER_KIND_ROUTING_RANGE: { "color": (0.0, 0.65, 0.35), "width": 1.0, }, ROUTE_CARRIER_KIND_TERMINAL_ACCESS: { "color": (0.65, 0.2, 1.0), "width": 2.0, }, ROUTE_CARRIER_KIND_AUXILIARY_PATH: { "color": (0.45, 0.45, 0.45), "width": 2.0, }, ROUTE_CARRIER_KIND_USER_PATH: { "color": (0.95, 0.15, 0.15), "width": 3.0, }, ROUTE_CARRIER_KIND: { "color": (0.0, 0.45, 0.85), "width": 2.0, }, } class RoutingNetworkError(RuntimeError): pass class _SimpleBoundBox: def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax): self.XMin = float(xmin) self.XMax = float(xmax) self.YMin = float(ymin) self.YMax = float(ymax) self.ZMin = float(zmin) self.ZMax = float(zmax) class _PointVertex: def __init__(self, point): self.Point = point class _BBoxFace: ShapeType = "Face" def __init__(self, points, normal): self.Vertexes = [_PointVertex(point) for point in points] self._normal = normal self.QetSurfaceUAxis = _subtract(points[1], points[0]) if len(points) > 1 else None self.CenterOfMass = _average_points(points) xs = [point.x for point in points] ys = [point.y for point in points] zs = [point.z for point in points] self.BoundBox = _SimpleBoundBox(min(xs), max(xs), min(ys), max(ys), min(zs), max(zs)) def normalAt(self, _u, _v): return self._normal 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)) raise RoutingNetworkError("Route carrier point must be a 3D point.") 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 _placement_mult_vec(placement, point): if placement is None: return point try: transformed = placement.multVec(point) if transformed is not None: return _vector(transformed) except Exception: pass base = getattr(placement, "Base", None) if base is not None: return App.Vector(point.x + base.x, point.y + base.y, point.z + base.z) return point def _add(left, right): return App.Vector( float(left.x) + float(right.x), float(left.y) + float(right.y), float(left.z) + float(right.z), ) def _subtract(left, right): return App.Vector( float(left.x) - float(right.x), float(left.y) - float(right.y), float(left.z) - float(right.z), ) def _scale(vector, factor): return App.Vector( float(vector.x) * float(factor), float(vector.y) * float(factor), float(vector.z) * float(factor), ) def _closest_point_on_segment(point, start, end): target = _vector(point) start = _vector(start) end = _vector(end) segment = _subtract(end, start) length_squared = _dot(segment, segment) if length_squared <= DEFAULT_NODE_TOLERANCE * DEFAULT_NODE_TOLERANCE: return start parameter = _dot(_subtract(target, start), segment) / length_squared parameter = max(0.0, min(1.0, parameter)) return _add(start, _scale(segment, parameter)) def _dot(left, right): return ( float(left.x) * float(right.x) + float(left.y) * float(right.y) + float(left.z) * float(right.z) ) def _cross(left, right): return App.Vector( float(left.y) * float(right.z) - float(left.z) * float(right.y), float(left.z) * float(right.x) - float(left.x) * float(right.z), float(left.x) * float(right.y) - float(left.y) * float(right.x), ) def _normalize(vector): length = _distance(vector, App.Vector(0, 0, 0)) if length <= DEFAULT_NODE_TOLERANCE: return None return _scale(vector, 1.0 / length) def _direction_key(left, right, tolerance=DEFAULT_NODE_TOLERANCE): dx = float(right.x) - float(left.x) dy = float(right.y) - float(left.y) dz = float(right.z) - float(left.z) length = (dx * dx + dy * dy + dz * dz) ** 0.5 if length <= tolerance: return (0, 0, 0) return ( int(round(dx / length * 1000.0)), int(round(dy / length * 1000.0)), int(round(dz / length * 1000.0)), ) def _dominant_axis(vector): components = { "x": abs(float(getattr(vector, "x", 0.0))), "y": abs(float(getattr(vector, "y", 0.0))), "z": abs(float(getattr(vector, "z", 0.0))), } axis = max(components, key=components.get) if components[axis] <= 0.000001: return None return axis def _axis_value(point, axis): return float(getattr(point, axis, 0.0)) def _set_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 _bound_box_from_object(obj): if obj is None: return None shape = getattr(obj, "Shape", None) bbox = getattr(shape, "BoundBox", None) if bbox is not None: return bbox bbox = getattr(obj, "BoundBox", None) if bbox is not None: return bbox merged = None for child in list(getattr(obj, "Group", []) or []): child_bbox = _bound_box_from_object(child) if child_bbox is None: continue if merged is None: merged = _SimpleBoundBox( child_bbox.XMin, child_bbox.XMax, child_bbox.YMin, child_bbox.YMax, child_bbox.ZMin, child_bbox.ZMax, ) continue merged = _SimpleBoundBox( min(merged.XMin, child_bbox.XMin), max(merged.XMax, child_bbox.XMax), min(merged.YMin, child_bbox.YMin), max(merged.YMax, child_bbox.YMax), min(merged.ZMin, child_bbox.ZMin), max(merged.ZMax, child_bbox.ZMax), ) return merged def _bbox_center(bbox): return App.Vector( (float(bbox.XMin) + float(bbox.XMax)) * 0.5, (float(bbox.YMin) + float(bbox.YMax)) * 0.5, (float(bbox.ZMin) + float(bbox.ZMax)) * 0.5, ) def _average_points(points): points = list(points or []) if not points: return App.Vector(0, 0, 0) total = App.Vector(0, 0, 0) for point in points: total = _add(total, point) return _scale(total, 1.0 / len(points)) def _bbox_extent(bbox, axis): low, high = _bbox_axis_range(bbox, axis) return abs(high - low) def _point_key(point, tolerance=DEFAULT_NODE_TOLERANCE): scale = 1.0 / float(tolerance or DEFAULT_NODE_TOLERANCE) return ( int(round(float(point.x) * scale)), int(round(float(point.y) * scale)), int(round(float(point.z) * scale)), ) def _point_payload(point): return { "x": float(point.x), "y": float(point.y), "z": float(point.z), } def _bbox_axis_value(bbox, attr_name, dict_name): if isinstance(bbox, dict): return float(bbox[dict_name]) return float(getattr(bbox, attr_name)) def _point_inside_bbox(point, bbox, tolerance=0.000001): if point is None or bbox is None: return False try: point = _vector(point) return ( _bbox_axis_value(bbox, "XMin", "xmin") - tolerance <= float(point.x) <= _bbox_axis_value(bbox, "XMax", "xmax") + tolerance and _bbox_axis_value(bbox, "YMin", "ymin") - tolerance <= float(point.y) <= _bbox_axis_value(bbox, "YMax", "ymax") + tolerance and _bbox_axis_value(bbox, "ZMin", "zmin") - tolerance <= float(point.z) <= _bbox_axis_value(bbox, "ZMax", "zmax") + tolerance ) except Exception: return False def _point_inside_any_bbox(point, bboxes): return any(_point_inside_bbox(point, bbox) for bbox in bboxes or []) def _segment_inside_any_bbox(start, end, bboxes): if not bboxes: return True # 柜内区域采用包围盒语义;一段线必须整体落在同一个柜内盒里才进入优先路径图。 return any(_point_inside_bbox(start, bbox) and _point_inside_bbox(end, bbox) for bbox in bboxes or []) 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 _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 _ensure_vector_list_property(obj, prop_name, description): if prop_name not in getattr(obj, "PropertiesList", []): obj.addProperty( "App::PropertyVectorList", prop_name, PROPERTY_GROUP, description, ) def _ensure_integer_property(obj, prop_name, description, value): if prop_name not in getattr(obj, "PropertiesList", []): obj.addProperty( "App::PropertyInteger", prop_name, PROPERTY_GROUP, description, ) try: setattr(obj, prop_name, int(value)) except Exception: setattr(obj, prop_name, 0) def _ensure_float_property(obj, prop_name, description, value): if prop_name not in getattr(obj, "PropertiesList", []): obj.addProperty( "App::PropertyFloat", prop_name, PROPERTY_GROUP, description, ) try: setattr(obj, prop_name, float(value)) except Exception: setattr(obj, prop_name, 0.0) def _wiring_cut_out_bridge_extension_value(source, default=DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION): try: value = float(getattr(source, "QetWiringCutOutBridgeExtensionMm", default) or 0.0) except Exception: value = float(default or 0.0) if value < 0.0: return 0.0 return value def _set_route_carrier_semantics(obj, project_uuid="", kind=ROUTE_CARRIER_KIND, capacity=1): TerminalObjects.ensure_string_property( obj, "QetRoutingRole", PROPERTY_GROUP, "Routing role marker", ROUTING_ROLE, ) TerminalObjects.ensure_string_property( obj, "QetRouteCarrierKind", PROPERTY_GROUP, "Route carrier kind", kind, ) TerminalObjects.ensure_string_property( obj, "QetProjectUuid", PROPERTY_GROUP, "Project UUID for this route carrier", project_uuid, ) TerminalObjects.ensure_bool_property( obj, "CanRouteWire", PROPERTY_GROUP, "Whether routing connections can use this path", True, ) _ensure_integer_property( obj, "QetRouteCarrierCapacity", "How many routed wires can reuse this carrier segment before detouring is preferred", capacity, ) return obj def _route_carrier_capacity_value(obj, default=1): for property_name in ("QetRouteCarrierCapacity", "QetWireCapacity"): try: value = int(float(getattr(obj, property_name, 0) or 0)) except Exception: value = 0 if value > 0: return value return int(default or 1) def _normalized_route_capacity(capacity): try: normalized = int(float(capacity or 0)) except Exception: normalized = 1 return max(normalized, 1) def _set_route_carrier_capacity_value(obj, capacity): if obj is None: return _normalized_route_capacity(capacity) normalized = _normalized_route_capacity(capacity) _ensure_integer_property( obj, "QetRouteCarrierCapacity", "How many routed wires can reuse this carrier segment before detouring is preferred", normalized, ) return normalized def _wire_duct_end_margin_value(source, default=DEFAULT_WIRE_DUCT_MARGIN): try: value = float(getattr(source, "QetWireDuctEndMarginMm", default) or 0.0) except Exception: value = float(default or 0.0) if value < 0.0: return 0.0 return value def _set_wire_duct_source_semantics(source, end_margin=DEFAULT_WIRE_DUCT_MARGIN): if source is None: return TerminalObjects.ensure_string_property( source, "QetRoutingSourceKind", PROPERTY_GROUP, "Routing source kind", ROUTE_CARRIER_KIND_WIRE_DUCT, ) TerminalObjects.ensure_string_property( source, "QetRoutingObstacleMode", PROPERTY_GROUP, "How routing connection collision checks should treat this object", WIRE_DUCT_OBSTACLE_MODE, ) _ensure_integer_property( source, "QetRouteCarrierCapacity", "How many routed wires can reuse generated wire duct segments before detouring is preferred", _route_carrier_capacity_value(source, default=1), ) _ensure_float_property( source, "QetWireDuctEndMarginMm", "How far generated wire duct centerlines stay inside each duct end", _wire_duct_end_margin_value(source, default=end_margin), ) def _set_support_surface_source_semantics(source): if source is None: return TerminalObjects.ensure_string_property( source, "QetRoutingSourceKind", PROPERTY_GROUP, "Routing source kind", ROUTE_CARRIER_KIND_ROUTING_RANGE, ) TerminalObjects.ensure_string_property( source, "QetRoutingObstacleMode", PROPERTY_GROUP, "How routing connection collision checks should treat this object", SUPPORT_SURFACE_OBSTACLE_MODE, ) def _set_wiring_cut_out_source_semantics(source, bridge_extension=DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION): if source is None: return TerminalObjects.ensure_string_property( source, "QetRoutingSourceKind", PROPERTY_GROUP, "Routing source kind", ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ) TerminalObjects.ensure_string_property( source, "QetRoutingObstacleMode", PROPERTY_GROUP, "How routing connection collision checks should treat this object", WIRE_DUCT_OBSTACLE_MODE, ) _ensure_float_property( source, "QetWiringCutOutBridgeExtensionMm", "How far the generated wiring cut-out carrier extends beyond each side of the opening", _wiring_cut_out_bridge_extension_value(source, default=bridge_extension), ) def _set_user_path_source_semantics(source): if source is None: return TerminalObjects.ensure_string_property( source, "QetRoutingSourceKind", PROPERTY_GROUP, "Routing source kind", ROUTE_CARRIER_KIND_USER_PATH, ) def _set_user_path_sketch_semantics(sketch, project_uuid="", support=None, sub_element_name="", offset=DEFAULT_ROUTE_PATH_FACE_OFFSET): _set_user_path_source_semantics(sketch) TerminalObjects.ensure_string_property( sketch, "QetProjectUuid", PROPERTY_GROUP, "Project UUID for this route sketch", project_uuid, ) TerminalObjects.ensure_string_property( sketch, "QetRouteSketchMode", PROPERTY_GROUP, "Manual route sketch mode", "ManualUserPathSketch", ) TerminalObjects.ensure_string_property( sketch, "QetRouteSketchSupportName", PROPERTY_GROUP, "One-shot support object used when creating this manual route sketch", getattr(support, "Name", "") if support is not None else "", ) TerminalObjects.ensure_string_property( sketch, "QetRouteSketchSupportLabel", PROPERTY_GROUP, "One-shot support label used when creating this manual route sketch", getattr(support, "Label", "") if support is not None else "", ) TerminalObjects.ensure_string_property( sketch, "QetRouteSketchSupportSubElement", PROPERTY_GROUP, "One-shot support sub-element used when creating this manual route sketch", sub_element_name or "", ) _ensure_float_property( sketch, "QetRouteSketchFaceOffsetMm", "Offset from selected support face when creating this manual route sketch", offset, ) def _style_user_path_sketch(sketch): view = getattr(sketch, "ViewObject", None) if view is None: return for attr_name, value in ( ("LineColor", (1.0, 0.85, 0.0)), ("ShapeColor", (1.0, 0.85, 0.0)), ("PointColor", (1.0, 0.85, 0.0)), ): try: setattr(view, attr_name, value) except Exception: pass try: view.LineWidth = 3.0 except Exception: pass def _object_has_bbox(obj): shape = getattr(obj, "Shape", None) return getattr(shape, "BoundBox", None) is not None def _set_cabinet_interior_boundary_semantics(source): if source is None: return TerminalObjects.ensure_string_property( source, "QetRoutingRole", PROPERTY_GROUP, "Routing role marker", ROUTING_BOUNDARY_ROLE, ) TerminalObjects.ensure_string_property( source, "QetRoutingBoundaryKind", PROPERTY_GROUP, "Routing boundary kind", ROUTING_BOUNDARY_KIND_CABINET_INTERIOR, ) TerminalObjects.ensure_string_property( source, "QetRoutingObstacleMode", PROPERTY_GROUP, "How routing connection collision checks should treat this object", WIRE_DUCT_OBSTACLE_MODE, ) def _set_routing_obstacle_mode(source, mode): if source is None: return normalized = str(mode or "").strip() TerminalObjects.ensure_string_property( source, "QetRoutingObstacleMode", PROPERTY_GROUP, "How routing connection collision checks should treat this object", normalized, ) def set_routing_obstacle_mode(source, mode): _set_routing_obstacle_mode(source, mode) return source def mark_obstacle_mode_from_selection(selection_ex, mode): marked = [] seen = set() for item in selection_ex or []: source = getattr(item, "Object", None) if source is None or is_route_carrier(source) or id(source) in seen: continue set_routing_obstacle_mode(source, mode) seen.add(id(source)) marked.append(source) return marked def _style_route_carrier(carrier, kind): style = ROUTE_CARRIER_VIEW_STYLES.get(kind) or ROUTE_CARRIER_VIEW_STYLES[ROUTE_CARRIER_KIND] try: carrier.ViewObject.Visibility = True carrier.ViewObject.LineWidth = float(style.get("width", 2.0)) carrier.ViewObject.LineColor = style.get("color", (0.0, 0.45, 0.85)) # Keep routing helper geometry on stable solid-line rendering. Dashed/dotted # Coin3D line rendering can make large FreeCAD scenes disappear while rotating. if hasattr(carrier.ViewObject, "DrawStyle"): carrier.ViewObject.DrawStyle = "Solid" if hasattr(carrier.ViewObject, "DisplayMode"): carrier.ViewObject.DisplayMode = "Wireframe" except Exception: pass def _create_carrier_geometry(doc, name, points): # Use a simple Part edge shape by default. It is less feature-rich than Draft # Wire, but much more stable for large 3D scenes while rotating the view. try: import Part obj = doc.addObject("Part::Feature", name) obj.Shape = Part.makePolygon(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 return obj except Exception: pass obj = doc.addObject("App::FeaturePython", name) return obj def _normalized_route_points(points): normalized = [] for point in points or []: vector = _vector(point) if not _is_finite_point(vector): continue if not normalized or _distance(normalized[-1], vector) > DEFAULT_NODE_TOLERANCE: normalized.append(vector) return normalized def _set_route_carrier_points(carrier, points): _ensure_vector_list_property( carrier, "Points", "Ordered centerline points used by the 3D router", ) carrier.Points = list(points) try: import Part carrier.Shape = Part.makePolygon(points) except Exception: pass def _update_route_carrier(carrier, points, project_uuid="", kind=ROUTE_CARRIER_KIND, capacity=1): normalized = _normalized_route_points(points) if len(normalized) < 2: return False _set_route_carrier_points(carrier, normalized) _set_route_carrier_semantics(carrier, project_uuid=project_uuid, kind=kind, capacity=capacity) _style_route_carrier(carrier, kind) return True def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARRIER_KIND, capacity=1): """Create a routable carrier from ordered 3D points.""" if doc is None: raise RoutingNetworkError("No FreeCAD document is available.") normalized = _normalized_route_points(points) if len(normalized) < 2: raise RoutingNetworkError("A route carrier requires at least two distinct points.") name = _unique_name(doc, "QETRouteCarrier") carrier = _create_carrier_geometry(doc, name, normalized) carrier.Label = label or "QET Route Carrier" _set_route_carrier_points(carrier, normalized) _set_route_carrier_semantics(carrier, project_uuid=project_uuid, kind=kind, capacity=capacity) group = WiringObjects.ensure_carrier_group(doc, project_uuid) if carrier not in getattr(group, "Group", []): group.addObject(carrier) _style_route_carrier(carrier, kind) try: doc.recompute() except Exception: pass return carrier def is_route_carrier(obj): if obj is None: return False try: role = (getattr(obj, "QetRoutingRole", "") or "").strip() return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False)) except Exception: return False def is_routing_boundary(obj): if obj is None: return False boundary_kind = (getattr(obj, "QetRoutingBoundaryKind", "") or "").strip() if boundary_kind in {ROUTING_BOUNDARY_KIND_CABINET_INTERIOR, ROUTING_BOUNDARY_ROLE}: return True role = (getattr(obj, "QetRoutingRole", "") or "").strip() return role == ROUTING_BOUNDARY_ROLE def _carrier_points(obj): points = list(getattr(obj, "Points", []) or []) if points: return [_vector(point) for point in points] shape = getattr(obj, "Shape", None) ordered = getattr(shape, "OrderedVertexes", None) if ordered: return [_vector(vertex.Point) for vertex in ordered if getattr(vertex, "Point", None) is not None] vertexes = getattr(shape, "Vertexes", None) if vertexes: return [_vector(vertex.Point) for vertex in vertexes if getattr(vertex, "Point", None) is not None] return [] def _segment_axis(start, end, tolerance=DEFAULT_NODE_TOLERANCE): varying = [ axis for axis in ("x", "y", "z") if abs(_axis_value(start, axis) - _axis_value(end, axis)) > tolerance ] if len(varying) == 1: return varying[0] return None def _between(value, first, second, tolerance=DEFAULT_NODE_TOLERANCE): low = min(float(first), float(second)) - float(tolerance) high = max(float(first), float(second)) + float(tolerance) return low <= float(value) <= high def _dedupe_points(points, tolerance=DEFAULT_NODE_TOLERANCE): deduped = [] seen = set() for point in points: key = _point_key(point, tolerance=tolerance) if key in seen: continue seen.add(key) deduped.append(point) return deduped def _orthogonal_segment_intersections( first_start, first_end, second_start, second_end, tolerance=DEFAULT_NODE_TOLERANCE, ): first_axis = _segment_axis(first_start, first_end, tolerance=tolerance) second_axis = _segment_axis(second_start, second_end, tolerance=tolerance) if first_axis is None or second_axis is None: return [] if first_axis == second_axis: for axis in ("x", "y", "z"): if axis == first_axis: continue if abs(_axis_value(first_start, axis) - _axis_value(second_start, axis)) > tolerance: return [] first_low = min(_axis_value(first_start, first_axis), _axis_value(first_end, first_axis)) first_high = max(_axis_value(first_start, first_axis), _axis_value(first_end, first_axis)) second_low = min(_axis_value(second_start, second_axis), _axis_value(second_end, second_axis)) second_high = max(_axis_value(second_start, second_axis), _axis_value(second_end, second_axis)) overlap_low = max(first_low, second_low) overlap_high = min(first_high, second_high) if overlap_high < overlap_low - tolerance: return [] if abs(overlap_high - overlap_low) <= tolerance: return [_set_axis(first_start, first_axis, overlap_low)] return [ _set_axis(first_start, first_axis, overlap_low), _set_axis(first_start, first_axis, overlap_high), ] remaining_axes = [axis for axis in ("x", "y", "z") if axis not in {first_axis, second_axis}] if len(remaining_axes) != 1: return [] shared_axis = remaining_axes[0] if abs(_axis_value(first_start, shared_axis) - _axis_value(second_start, shared_axis)) > tolerance: return [] first_axis_value = _axis_value(second_start, first_axis) second_axis_value = _axis_value(first_start, second_axis) if not _between(first_axis_value, _axis_value(first_start, first_axis), _axis_value(first_end, first_axis), tolerance): return [] if not _between(second_axis_value, _axis_value(second_start, second_axis), _axis_value(second_end, second_axis), tolerance): return [] coordinates = { first_axis: first_axis_value, second_axis: second_axis_value, shared_axis: (_axis_value(first_start, shared_axis) + _axis_value(second_start, shared_axis)) * 0.5, } return [App.Vector(coordinates["x"], coordinates["y"], coordinates["z"])] def _sorted_segment_points(start, end, points, tolerance=DEFAULT_NODE_TOLERANCE): points = _dedupe_points(points, tolerance=tolerance) axis = _segment_axis(start, end, tolerance=tolerance) if axis is not None: reverse = _axis_value(start, axis) > _axis_value(end, axis) return sorted(points, key=lambda point: _axis_value(point, axis), reverse=reverse) return sorted(points, key=lambda point: _distance(start, point)) def _segment_intersects_bbox_payload(start, end, bbox): if not isinstance(bbox, dict): return False try: 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) <= DEFAULT_NODE_TOLERANCE: 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 except Exception: return False return True def _segment_hits_blocked_bbox(start, end, blocked_bboxes): for bbox in blocked_bboxes or []: if _segment_intersects_bbox_payload(start, end, bbox): return True return False def _bbox_payload(bbox, clearance=0.0): if bbox is None: return None margin = max(float(clearance or 0.0), 0.0) try: return { "xmin": float(bbox.XMin) - margin, "xmax": float(bbox.XMax) + margin, "ymin": float(bbox.YMin) - margin, "ymax": float(bbox.YMax) + margin, "zmin": float(bbox.ZMin) - margin, "zmax": float(bbox.ZMax) + margin, } except Exception: return None def collect_route_carriers(doc): if doc is None: return [] group = None try: group = doc.getObject("QETWiring_02_Carriers") except Exception: group = None candidates = [] if group is not None: candidates.extend(list(getattr(group, "Group", []) or [])) candidates.extend(list(getattr(doc, "Objects", []) or [])) result = [] seen = set() for obj in candidates: if obj is None or id(obj) in seen: continue seen.add(id(obj)) if is_route_carrier(obj): result.append(obj) return result def set_route_carriers_visibility(doc, visible): """Show or hide generated route carrier helper objects.""" updated = 0 for carrier in collect_route_carriers(doc): try: carrier.ViewObject.Visibility = bool(visible) updated += 1 except Exception: pass try: doc.recompute() except Exception: pass return updated def _detach_from_groups(doc, obj): for parent in list(getattr(obj, "InList", []) or []): 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 for parent in list(getattr(doc, "Objects", []) or []): 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 _remove_route_carriers(doc, carriers): removed = 0 for carrier in list(carriers or []): if carrier is None or not is_route_carrier(carrier): continue _detach_from_groups(doc, carrier) try: if doc.getObject(getattr(carrier, "Name", "")) is not None: doc.removeObject(carrier.Name) removed += 1 except Exception: pass return removed def clear_route_carriers(doc): """Delete generated route carriers while keeping terminals and routed wires.""" removed = _remove_route_carriers(doc, collect_route_carriers(doc)) try: doc.recompute() except Exception: pass return removed def _shape_center(shape): bbox = getattr(shape, "BoundBox", None) if bbox is None: return None return App.Vector( (float(bbox.XMin) + float(bbox.XMax)) * 0.5, (float(bbox.YMin) + float(bbox.YMax)) * 0.5, (float(bbox.ZMin) + float(bbox.ZMax)) * 0.5, ) def _edge_points(edge): discretize = getattr(edge, "discretize", None) if callable(discretize): try: # 草图弧线/样条需要离散成折线,否则会被首尾点直接拉直。 points = [_vector(point) for point in discretize(Deflection=DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION)] if len(points) >= 2: return points except Exception: pass first = None last = None vertexes = list(getattr(edge, "Vertexes", []) or []) if len(vertexes) >= 2: first = getattr(vertexes[0], "Point", None) last = getattr(vertexes[-1], "Point", None) if first is not None and last is not None: return [_vector(first), _vector(last)] try: first = edge.valueAt(edge.FirstParameter) last = edge.valueAt(edge.LastParameter) return [_vector(first), _vector(last)] except Exception: return [] def _wire_points(wire): discretize = getattr(wire, "discretize", None) if callable(discretize): try: # 整条 Wire 的拓扑顺序通常比逐条 Edge 更稳定,优先用它保留草图路径走向。 points = [_vector(point) for point in discretize(Deflection=DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION)] if len(points) >= 2: return points except Exception: pass points = [] for edge in list(getattr(wire, "Edges", []) or []): points.extend(_edge_points(edge)) return points def _object_global_placement(obj): if obj is None: return None try: if hasattr(obj, "getGlobalPlacement"): placement = obj.getGlobalPlacement() if placement is not None: return placement except Exception: pass return getattr(obj, "Placement", None) def _route_source_geometry_placement(obj): type_id = (getattr(obj, "TypeId", "") or "").lower() if "sketch" in type_id: # 真实 Sketcher::SketchObject 的 Shape 点已经带有 Attachment/Placement,不能再平移一次。 return None return _object_global_placement(obj) def _points_with_placement(points, placement): return [_placement_mult_vec(placement, _vector(point)) for point in points] def _is_valid_route_source_bbox(bbox, max_extent=1.0e9): if bbox is None: return True for axis in ("x", "y", "z"): try: extent = float(_bbox_extent(bbox, axis)) except Exception: return False if not math.isfinite(extent) or abs(extent) > float(max_extent or 0.0): return False return True def _is_route_path_source_object(obj): if obj is None: return False if is_route_carrier(obj): return False if is_routing_boundary(obj): return False name = str(getattr(obj, "Name", "") or "").lower() label = str(getattr(obj, "Label", "") or "").lower() if name.startswith(("x_axis", "y_axis", "z_axis")) or label.startswith(("x轴", "y轴", "z轴")): return False type_id = (getattr(obj, "TypeId", "") or "").lower() if "sketch" in type_id: return True if list(getattr(obj, "Points", []) or []): return True shape = getattr(obj, "Shape", None) if shape is None: return False if not _is_valid_route_source_bbox(getattr(shape, "BoundBox", None)): return False # SOLIDWORKS/EPLAN 的 routing path 是草图/线槽路径,不是把实体零件的全部边都当路径。 # 所以只有纯线状对象才允许整对象转换;带 Face/Solid 的实体必须显式选中边。 faces = list(getattr(shape, "Faces", []) or []) solids = list(getattr(shape, "Solids", []) or []) shells = list(getattr(shape, "Shells", []) or []) if faces or solids or shells: return False return bool(list(getattr(shape, "Wires", []) or []) or list(getattr(shape, "Edges", []) or [])) def _routing_source_text(obj): return " ".join( str(value or "") for value in ( getattr(obj, "Name", ""), getattr(obj, "Label", ""), getattr(obj, "QetCarrierKind", ""), getattr(obj, "QetCarrierRoleLabel", ""), getattr(obj, "QetRoutingSourceKind", ""), ) ).lower() def _is_explicit_user_path_source(obj): return ( (getattr(obj, "QetRoutingSourceKind", "") or "").strip() == ROUTE_CARRIER_KIND_USER_PATH or (getattr(obj, "QetRouteSketchMode", "") or "").strip() == "ManualUserPathSketch" ) def _bbox_aspect_ratio(bbox): extents = sorted( (_bbox_extent(bbox, axis) for axis in ("x", "y", "z")), reverse=True, ) if not extents or extents[0] <= DEFAULT_NODE_TOLERANCE: return 0.0 if len(extents) < 2 or extents[1] <= DEFAULT_NODE_TOLERANCE: return float("inf") return extents[0] / extents[1] def _is_wire_duct_candidate(obj, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): if obj is None: return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if _is_explicit_user_path_source(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False text = _routing_source_text(obj) if any(keyword in text for keyword in WIRE_DUCT_EXCLUDE_KEYWORDS): return False has_semantic_hint = ( (getattr(obj, "QetRoutingSourceKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT or (getattr(obj, "QetCarrierKind", "") or "").strip().lower() == "wire_duct" ) has_name_hint = any(keyword in text for keyword in WIRE_DUCT_NAME_KEYWORDS) if not has_semantic_hint and not has_name_hint: return False bbox = _bound_box_from_object(obj) if bbox is None: return False # 自动识别只接受明显细长的对象,避免把柜体、门板、安装板误判成线槽。 return _bbox_aspect_ratio(bbox) >= float(min_aspect or 1.0) def _bbox_extents(bbox): return { axis: _bbox_extent(bbox, axis) for axis in ("x", "y", "z") } def _is_thin_surface_bbox(bbox, min_surface_extent=50.0, max_thickness=40.0, thickness_ratio=0.2): extents = _bbox_extents(bbox) ordered = sorted(extents.values()) if len(ordered) < 3 or ordered[-1] <= DEFAULT_NODE_TOLERANCE: return False thickness = ordered[0] second_extent = ordered[1] longest = ordered[2] if second_extent < float(min_surface_extent or 0.0): return False allowed_thickness = min(float(max_thickness or 0.0), longest * float(thickness_ratio or 0.0)) return thickness <= max(allowed_thickness, DEFAULT_NODE_TOLERANCE) def _is_support_surface_candidate(obj): if obj is None: return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if _is_explicit_user_path_source(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False text = _routing_source_text(obj) if any(keyword in text for keyword in WIRE_DUCT_NAME_KEYWORDS): return False carrier_kind = (getattr(obj, "QetCarrierKind", "") or "").strip().lower() source_kind = (getattr(obj, "QetRoutingSourceKind", "") or "").strip() has_semantic_hint = ( source_kind == ROUTE_CARRIER_KIND_ROUTING_RANGE or carrier_kind in SUPPORT_SURFACE_CARRIER_KINDS ) has_name_hint = any(keyword in text for keyword in SUPPORT_SURFACE_NAME_KEYWORDS) if not has_semantic_hint and not has_name_hint: return False bbox = _bound_box_from_object(obj) if bbox is None: return False return _is_thin_surface_bbox(bbox) def _is_wiring_cut_out_candidate(obj): if obj is None: return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if _is_explicit_user_path_source(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False source_kind = (getattr(obj, "QetRoutingSourceKind", "") or "").strip() carrier_kind = (getattr(obj, "QetCarrierKind", "") or "").strip().lower() has_semantic_hint = ( source_kind == ROUTE_CARRIER_KIND_WIRING_CUT_OUT or carrier_kind in {"wiring_cut_out", "wiring_cutout", "wire_cutout"} ) text = _routing_source_text(obj) has_name_hint = any(keyword in text for keyword in WIRING_CUT_OUT_NAME_KEYWORDS) if not has_semantic_hint and not has_name_hint: return False return _bound_box_from_object(obj) is not None def _support_face_from_bbox(bbox): extents = _bbox_extents(bbox) normal_axis = min(extents, key=extents.get) surface_axes = sorted( [axis for axis in ("x", "y", "z") if axis != normal_axis], key=lambda axis: _bbox_extent(bbox, axis), reverse=True, ) normal_value = _bbox_axis_range(bbox, normal_axis)[1] normal = App.Vector( 1.0 if normal_axis == "x" else 0.0, 1.0 if normal_axis == "y" else 0.0, 1.0 if normal_axis == "z" else 0.0, ) first_axis = surface_axes[0] second_axis = surface_axes[1] first_low, first_high = _bbox_axis_range(bbox, first_axis) second_low, second_high = _bbox_axis_range(bbox, second_axis) points = [] for first_value, second_value in ( (first_low, second_low), (first_high, second_low), (first_high, second_high), (first_low, second_high), ): coordinates = { normal_axis: normal_value, first_axis: first_value, second_axis: second_value, } points.append(App.Vector(coordinates["x"], coordinates["y"], coordinates["z"])) return _BBoxFace(points, normal) def _normalize_point_run(points): normalized = [] for point in points or []: if not normalized or _distance(normalized[-1], point) > DEFAULT_NODE_TOLERANCE: normalized.append(point) return normalized def _point_runs_from_selection_item(selection_item): runs = [] points = [] obj = getattr(selection_item, "Object", None) placement = _object_global_placement(obj) geometry_placement = _route_source_geometry_placement(obj) for point in list(getattr(selection_item, "PickedPoints", []) or []): points.append(_vector(point)) for sub_object in list(getattr(selection_item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() if shape_type == "wire": if points: runs.append(points) points = [] runs.append(_points_with_placement(_wire_points(sub_object), geometry_placement)) continue if shape_type == "edge": points.extend(_points_with_placement(_edge_points(sub_object), geometry_placement)) continue if shape_type == "vertex": point = getattr(sub_object, "Point", None) if point is not None: points.append(_placement_mult_vec(geometry_placement, _vector(point))) continue center = _shape_center(sub_object) if center is not None: points.append(_placement_mult_vec(geometry_placement, center)) if obj is not None and _is_route_path_source_object(obj): for point in list(getattr(obj, "Points", []) or []): points.append(_placement_mult_vec(placement, _vector(point))) shape = getattr(obj, "Shape", None) if shape is not None and _is_route_path_source_object(obj): wires = list(getattr(shape, "Wires", []) or []) if wires: if points: runs.append(points) points = [] for wire in wires: runs.append(_points_with_placement(_wire_points(wire), geometry_placement)) else: for edge in list(getattr(shape, "Edges", []) or []): points.extend(_points_with_placement(_edge_points(edge), geometry_placement)) if not runs and not points: center = _shape_center(shape) if center is not None: points.append(_placement_mult_vec(geometry_placement, center)) if points: runs.append(points) normalized_runs = [] for run in runs: normalized = _normalize_point_run(run) if len(normalized) >= 2: normalized_runs.append(normalized) return normalized_runs def _points_from_selection_item(selection_item): points = [] for run in _point_runs_from_selection_item(selection_item): points.extend(run) return _normalize_point_run(points) def _support_face_from_selection(selection_ex): for item in selection_ex or []: for sub_object in list(getattr(item, "SubObjects", []) or []): if (getattr(sub_object, "ShapeType", "") or "").lower() == "face": return sub_object return None def _support_face_selection_from_selection(selection_ex): for item in selection_ex or []: source = getattr(item, "Object", None) sub_names = list(getattr(item, "SubElementNames", []) or []) for index, sub_object in enumerate(list(getattr(item, "SubObjects", []) or [])): if (getattr(sub_object, "ShapeType", "") or "").lower() != "face": continue return { "face": sub_object, "source": source, "sub_element_name": sub_names[index] if index < len(sub_names) else "", } return None def _selection_item_is_only_support_face(selection_item): sub_objects = list(getattr(selection_item, "SubObjects", []) or []) if not sub_objects: return False return all( (getattr(sub_object, "ShapeType", "") or "").lower() == "face" for sub_object in sub_objects ) def _face_normal(face): try: return _vector(face.normalAt(0.5, 0.5)) except Exception: pass try: return _vector(face.normalAt(0.0, 0.0)) except Exception: pass return None def _bbox_axis_range(bbox, axis): if axis == "x": return float(bbox.XMin), float(bbox.XMax) if axis == "y": return float(bbox.YMin), float(bbox.YMax) return float(bbox.ZMin), float(bbox.ZMax) def _surface_grid_values(min_value, max_value, spacing, margin): low = float(min_value) + float(margin) high = float(max_value) - float(margin) if high < low: low = float(min_value) high = float(max_value) if abs(high - low) <= DEFAULT_NODE_TOLERANCE: return [low] spacing = max(float(spacing or DEFAULT_SURFACE_LANE_SPACING), 1.0) values = [low] current = low + spacing while current < high - DEFAULT_NODE_TOLERANCE: values.append(current) current += spacing if abs(values[-1] - high) > DEFAULT_NODE_TOLERANCE: values.append(high) return values def _face_points(face): points = [] for vertex in list(getattr(face, "Vertexes", []) or []): point = getattr(vertex, "Point", None) if point is not None: points.append(_vector(point)) if points: return points bbox = getattr(face, "BoundBox", None) if bbox is None: return [] return [ App.Vector(bbox.XMin, bbox.YMin, bbox.ZMin), App.Vector(bbox.XMin, bbox.YMin, bbox.ZMax), App.Vector(bbox.XMin, bbox.YMax, bbox.ZMin), App.Vector(bbox.XMin, bbox.YMax, bbox.ZMax), App.Vector(bbox.XMax, bbox.YMin, bbox.ZMin), App.Vector(bbox.XMax, bbox.YMin, bbox.ZMax), App.Vector(bbox.XMax, bbox.YMax, bbox.ZMin), App.Vector(bbox.XMax, bbox.YMax, bbox.ZMax), ] def _face_origin(face, fallback_points): center = getattr(face, "CenterOfMass", None) if center is not None: return _vector(center) if fallback_points: total = App.Vector(0, 0, 0) for point in fallback_points: total = _add(total, point) return _scale(total, 1.0 / len(fallback_points)) bbox = getattr(face, "BoundBox", None) if bbox is not None: return _bbox_center(bbox) return App.Vector(0, 0, 0) def _face_u_axis(face, normal, points, origin): explicit_axis = getattr(face, "QetSurfaceUAxis", None) if explicit_axis is not None: explicit_axis = _vector(explicit_axis) candidate = _subtract(explicit_axis, _scale(normal, _dot(explicit_axis, normal))) normalized = _normalize(candidate) if normalized is not None: return normalized best = None best_length = 0.0 for left in points: for right in points: candidate = _subtract(right, left) candidate = _subtract(candidate, _scale(normal, _dot(candidate, normal))) length = _distance(candidate, App.Vector(0, 0, 0)) if length > best_length: best = candidate best_length = length if best is not None and best_length > DEFAULT_NODE_TOLERANCE: return _normalize(best) seed = App.Vector(1, 0, 0) if abs(_dot(normal, seed)) > 0.9: seed = App.Vector(0, 1, 0) candidate = _subtract(seed, _scale(normal, _dot(seed, normal))) return _normalize(candidate) def _surface_face_grid_points(face, spacing, offset, margin): normal = _normalize(_face_normal(face)) if normal is None: return [] face_points = _face_points(face) origin = _face_origin(face, face_points) if not face_points: return [] u_axis = _face_u_axis(face, normal, face_points, origin) if u_axis is None: return [] v_axis = _normalize(_cross(normal, u_axis)) if v_axis is None: return [] projected_u = [] projected_v = [] for point in face_points: relative = _subtract(point, origin) projected_u.append(_dot(relative, u_axis)) projected_v.append(_dot(relative, v_axis)) first_values = _surface_grid_values(min(projected_u), max(projected_u), spacing, margin) second_values = _surface_grid_values(min(projected_v), max(projected_v), spacing, margin) if len(first_values) < 2 or len(second_values) < 2: return [] plane_origin = _add(origin, _scale(normal, float(offset or 0.0))) rows = [] for second_value in second_values: row = [] for first_value in first_values: point = _add( _add(plane_origin, _scale(u_axis, first_value)), _scale(v_axis, second_value), ) row.append(point) rows.append(row) columns = [] for first_index in range(len(first_values)): column = [] for row in rows: column.append(row[first_index]) columns.append(column) # 行和列都要生成 carrier,Dijkstra 才能在网格交点处横竖换向。 return rows + columns def _project_points_to_face(points, face, offset=DEFAULT_ROUTE_PATH_FACE_OFFSET): normal = _normalize(_face_normal(face)) if normal is None: return list(points or []) face_points = _face_points(face) origin = _face_origin(face, face_points) distances = [_dot(_subtract(point, origin), normal) for point in points or []] if not distances: return [] # 保留线段原本所在的面侧,避免投影到板子的背面。 average_distance = sum(distances) / float(len(distances)) signed_offset = abs(float(offset or 0.0)) if average_distance < 0.0: signed_offset = -signed_offset projected = [] for point, distance in zip(points, distances): projected.append(_subtract(point, _scale(normal, distance - signed_offset))) return projected def _rotation_from_face_normal(normal): try: rotation = App.Rotation(App.Vector(0, 0, 1), normal) if rotation is not None: return rotation except Exception: pass try: return App.Rotation() except Exception: return None def _attach_sketch_to_support_face(sketch, support, sub_element_name, offset, fallback_base, normal): if sketch is None: return attached = False if support is not None and sub_element_name: try: sketch.AttachmentSupport = [(support, sub_element_name)] sketch.MapMode = "FlatFace" sketch.AttachmentOffset = App.Placement( App.Vector(0, 0, abs(float(offset or 0.0))), App.Rotation(), ) attached = True except Exception: attached = False try: sketch.Placement = App.Placement(fallback_base, _rotation_from_face_normal(normal)) except Exception: pass if attached: # Attachment 是真实 FreeCAD 里的编辑语义;Placement 仍保留为测试和旧对象兜底。 return def create_user_path_sketch_from_selection( doc, selection_ex, project_uuid="", offset=DEFAULT_ROUTE_PATH_FACE_OFFSET, ): """Create a one-shot Sketcher sketch on the selected cabinet/support face for manual UserPath drawing.""" if doc is None: raise RoutingNetworkError("没有可用的 FreeCAD 文档。") support = _support_face_selection_from_selection(selection_ex) if support is None: raise RoutingNetworkError("请先选中机柜面板、安装板、门板或线槽上的一个面 Face,再创建布线路径草图。") face = support["face"] normal = _normalize(_face_normal(face)) if normal is None: raise RoutingNetworkError("选中的 Face 无法确定法向,不能创建布线路径草图。") face_points = _face_points(face) origin = _face_origin(face, face_points) base = _add(origin, _scale(normal, abs(float(offset or 0.0)))) name = _unique_name(doc, "QETUserPathSketch") sketch = doc.addObject("Sketcher::SketchObject", name) sketch.Label = "布线路径草图" _attach_sketch_to_support_face( sketch, support.get("source"), support.get("sub_element_name", ""), abs(float(offset or 0.0)), base, normal, ) _set_user_path_sketch_semantics( sketch, project_uuid=project_uuid, support=support.get("source"), sub_element_name=support.get("sub_element_name", ""), offset=abs(float(offset or 0.0)), ) _style_user_path_sketch(sketch) try: doc.recompute() except Exception: pass return { "sketch": sketch, "support": support.get("source"), "sub_element_name": support.get("sub_element_name", ""), "offset": abs(float(offset or 0.0)), } def _selected_points_from_selection(selection_ex): points = [] for item in selection_ex or []: obj = getattr(item, "Object", None) placement = _object_global_placement(obj) geometry_placement = _route_source_geometry_placement(obj) for point in list(getattr(item, "PickedPoints", []) or []): points.append(_vector(point)) for sub_object in list(getattr(item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() if shape_type == "wire": points.extend(_points_with_placement(_wire_points(sub_object), geometry_placement)) continue if shape_type == "edge": points.extend(_points_with_placement(_edge_points(sub_object), geometry_placement)) continue if shape_type == "vertex": point = getattr(sub_object, "Point", None) if point is not None: points.append(_placement_mult_vec(geometry_placement, _vector(point))) continue center = _shape_center(sub_object) if center is not None: points.append(_placement_mult_vec(geometry_placement, center)) if obj is not None and _is_route_path_source_object(obj): for point in list(getattr(obj, "Points", []) or []): points.append(_placement_mult_vec(placement, _vector(point))) return _normalize_point_run(points) def create_user_path_carrier_from_selected_points(doc, selection_ex, project_uuid="", label="QET 3D User Route Path"): """Create one 3D UserPath from selected vertices, picked points, or point-like sub-objects in selection order.""" if doc is None: raise RoutingNetworkError("没有可用的 FreeCAD 文档。") points = _selected_points_from_selection(selection_ex) if len(points) < 2: raise RoutingNetworkError("请至少按顺序选择两个 3D 点、顶点、边端点或带 Points 的路径对象。") carrier = create_route_carrier( doc, points, label=label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, ) try: doc.recompute() except Exception: pass return { "carrier": carrier, "points": points, } def _orthogonal_points_between(start, end, axis_order=("x", "y", "z")): current = _vector(start) end = _vector(end) points = [current] for axis in axis_order or ("x", "y", "z"): if abs(_axis_value(current, axis) - _axis_value(end, axis)) <= DEFAULT_NODE_TOLERANCE: continue current = _set_axis(current, axis, _axis_value(end, axis)) if _distance(points[-1], current) > DEFAULT_NODE_TOLERANCE: points.append(current) if _distance(points[-1], end) > DEFAULT_NODE_TOLERANCE: points.append(end) return points def _orthogonalize_points(points, axis_order=("x", "y", "z")): source_points = _normalize_point_run([_vector(point) for point in points or []]) if len(source_points) < 2: return source_points result = [source_points[0]] for end in source_points[1:]: segment_points = _orthogonal_points_between(result[-1], end, axis_order=axis_order) for point in segment_points[1:]: if _distance(result[-1], point) > DEFAULT_NODE_TOLERANCE: result.append(point) return _normalize_point_run(result) def create_orthogonal_user_path_carrier_from_selected_points( doc, selection_ex, project_uuid="", label="QET Orthogonal 3D User Route Path", axis_order=("x", "y", "z"), ): """Create one X/Y/Z orthogonal 3D UserPath from selected points in selection order.""" if doc is None: raise RoutingNetworkError("没有可用的 FreeCAD 文档。") points = _selected_points_from_selection(selection_ex) if len(points) < 2: raise RoutingNetworkError("请至少按顺序选择两个 3D 点、顶点、边端点或带 Points 的路径对象。") orthogonal_points = _orthogonalize_points(points, axis_order=axis_order) carrier = create_route_carrier( doc, orthogonal_points, label=label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, ) TerminalObjects.ensure_string_property( carrier, "QetRoutePathMode", PROPERTY_GROUP, "Manual 3D route path mode", "Orthogonal3D", ) try: doc.recompute() except Exception: pass return { "carrier": carrier, "points": orthogonal_points, } def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUTE_CARRIER_KIND): created = [] support_face = _support_face_from_selection(selection_ex) for index, item in enumerate(selection_ex or [], start=1): if support_face is not None and _selection_item_is_only_support_face(item): continue point_runs = _point_runs_from_selection_item(item) if support_face is not None: # 如果同时选中了支撑面和草图/线段,先把草图点投影到支撑面的平面上。 # Draft 自身只记录工作平面坐标,不会自动吸附到柜板面。 point_runs = [_project_points_to_face(points, support_face) for points in point_runs] point_runs = [_normalize_point_run(points) for points in point_runs] point_runs = [points for points in point_runs if len(points) >= 2] if not point_runs: continue for run_index, points in enumerate(point_runs, start=1): label = "QET Route Carrier {0}".format(index) if len(point_runs) > 1: label = "{0} {1}".format(label, run_index) created.append( create_route_carrier( doc, points, label=label, project_uuid=project_uuid, kind=kind, ) ) return created def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid=""): """Create or refresh user-defined spatial route paths from selected sketches/edges.""" cleanup_invalid_source_carriers(doc) created = [] support_face = _support_face_from_selection(selection_ex) seen_sources = set() for index, item in enumerate(selection_ex or [], start=1): if support_face is not None and _selection_item_is_only_support_face(item): continue source = getattr(item, "Object", None) if source is not None: if id(source) in seen_sources: continue seen_sources.add(id(source)) if is_route_carrier(source) or is_routing_boundary(source): continue if ( _is_wire_duct_candidate(source) or _is_support_surface_candidate(source) or _is_wiring_cut_out_candidate(source) ): continue point_runs = _point_runs_from_selection_item(item) if support_face is not None: point_runs = [_project_points_to_face(points, support_face) for points in point_runs] point_runs = [_normalize_point_run(points) for points in point_runs] point_runs = [points for points in point_runs if len(points) >= 2] if not point_runs: if source is not None: live_carriers = _live_source_carriers(doc, source) if live_carriers: _remove_route_carriers(doc, live_carriers) _mark_user_path_source_carriers(source, []) continue label = "QET User Route Path {0}".format(index) capacity = 1 if source is not None: label = "QET User Route Path {0}".format( getattr(source, "Label", "") or getattr(source, "Name", "") or index ) capacity = _route_carrier_capacity_value(source, default=1) live_carriers = _live_source_carriers(doc, source) if live_carriers: refreshed = [] for run_index, points in enumerate(point_runs, start=1): if run_index <= len(live_carriers): carrier = live_carriers[run_index - 1] if _update_route_carrier( carrier, points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=capacity, ): refreshed.append(carrier) continue run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) refreshed.append( create_route_carrier( doc, points, label=run_label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=capacity, ) ) if len(live_carriers) > len(point_runs): _remove_route_carriers(doc, live_carriers[len(point_runs) :]) _mark_user_path_source_carriers(source, refreshed) created.extend(refreshed) continue new_carriers = [] for run_index, points in enumerate(point_runs, start=1): run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) carrier = create_route_carrier( doc, points, label=run_label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=capacity, ) new_carriers.append(carrier) created.append(carrier) if source is not None: _mark_user_path_source_carriers(source, new_carriers) return created def _create_or_refresh_user_path_source(doc, source, project_uuid="", label_prefix="QET User Route Path"): if source is None or is_route_carrier(source) or is_routing_boundary(source): return [] point_runs = _point_runs_from_selection_item(type("_Selection", (), {"Object": source, "SubObjects": []})()) point_runs = [_normalize_point_run(points) for points in point_runs] point_runs = [points for points in point_runs if len(points) >= 2] if not point_runs: live_carriers = _live_source_carriers(doc, source) if live_carriers: _remove_route_carriers(doc, live_carriers) _mark_user_path_source_carriers(source, []) return [] label = "{0} {1}".format( label_prefix, getattr(source, "Label", "") or getattr(source, "Name", "") or "", ).strip() capacity = _route_carrier_capacity_value(source, default=1) live_carriers = _live_source_carriers(doc, source) refreshed = [] for run_index, points in enumerate(point_runs, start=1): run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) if run_index <= len(live_carriers): carrier = live_carriers[run_index - 1] if _update_route_carrier( carrier, points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=capacity, ): refreshed.append(carrier) continue refreshed.append( create_route_carrier( doc, points, label=run_label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=capacity, ) ) if len(live_carriers) > len(point_runs): _remove_route_carriers(doc, live_carriers[len(point_runs) :]) _mark_user_path_source_carriers(source, refreshed) return refreshed def create_user_path_carriers_from_document(doc, project_uuid=""): """Create or refresh UserPath carriers from all sketch/Draft-like path sources in the document.""" cleanup_invalid_source_carriers(doc) created = [] for source in detect_document_user_path_sources(doc): if ( _is_wire_duct_candidate(source) or _is_support_surface_candidate(source) or _is_wiring_cut_out_candidate(source) ): continue created.extend(_create_or_refresh_user_path_source(doc, source, project_uuid=project_uuid)) return created def _nearest_points_between_route_point_runs(left_points, right_points): left_points = _normalized_route_points(left_points) right_points = _normalized_route_points(right_points) if len(left_points) < 2 or len(right_points) < 2: return None best = None def remember(left_point, right_point): nonlocal best distance = _distance(left_point, right_point) if best is None or distance < best[0]: best = (distance, left_point, right_point) for point in left_points: for index in range(len(right_points) - 1): projected = _closest_point_on_segment(point, right_points[index], right_points[index + 1]) remember(point, projected) for point in right_points: for index in range(len(left_points) - 1): projected = _closest_point_on_segment(point, left_points[index], left_points[index + 1]) remember(projected, point) return best def create_user_path_bridge_from_selection(doc, selection_ex, project_uuid=""): """Create a short user-controlled bridge between two selected route carriers. 这里刻意只连接用户选中的路径,不做全局远距离自动桥接; 否则真实机柜里相互无关的线槽、布线面可能会被误接成一个错误网络。 """ carriers = _selected_route_carriers_for_constraint(doc, selection_ex) if len(carriers) < 2: return [] left = carriers[0] right = carriers[1] left_points = _carrier_points(left) right_points = _carrier_points(right) nearest = _nearest_points_between_route_point_runs(left_points, right_points) if nearest is None: return [] _distance_mm, left_point, right_point = nearest if _distance(left_point, right_point) <= DEFAULT_NODE_TOLERANCE: return [] left_label = getattr(left, "Label", "") or getattr(left, "Name", "") or "Path A" right_label = getattr(right, "Label", "") or getattr(right, "Name", "") or "Path B" bridge = create_route_carrier( doc, [left_point, right_point], label="QET User Bridge {0} -> {1}".format(left_label, right_label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=min( _route_carrier_capacity_value(left, default=1), _route_carrier_capacity_value(right, default=1), ), ) return [bridge] def _route_carriers_for_bridge_object(doc, source): if source is None: return [] if is_route_carrier(source): return [source] carriers = [ carrier for carrier in _live_source_carriers(doc, source) if carrier is not None and is_route_carrier(carrier) ] if carriers: return carriers source_name = (getattr(source, "Name", "") or "").strip() source_label = (getattr(source, "Label", "") or "").strip() source_route_label = (getattr(source, "QetRouteSourceLabel", "") or "").strip() seen = set() for candidate in collect_route_carriers(doc): if candidate is None or not is_route_carrier(candidate): continue if ( source_name and (getattr(candidate, "QetRouteSourceName", "") or "").strip() == source_name ) or ( source_label and (getattr(candidate, "QetRouteSourceLabel", "") or "").strip() == source_label ) or ( source_route_label and (getattr(candidate, "QetRouteSourceLabel", "") or "").strip() == source_route_label ): identity = id(candidate) if identity in seen: continue seen.add(identity) carriers.append(candidate) return carriers def nearest_route_bridge_candidate_between_objects(doc, left_source, right_source): """Return the nearest bridge candidate between two route sources/carriers.""" left_carriers = _route_carriers_for_bridge_object(doc, left_source) right_carriers = _route_carriers_for_bridge_object(doc, right_source) best = None for left in left_carriers: left_points = _carrier_points(left) for right in right_carriers: if left is right: continue right_points = _carrier_points(right) nearest = _nearest_points_between_route_point_runs(left_points, right_points) if nearest is None: continue distance_mm, left_point, right_point = nearest if best is None or float(distance_mm) < float(best["distance_mm"]): best = { "distance_mm": float(distance_mm), "left_carrier": left, "right_carrier": right, "left_point": left_point, "right_point": right_point, } return best def create_user_path_bridge_between_objects( doc, left_source, right_source, project_uuid="", bridge_kind="MainPathDetourBridge", ): """Create a UserPath bridge between the nearest carriers of two selected source objects.""" best = nearest_route_bridge_candidate_between_objects(doc, left_source, right_source) if best is None: return [] left = best["left_carrier"] right = best["right_carrier"] left_point = best["left_point"] right_point = best["right_point"] if _distance(left_point, right_point) <= DEFAULT_NODE_TOLERANCE: return [] if _route_bridge_already_exists(doc, left_point, right_point): return [] left_label = ( getattr(left_source, "QetRouteSourceLabel", "") or getattr(left_source, "Label", "") or getattr(left, "QetRouteSourceLabel", "") or getattr(left, "Label", "") or getattr(left, "Name", "") or "Path A" ) right_label = ( getattr(right_source, "QetRouteSourceLabel", "") or getattr(right_source, "Label", "") or getattr(right, "QetRouteSourceLabel", "") or getattr(right, "Label", "") or getattr(right, "Name", "") or "Path B" ) bridge = create_route_carrier( doc, [left_point, right_point], label="QET User Bridge {0} -> {1}".format(left_label, right_label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=min( _route_carrier_capacity_value(left, default=1), _route_carrier_capacity_value(right, default=1), ), ) # 缺主路径绕行桥接需要保留来源,便于用户后续在 FreeCAD 树中复核是哪两个区域被自动补路。 TerminalObjects.ensure_string_property( bridge, "QetRouteBridgeKind", PROPERTY_GROUP, "QET route bridge kind", str(bridge_kind or "MainPathDetourBridge"), ) TerminalObjects.ensure_string_property( bridge, "QetRouteBridgePairLabel", PROPERTY_GROUP, "Human readable source pair for this generated bridge", "{0} -> {1}".format(left_label, right_label), ) TerminalObjects.ensure_string_property( bridge, "QetRouteBridgeLeftSourceName", PROPERTY_GROUP, "Left/source object name for this generated bridge", getattr(left_source, "Name", "") or getattr(left, "QetRouteSourceName", "") or getattr(left, "Name", ""), ) TerminalObjects.ensure_string_property( bridge, "QetRouteBridgeRightSourceName", PROPERTY_GROUP, "Right/source object name for this generated bridge", getattr(right_source, "Name", "") or getattr(right, "QetRouteSourceName", "") or getattr(right, "Name", ""), ) TerminalObjects.ensure_string_property( bridge, "QetRouteBridgeLeftSourceLabel", PROPERTY_GROUP, "Left/source object label for this generated bridge", left_label, ) TerminalObjects.ensure_string_property( bridge, "QetRouteBridgeRightSourceLabel", PROPERTY_GROUP, "Right/source object label for this generated bridge", right_label, ) return [bridge] def _route_bridge_already_exists(doc, left_point, right_point): for carrier in collect_route_carriers(doc): kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() if kind != ROUTE_CARRIER_KIND_USER_PATH: continue points = _normalized_route_points(_carrier_points(carrier)) if len(points) != 2: continue if ( _distance(points[0], left_point) <= DEFAULT_NODE_TOLERANCE and _distance(points[1], right_point) <= DEFAULT_NODE_TOLERANCE ) or ( _distance(points[0], right_point) <= DEFAULT_NODE_TOLERANCE and _distance(points[1], left_point) <= DEFAULT_NODE_TOLERANCE ): return True return False def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, project_uuid=""): """Create UserPath bridges from explicit path-network diagnostic suggestions.""" report = { "suggestions": 0, "created": [], "duplicates": 0, "stale_suggestions": 0, } if doc is None or not isinstance(diagnostic, dict): return report for item in diagnostic.get("wire_ducts_without_terminal_access", []) or []: if not isinstance(item, dict): continue suggestion = item.get("bridge_suggestion", {}) if not isinstance(suggestion, dict) or not suggestion: continue report["suggestions"] += 1 from_carrier_payload = suggestion.get("from_carrier", {}) to_carrier_payload = suggestion.get("to_carrier", {}) if not isinstance(from_carrier_payload, dict) or not isinstance(to_carrier_payload, dict): report["stale_suggestions"] += 1 continue from_carrier = _document_object_by_name(doc, from_carrier_payload.get("name", "")) to_carrier = _document_object_by_name(doc, to_carrier_payload.get("name", "")) if not is_route_carrier(from_carrier) or not is_route_carrier(to_carrier): report["stale_suggestions"] += 1 continue try: from_point = _vector(suggestion.get("from_point", {})) to_point = _vector(suggestion.get("to_point", {})) except Exception: report["stale_suggestions"] += 1 continue if _distance(from_point, to_point) <= DEFAULT_NODE_TOLERANCE: report["duplicates"] += 1 continue if _route_bridge_already_exists(doc, from_point, to_point): report["duplicates"] += 1 continue label = "QET User Bridge {0} -> {1}".format( from_carrier_payload.get("label") or from_carrier_payload.get("name") or "Path A", to_carrier_payload.get("label") or to_carrier_payload.get("name") or "Path B", ) bridge = create_route_carrier( doc, [from_point, to_point], label=label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, capacity=min( _route_carrier_capacity_value(from_carrier, default=1), _route_carrier_capacity_value(to_carrier, default=1), ), ) report["created"].append(bridge) return report def mark_cabinet_interior_boundaries_from_selection(selection_ex): """Mark selected FreeCAD objects as cabinet interior routing boundaries.""" marked = [] seen_sources = set() for item in selection_ex or []: source = getattr(item, "Object", None) if source is None or id(source) in seen_sources: continue seen_sources.add(id(source)) if is_route_carrier(source) or not _object_has_bbox(source): continue # 这里只写 FreeCAD 文档对象语义,后续布线按包围盒判断是否跑出柜内区域。 _set_cabinet_interior_boundary_semantics(source) marked.append(source) return marked def _wire_duct_centerline_spec_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): extents = { axis: _bbox_extent(bbox, axis) for axis in ("x", "y", "z") } main_axis = max(extents, key=extents.get) sorted_extents = sorted(extents.values(), reverse=True) if sorted_extents[0] <= DEFAULT_NODE_TOLERANCE: return {"centerline": [], "open_ends": []} if len(sorted_extents) > 1 and sorted_extents[1] > DEFAULT_NODE_TOLERANCE: if sorted_extents[0] / sorted_extents[1] < float(min_aspect or 1.0): return {"centerline": [], "open_ends": []} low, high = _bbox_axis_range(bbox, main_axis) center = _bbox_center(bbox) usable_margin = max(float(margin or 0.0), 0.0) if abs(high - low) <= usable_margin * 2.0: usable_margin = 0.0 start = _set_axis(center, main_axis, low + usable_margin) end = _set_axis(center, main_axis, high - usable_margin) if _distance(start, end) <= DEFAULT_NODE_TOLERANCE: return {"centerline": [], "open_ends": []} cross_axes = sorted( [axis for axis in ("x", "y", "z") if axis != main_axis], key=lambda axis: _bbox_extent(bbox, axis), reverse=True, ) open_ends = [] if cross_axes: cross_axis = cross_axes[0] cross_extent = _bbox_extent(bbox, cross_axis) half_length = max( min(cross_extent * 0.5, float(margin or DEFAULT_WIRE_DUCT_MARGIN)), min(cross_extent * 0.5, DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH * 0.5), ) if half_length > DEFAULT_NODE_TOLERANCE: for endpoint in (start, end): open_ends.append( [ _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) - half_length), _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) + half_length), ] ) return { "centerline": [start, end], "open_ends": open_ends, "main_axis": main_axis, } def _wire_duct_centerline_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): return _wire_duct_centerline_spec_from_bbox( bbox, margin=margin, min_aspect=min_aspect, ).get("centerline", []) def _sync_wire_duct_source_carriers( doc, source, spec, project_uuid="", capacity=1, end_margin=DEFAULT_WIRE_DUCT_MARGIN, ): carriers = _live_source_carriers(doc, source) if not carriers: return False desired = [ (spec.get("centerline", []), ROUTE_CARRIER_KIND_WIRE_DUCT), ] desired.extend( (points, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END) for points in (spec.get("open_ends", []) or []) ) updated = [] for carrier, desired_item in zip(carriers, desired): points, kind = desired_item if _update_route_carrier( carrier, points, project_uuid=project_uuid, kind=kind, capacity=capacity, ): updated.append(carrier) if updated: _mark_wire_duct_source(source, updated[0], updated, end_margin=end_margin) try: doc.recompute() except Exception: pass return True def _wiring_cut_out_points_from_bbox(bbox, bridge_extension=0.0): extents = _bbox_extents(bbox) if not extents: return [] through_axis = min(extents, key=extents.get) low, high = _bbox_axis_range(bbox, through_axis) center = _bbox_center(bbox) if abs(high - low) <= DEFAULT_NODE_TOLERANCE: other_extents = [ _bbox_extent(bbox, axis) for axis in ("x", "y", "z") if axis != through_axis ] fallback = max(other_extents or [DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH]) low = _axis_value(center, through_axis) - fallback * 0.5 high = _axis_value(center, through_axis) + fallback * 0.5 extension = max(float(bridge_extension or 0.0), 0.0) low -= extension high += extension start = _set_axis(center, through_axis, low) end = _set_axis(center, through_axis, high) if _distance(start, end) <= DEFAULT_NODE_TOLERANCE: return [] return [start, end] def _wire_duct_sources_from_selection(selection_ex): sources = [] seen = set() for item in selection_ex or []: obj = getattr(item, "Object", None) if obj is not None and id(obj) not in seen: seen.add(id(obj)) sources.append(obj) continue for sub_object in list(getattr(item, "SubObjects", []) or []): if sub_object is None or id(sub_object) in seen: continue seen.add(id(sub_object)) sources.append(sub_object) return sources def _route_source_carrier_names(source): names = [] try: raw = (getattr(source, "QetRouteCarrierNamesJson", "") or "").strip() if raw: parsed = json.loads(raw) if isinstance(parsed, list): names.extend(str(item).strip() for item in parsed if str(item).strip()) except Exception: names = [] carrier_name = (getattr(source, "QetRouteCarrierName", "") or "").strip() if carrier_name: names.insert(0, carrier_name) result = [] seen = set() for name in names: if name in seen: continue seen.add(name) result.append(name) return result def _live_source_carriers(doc, source): if doc is None or source is None: return [] carriers = [] for carrier_name in _route_source_carrier_names(source): carrier = doc.getObject(carrier_name) if carrier is not None and is_route_carrier(carrier): carriers.append(carrier) return carriers def _source_kind_value(source): return (getattr(source, "QetRoutingSourceKind", "") or "").strip() def _set_route_carrier_source_metadata(carrier, source, source_kind="", source_path_index=None): if carrier is None or source is None: return source_name = (getattr(source, "Name", "") or "").strip() if not source_name: return kind = (source_kind or _source_kind_value(source)).strip() TerminalObjects.ensure_string_property( carrier, "QetRouteSourceName", PROPERTY_GROUP, "FreeCAD source object name that generated this route carrier", source_name, ) TerminalObjects.ensure_string_property( carrier, "QetRouteSourceLabel", PROPERTY_GROUP, "FreeCAD source object label that generated this route carrier", getattr(source, "Label", "") or source_name, ) TerminalObjects.ensure_string_property( carrier, "QetRouteSourceKind", PROPERTY_GROUP, "Routing source kind that generated this route carrier", kind, ) if source_path_index is not None: TerminalObjects.ensure_string_property( carrier, "QetRouteSourcePathIndex", PROPERTY_GROUP, "1-based path index generated from the same routing source", str(source_path_index), ) elif "QetRouteSourcePathIndex" in getattr(carrier, "PropertiesList", []) or getattr( carrier, "QetRouteSourcePathIndex", "" ): TerminalObjects.ensure_string_property( carrier, "QetRouteSourcePathIndex", PROPERTY_GROUP, "1-based path index generated from the same routing source", "", ) constraint_mode = (getattr(source, "QetRouteConstraintMode", "") or "").strip() TerminalObjects.ensure_string_property( carrier, "QetRouteConstraintMode", PROPERTY_GROUP, "Route constraint mode for automatic routing", constraint_mode, ) def _remember_source_carriers(source, carriers): if source is None: return live_names = [ getattr(carrier, "Name", "") for carrier in (carriers or []) if carrier is not None and getattr(carrier, "Name", "") ] if live_names: source_kind = _source_kind_value(source) for index, carrier in enumerate(carriers or [], start=1): # 多 Wire 草图会生成多条 UserPath,序号用于诊断和路径样例回溯。 source_path_index = ( index if source_kind == ROUTE_CARRIER_KIND_USER_PATH and len(live_names) > 1 else None ) _set_route_carrier_source_metadata( carrier, source, source_kind=source_kind, source_path_index=source_path_index, ) TerminalObjects.ensure_string_property( source, "QetRouteCarrierNamesJson", PROPERTY_GROUP, "Generated route carriers for this source", json.dumps(live_names, ensure_ascii=False), ) def _mark_wire_duct_source(source, carrier, carriers=None, end_margin=DEFAULT_WIRE_DUCT_MARGIN): if source is None: return try: _set_wire_duct_source_semantics(source, end_margin=end_margin) if carrier is not None: TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carrier, "Name", ""), ) _remember_source_carriers(source, carriers or ([carrier] if carrier is not None else [])) except Exception: pass def _mark_support_surface_source(source, carriers): if source is None or not carriers: return try: _set_support_surface_source_semantics(source) TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carriers[0], "Name", ""), ) _remember_source_carriers(source, carriers) except Exception: pass def _mark_wiring_cut_out_source(source, carrier, bridge_extension=DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION): if source is None or carrier is None: return try: _set_wiring_cut_out_source_semantics(source, bridge_extension=bridge_extension) TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carrier, "Name", ""), ) _remember_source_carriers(source, [carrier]) except Exception: pass def _mark_user_path_source(source, carrier): if source is None or carrier is None: return try: _set_user_path_source_semantics(source) TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carrier, "Name", ""), ) _remember_source_carriers(source, [carrier]) except Exception: pass def _mark_user_path_source_carriers(source, carriers): carriers = [carrier for carrier in (carriers or []) if carrier is not None] if source is None: return try: _set_user_path_source_semantics(source) TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carriers[0], "Name", "") if carriers else "", ) _remember_source_carriers(source, carriers) except Exception: pass def _mark_terminal_access_source(source, carrier): if source is None or carrier is None: return try: TerminalObjects.ensure_string_property( source, "QetRoutingSourceKind", PROPERTY_GROUP, "Routing source kind", ROUTE_CARRIER_KIND_TERMINAL_ACCESS, ) TerminalObjects.ensure_string_property( source, "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", getattr(carrier, "Name", ""), ) _remember_source_carriers(source, [carrier]) except Exception: pass def _live_source_carrier(doc, source): carriers = _live_source_carriers(doc, source) return carriers[0] if carriers else None def _source_is_valid_for_kind(source, source_kind): if source_kind == ROUTE_CARRIER_KIND_WIRE_DUCT: return _is_wire_duct_candidate(source) if source_kind == ROUTE_CARRIER_KIND_ROUTING_RANGE: return _is_support_surface_candidate(source) if source_kind == ROUTE_CARRIER_KIND_WIRING_CUT_OUT: return _is_wiring_cut_out_candidate(source) if source_kind == ROUTE_CARRIER_KIND_USER_PATH: return _is_route_path_source_object(source) return True def _clear_invalid_source_route_metadata(source): for property_name in ( "QetRouteCarrierName", "QetRouteCarrierNamesJson", "QetRoutingObstacleMode", ): if property_name not in getattr(source, "PropertiesList", []) and not getattr(source, property_name, ""): continue TerminalObjects.ensure_string_property( source, property_name, PROPERTY_GROUP, "Cleared invalid routing source metadata", "", ) def _document_object_by_name(doc, name): if doc is None or not name: return None try: return doc.getObject(name) except Exception: return None def cleanup_invalid_source_carriers(doc): """Remove generated carriers whose FreeCAD source object is missing or invalid.""" if doc is None: return 0 removed = 0 for carrier in list(collect_route_carriers(doc)): source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() source_kind = (getattr(carrier, "QetRouteSourceKind", "") or "").strip() if source_kind not in MANAGED_ROUTE_SOURCE_KINDS or not source_name: continue if _document_object_by_name(doc, source_name) is None: removed += _remove_route_carriers(doc, [carrier]) for source in list(getattr(doc, "Objects", []) or []): if source is None or is_route_carrier(source): continue source_kind = _source_kind_value(source) if source_kind not in MANAGED_ROUTE_SOURCE_KINDS: continue if not _route_source_carrier_names(source): continue if _source_is_valid_for_kind(source, source_kind): continue removed += _remove_route_carriers(doc, _live_source_carriers(doc, source)) _clear_invalid_source_route_metadata(source) if removed: try: doc.recompute() except Exception: pass return removed def detect_wire_duct_sources(doc, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): """Return document objects that look like wire ducts based on semantics/name and shape.""" sources = [] seen = set() for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in seen: continue seen.add(id(obj)) if _is_wire_duct_candidate(obj, min_aspect=min_aspect): sources.append(obj) return sources def detect_support_surface_sources(doc): """Return thin cabinet/panel objects that can provide low-priority support routes.""" sources = [] seen = set() for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in seen: continue seen.add(id(obj)) if _is_support_surface_candidate(obj): sources.append(obj) return sources def detect_wiring_cut_out_sources(doc): """Return pass-through cut-out objects that can bridge routing carriers.""" sources = [] seen = set() for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in seen: continue seen.add(id(obj)) if _is_wiring_cut_out_candidate(obj): sources.append(obj) return sources def detect_user_path_sources(doc): """Return sketch/Draft-like route path source objects that can become UserPath carriers.""" sources = [] seen = set() for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in seen: continue seen.add(id(obj)) if _is_route_path_source_object(obj): sources.append(obj) return sources def _is_document_user_path_source(obj): if not _is_route_path_source_object(obj): return False if _is_explicit_user_path_source(obj): return True text = " ".join( str(value or "") for value in ( getattr(obj, "Name", ""), getattr(obj, "Label", ""), ) ).lower() return any( keyword in text for keyword in ( "userpath", "user path", "route path", "routing path", "wire path", "wiring path", "布线路径", "走线路径", "用户路径", ) ) def detect_document_user_path_sources(doc): """Return path sources that are safe to auto-convert during full network generation.""" sources = [] seen = set() for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in seen: continue seen.add(id(obj)) if _is_document_user_path_source(obj): sources.append(obj) return sources def _source_sample(obj): return { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), "source_kind": (getattr(obj, "QetRoutingSourceKind", "") or "").strip(), } def routing_source_summary(doc): """Summarize routing sources without creating or modifying carriers.""" wire_duct_sources = detect_wire_duct_sources(doc) support_surface_sources = detect_support_surface_sources(doc) wiring_cut_out_sources = detect_wiring_cut_out_sources(doc) user_path_sources = detect_user_path_sources(doc) carriers = collect_route_carriers(doc) candidate_objects = [] seen = set() for obj in ( list(wire_duct_sources) + list(support_surface_sources) + list(wiring_cut_out_sources) + list(user_path_sources) ): if id(obj) in seen: continue seen.add(id(obj)) candidate_objects.append(obj) marked_source_counts = {} for obj in list(getattr(doc, "Objects", []) or []): if obj is None or is_route_carrier(obj): continue source_kind = _source_kind_value(obj) if not source_kind: continue marked_source_counts[source_kind] = marked_source_counts.get(source_kind, 0) + 1 return { "wire_duct_sources": len(wire_duct_sources), "support_surface_sources": len(support_surface_sources), "wiring_cut_out_sources": len(wiring_cut_out_sources), "user_path_sources": len(user_path_sources), "candidate_sources": len(candidate_objects), "route_carriers": len(carriers), "marked_source_counts": marked_source_counts, "candidate_samples": [_source_sample(obj) for obj in candidate_objects[:8]], } def prepare_layout_space_sources_from_document(doc, project_uuid=""): """Normalize the current FreeCAD document as an EPLAN-style layout space. This does not generate the routing path network. It marks source objects so wire ducts are pass-through objects, support panels can become routing ranges, and the wiring buckets exist before network generation or routing. """ if doc is None: raise RoutingNetworkError("No FreeCAD document is available.") WiringObjects.ensure_wiring_root_group(doc, project_uuid) cleanup_invalid_source_carriers(doc) wire_duct_sources = detect_wire_duct_sources(doc) support_surface_sources = detect_support_surface_sources(doc) wiring_cut_out_sources = detect_wiring_cut_out_sources(doc) for source in wire_duct_sources: try: _set_wire_duct_source_semantics(source) except Exception: pass for source in support_surface_sources: try: _set_support_surface_source_semantics(source) except Exception: pass for source in wiring_cut_out_sources: try: _set_wiring_cut_out_source_semantics(source) except Exception: pass try: doc.recompute() except Exception: pass return { "wire_duct_sources": len(wire_duct_sources), "support_surface_sources": len(support_surface_sources), "wiring_cut_out_sources": len(wiring_cut_out_sources), "routable_terminals": len(_collect_routable_terminals(doc)), "existing_network": network_summary(doc), } def create_wire_duct_carriers_from_document( doc, project_uuid="", margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT, ): """Auto-detect wire duct objects in the document and create WireDuct centerlines.""" cleanup_invalid_source_carriers(doc) created = [] for index, source in enumerate(detect_wire_duct_sources(doc, min_aspect=min_aspect), start=1): bbox = _bound_box_from_object(source) if bbox is None: continue source_margin = _wire_duct_end_margin_value(source, default=margin) spec = _wire_duct_centerline_spec_from_bbox( bbox, margin=source_margin, min_aspect=min_aspect, ) points = spec.get("centerline", []) if len(points) < 2: continue label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Wire Duct" capacity = _route_carrier_capacity_value(source, default=1) if _sync_wire_duct_source_carriers( doc, source, spec, project_uuid=project_uuid, capacity=capacity, end_margin=source_margin, ): continue carrier = create_route_carrier( doc, points, label="QET Auto Wire Duct Centerline {0}".format(label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRE_DUCT, capacity=capacity, ) source_created = [carrier] created.append(carrier) for end_index, open_end_points in enumerate(spec.get("open_ends", []) or [], start=1): if len(open_end_points) < 2: continue open_end_carrier = create_route_carrier( doc, open_end_points, label="QET Auto Wire Duct Open End {0} {1}".format(label, end_index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, capacity=capacity, ) source_created.append(open_end_carrier) created.append(open_end_carrier) _mark_wire_duct_source(source, carrier, source_created, end_margin=source_margin) return created def create_wiring_cut_out_carriers_from_document( doc, project_uuid="", bridge_extension=DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION, ): """Create pass-through route carriers for wiring cut-out objects.""" cleanup_invalid_source_carriers(doc) created = [] for source in detect_wiring_cut_out_sources(doc): bbox = _bound_box_from_object(source) if bbox is None: continue source_bridge_extension = _wiring_cut_out_bridge_extension_value(source, default=bridge_extension) points = _wiring_cut_out_points_from_bbox(bbox, bridge_extension=source_bridge_extension) if len(points) < 2: continue live_carrier = _live_source_carrier(doc, source) if live_carrier is not None: if _update_route_carrier( live_carrier, points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ): _mark_wiring_cut_out_source(source, live_carrier, bridge_extension=source_bridge_extension) try: doc.recompute() except Exception: pass continue label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Wiring Cut-Out" carrier = create_route_carrier( doc, points, label="QET Auto Wiring Cut-Out {0}".format(label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ) _mark_wiring_cut_out_source(source, carrier, bridge_extension=source_bridge_extension) created.append(carrier) return created def create_surface_carriers_from_document( doc, project_uuid="", spacing=DEFAULT_SURFACE_LANE_SPACING, offset=DEFAULT_SURFACE_OFFSET, margin=DEFAULT_SURFACE_MARGIN, ): """Auto-detect thin support panels and create low-priority RoutingRange grids.""" cleanup_invalid_source_carriers(doc) created = [] for source in detect_support_surface_sources(doc): bbox = _bound_box_from_object(source) if bbox is None: continue support_face = _support_face_from_bbox(bbox) grids = _surface_face_grid_points( support_face, spacing=spacing, offset=offset, margin=margin, ) capacity = _route_carrier_capacity_value(source, default=1) live_carriers = _live_source_carriers(doc, source) if live_carriers: updated = [] for carrier, points in zip(live_carriers, grids): if _update_route_carrier( carrier, points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, capacity=capacity, ): updated.append(carrier) source_created = [] label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Support Surface" for index, points in enumerate(grids[len(live_carriers):], start=len(live_carriers) + 1): if len(points) < 2: continue carrier = create_route_carrier( doc, points, label="QET Auto Support Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, capacity=capacity, ) source_created.append(carrier) created.append(carrier) for stale_carrier in live_carriers[len(grids):]: _detach_from_groups(doc, stale_carrier) try: if doc.getObject(getattr(stale_carrier, "Name", "")) is not None: doc.removeObject(stale_carrier.Name) except Exception: pass current_carriers = updated + source_created if updated: _mark_support_surface_source(source, current_carriers) try: doc.recompute() except Exception: pass continue source_created = [] label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Support Surface" for index, points in enumerate(grids, start=1): if len(points) < 2: continue carrier = create_route_carrier( doc, points, label="QET Auto Support Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, capacity=capacity, ) source_created.append(carrier) created.append(carrier) _mark_support_surface_source(source, source_created) return created def _collect_routable_terminals(doc): terminals = [] seen = set() 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) ) result = [] for terminal in terminals: if terminal is None or id(terminal) in seen: continue seen.add(id(terminal)) result.append(terminal) return result def _terminal_exit_point(terminal, exit_length): origin = _vector(TerminalObjects.terminal_origin(terminal)) direction = _normalize(_vector(TerminalObjects.terminal_direction(terminal))) if direction is None: direction = App.Vector(0, 0, 1) return _add(origin, _scale(direction, max(float(exit_length or 0.0), 0.0))) def _json_route_point(item): try: if isinstance(item, dict): return App.Vector( float(item.get("x", 0.0) or 0.0), float(item.get("y", 0.0) or 0.0), float(item.get("z", 0.0) or 0.0), ) if isinstance(item, (list, tuple)) and len(item) >= 3: return App.Vector(float(item[0] or 0.0), float(item[1] or 0.0), float(item[2] or 0.0)) except Exception: return None return None def _local_route_point_items(parsed): if isinstance(parsed, list): return parsed if isinstance(parsed, dict): for key in ("points", "route_points", "local_points"): value = parsed.get(key) if isinstance(value, list): return value return None def _terminal_local_route_points(terminal): for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): raw = (getattr(terminal, property_name, "") or "").strip() if not raw: continue try: parsed = json.loads(raw) except Exception: continue point_items = _local_route_point_items(parsed) if point_items is None: continue points = [_json_route_point(item) for item in point_items if item is not None] points = [point for point in points if point is not None] if points: return points return [] def _terminal_local_route_issue(terminal): invalid_samples = [] saw_raw = False for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): raw = (getattr(terminal, property_name, "") or "").strip() if not raw: continue saw_raw = True try: parsed = json.loads(raw) except Exception as exc: invalid_samples.append( { "property_name": property_name, "reason": "invalid_json", "message": str(exc), "raw_sample": raw[:160], } ) continue point_items = _local_route_point_items(parsed) if point_items is None: invalid_samples.append( { "property_name": property_name, "reason": "not_array", "message": "Local route points JSON must be an array or an object with a points array.", "raw_sample": raw[:160], } ) continue points = [_json_route_point(item) for item in point_items if item is not None] valid_points = [point for point in points if point is not None] if len(_normalized_route_points(valid_points)) >= 2: return None invalid_samples.append( { "property_name": property_name, "reason": "too_few_valid_points", "message": "Local route points must contain at least two distinct valid points.", "raw_sample": raw[:160], "valid_point_count": len(valid_points), } ) if not saw_raw or not invalid_samples: return None payload = _terminal_diagnostic_payload(terminal) payload.update(invalid_samples[0]) payload["invalid_samples"] = invalid_samples payload["code"] = "terminal_local_route_invalid" return payload def _terminal_exit_direction_issue(terminal): invalid_samples = [] saw_raw = False for property_name in ("QetTerminalExitDirectionJson", "QetExitDirectionJson"): raw = (getattr(terminal, property_name, "") or "").strip() if not raw: continue saw_raw = True parsed = None try: parsed = json.loads(raw) except Exception as exc: parts = [part.strip() for part in raw.replace(";", ",").split(",")] if len(parts) < 3: invalid_samples.append( { "property_name": property_name, "reason": "invalid_json", "message": str(exc), "raw_sample": raw[:160], } ) continue parsed = parts[:3] direction = None if isinstance(parsed, dict): try: direction = App.Vector( float(parsed.get("x", 0.0) or 0.0), float(parsed.get("y", 0.0) or 0.0), float(parsed.get("z", 0.0) or 0.0), ) except Exception as exc: invalid_samples.append( { "property_name": property_name, "reason": "invalid_vector", "message": str(exc), "raw_sample": raw[:160], } ) continue elif isinstance(parsed, (list, tuple)) and len(parsed) >= 3: try: direction = App.Vector(float(parsed[0] or 0.0), float(parsed[1] or 0.0), float(parsed[2] or 0.0)) except Exception as exc: invalid_samples.append( { "property_name": property_name, "reason": "invalid_vector", "message": str(exc), "raw_sample": raw[:160], } ) continue else: invalid_samples.append( { "property_name": property_name, "reason": "unsupported_shape", "message": "Exit direction must be a vector object, array, or comma-separated x,y,z text.", "raw_sample": raw[:160], } ) continue normalized = _normalize(direction) if normalized is not None: return None invalid_samples.append( { "property_name": property_name, "reason": "zero_vector", "message": "Exit direction vector length must be greater than 0.", "raw_sample": raw[:160], } ) if not saw_raw or not invalid_samples: return None payload = _terminal_diagnostic_payload(terminal) payload.update(invalid_samples[0]) payload["invalid_samples"] = invalid_samples payload["code"] = "terminal_exit_direction_invalid" return payload def _terminal_parent_chain(terminal): chain = [] current = terminal visited = set() while current is not None: parents = list(getattr(current, "InList", []) or []) parent = None for candidate in parents: if id(candidate) in visited: continue if getattr(candidate, "Placement", None) is not None: parent = candidate break if parent is None: break visited.add(id(parent)) chain.append(parent) current = parent return chain def _bbox_volume(bbox): try: return ( max(float(bbox.XMax) - float(bbox.XMin), 0.0) * max(float(bbox.YMax) - float(bbox.YMin), 0.0) * max(float(bbox.ZMax) - float(bbox.ZMin), 0.0) ) except Exception: return float("inf") def _terminal_parent_device_bbox(terminal, origin): candidates = [] seen = set() pending = list(getattr(terminal, "InList", []) or []) while pending: parent = pending.pop(0) if parent is None or id(parent) in seen: continue seen.add(id(parent)) bbox = _bound_box_from_object(parent) if bbox is not None and _point_inside_bbox(origin, bbox, tolerance=DEFAULT_NODE_TOLERANCE): candidates.append(bbox) pending.extend(list(getattr(parent, "InList", []) or [])) if not candidates: return None return min(candidates, key=_bbox_volume) def _ray_exit_distance_from_bbox(origin, direction, bbox): if not _point_inside_bbox(origin, bbox, tolerance=DEFAULT_NODE_TOLERANCE): return None distances = [] for axis in ("x", "y", "z"): component = _axis_value(direction, axis) if abs(component) <= DEFAULT_NODE_TOLERANCE: continue low, high = _bbox_axis_range(bbox, axis) boundary = high if component > 0 else low distance = (boundary - _axis_value(origin, axis)) / component if distance >= -DEFAULT_NODE_TOLERANCE: distances.append(max(float(distance), 0.0)) if not distances: return None return min(distances) def _terminal_exit_direction_candidates(preferred_direction): preferred = _normalize(_vector(preferred_direction)) candidates = [] def add_candidate(direction): normalized = _normalize(_vector(direction)) if normalized is None: return key = ( round(float(normalized.x), 6), round(float(normalized.y), 6), round(float(normalized.z), 6), ) if key in [item[0] for item in candidates]: return candidates.append((key, normalized)) add_candidate(preferred or App.Vector(0, 0, 1)) for direction in ( App.Vector(1, 0, 0), App.Vector(-1, 0, 0), App.Vector(0, 1, 0), App.Vector(0, -1, 0), App.Vector(0, 0, 1), App.Vector(0, 0, -1), ): add_candidate(direction) if preferred is not None: # 反向通常意味着从设备背面/底面退出,只有其它轴向没有更好出口时才采用。 add_candidate(_scale(preferred, -1.0)) return [direction for _key, direction in candidates] def _terminal_exit_required_length(origin, direction, bbox): exit_distance = _ray_exit_distance_from_bbox(origin, direction, bbox) if exit_distance is None: return None return exit_distance + DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE def _correct_default_terminal_exit_direction(origin, direction, bbox, max_length): if bbox is None or max_length <= 0.0: return None current_required = _terminal_exit_required_length(origin, direction, bbox) if current_required is None or current_required <= max_length + DEFAULT_NODE_TOLERANCE: return None ranked = [] for candidate in _terminal_exit_direction_candidates(direction): required = _terminal_exit_required_length(origin, candidate, bbox) if required is None: continue ranked.append((float(required), candidate)) if not ranked: return None ranked.sort(key=lambda item: item[0]) best_required, best_direction = ranked[0] if best_required + DEFAULT_NODE_TOLERANCE >= current_required: return None return { "direction": best_direction, "device_exit_required_length_mm": float(best_required), "original_device_exit_required_length_mm": float(current_required), } def _terminal_exit_plan(terminal, exit_length=20.0, max_exit_length=None): origin = _vector(TerminalObjects.terminal_origin(terminal)) direction = _normalize(_vector(TerminalObjects.terminal_direction(terminal))) if direction is None: direction = App.Vector(0, 0, 1) original_direction = direction try: direction_source = TerminalObjects.terminal_direction_source(terminal) except Exception: direction_source = "lcs" requested_length = max(float(exit_length or 0.0), 0.0) max_length = DEFAULT_TERMINAL_EXIT_MAX_LENGTH if max_exit_length is None else max(float(max_exit_length or 0.0), 0.0) length = requested_length required_length = 0.0 bbox = _terminal_parent_device_bbox(terminal, origin) corrected = False original_required_length = 0.0 if direction_source != "explicit": correction = _correct_default_terminal_exit_direction(origin, direction, bbox, max_length) if correction is not None: # 默认 LCS 方向如果要穿过很深的设备包围盒,优先改用最近的出线面; # 显式方向不自动改,留给设备模板或人工 CPoint 数据负责。 direction = correction["direction"] required_length = float(correction["device_exit_required_length_mm"]) original_required_length = float(correction["original_device_exit_required_length_mm"]) corrected = True if not corrected: exit_distance = _ray_exit_distance_from_bbox(origin, direction, bbox) if exit_distance is not None: required_length = exit_distance + DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE if required_length > 0.0: # 没有人工局部路径时,默认出线至少先离开所属设备外轮廓,避免导线贴在模型内部。 length = max(length, required_length) capped = False if max_length > 0.0 and length > max_length: # 工程上不能为了离开一个过大的包围盒无限拉长端子出线;超限时截断并交给诊断提示。 length = max_length capped = True return { "origin": origin, "direction": direction, "requested_exit_length_mm": float(requested_length), "actual_exit_length_mm": float(length), "max_exit_length_mm": float(max_length), "device_exit_required_length_mm": float(required_length), "original_device_exit_required_length_mm": float(original_required_length), "exit_length_capped": capped, "exit_direction_source": direction_source, "exit_direction_corrected": corrected, "original_direction": original_direction, "point": _add(origin, _scale(direction, length)), "device_bbox_detected": required_length > 0.0, } def _terminal_device_aware_exit_point(terminal, exit_length, max_exit_length=None): return _terminal_exit_plan( terminal, exit_length=exit_length, max_exit_length=max_exit_length, )["point"] def terminal_access_diagnostics(terminal, exit_length=20.0, max_exit_length=None): """Return engineering diagnostics for the terminal's first exit segment.""" local_points = _terminal_local_route_points(terminal) if local_points: origin = _vector(TerminalObjects.terminal_origin(terminal)) points = [_terminal_local_point_to_global(terminal, point) for point in local_points] if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE: points.insert(0, origin) points = _normalized_route_points(points) if len(points) >= 2: direction = _normalize(_subtract(points[1], points[0])) or App.Vector(0, 0, 1) return { "requested_exit_length_mm": max(float(exit_length or 0.0), 0.0), "actual_exit_length_mm": float(sum(_distance(points[index], points[index + 1]) for index in range(len(points) - 1))), "max_exit_length_mm": DEFAULT_TERMINAL_EXIT_MAX_LENGTH if max_exit_length is None else max(float(max_exit_length or 0.0), 0.0), "device_exit_required_length_mm": 0.0, "original_device_exit_required_length_mm": 0.0, "exit_length_capped": False, "exit_direction_source": "local_route", "exit_direction_corrected": False, "exit_rule": "local_route", "local_route_used": True, "local_route_point_count": len(points), "device_bbox_detected": False, "exit_direction": { "x": round(float(direction.x), 6), "y": round(float(direction.y), 6), "z": round(float(direction.z), 6), }, "original_exit_direction": { "x": round(float(direction.x), 6), "y": round(float(direction.y), 6), "z": round(float(direction.z), 6), }, "origin": _point_payload(points[0]), "exit_point": _point_payload(points[-1]), } plan = _terminal_exit_plan( terminal, exit_length=exit_length, max_exit_length=max_exit_length, ) direction = plan["direction"] original_direction = plan["original_direction"] return { "requested_exit_length_mm": plan["requested_exit_length_mm"], "actual_exit_length_mm": plan["actual_exit_length_mm"], "max_exit_length_mm": plan["max_exit_length_mm"], "device_exit_required_length_mm": plan["device_exit_required_length_mm"], "original_device_exit_required_length_mm": plan["original_device_exit_required_length_mm"], "exit_length_capped": bool(plan["exit_length_capped"]), "exit_direction_source": plan["exit_direction_source"], "exit_direction_corrected": bool(plan["exit_direction_corrected"]), "exit_rule": "default_exit", "local_route_used": False, "local_route_point_count": 0, "device_bbox_detected": bool(plan["device_bbox_detected"]), "exit_direction": { "x": round(float(direction.x), 6), "y": round(float(direction.y), 6), "z": round(float(direction.z), 6), }, "original_exit_direction": { "x": round(float(original_direction.x), 6), "y": round(float(original_direction.y), 6), "z": round(float(original_direction.z), 6), }, "origin": _point_payload(plan["origin"]), "exit_point": _point_payload(plan["point"]), } def _terminal_local_point_to_global(terminal, local_point): try: if hasattr(terminal, "getGlobalPlacement"): placement = terminal.getGlobalPlacement() return _placement_mult_vec(placement, _vector(local_point)) except Exception: pass point = _placement_mult_vec(getattr(terminal, "Placement", None), _vector(local_point)) for parent in _terminal_parent_chain(terminal): point = _placement_mult_vec(getattr(parent, "Placement", None), point) return point def _document_point_to_terminal_local(terminal, point): point = _vector(point) try: if hasattr(terminal, "getGlobalPlacement"): placement = terminal.getGlobalPlacement() inverse = placement.inverse() transformed = inverse.multVec(point) if transformed is not None: return _vector(transformed) except Exception: pass origin = _vector(TerminalObjects.terminal_origin(terminal)) return App.Vector( float(point.x) - float(origin.x), float(point.y) - float(origin.y), float(point.z) - float(origin.z), ) def _local_route_point_payload(point): point = _vector(point) return [float(point.x), float(point.y), float(point.z)] def set_terminal_local_route_points(terminal, document_points): """Store a field-authored local exit path on one engineering terminal.""" if not TerminalObjects.is_terminal_object(terminal): raise RoutingNetworkError("请选择一个可布线端子,再设置端子局部出线路径。") points = _normalized_route_points([_vector(point) for point in list(document_points or [])]) if len(points) < 2: raise RoutingNetworkError("端子局部出线路径至少需要两个有效路径点。") local_points = [_document_point_to_terminal_local(terminal, point) for point in points] local_points = _normalized_route_points(local_points) if len(local_points) < 2: raise RoutingNetworkError("端子局部出线路径转换后少于两个有效路径点。") payload = [_local_route_point_payload(point) for point in local_points] TerminalObjects.ensure_string_property( terminal, "QetTerminalLocalRoutePointsJson", PROPERTY_GROUP, "端子到柜内主路径网络前的局部出线路径点", json.dumps(payload, ensure_ascii=False), ) try: terminal.Document.recompute() except Exception: pass return { "terminal": terminal, "point_count": len(payload), "property_name": "QetTerminalLocalRoutePointsJson", "points": payload, } def set_terminal_exit_direction(terminal, document_points): """Store an explicit document-space CPoint direction on one engineering terminal.""" if not TerminalObjects.is_terminal_object(terminal): raise RoutingNetworkError("请选择一个可布线端子,再设置端子出线方向。") points = _normalized_route_points([_vector(point) for point in list(document_points or [])]) if len(points) < 2: raise RoutingNetworkError("端子出线方向至少需要两个有效点。") direction = _normalize(_subtract(points[1], points[0])) if direction is None: raise RoutingNetworkError("端子出线方向长度为 0,请选择一条有效线段或两个不同点。") payload = { "x": round(float(direction.x), 6), "y": round(float(direction.y), 6), "z": round(float(direction.z), 6), } TerminalObjects.ensure_string_property( terminal, "QetTerminalExitDirectionJson", PROPERTY_GROUP, "端子显式出线方向,使用 FreeCAD 文档坐标", json.dumps(payload, ensure_ascii=False), ) try: terminal.Document.recompute() except Exception: pass return { "terminal": terminal, "property_name": "QetTerminalExitDirectionJson", "direction": payload, "point_count": len(points), } def set_terminal_exit_direction_from_selection(selection_ex): """Use one selected terminal and one selected line/path as its explicit exit direction.""" terminal = None direction_runs = [] for item in list(selection_ex or []): source = getattr(item, "Object", None) if TerminalObjects.is_terminal_object(source): if terminal is not None and source is not terminal: raise RoutingNetworkError("一次只能为一个端子设置出线方向。") terminal = source continue for points in _point_runs_from_selection_item(item): normalized = _normalize_point_run(points) if len(normalized) >= 2: direction_runs.append(normalized) if terminal is None: raise RoutingNetworkError("请同时选中一个可布线端子和一条表示方向的草图/Draft 线或边。") if not direction_runs: raise RoutingNetworkError("请选择至少包含两个点的草图、Draft 线、边或路径对象作为端子出线方向。") if len(direction_runs) > 1: raise RoutingNetworkError("端子出线方向一次只支持一条方向线,请只选择一条草图线或一个连续 Wire。") return set_terminal_exit_direction(terminal, direction_runs[0]) def set_terminal_local_route_points_from_selection(selection_ex): """Use one selected terminal and one selected sketch/edge path as its local exit path.""" terminal = None support_face = _support_face_from_selection(selection_ex) route_runs = [] for item in list(selection_ex or []): source = getattr(item, "Object", None) if TerminalObjects.is_terminal_object(source): if terminal is not None and source is not terminal: raise RoutingNetworkError("一次只能为一个端子设置局部出线路径。") terminal = source continue if support_face is not None and _selection_item_is_only_support_face(item): continue point_runs = _point_runs_from_selection_item(item) if support_face is not None: # 允许用户先选安装板/面,再选草图线,把局部出线贴到该面附近。 point_runs = [_project_points_to_face(points, support_face) for points in point_runs] for points in point_runs: normalized = _normalize_point_run(points) if len(normalized) >= 2: route_runs.append(normalized) if terminal is None: raise RoutingNetworkError("请同时选中一个可布线端子和一条草图/Draft 局部出线路径。") if not route_runs: raise RoutingNetworkError("请选择至少包含两个点的草图、Draft 线、边或路径对象。") if len(route_runs) > 1: raise RoutingNetworkError("端子局部出线路径一次只支持一条连续路径,请只选择一条草图线或一个连续 Wire。") return set_terminal_local_route_points(terminal, route_runs[0]) def terminal_access_path_points(terminal, exit_length=20.0, max_exit_length=None): """Return terminal-to-network access points, honoring optional local route metadata.""" origin = _vector(TerminalObjects.terminal_origin(terminal)) local_points = _terminal_local_route_points(terminal) if local_points: points = [_terminal_local_point_to_global(terminal, point) for point in local_points] if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE: points.insert(0, origin) normalized = _normalized_route_points(points) if len(normalized) >= 2: return normalized return _normalized_route_points( [origin, _terminal_device_aware_exit_point(terminal, exit_length, max_exit_length=max_exit_length)] ) def terminal_access_carrier_for_terminal(terminal): doc = getattr(terminal, "Document", None) carrier = _live_source_carrier(doc, terminal) if ( carrier is not None and (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() == ROUTE_CARRIER_KIND_TERMINAL_ACCESS ): return carrier return None def terminal_access_path_points_with_network_access(terminal, exit_length=20.0, max_exit_length=None): """Return terminal local exit plus its generated TerminalAccess carrier. TerminalAccess 是端子自己的短接入路径。布线结果应沿这段接入线进入 线槽/UserPath 主网络,但它仍不能作为其它导线共享的主路径。 """ points = list( terminal_access_path_points( terminal, exit_length, max_exit_length=max_exit_length, ) ) carrier = terminal_access_carrier_for_terminal(terminal) if carrier is None: return points carrier_points = _normalized_route_points(getattr(carrier, "Points", []) or []) if len(carrier_points) < 2: return points for point in carrier_points: if not points or _distance(points[-1], point) > DEFAULT_NODE_TOLERANCE: points.append(point) return _normalized_route_points(points) def _orthogonal_access_points(start, end): """Create a Manhattan path so access carriers can join the routing graph.""" start = _vector(start) end = _vector(end) points = [start] current = start axes = sorted( ("x", "y", "z"), key=lambda axis: abs(_axis_value(end, axis) - _axis_value(start, axis)), reverse=True, ) for axis in axes: if abs(_axis_value(end, axis) - _axis_value(current, axis)) <= DEFAULT_NODE_TOLERANCE: continue current = _set_axis(current, axis, _axis_value(end, axis)) if _distance(points[-1], current) > DEFAULT_NODE_TOLERANCE: points.append(current) if _distance(points[-1], end) > DEFAULT_NODE_TOLERANCE: points.append(end) return points def _route_points_hit_bbox(points, bbox_payload): if not bbox_payload: return False normalized = _normalized_route_points(points) for index in range(max(len(normalized) - 1, 0)): if _segment_intersects_bbox_payload(normalized[index], normalized[index + 1], bbox_payload): return True return False def _orthogonal_access_point_candidates(start, end): start = _vector(start) end = _vector(end) candidates = [_orthogonal_access_points(start, end)] for axes in ( ("x", "y", "z"), ("x", "z", "y"), ("y", "x", "z"), ("y", "z", "x"), ("z", "x", "y"), ("z", "y", "x"), ): points = [start] current = start for axis in axes: if abs(_axis_value(end, axis) - _axis_value(current, axis)) <= DEFAULT_NODE_TOLERANCE: continue current = _set_axis(current, axis, _axis_value(end, axis)) points.append(current) if _distance(points[-1], end) > DEFAULT_NODE_TOLERANCE: points.append(end) candidates.append(points) return [_normalized_route_points(points) for points in candidates] def _terminal_access_dogleg_candidates(start, end, bbox, clearance): start = _vector(start) end = _vector(end) candidates = [] for axis in ("x", "y", "z"): low, high = _bbox_axis_range(bbox, axis) for via_value in ( float(low) - float(clearance), float(high) + float(clearance), ): first = _set_axis(start, axis, via_value) second = _set_axis(end, axis, via_value) candidates.append(_normalized_route_points([start, first, second, end])) return candidates def _route_length(points): total = 0.0 normalized = _normalized_route_points(points) for index in range(max(len(normalized) - 1, 0)): total += _distance(normalized[index], normalized[index + 1]) return total def _terminal_access_points_to_target(exit_point, target_point, endpoint_bbox=None): default_points = _orthogonal_access_points(exit_point, target_point) if endpoint_bbox is None: return default_points, False start = _vector(exit_point) end = _vector(target_point) if _point_inside_bbox(start, endpoint_bbox) or _point_inside_bbox(end, endpoint_bbox): return default_points, False blocked_bbox = _bbox_payload(endpoint_bbox, clearance=0.0) if not _route_points_hit_bbox(default_points, blocked_bbox): return default_points, False avoid_clearance = DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE + 1.0 expanded_bbox = _bbox_payload(endpoint_bbox, clearance=DEFAULT_NODE_TOLERANCE) candidates = [] candidates.extend(_orthogonal_access_point_candidates(start, end)) candidates.extend(_terminal_access_dogleg_candidates(start, end, endpoint_bbox, avoid_clearance)) valid = [ points for points in candidates if len(points) >= 2 and not _route_points_hit_bbox(points, expanded_bbox) ] if not valid: return default_points, False valid.sort(key=_route_length) return valid[0], True def _is_primary_route_carrier(carrier): kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND return kind in { ROUTE_CARRIER_KIND, ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_USER_PATH, ROUTE_CARRIER_KIND_AUXILIARY_PATH, } def _is_terminal_access_main_path_target(carrier): kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND return kind in { ROUTE_CARRIER_KIND, ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_USER_PATH, } def _component_metrics_by_node(network): nodes = network.get("nodes", {}) if isinstance(network, dict) else {} edges = network.get("edges", {}) if isinstance(network, dict) else {} seen = set() metrics_by_node = {} for start_key in nodes.keys(): if start_key in seen: continue stack = [start_key] seen.add(start_key) component_nodes = [] component_edges = set() primary_edges = set() while stack: key = stack.pop() component_nodes.append(key) for next_key, _weight, carrier in edges.get(key, []) or []: edge_key = tuple(sorted((key, next_key))) component_edges.add(edge_key) if _is_primary_route_carrier(carrier): primary_edges.add(edge_key) if next_key not in seen: seen.add(next_key) stack.append(next_key) metrics = { "segments": len(component_edges), "primary_segments": len(primary_edges), } for key in component_nodes: metrics_by_node[key] = metrics return metrics_by_node def rank_connection_point_candidates(network, candidates): """Sort graph entry candidates by route usefulness, not only distance.""" candidates = [candidate for candidate in list(candidates or []) if isinstance(candidate, dict)] if not candidates: return [] metrics_by_node = _component_metrics_by_node(network) max_segments = max( [int(metrics.get("segments", 0) or 0) for metrics in metrics_by_node.values()] or [0] ) max_primary_segments = max( [int(metrics.get("primary_segments", 0) or 0) for metrics in metrics_by_node.values()] or [0] ) ranked = [] for candidate in candidates: left_metrics = metrics_by_node.get(candidate.get("key"), {}) right_metrics = metrics_by_node.get(candidate.get("next_key"), {}) component_segments = max( int(left_metrics.get("segments", 0) or 0), int(right_metrics.get("segments", 0) or 0), ) component_primary_segments = max( int(left_metrics.get("primary_segments", 0) or 0), int(right_metrics.get("primary_segments", 0) or 0), ) score = float(candidate.get("distance", 0.0) or 0.0) if max_primary_segments > 0 and component_primary_segments <= 0: score += DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY carrier_kind = (getattr(candidate.get("carrier"), "QetRouteCarrierKind", "") or "").strip() if max_primary_segments > 0 and not _is_primary_route_carrier(candidate.get("carrier")): # 同一网络组件里也优先接线槽/UserPath/过线孔;RoutingRange 只是兜底布线面。 score += DEFAULT_TERMINAL_ACCESS_FALLBACK_CARRIER_PENALTY if max_primary_segments > 0 and carrier_kind == ROUTE_CARRIER_KIND_TERMINAL_ACCESS: # 入口候选也要优先真实主路径,避免导线贴到其它端子的局部接入线上起步。 score += DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY score += max(0, max_segments - component_segments) * DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY item = dict(candidate) item["route_entry_score"] = float(score) item["route_entry_component_segments"] = int(component_segments) item["route_entry_component_primary_segments"] = int(component_primary_segments) ranked.append(item) ranked.sort(key=lambda item: float(item.get("route_entry_score", 0.0) or 0.0)) return ranked def _component_index_by_node(network): nodes = network.get("nodes", {}) if isinstance(network, dict) else {} edges = network.get("edges", {}) if isinstance(network, dict) else {} component_by_node = {} seen = set() component_index = 0 for start_key in nodes.keys(): if start_key in seen: continue stack = [start_key] seen.add(start_key) while stack: key = stack.pop() component_by_node[key] = component_index for next_key, _weight, _carrier in edges.get(key, []) or []: if next_key not in seen: seen.add(next_key) stack.append(next_key) component_index += 1 return component_by_node def _candidate_component_index(candidate, component_by_node): for key_name in ("projected_key", "key", "next_key"): key = candidate.get(key_name) if key in component_by_node: return component_by_node[key] return None def _candidate_identity(candidate): carrier = candidate.get("carrier") return ( candidate.get("projected_key"), candidate.get("key"), candidate.get("next_key"), id(carrier) if carrier is not None else None, ) def select_diverse_connection_point_candidates(network, candidates, limit=8): """Select ranked entry candidates while keeping alternate components visible.""" max_items = max(int(limit or 0), 0) ranked = [] seen_identities = set() for candidate in rank_connection_point_candidates(network, candidates): identity = _candidate_identity(candidate) if identity in seen_identities: continue seen_identities.add(identity) ranked.append(candidate) if max_items <= 0 or len(ranked) <= max_items: return ranked component_by_node = _component_index_by_node(network) selected = [] selected_identities = set() selected_components = set() deferred = [] for candidate in ranked: identity = _candidate_identity(candidate) component_index = _candidate_component_index(candidate, component_by_node) if component_index is None or component_index in selected_components: deferred.append(candidate) continue selected.append(candidate) selected_identities.add(identity) selected_components.add(component_index) if len(selected) >= max_items: return selected for candidate in deferred: identity = _candidate_identity(candidate) if identity in selected_identities: continue selected.append(candidate) selected_identities.add(identity) if len(selected) >= max_items: break return selected def _terminal_access_target_candidate(network, exit_point, max_distance): candidates = connection_point_candidates( network, exit_point, limit=0, max_distance=max_distance, ) ranked = rank_connection_point_candidates(network, candidates) if not ranked: return None nearest_physical = min( candidates, key=lambda candidate: float(candidate.get("distance", 0.0) or 0.0), ) main_path_candidates = [ candidate for candidate in ranked if _is_terminal_access_main_path_target(candidate.get("carrier")) ] if main_path_candidates: main_path_candidates.sort( key=lambda candidate: ( -int(candidate.get("route_entry_component_primary_segments", 0) or 0), float(candidate.get("distance", 0.0) or 0.0), ) ) selected = dict(main_path_candidates[0]) selected["terminal_access_target_rule"] = ( "main_path_preferred_over_fallback" if not _is_terminal_access_main_path_target(nearest_physical.get("carrier")) else "main_path_nearest" ) selected["terminal_access_fallback_target"] = False return selected selected = dict(ranked[0]) selected["terminal_access_target_rule"] = "fallback_only" selected["terminal_access_fallback_target"] = True return selected def _set_terminal_access_target_metadata(carrier, candidate): if carrier is None or not isinstance(candidate, dict): return target_carrier = candidate.get("carrier") target_kind = (getattr(target_carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND target_name = (getattr(target_carrier, "Name", "") or "").strip() target_label = (getattr(target_carrier, "Label", "") or "").strip() or target_name TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessTargetKind", PROPERTY_GROUP, "Carrier kind selected as terminal access target", target_kind, ) TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessTargetName", PROPERTY_GROUP, "Carrier name selected as terminal access target", target_name, ) TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessTargetLabel", PROPERTY_GROUP, "Carrier label selected as terminal access target", target_label, ) TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessTargetRule", PROPERTY_GROUP, "Why this carrier was selected as terminal access target", str(candidate.get("terminal_access_target_rule", "") or ""), ) TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessFallbackTarget", PROPERTY_GROUP, "Whether the terminal access target is only a fallback carrier", "1" if bool(candidate.get("terminal_access_fallback_target", False)) else "0", ) _ensure_float_property( carrier, "QetTerminalAccessTargetDistanceMm", "Distance from terminal local exit to selected access target", float(candidate.get("distance", 0.0) or 0.0), ) _ensure_integer_property( carrier, "QetTerminalAccessTargetComponentPrimarySegments", "Primary route segment count in the selected target component", int(candidate.get("route_entry_component_primary_segments", 0) or 0), ) _ensure_integer_property( carrier, "QetTerminalAccessTargetComponentSegments", "Route segment count in the selected target component", int(candidate.get("route_entry_component_segments", 0) or 0), ) def create_terminal_access_carriers_from_document( doc, project_uuid="", terminal_exit_length=20.0, terminal_exit_max_length=DEFAULT_TERMINAL_EXIT_MAX_LENGTH, max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, ): """Connect every engineering terminal to the generated route network. EPLAN/SW 的一键布线不是让用户给每个端子手工画辅助线,而是先把端子 自动接入路由网络。这里生成短的 TerminalAccess carrier,后续 Dijkstra 才能从端子入口进入线槽/布线面。 """ # TerminalAccess depends directly on current terminal placement, so regenerate it # every time the layout space is prepared. This keeps one-click routing predictable # after devices or terminals are moved in FreeCAD. for carrier in list(collect_route_carriers(doc)): if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() != ROUTE_CARRIER_KIND_TERMINAL_ACCESS: continue _detach_from_groups(doc, carrier) try: if doc.getObject(getattr(carrier, "Name", "")) is not None: doc.removeObject(carrier.Name) except Exception: pass try: doc.recompute() except Exception: pass network = build_route_graph(doc) if network.get("segment_count", 0) <= 0: return [] created = [] for terminal in _collect_routable_terminals(doc): if _live_source_carrier(doc, terminal) is not None: continue has_local_route_points = bool(_terminal_local_route_points(terminal)) terminal_access_points = terminal_access_path_points( terminal, terminal_exit_length, max_exit_length=terminal_exit_max_length, ) if len(terminal_access_points) < 2: continue exit_point = terminal_access_points[-1] candidate = _terminal_access_target_candidate(network, exit_point, max_distance) if candidate is None: continue nearest_point = _vector(candidate.get("point")) distance = float(candidate.get("distance", 0.0) or 0.0) if max_distance and float(distance or 0.0) > float(max_distance): continue if float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE: continue endpoint_bbox = _terminal_parent_device_bbox(terminal, _vector(TerminalObjects.terminal_origin(terminal))) access_to_target_points, avoided_endpoint_device = _terminal_access_points_to_target( exit_point, nearest_point, endpoint_bbox=endpoint_bbox, ) if has_local_route_points: points = list(terminal_access_points) for point in access_to_target_points[1:]: if _distance(points[-1], point) > DEFAULT_NODE_TOLERANCE: points.append(point) else: points = access_to_target_points if len(points) < 2: continue label = getattr(terminal, "Label", "") or getattr(terminal, "Name", "") or "Terminal" carrier = create_route_carrier( doc, points, label="QET Auto Terminal Access {0}".format(label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_TERMINAL_ACCESS, ) _mark_terminal_access_source(terminal, carrier) _set_terminal_access_target_metadata(carrier, candidate) TerminalObjects.ensure_string_property( carrier, "QetTerminalAccessAvoidedEndpointDevice", PROPERTY_GROUP, "Whether TerminalAccess detoured around the terminal parent device bbox", "1" if avoided_endpoint_device else "0", ) created.append(carrier) return created def create_routing_path_network_from_document( doc, project_uuid="", selection_ex=None, terminal_exit_length=20.0, terminal_exit_max_length=DEFAULT_TERMINAL_EXIT_MAX_LENGTH, terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): """Generate the EPLAN-style routing path network for the layout space. Selection is treated as a hint for wire ducts that cannot be detected from names or semantics. The full document is still scanned afterwards, matching EPLAN's "generate routing path network for the layout space" behavior. """ layout_space = prepare_layout_space_sources_from_document( doc, project_uuid=project_uuid, ) selected_wire_ducts = [] selected_user_paths = [] if selection_ex: selected_wire_ducts = create_wire_duct_carriers_from_selection( doc, selection_ex, project_uuid=project_uuid, ) selected_user_paths = create_user_path_carriers_from_selection( doc, selection_ex, project_uuid=project_uuid, ) document_user_paths = create_user_path_carriers_from_document( doc, project_uuid=project_uuid, ) wire_ducts = create_wire_duct_carriers_from_document( doc, project_uuid=project_uuid, ) cut_outs = create_wiring_cut_out_carriers_from_document( doc, project_uuid=project_uuid, ) surfaces = create_surface_carriers_from_document( doc, project_uuid=project_uuid, ) terminal_access = create_terminal_access_carriers_from_document( doc, project_uuid=project_uuid, terminal_exit_length=terminal_exit_length, terminal_exit_max_length=terminal_exit_max_length, max_distance=terminal_access_max_distance, ) all_wire_duct_created = list(selected_wire_ducts) + list(wire_ducts) wire_duct_main_count = sum( 1 for carrier in all_wire_duct_created if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT ) selected_wire_duct_main_count = sum( 1 for carrier in selected_wire_ducts if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT ) all_user_paths = [] seen_user_paths = set() for carrier in list(selected_user_paths) + list(document_user_paths): if carrier is None or id(carrier) in seen_user_paths: continue seen_user_paths.add(id(carrier)) all_user_paths.append(carrier) open_end_count = sum( 1 for carrier in all_wire_duct_created if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END ) return { "wire_duct_carriers": wire_duct_main_count, "selected_wire_duct_carriers": selected_wire_duct_main_count, "user_path_carriers": len(all_user_paths), "selected_user_path_carriers": len(selected_user_paths), "document_user_path_carriers": len(document_user_paths), "wire_duct_open_end_carriers": open_end_count, "wiring_cut_out_carriers": len(cut_outs), "surface_carriers": len(surfaces), "terminal_access_carriers": len(terminal_access), "layout_space": layout_space, "network": network_summary( doc, adjoining_duct_tolerance=adjoining_duct_tolerance, ), } def create_wire_duct_carriers_from_selection( doc, selection_ex, project_uuid="", margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5, ): """Create WireDuct centerline carriers from selected duct-like solids.""" cleanup_invalid_source_carriers(doc) created = [] for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1): if _source_kind_value(source) == ROUTE_CARRIER_KIND_USER_PATH or _is_route_path_source_object(source): continue bbox = _bound_box_from_object(source) if bbox is None: continue source_margin = _wire_duct_end_margin_value(source, default=margin) spec = _wire_duct_centerline_spec_from_bbox( bbox, margin=source_margin, min_aspect=min_aspect, ) points = spec.get("centerline", []) if len(points) < 2: continue label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Wire Duct" capacity = _route_carrier_capacity_value(source, default=1) if _sync_wire_duct_source_carriers( doc, source, spec, project_uuid=project_uuid, capacity=capacity, end_margin=source_margin, ): continue carrier = create_route_carrier( doc, points, label="QET Wire Duct Centerline {0}".format(label), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRE_DUCT, capacity=capacity, ) source_created = [carrier] created.append(carrier) for end_index, open_end_points in enumerate(spec.get("open_ends", []) or [], start=1): if len(open_end_points) < 2: continue open_end_carrier = create_route_carrier( doc, open_end_points, label="QET Wire Duct Open End {0} {1}".format(label, end_index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, capacity=capacity, ) source_created.append(open_end_carrier) created.append(open_end_carrier) _mark_wire_duct_source(source, carrier, source_created, end_margin=source_margin) return created def create_surface_carriers_from_selection( doc, selection_ex, project_uuid="", spacing=DEFAULT_SURFACE_LANE_SPACING, offset=DEFAULT_SURFACE_OFFSET, margin=DEFAULT_SURFACE_MARGIN, ): """Create a supported route grid on selected planar cabinet/panel faces.""" created = [] for item in selection_ex or []: item_created = [] selection_source = getattr(item, "Object", None) capacity = _route_carrier_capacity_value(selection_source, default=1) for sub_object in list(getattr(item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() if shape_type != "face": continue grids = _surface_face_grid_points( sub_object, spacing=spacing, offset=offset, margin=margin, ) for index, points in enumerate(grids, start=1): if len(points) < 2: continue carrier = create_route_carrier( doc, points, label="QET Surface Route {0}".format(index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, capacity=capacity, ) item_created.append(carrier) created.append(carrier) if item_created: continue obj = getattr(item, "Object", None) if not _is_support_surface_candidate(obj): continue if _live_source_carrier(doc, obj) is not None: continue bbox = _bound_box_from_object(obj) if bbox is None: continue support_face = _support_face_from_bbox(bbox) grids = _surface_face_grid_points( support_face, spacing=spacing, offset=offset, margin=margin, ) capacity = _route_carrier_capacity_value(obj, default=1) source_created = [] label = getattr(obj, "Label", "") or getattr(obj, "Name", "") or "Support Surface" for index, points in enumerate(grids, start=1): if len(points) < 2: continue carrier = create_route_carrier( doc, points, label="QET Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, capacity=capacity, ) source_created.append(carrier) created.append(carrier) _mark_support_surface_source(obj, source_created) return created def _carrier_cost_factor(carrier, kind_cost_factors=None): kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() factors = dict(DEFAULT_KIND_COST_FACTORS) if isinstance(kind_cost_factors, dict): factors.update(kind_cost_factors) try: return max(float(factors.get(kind, 1.0)), 0.01) except Exception: return 1.0 def build_route_graph( doc, tolerance=DEFAULT_NODE_TOLERANCE, blocked_bboxes=None, allowed_bboxes=None, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): """Build an undirected graph from every enabled route carrier.""" nodes = {} edges = {} carriers = collect_route_carriers(doc) segment_count = 0 blocked_segment_count = 0 boundary_filtered_segment_count = 0 bridged_segment_count = 0 blocked_bboxes = list(blocked_bboxes or []) allowed_bboxes = list(allowed_bboxes or []) segments = [] bridgeable_endpoint_nodes = [] projection_bridge_candidates = [] adjoining_limit = max(float(adjoining_duct_tolerance or 0.0), 0.0) def ensure_node(point): key = _point_key(point, tolerance=tolerance) if key not in nodes: nodes[key] = point edges[key] = [] return key for carrier in carriers: points = _carrier_points(carrier) if len(points) < 2: continue for index in range(len(points) - 1): start = points[index] end = points[index + 1] if _distance(start, end) <= tolerance: continue segments.append( { "carrier": carrier, "start": start, "end": end, "points": [start, end], } ) # Several wire ducts often touch or cross geometrically without sharing endpoint # coordinates. Split those carrier segments at the intersection points so Dijkstra # can change direction there, which matches CAD routing path behavior. for left_index in range(len(segments)): left = segments[left_index] for right in segments[left_index + 1:]: intersections = _orthogonal_segment_intersections( left["start"], left["end"], right["start"], right["end"], tolerance=tolerance, ) if not intersections: continue left["points"].extend(intersections) right["points"].extend(intersections) # 现场线槽/UserPath 常见“支路端点靠近主干中段”,并不总是端点对端点。 # 在容差内时先把主干投影点加入分段点,后面再补一条虚拟桥接边。 if adjoining_limit > tolerance: for left in segments: left_carrier = left["carrier"] left_kind = (getattr(left_carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND if left_kind not in BRIDGEABLE_ENDPOINT_CARRIER_KINDS: continue for endpoint in (left["start"], left["end"]): for right in segments: right_carrier = right["carrier"] if right_carrier is left_carrier: continue right_kind = (getattr(right_carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND if right_kind not in BRIDGEABLE_ENDPOINT_CARRIER_KINDS: continue projected = _closest_point_on_segment(endpoint, right["start"], right["end"]) distance = _distance(endpoint, projected) if distance > adjoining_limit: continue right["points"].append(projected) if distance > tolerance: projection_bridge_candidates.append( (endpoint, projected, left_carrier, right_carrier) ) for segment in segments: ordered = _sorted_segment_points( segment["start"], segment["end"], segment["points"], tolerance=tolerance, ) if len(ordered) < 2: continue carrier = segment["carrier"] carrier_kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND if carrier_kind in BRIDGEABLE_ENDPOINT_CARRIER_KINDS: for endpoint in (ordered[0], ordered[-1]): endpoint_key = ensure_node(endpoint) bridgeable_endpoint_nodes.append((endpoint_key, nodes[endpoint_key], carrier)) previous_key = ensure_node(ordered[0]) previous_point = nodes[previous_key] for point in ordered[1:]: current_key = ensure_node(point) current_point = nodes[current_key] weight = _distance(previous_point, current_point) if weight > tolerance: if allowed_bboxes and not _segment_inside_any_bbox(previous_point, current_point, allowed_bboxes): boundary_filtered_segment_count += 1 previous_key = current_key previous_point = current_point continue if _segment_hits_blocked_bbox(previous_point, current_point, blocked_bboxes): blocked_segment_count += 1 previous_key = current_key previous_point = current_point continue edges[previous_key].append((current_key, weight, carrier)) edges[current_key].append((previous_key, weight, carrier)) segment_count += 1 previous_key = current_key previous_point = current_point bridged_pairs = set() def add_bridge_edge(left_key, left_point, left_carrier, right_key, right_point, right_carrier): nonlocal blocked_segment_count, boundary_filtered_segment_count, bridged_segment_count, segment_count if left_key == right_key or left_carrier is right_carrier: return pair = tuple(sorted((left_key, right_key))) if pair in bridged_pairs: return distance = _distance(left_point, right_point) if distance <= tolerance or distance > adjoining_limit: return if any(next_key == right_key for next_key, _weight, _carrier in edges.get(left_key, [])): return if allowed_bboxes and not _segment_inside_any_bbox(left_point, right_point, allowed_bboxes): boundary_filtered_segment_count += 1 return if _segment_hits_blocked_bbox(left_point, right_point, blocked_bboxes): blocked_segment_count += 1 return edges[left_key].append((right_key, distance, left_carrier)) edges[right_key].append((left_key, distance, right_carrier)) segment_count += 1 bridged_segment_count += 1 bridged_pairs.add(pair) if adjoining_limit > tolerance: for endpoint, projected, endpoint_carrier, projected_carrier in projection_bridge_candidates: endpoint_key = ensure_node(endpoint) projected_key = ensure_node(projected) add_bridge_edge( endpoint_key, nodes[endpoint_key], endpoint_carrier, projected_key, nodes[projected_key], projected_carrier, ) for left_index, left in enumerate(bridgeable_endpoint_nodes): left_key, left_point, left_carrier = left for right_key, right_point, right_carrier in bridgeable_endpoint_nodes[left_index + 1:]: add_bridge_edge(left_key, left_point, left_carrier, right_key, right_point, right_carrier) return { "nodes": nodes, "edges": edges, "carriers": carriers, # 自动桥接边只存在于路径图里;保存 key 对用于 route track 标记实际走过的桥接段。 "bridge_pairs": set(bridged_pairs), "carrier_count": len(carriers), "segment_count": segment_count, "bridged_segment_count": bridged_segment_count, "blocked_segment_count": blocked_segment_count, "boundary_filtered": bool(allowed_bboxes), "boundary_filtered_segment_count": boundary_filtered_segment_count, "tolerance": tolerance, } def nearest_node(network, point): nodes = network.get("nodes", {}) if isinstance(network, dict) else {} if not nodes: return None, None target = _vector(point) best_key = None best_distance = None for key, node_point in nodes.items(): distance = _distance(target, node_point) if best_distance is None or distance < best_distance: best_key = key best_distance = distance return best_key, best_distance def nearest_point_on_network(network, point): """Return the closest point on any route-network edge. The point may lie in the middle of a carrier segment. If a TerminalAccess carrier ends there, the next graph build will split the crossed segment at that point and create an EPLAN-like jump-in routing point. """ if not isinstance(network, dict): return None, None nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} if not nodes or not edges: return None, None target = _vector(point) best_point = None best_distance = None seen = set() for key, neighbors in edges.items(): start = nodes.get(key) if start is None: continue for next_key, _weight, _carrier in neighbors: pair = tuple(sorted((key, next_key))) if pair in seen: continue seen.add(pair) end = nodes.get(next_key) if end is None: continue candidate = _closest_point_on_segment(target, start, end) distance = _distance(target, candidate) if best_distance is None or distance < best_distance: best_point = candidate best_distance = distance if best_point is not None: return best_point, best_distance return nearest_node(network, target) def connection_point_candidates(network, point, limit=8, max_distance=0.0): """Return nearby graph entry candidates sorted by distance.""" if not isinstance(network, dict): return [] nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} if not nodes or not edges: return [] tolerance = float(network.get("tolerance", DEFAULT_NODE_TOLERANCE) or DEFAULT_NODE_TOLERANCE) target = _vector(point) candidates = [] seen_candidates = set() seen = set() for key, neighbors in edges.items(): start = nodes.get(key) if start is None: continue for next_key, _weight, carrier in neighbors: pair = tuple(sorted((key, next_key))) if pair in seen: continue seen.add(pair) end = nodes.get(next_key) if end is None: continue projected = _closest_point_on_segment(target, start, end) distance = _distance(target, projected) if max_distance > 0.0 and distance > max_distance: continue projected_key = _point_key(projected, tolerance=tolerance) candidate_key = (projected_key, key, next_key, id(carrier)) if candidate_key in seen_candidates: continue seen_candidates.add(candidate_key) candidates.append( { "key": key, "next_key": next_key, "carrier": carrier, "point": projected, "projected_key": projected_key, "distance": distance, } ) if not candidates: node_key, distance = nearest_node(network, target) if node_key is None: return [] if max_distance > 0.0 and float(distance or 0.0) > max_distance: return [] candidates.append( { "key": node_key, "next_key": None, "carrier": None, "point": nodes.get(node_key, target), "projected_key": node_key, "distance": float(distance or 0.0), "mode": "node", } ) candidates.sort(key=lambda item: float(item.get("distance", 0.0) or 0.0)) max_items = max(int(limit or 0), 0) if max_items: return candidates[:max_items] return candidates def connect_point_candidate_to_network(network, candidate): """Connect a preselected projected point to a route graph.""" if not isinstance(network, dict) or not isinstance(candidate, dict): return None, None, "none" nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} if not nodes or not edges: return None, None, "none" tolerance = float(network.get("tolerance", DEFAULT_NODE_TOLERANCE) or DEFAULT_NODE_TOLERANCE) projected = _vector(candidate.get("point")) projected_key = candidate.get("projected_key") or _point_key(projected, tolerance=tolerance) if projected_key in nodes: return projected_key, float(candidate.get("distance", 0.0) or 0.0), "node" start_key = candidate.get("key") end_key = candidate.get("next_key") if start_key not in nodes or end_key not in nodes: return None, None, "none" start = nodes[start_key] end = nodes[end_key] carrier = candidate.get("carrier") def remove_edge_once(left_key, right_key, fallback_to_pair=False): neighbors = list(edges.get(left_key, []) or []) for index, (candidate_key, _weight, candidate_carrier) in enumerate(neighbors): if candidate_key == right_key and candidate_carrier is carrier: del neighbors[index] edges[left_key] = neighbors return True if fallback_to_pair: for index, (candidate_key, _weight, _candidate_carrier) in enumerate(neighbors): if candidate_key == right_key: del neighbors[index] edges[left_key] = neighbors return True return False removed_forward = remove_edge_once(start_key, end_key) remove_edge_once(end_key, start_key, fallback_to_pair=removed_forward) nodes[projected_key] = projected edges[projected_key] = [] added_segments = 0 for left_key, left_point, right_key, right_point in ( (start_key, start, projected_key, projected), (projected_key, projected, end_key, end), ): weight = _distance(left_point, right_point) if weight <= tolerance: continue edges[left_key].append((right_key, weight, carrier)) edges[right_key].append((left_key, weight, carrier)) added_segments += 1 network["segment_count"] = max(int(network.get("segment_count", 0) or 0) - 1 + added_segments, 0) return projected_key, float(candidate.get("distance", 0.0) or 0.0), "segment_projection" def connect_point_to_network(network, point): """Connect the closest projected point to a route graph and return key/distance/mode.""" candidates = connection_point_candidates(network, point, limit=1) if not candidates: return None, None, "none" return connect_point_candidate_to_network(network, candidates[0]) def _carrier_track_payload(carrier): payload = { "name": getattr(carrier, "Name", ""), "label": getattr(carrier, "Label", ""), "kind": (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND, "capacity": _route_carrier_capacity_value(carrier, default=1), } source_fields = ( ("source_name", "QetRouteSourceName"), ("source_label", "QetRouteSourceLabel"), ("source_kind", "QetRouteSourceKind"), ("source_path_index", "QetRouteSourcePathIndex"), ) for payload_key, property_name in source_fields: value = (getattr(carrier, property_name, "") or "").strip() if value: payload[payload_key] = value return payload def _segment_usage_key(carrier, from_key, to_key): carrier_name = getattr(carrier, "Name", "") if carrier is not None else "" return ( carrier_name, tuple(sorted((from_key, to_key))), ) def _carrier_capacity(carrier): if carrier is None: return 1 for property_name in ("QetRouteCarrierCapacity", "QetWireCapacity"): try: value = int(float(getattr(carrier, property_name, 0) or 0)) except Exception: value = 0 if value > 0: return value return 1 def _normalized_text_set(values): return { str(value or "").strip() for value in (values or []) if str(value or "").strip() } def _carrier_forbidden( carrier, forbidden_carrier_names=None, forbidden_carrier_labels=None, forbidden_carrier_source_names=None, forbidden_carrier_source_labels=None, forbidden_carrier_kinds=None, ): if carrier is None: return False names = _normalized_text_set(forbidden_carrier_names) labels = _normalized_text_set(forbidden_carrier_labels) source_names = _normalized_text_set(forbidden_carrier_source_names) source_labels = _normalized_text_set(forbidden_carrier_source_labels) kinds = _normalized_text_set(forbidden_carrier_kinds) if names and (getattr(carrier, "Name", "") or "").strip() in names: return True if labels and (getattr(carrier, "Label", "") or "").strip() in labels: return True if source_names and (getattr(carrier, "QetRouteSourceName", "") or "").strip() in source_names: return True if source_labels and (getattr(carrier, "QetRouteSourceLabel", "") or "").strip() in source_labels: return True kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND return bool(kinds and kind in kinds) def _required_carrier_criteria( required_carrier_names=None, required_carrier_labels=None, required_carrier_source_names=None, required_carrier_source_labels=None, required_carrier_kinds=None, ): criteria = [] for kind, values in ( ("name", required_carrier_names), ("label", required_carrier_labels), ("source_name", required_carrier_source_names), ("source_label", required_carrier_source_labels), ("kind", required_carrier_kinds), ): for value in sorted(_normalized_text_set(values)): criteria.append((kind, value)) return criteria[:30] def _carrier_required_mask(carrier, criteria): if carrier is None or not criteria: return 0 values = { "name": (getattr(carrier, "Name", "") or "").strip(), "label": (getattr(carrier, "Label", "") or "").strip(), "source_name": (getattr(carrier, "QetRouteSourceName", "") or "").strip(), "source_label": (getattr(carrier, "QetRouteSourceLabel", "") or "").strip(), "kind": (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND, } mask = 0 for index, (kind, expected) in enumerate(criteria): if values.get(kind, "") == expected: mask |= 1 << index return mask def shortest_path_with_carriers( network, start_key, end_key, bend_penalty=0.0, kind_cost_factors=None, segment_usage_costs=None, segment_reuse_penalty=0.0, excluded_transit_carrier_kinds=None, forbidden_carrier_names=None, forbidden_carrier_labels=None, forbidden_carrier_source_names=None, forbidden_carrier_source_labels=None, forbidden_carrier_kinds=None, required_carrier_names=None, required_carrier_labels=None, required_carrier_source_names=None, required_carrier_source_labels=None, required_carrier_kinds=None, ): """Dijkstra search with a small extra cost when route direction changes.""" if start_key is None or end_key is None: return None required_criteria = _required_carrier_criteria( required_carrier_names=required_carrier_names, required_carrier_labels=required_carrier_labels, required_carrier_source_names=required_carrier_source_names, required_carrier_source_labels=required_carrier_source_labels, required_carrier_kinds=required_carrier_kinds, ) required_all_mask = (1 << len(required_criteria)) - 1 if start_key == end_key and required_all_mask == 0: return { "path": [start_key], "segments": [], "bridged_segments": 0, "cost": 0.0, } nodes = network.get("nodes", {}) edges = network.get("edges", {}) bridge_pairs = set(network.get("bridge_pairs", set()) or set()) excluded_transit_kinds = { str(kind or "").strip() for kind in (excluded_transit_carrier_kinds or []) if str(kind or "").strip() } queue = [] counter = 0 start_state = (start_key, None, 0) distances = {start_state: 0.0} previous = {} heapq.heappush(queue, (0.0, counter, start_key, None, 0)) while queue: cost, _counter, key, previous_direction, required_mask = heapq.heappop(queue) state = (key, previous_direction, required_mask) if cost > distances.get(state, float("inf")): continue if key == end_key and required_mask == required_all_mask: path = [key] segments = [] current_state = state while current_state in previous: previous_entry = previous[current_state] previous_state = previous_entry["state"] previous_key = previous_state[0] current_key = current_state[0] carrier = previous_entry.get("carrier") segment_pair = tuple(sorted((previous_key, current_key))) segment_payload = { "from_key": list(previous_key), "to_key": list(current_key), "from": _point_payload(nodes[previous_key]), "to": _point_payload(nodes[current_key]), "length_mm": float(previous_entry.get("weight", 0.0) or 0.0), "carrier": _carrier_track_payload(carrier), } if segment_pair in bridge_pairs: segment_payload["is_bridge"] = True segments.append(segment_payload) current_state = previous_state path.append(current_state[0]) path.reverse() segments.reverse() carrier_names = [] carrier_kinds = {} bridged_segments = 0 for segment in segments: if bool(segment.get("is_bridge", False)): bridged_segments += 1 # 桥接段是虚拟连通边,不纳入“真实经过的 carrier 类型”汇总。 continue carrier = segment.get("carrier", {}) name = carrier.get("name", "") if name and name not in carrier_names: carrier_names.append(name) kind = carrier.get("kind", "") or ROUTE_CARRIER_KIND carrier_kinds[kind] = carrier_kinds.get(kind, 0) + 1 return { "path": path, "segments": segments, "carrier_names": carrier_names, "carrier_kinds": carrier_kinds, "bridged_segments": bridged_segments, "cost": float(cost), } for next_key, weight, carrier in edges.get(key, []): carrier_kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND # TerminalAccess 是端子局部接入线,不能被其它导线当作柜内主路径或公共桥接段。 if carrier_kind in excluded_transit_kinds: continue if _carrier_forbidden( carrier, forbidden_carrier_names=forbidden_carrier_names, forbidden_carrier_labels=forbidden_carrier_labels, forbidden_carrier_source_names=forbidden_carrier_source_names, forbidden_carrier_source_labels=forbidden_carrier_source_labels, forbidden_carrier_kinds=forbidden_carrier_kinds, ): continue direction = _direction_key(nodes[key], nodes[next_key]) next_required_mask = required_mask | _carrier_required_mask(carrier, required_criteria) bend_cost = 0.0 if previous_direction is not None and direction != previous_direction: bend_cost = float(bend_penalty or 0.0) usage_cost = 0.0 if segment_usage_costs: usage_count = float(segment_usage_costs.get(_segment_usage_key(carrier, key, next_key), 0.0) or 0.0) capacity = float(_carrier_capacity(carrier)) excess_usage = max(usage_count - capacity + 1.0, 0.0) usage_cost = excess_usage * float(segment_reuse_penalty or 0.0) next_state = (next_key, direction, next_required_mask) next_cost = ( cost + float(weight) * _carrier_cost_factor(carrier, kind_cost_factors) + bend_cost + usage_cost ) if next_cost < distances.get(next_state, float("inf")): distances[next_state] = next_cost previous[next_state] = { "state": state, "carrier": carrier, "weight": weight, } counter += 1 heapq.heappush(queue, (next_cost, counter, next_key, direction, next_required_mask)) return None def path_points(network, path_keys): nodes = network.get("nodes", {}) if isinstance(network, dict) else {} return [nodes[key] for key in path_keys or [] if key in nodes] def network_summary(doc, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE): network = build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance) return _network_summary_from_graph(network) def collect_route_constraint_options(doc): """Collect global route constraints marked directly on route carrier objects.""" payload = { "required_route_carrier_names": [], "required_route_carrier_source_names": [], "required_route_carrier_source_labels": [], "forbidden_route_carrier_names": [], "forbidden_route_carrier_source_names": [], "forbidden_route_carrier_source_labels": [], } def append_once(key, value): text = str(value or "").strip() if text and text not in payload[key]: payload[key].append(text) for carrier in collect_route_carriers(doc): mode = _route_constraint_mode_value(getattr(carrier, "QetRouteConstraintMode", "")) name = (getattr(carrier, "Name", "") or "").strip() if not mode or not name: continue source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() source_label = (getattr(carrier, "QetRouteSourceLabel", "") or "").strip() if mode == ROUTE_CONSTRAINT_MODE_REQUIRED: if source_name: # 一个草图/Draft 源对象可能生成多条 UserPath;必经源对象表示经过其中任一相关路径即可。 append_once("required_route_carrier_source_names", source_name) append_once("required_route_carrier_source_labels", source_label) else: append_once("required_route_carrier_names", name) elif mode == ROUTE_CONSTRAINT_MODE_FORBIDDEN: if source_name: append_once("forbidden_route_carrier_source_names", source_name) append_once("forbidden_route_carrier_source_labels", source_label) else: append_once("forbidden_route_carrier_names", name) return payload def collect_route_constraint_source_counts(doc): """Count Required/Forbidden modes stored on route source objects for UI summaries.""" counts = { "required": 0, "forbidden": 0, } if doc is None: return counts seen = set() for obj in list(getattr(doc, "Objects", []) or []): if obj is None or id(obj) in seen or is_route_carrier(obj): continue seen.add(id(obj)) if not _source_kind_value(obj) and not _is_route_path_source_object(obj): continue mode = _route_constraint_mode_value(getattr(obj, "QetRouteConstraintMode", "")) if mode == ROUTE_CONSTRAINT_MODE_REQUIRED: counts["required"] += 1 elif mode == ROUTE_CONSTRAINT_MODE_FORBIDDEN: counts["forbidden"] += 1 return counts def _route_constraint_mode_value(mode): text = str(mode or "").strip() normalized = text.lower() if normalized in { ROUTE_CONSTRAINT_MODE_REQUIRED.lower(), "must", "mustpass", "must_pass", "requiredpass", } or text in {"必须经过", "必经"}: return ROUTE_CONSTRAINT_MODE_REQUIRED if normalized in { ROUTE_CONSTRAINT_MODE_FORBIDDEN.lower(), "forbid", "blocked", "avoid", } or text in {"禁止经过", "禁经", "禁止"}: return ROUTE_CONSTRAINT_MODE_FORBIDDEN return "" def _set_route_constraint_mode(obj, mode): TerminalObjects.ensure_string_property( obj, "QetRouteConstraintMode", PROPERTY_GROUP, "Route constraint mode for automatic routing", mode, ) def _selected_route_carriers_for_constraint(doc, selection_ex): carriers = [] seen = set() for item in selection_ex or []: source = getattr(item, "Object", None) if source is None: continue candidates = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) for carrier in candidates: if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen: continue seen.add(id(carrier)) carriers.append(carrier) return carriers def mark_route_constraint_mode_from_selection(doc, selection_ex, mode): normalized = _route_constraint_mode_value(mode) marked = [] seen_marked = set() for item in selection_ex or []: source = getattr(item, "Object", None) if source is None: continue if not is_route_carrier(source): _set_route_constraint_mode(source, normalized) carriers = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) for carrier in carriers: if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen_marked: continue _set_route_constraint_mode(carrier, normalized) source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() source_obj = _document_object_by_name(doc, source_name) if source_obj is not None: _set_route_constraint_mode(source_obj, normalized) seen_marked.add(id(carrier)) marked.append(carrier) return marked def set_route_carrier_capacity_from_selection(doc, selection_ex, capacity): normalized = _normalized_route_capacity(capacity) marked = [] seen_marked = set() source_count = 0 seen_sources = set() for item in selection_ex or []: source = getattr(item, "Object", None) if source is None: continue if not is_route_carrier(source) and id(source) not in seen_sources: _set_route_carrier_capacity_value(source, normalized) seen_sources.add(id(source)) source_count += 1 carriers = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) for carrier in carriers: if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen_marked: continue _set_route_carrier_capacity_value(carrier, normalized) source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() source_obj = _document_object_by_name(doc, source_name) if source_obj is not None and id(source_obj) not in seen_sources: _set_route_carrier_capacity_value(source_obj, normalized) seen_sources.add(id(source_obj)) source_count += 1 seen_marked.add(id(carrier)) marked.append(carrier) return { "route_capacity": normalized, "route_capacity_carriers": len(marked), "route_capacity_sources": source_count, } def clear_all_route_constraint_modes(doc): """Clear global Required/Forbidden route constraints stored in the FreeCAD document.""" report = { "route_constraint_carriers": 0, "route_constraint_sources": 0, } if doc is None: return report seen = set() for obj in list(getattr(doc, "Objects", []) or []): if obj is None or id(obj) in seen: continue seen.add(id(obj)) mode = str(getattr(obj, "QetRouteConstraintMode", "") or "").strip() if not mode: continue _set_route_constraint_mode(obj, "") # 源路径对象也可能保存约束;清空它才能避免重生成 carrier 后又继承旧规则。 if is_route_carrier(obj): report["route_constraint_carriers"] += 1 else: report["route_constraint_sources"] += 1 return report def _network_summary_from_graph(network): kinds = {} for carrier in network.get("carriers", []) or []: kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND kinds[kind] = kinds.get(kind, 0) + 1 return { "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", {})), "kinds": kinds, } def _routing_range_only_network_payload(summary): if not isinstance(summary, dict): return {} kinds = summary.get("kinds", {}) if not isinstance(kinds, dict): return {} primary_route_carriers = sum( int(kinds.get(kind, 0) or 0) for kind in ( ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_USER_PATH, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ) ) routing_range_carriers = int(kinds.get(ROUTE_CARRIER_KIND_ROUTING_RANGE, 0) or 0) if routing_range_carriers <= 0 or primary_route_carriers > 0: return {} return { "primary_route_carriers": primary_route_carriers, "routing_range_carriers": routing_range_carriers, "primary_route_kinds": [ ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_USER_PATH, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ], "fallback_kind": ROUTE_CARRIER_KIND_ROUTING_RANGE, } def _component_has_actionable_route_carriers(component): kinds = component.get("carrier_kinds", {}) if isinstance(component, dict) else {} if not isinstance(kinds, dict): return False actionable_kinds = { ROUTE_CARRIER_KIND, ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_USER_PATH, ROUTE_CARRIER_KIND_AUXILIARY_PATH, ROUTE_CARRIER_KIND_TERMINAL_ACCESS, } return any(int(kinds.get(kind, 0) or 0) > 0 for kind in actionable_kinds) def _actionable_isolated_components(components): actionable = [ component for component in components or [] if isinstance(component, dict) and _component_has_actionable_route_carriers(component) ] return actionable if len(actionable) > 1 else [] def _route_graph_components(network): nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} seen = set() components = [] for start_key in nodes.keys(): if start_key in seen: continue stack = [start_key] seen.add(start_key) node_keys = [] edge_pairs = set() carriers = {} kinds = {} while stack: key = stack.pop() node_keys.append(key) for next_key, _weight, carrier in edges.get(key, []) or []: pair = tuple(sorted((key, next_key))) edge_pairs.add(pair) if carrier is not None: carrier_name = getattr(carrier, "Name", "") if carrier_name: carriers[carrier_name] = _carrier_track_payload(carrier) kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND kinds[kind] = kinds.get(kind, 0) + 1 if next_key not in seen: seen.add(next_key) stack.append(next_key) components.append( { "index": len(components), "nodes": len(node_keys), "segments": len(edge_pairs), "carrier_names": sorted(carriers.keys()), "carrier_kinds": kinds, "has_terminal_access": any( carrier.get("kind") == ROUTE_CARRIER_KIND_TERMINAL_ACCESS for carrier in carriers.values() ), } ) return components def _wire_duct_endpoint_breaks(network): nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} breaks = [] for carrier in network.get("carriers", []) or []: if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() != ROUTE_CARRIER_KIND_WIRE_DUCT: continue points = _carrier_points(carrier) if len(points) < 2: continue for endpoint in (points[0], points[-1]): key = _point_key(endpoint, tolerance=network.get("tolerance", DEFAULT_NODE_TOLERANCE)) degree = len(edges.get(key, []) or []) if degree > 1: continue breaks.append( { "carrier": _carrier_track_payload(carrier), "point": _point_payload(nodes.get(key, endpoint)), "degree": degree, } ) return breaks def _route_component_bridge_suggestion(component, components, network): carrier_by_name = { getattr(carrier, "Name", ""): carrier for carrier in network.get("carriers", []) or [] if getattr(carrier, "Name", "") } source_names = set(component.get("carrier_names", []) or []) source_carriers = [] for name in source_names: carrier = carrier_by_name.get(name) kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() if carrier is not None else "" if kind in {ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END}: source_carriers.append(carrier) if not source_carriers: source_carriers = [carrier_by_name.get(name) for name in source_names if carrier_by_name.get(name) is not None] target_carriers = [] for target_component in components or []: if target_component is component or not bool(target_component.get("has_terminal_access", False)): continue for name in target_component.get("carrier_names", []) or []: carrier = carrier_by_name.get(name) if carrier is not None: target_carriers.append((target_component, carrier)) best = None for source in source_carriers: source_points = _carrier_points(source) if len(source_points) < 2: continue for target_component, target in target_carriers: target_points = _carrier_points(target) if len(target_points) < 2: continue nearest = _nearest_points_between_route_point_runs(source_points, target_points) if nearest is None: continue distance, source_point, target_point = nearest if best is None or distance < best[0]: best = (distance, source, target, source_point, target_point, target_component) if best is None: return {} distance, source, target, source_point, target_point, target_component = best return { "distance_mm": float(distance), "from_component_index": component.get("index"), "to_component_index": target_component.get("index"), "from_carrier": _carrier_track_payload(source), "to_carrier": _carrier_track_payload(target), "from_point": _point_payload(source_point), "to_point": _point_payload(target_point), "suggested_action": "create_user_path_bridge", } def _wire_duct_components_without_terminal_access(components, network=None): has_terminal_access_network = any( bool(component.get("has_terminal_access", False)) for component in components or [] if isinstance(component, dict) ) if not has_terminal_access_network: return [] result = [] for component in components or []: kinds = component.get("carrier_kinds", {}) if isinstance(component, dict) else {} if not isinstance(kinds, dict): continue has_wire_duct = ( int(kinds.get(ROUTE_CARRIER_KIND_WIRE_DUCT, 0) or 0) > 0 or int(kinds.get(ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, 0) or 0) > 0 ) if not has_wire_duct or bool(component.get("has_terminal_access", False)): continue payload = { "index": component.get("index"), "nodes": int(component.get("nodes", 0) or 0), "segments": int(component.get("segments", 0) or 0), "carrier_kinds": dict(kinds), "carrier_names": list(component.get("carrier_names", []) or [])[:12], "code": "wire_duct_without_terminal_access", } if isinstance(network, dict): suggestion = _route_component_bridge_suggestion(component, components, network) if suggestion: payload["bridge_suggestion"] = suggestion result.append(payload) return result def _invalid_route_carriers(network): invalid = [] for carrier in network.get("carriers", []) or []: points = _carrier_points(carrier) normalized = _normalized_route_points(points) if len(normalized) >= 2: continue invalid.append( { "carrier": _carrier_track_payload(carrier), "point_count": len(points), "distinct_point_count": len(normalized), "code": "route_carrier_invalid_geometry", } ) return invalid def _terminal_for_access_carrier(carrier): doc = getattr(carrier, "Document", None) carrier_name = (getattr(carrier, "Name", "") or "").strip() if doc is None or not carrier_name: return None for terminal in _collect_routable_terminals(doc): if (getattr(terminal, "QetRouteCarrierName", "") or "").strip() == carrier_name: return terminal return None def _terminal_access_diagnostic_payload(carrier): terminal = _terminal_for_access_carrier(carrier) access_points = _normalized_route_points(_carrier_points(carrier)) payload = { "access_carrier_name": getattr(carrier, "Name", "") or "", "access_carrier_label": getattr(carrier, "Label", "") or "", "target_kind": (getattr(carrier, "QetTerminalAccessTargetKind", "") or "").strip(), "target_name": (getattr(carrier, "QetTerminalAccessTargetName", "") or "").strip(), "target_label": (getattr(carrier, "QetTerminalAccessTargetLabel", "") or "").strip(), "target_rule": (getattr(carrier, "QetTerminalAccessTargetRule", "") or "").strip(), "target_distance_mm": float(getattr(carrier, "QetTerminalAccessTargetDistanceMm", 0.0) or 0.0), "access_length_mm": float(_route_length(access_points)), "access_points": [_point_payload(point) for point in access_points], } if terminal is not None: terminal_payload = _terminal_diagnostic_payload(terminal) payload.update( { "terminal_name": terminal_payload.get("name", ""), "terminal_label": terminal_payload.get("label", ""), "terminal_uuid": terminal_payload.get("terminal_uuid", ""), "instance_id": terminal_payload.get("instance_id", ""), "parent_device_name": terminal_payload.get("parent_device_name", ""), "parent_device_label": terminal_payload.get("parent_device_label", ""), "parent_device_instance_id": terminal_payload.get("parent_device_instance_id", ""), "parent_device_element_uuid": terminal_payload.get("parent_device_element_uuid", ""), } ) return payload def _terminal_access_quality_diagnostics(network): fallback_targets = [] endpoint_device_avoidance = [] for carrier in network.get("carriers", []) or []: kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() if kind != ROUTE_CARRIER_KIND_TERMINAL_ACCESS: continue if str(getattr(carrier, "QetTerminalAccessFallbackTarget", "") or "").strip() == "1": payload = _terminal_access_diagnostic_payload(carrier) payload["code"] = "terminal_access_fallback_target" fallback_targets.append(payload) if str(getattr(carrier, "QetTerminalAccessAvoidedEndpointDevice", "") or "").strip() == "1": payload = _terminal_access_diagnostic_payload(carrier) payload["code"] = "terminal_access_endpoint_device_avoidance" endpoint_device_avoidance.append(payload) return fallback_targets, endpoint_device_avoidance def _cabinet_interior_boundary_bboxes(doc): bboxes = [] for obj in list(getattr(doc, "Objects", []) or []): if not is_routing_boundary(obj): continue bbox = _bound_box_from_object(obj) if bbox is not None: bboxes.append(bbox) return bboxes def _route_carriers_outside_boundary(network, boundary_bboxes): if not boundary_bboxes: return [] outside = [] for carrier in network.get("carriers", []) or []: kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND if kind == ROUTE_CARRIER_KIND_TERMINAL_ACCESS: continue points = _carrier_points(carrier) outside_points = [ point for point in points if not _point_inside_any_bbox(point, boundary_bboxes) ] if not outside_points: continue # 这里只检查路径源是否已经跑出柜内空间,真实导线结果仍由 AutoRouting 再做候选评分。 outside.append( { "carrier": _carrier_track_payload(carrier), "point_count": len(points), "outside_point_count": len(outside_points), "outside_points": [_point_payload(point) for point in outside_points[:5]], "code": "route_carrier_outside_boundary", } ) return outside def _terminals_outside_boundary(terminals, boundary_bboxes, terminal_exit_length=20.0): if not boundary_bboxes: return [] outside = [] for terminal in terminals or []: check_points = [] try: check_points.append(_vector(TerminalObjects.terminal_origin(terminal))) except Exception: pass try: access_points = terminal_access_path_points(terminal, terminal_exit_length) except Exception: access_points = [] if access_points: check_points.append(_vector(access_points[-1])) outside_points = [ point for point in check_points if not _point_inside_any_bbox(point, boundary_bboxes) ] if not outside_points: continue # 端子在柜外通常表示设备还没装进真实柜内位置,后续求路很容易产生长接入或柜外线。 payload = _terminal_diagnostic_payload(terminal) payload.update( { "outside_point_count": len(outside_points), "outside_points": [_point_payload(point) for point in outside_points[:3]], "code": "terminal_outside_boundary", } ) outside.append(payload) return outside def _diagnostic_issue_codes(issues): codes = [] seen = set() for issue in issues or []: if not isinstance(issue, dict): continue code = str(issue.get("code", "") or "").strip() if not code or code in seen: continue seen.add(code) codes.append(code) return codes def _polyline_length(points): total = 0.0 previous = None for point in points or []: current = _vector(point) if previous is not None: total += _distance(previous, current) previous = current return total def _terminal_diagnostic_payload(terminal): payload = { "name": getattr(terminal, "Name", ""), "label": getattr(terminal, "Label", ""), "terminal_uuid": (getattr(terminal, "QetTerminalUuid", "") or "").strip(), "instance_id": (getattr(terminal, "QetInstanceId", "") or "").strip(), } try: origin = TerminalObjects.terminal_origin(terminal) payload["terminal_origin"] = _point_payload(origin) except Exception: pass # 长接入通常和设备装配位置或端子局部出线路径有关,带上父设备便于手测时直接定位。 for parent in _terminal_parent_chain(terminal): payload["parent_device_name"] = getattr(parent, "Name", "") or "" payload["parent_device_label"] = getattr(parent, "Label", "") or "" payload["parent_device_instance_id"] = ( getattr(parent, "QetInstanceId", "") or "" ).strip() payload["parent_device_element_uuid"] = ( getattr(parent, "QetElementUuid", "") or "" ).strip() break return payload def _terminal_access_geometry_payload(access_points): points = [_vector(point) for point in list(access_points or [])] payload = { "terminal_access_points": [_point_payload(point) for point in points], "terminal_access_dominant_axis": "", "terminal_access_axis_lengths_mm": {"x": 0.0, "y": 0.0, "z": 0.0}, } if len(points) < 2: return payload axis_lengths = {"x": 0.0, "y": 0.0, "z": 0.0} previous = points[0] for current in points[1:]: axis_lengths["x"] += abs(float(current.x) - float(previous.x)) axis_lengths["y"] += abs(float(current.y) - float(previous.y)) axis_lengths["z"] += abs(float(current.z) - float(previous.z)) previous = current dominant_axis = max(axis_lengths, key=lambda axis: axis_lengths[axis]) payload["terminal_access_axis_lengths_mm"] = { axis: float(length) for axis, length in axis_lengths.items() } payload["terminal_access_dominant_axis"] = dominant_axis if axis_lengths[dominant_axis] > 0.0 else "" return payload def diagnose_routing_path_network( doc, terminal_exit_length=20.0, terminal_exit_max_length=DEFAULT_TERMINAL_EXIT_MAX_LENGTH, terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, terminal_access_warning_distance=0.0, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): """Inspect the generated routing path network without routing wires.""" if doc is None: raise RoutingNetworkError("No FreeCAD document is available.") network = build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance) components = _route_graph_components(network) summary = _network_summary_from_graph(network) isolated_components = _actionable_isolated_components(components) unconnected_terminals = [] long_terminal_accesses = [] capped_terminal_exits = [] corrected_terminal_exits = [] invalid_terminal_exit_directions = [] invalid_terminal_local_routes = [] routing_range_only_network = _routing_range_only_network_payload(summary) boundary_bboxes = _cabinet_interior_boundary_bboxes(doc) routable_terminals = _collect_routable_terminals(doc) max_distance = max(float(terminal_access_max_distance or 0.0), 0.0) configured_warning_distance = max(float(terminal_access_warning_distance or 0.0), 0.0) if configured_warning_distance > 0.0: warning_distance = configured_warning_distance else: warning_distance = min(max(max_distance * 0.5, DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE), max_distance) if max_distance > 0.0 else DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE for terminal in routable_terminals: exit_direction_issue = _terminal_exit_direction_issue(terminal) if exit_direction_issue is not None: invalid_terminal_exit_directions.append(exit_direction_issue) local_route_issue = _terminal_local_route_issue(terminal) if local_route_issue is not None: invalid_terminal_local_routes.append(local_route_issue) access_diagnostics = terminal_access_diagnostics( terminal, exit_length=terminal_exit_length, max_exit_length=terminal_exit_max_length, ) if access_diagnostics.get("exit_direction_corrected"): corrected_payload = _terminal_diagnostic_payload(terminal) corrected_payload.update(access_diagnostics) corrected_payload["code"] = "terminal_exit_direction_corrected" corrected_terminal_exits.append(corrected_payload) if access_diagnostics.get("exit_length_capped"): capped_payload = _terminal_diagnostic_payload(terminal) capped_payload.update(access_diagnostics) capped_payload["code"] = "terminal_exit_length_capped" capped_terminal_exits.append(capped_payload) terminal_access_points = terminal_access_path_points( terminal, terminal_exit_length, max_exit_length=terminal_exit_max_length, ) exit_point = terminal_access_points[-1] if terminal_access_points else _terminal_exit_point(terminal, terminal_exit_length) nearest_point, distance = nearest_point_on_network(network, exit_point) access_carrier = _live_source_carrier(doc, terminal) access_live = access_carrier is not None and is_route_carrier(access_carrier) too_far = nearest_point is None or (max_distance > 0.0 and float(distance or 0.0) > max_distance) connected_directly = nearest_point is not None and float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE if not ((access_live or connected_directly) and not too_far): payload = _terminal_diagnostic_payload(terminal) payload.update( { "access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "", "nearest_network_distance_mm": None if distance is None else float(distance), "nearest_network_point": None if nearest_point is None else _point_payload(nearest_point), "terminal_access_max_distance_mm": float(max_distance), "terminal_exit_length_mm": float(max(float(terminal_exit_length or 0.0), 0.0)), "code": "terminal_access_missing" if not access_live else "terminal_access_too_far", } ) unconnected_terminals.append(payload) continue access_points = _carrier_points(access_carrier) if access_live else [] access_length = _polyline_length(access_points) if access_length <= warning_distance: continue payload = _terminal_diagnostic_payload(terminal) payload.update( { "access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "", "terminal_access_length_mm": float(access_length), "terminal_access_warning_distance_mm": float(warning_distance), "terminal_access_max_distance_mm": float(max_distance), "code": "terminal_access_long", } ) payload.update(_terminal_access_geometry_payload(access_points)) long_terminal_accesses.append(payload) possible_breaks = _wire_duct_endpoint_breaks(network) wire_ducts_without_terminal_access = _wire_duct_components_without_terminal_access(components, network) terminal_access_fallback_targets, terminal_access_endpoint_device_avoidance = ( _terminal_access_quality_diagnostics(network) ) invalid_route_carriers = _invalid_route_carriers(network) route_carriers_outside_boundary = _route_carriers_outside_boundary(network, boundary_bboxes) terminals_outside_boundary = _terminals_outside_boundary( routable_terminals, boundary_bboxes, terminal_exit_length=terminal_exit_length, ) issues = [] if int(summary.get("segments", 0) or 0) <= 0: issues.append( { "severity": "error", "code": "empty_routing_path_network", "message": "Routing path network has no usable segments.", "count": 0, } ) if isolated_components: issues.append( { "severity": "warning", "code": "isolated_network_components", "message": "Routing path network contains isolated components.", "count": len(isolated_components), } ) if unconnected_terminals: issues.append( { "severity": "error", "code": "unconnected_terminals", "message": "Some terminals are not connected to the routing path network.", "count": len(unconnected_terminals), } ) if possible_breaks: issues.append( { "severity": "warning", "code": "wire_duct_endpoint_breaks", "message": "Some wire duct endpoints have no adjacent network connection.", "count": len(possible_breaks), } ) if wire_ducts_without_terminal_access: issues.append( { "severity": "warning", "code": "wire_ducts_without_terminal_access", "message": "Some wire duct components are not connected to terminal access carriers.", "count": len(wire_ducts_without_terminal_access), } ) if terminal_access_fallback_targets: issues.append( { "severity": "warning", "code": "terminal_access_fallback_targets", "message": "Some terminal access carriers connect to fallback routing ranges instead of main paths.", "count": len(terminal_access_fallback_targets), } ) if terminal_access_endpoint_device_avoidance: issues.append( { "severity": "info", "code": "terminal_access_endpoint_device_avoidance", "message": "Some terminal access carriers detoured around endpoint device bounding boxes.", "count": len(terminal_access_endpoint_device_avoidance), } ) if long_terminal_accesses: issues.append( { "severity": "warning", "code": "long_terminal_accesses", "message": "Some terminal access carriers are unusually long.", "count": len(long_terminal_accesses), } ) if capped_terminal_exits: issues.append( { "severity": "warning", "code": "terminal_exit_length_capped", "message": "Some terminal exit segments were capped before leaving the device bounding box.", "count": len(capped_terminal_exits), } ) if corrected_terminal_exits: issues.append( { "severity": "info", "code": "terminal_exit_direction_corrected", "message": "Some default terminal exit directions were corrected before routing.", "count": len(corrected_terminal_exits), } ) if invalid_terminal_exit_directions: issues.append( { "severity": "warning", "code": "invalid_terminal_exit_directions", "message": "Some terminals have invalid explicit exit direction metadata.", "count": len(invalid_terminal_exit_directions), } ) if invalid_terminal_local_routes: issues.append( { "severity": "warning", "code": "invalid_terminal_local_routes", "message": "Some terminals have invalid local route point metadata.", "count": len(invalid_terminal_local_routes), } ) if routing_range_only_network: issues.append( { "severity": "warning", "code": "routing_range_only_network", "message": "Routing path network only contains fallback routing ranges.", "count": int(routing_range_only_network.get("routing_range_carriers", 0) or 0), } ) if invalid_route_carriers: issues.append( { "severity": "error", "code": "invalid_route_carriers", "message": "Some route carriers have invalid or degenerate geometry.", "count": len(invalid_route_carriers), } ) if route_carriers_outside_boundary: issues.append( { "severity": "warning", "code": "route_carriers_outside_boundary", "message": "Some route carriers have points outside cabinet interior boundaries.", "count": len(route_carriers_outside_boundary), } ) if terminals_outside_boundary: issues.append( { "severity": "warning", "code": "terminals_outside_boundary", "message": "Some terminals are outside cabinet interior boundaries.", "count": len(terminals_outside_boundary), } ) return { "summary": summary, "component_count": len(components), "components": components, "isolated_components": isolated_components, "unconnected_terminals": unconnected_terminals, "long_terminal_accesses": long_terminal_accesses, "capped_terminal_exits": capped_terminal_exits, "corrected_terminal_exits": corrected_terminal_exits, "invalid_terminal_exit_directions": invalid_terminal_exit_directions, "invalid_terminal_local_routes": invalid_terminal_local_routes, "routing_range_only_network": routing_range_only_network, "invalid_route_carriers": invalid_route_carriers, "route_carriers_outside_boundary": route_carriers_outside_boundary, "terminals_outside_boundary": terminals_outside_boundary, "possible_breaks": possible_breaks, "wire_ducts_without_terminal_access": wire_ducts_without_terminal_access, "terminal_access_fallback_targets": terminal_access_fallback_targets, "terminal_access_endpoint_device_avoidance": terminal_access_endpoint_device_avoidance, "issues": issues, "issue_codes": _diagnostic_issue_codes(issues), "ok": not issues, } def _highlight_routing_network_diagnostics(doc, diagnostic): isolated_carriers = set() for component in diagnostic.get("isolated_components", []) or []: isolated_carriers.update(component.get("carrier_names", []) or []) unconnected_terminal_names = set( item.get("name", "") for item in diagnostic.get("unconnected_terminals", []) or [] if item.get("name", "") ) long_access_terminal_names = set( item.get("name", "") for item in diagnostic.get("long_terminal_accesses", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(long_access_terminal_names) capped_terminal_names = set( item.get("name", "") for item in diagnostic.get("capped_terminal_exits", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(capped_terminal_names) corrected_terminal_names = set( item.get("name", "") for item in diagnostic.get("corrected_terminal_exits", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(corrected_terminal_names) invalid_exit_direction_terminal_names = set( item.get("name", "") for item in diagnostic.get("invalid_terminal_exit_directions", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(invalid_exit_direction_terminal_names) invalid_local_route_terminal_names = set( item.get("name", "") for item in diagnostic.get("invalid_terminal_local_routes", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(invalid_local_route_terminal_names) outside_boundary_terminal_names = set( item.get("name", "") for item in diagnostic.get("terminals_outside_boundary", []) or [] if item.get("name", "") ) unconnected_terminal_names.update(outside_boundary_terminal_names) break_carriers = set( item.get("carrier", {}).get("name", "") for item in diagnostic.get("possible_breaks", []) or [] if item.get("carrier", {}).get("name", "") ) break_carriers.update( item.get("carrier", {}).get("name", "") for item in diagnostic.get("invalid_route_carriers", []) or [] if item.get("carrier", {}).get("name", "") ) break_carriers.update( item.get("carrier", {}).get("name", "") for item in diagnostic.get("route_carriers_outside_boundary", []) or [] if item.get("carrier", {}).get("name", "") ) for obj in list(getattr(doc, "Objects", []) or []): name = getattr(obj, "Name", "") try: if name in unconnected_terminal_names: obj.ViewObject.LineColor = (1.0, 0.0, 0.0) obj.ViewObject.LineWidth = 4.0 elif name in break_carriers: obj.ViewObject.LineColor = (1.0, 0.0, 0.0) obj.ViewObject.LineWidth = 4.0 elif name in isolated_carriers: obj.ViewObject.LineColor = (1.0, 0.35, 0.0) obj.ViewObject.LineWidth = 3.0 except Exception: pass def _clear_routing_path_network_diagnostics(doc, group): removed = 0 for obj in list(getattr(group, "Group", []) or []): if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "RoutingPathNetwork": continue _detach_from_groups(doc, obj) try: if doc.getObject(getattr(obj, "Name", "")) is not None: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _diagnostic_items(value): if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def _diagnostic_distance_text(value): try: return "{0:.1f} mm".format(float(value)) except Exception: return "未知距离" def _diagnostic_int(value, fallback=0): try: return int(value or 0) except Exception: return int(fallback or 0) def _diagnostic_terminal_text(sample): if not isinstance(sample, dict): return "未知端子" return ( str(sample.get("label", "") or "").strip() or str(sample.get("terminal_display", "") or "").strip() or str(sample.get("terminal_uuid", "") or "").strip() or str(sample.get("name", "") or "").strip() or "未知端子" ) def _routing_path_network_diagnostic_message(diagnostic): if not isinstance(diagnostic, dict): return "布线路径网络检查失败:诊断结果无效。" summary = diagnostic.get("summary", {}) if isinstance(diagnostic.get("summary", {}), dict) else {} issues = _diagnostic_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 = _diagnostic_int(summary.get("bridged_segments", 0)) if bridged_segments > 0: message += " 自动桥接 {0} 段相邻/投影主路径。".format(bridged_segments) return message message = "布线路径网络检查发现 {0} 类问题。".format(len(issues)) if any(issue.get("code") == "empty_routing_path_network" for issue in issues): message += "\n布线路径网络为空:没有可用路径段。" unconnected = _diagnostic_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: sample = unconnected[0] message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}。".format( _diagnostic_terminal_text(sample), _diagnostic_distance_text(sample.get("nearest_network_distance_mm")), _diagnostic_distance_text(sample.get("terminal_access_max_distance_mm")), ) long_accesses = _diagnostic_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: sample = long_accesses[0] message += "\n端子接入过长:{0},接入段 {1}。".format( _diagnostic_terminal_text(sample), _diagnostic_distance_text(sample.get("terminal_access_length_mm")), ) capped_exits = _diagnostic_items(diagnostic.get("capped_terminal_exits", []) or []) if capped_exits: sample = capped_exits[0] message += "\n端子出线长度截断:{0},实际 {1} / 上限 {2}。".format( _diagnostic_terminal_text(sample), _diagnostic_distance_text(sample.get("actual_exit_length_mm")), _diagnostic_distance_text(sample.get("max_exit_length_mm")), ) corrected_exits = _diagnostic_items(diagnostic.get("corrected_terminal_exits", []) or []) if corrected_exits: sample = corrected_exits[0] message += "\n端子默认出线方向已校正:{0},建议复查设备端子 LCS 或模板出线方向。".format( _diagnostic_terminal_text(sample) ) invalid_exit_directions = _diagnostic_items(diagnostic.get("invalid_terminal_exit_directions", []) or []) if invalid_exit_directions: sample = invalid_exit_directions[0] message += "\n端子出线方向无效:{0},属性 {1}。".format( _diagnostic_terminal_text(sample), sample.get("property_name", "QetTerminalExitDirectionJson"), ) invalid_carriers = _diagnostic_items(diagnostic.get("invalid_route_carriers", []) or []) if invalid_carriers: sample = invalid_carriers[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} message += "\n路径对象几何无效:{0}。".format( carrier.get("label") or carrier.get("name") or "未知路径对象" ) outside_carriers = _diagnostic_items(diagnostic.get("route_carriers_outside_boundary", []) or []) if outside_carriers: sample = outside_carriers[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} message += "\n路径越出柜内边界:{0},越界点 {1} 个。".format( carrier.get("label") or carrier.get("name") or "未知路径对象", _diagnostic_int(sample.get("outside_point_count", 0)), ) outside_terminals = _diagnostic_items(diagnostic.get("terminals_outside_boundary", []) or []) if outside_terminals: sample = outside_terminals[0] message += "\n端子越出柜内边界:{0},越界点 {1} 个。".format( _diagnostic_terminal_text(sample), _diagnostic_int(sample.get("outside_point_count", 0)), ) possible_breaks = _diagnostic_items(diagnostic.get("possible_breaks", []) or []) if possible_breaks: sample = possible_breaks[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} message += "\n线槽端点疑似断开:{0}。".format( carrier.get("label") or carrier.get("name") or "未知线槽" ) wire_duct_components = _diagnostic_items(diagnostic.get("wire_ducts_without_terminal_access", []) or []) if wire_duct_components: sample = wire_duct_components[0] carriers = sample.get("carrier_names") or [] carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知线槽" suggestion = sample.get("bridge_suggestion", {}) if isinstance(suggestion, dict) and suggestion: target = suggestion.get("to_carrier", {}) if isinstance(suggestion.get("to_carrier", {}), dict) else {} target_text = target.get("label") or target.get("name") or "主网络" message += "\n线槽未接入端子主网络:{0},建议桥接到 {1},距离 {2}。".format( carrier_text, target_text, _diagnostic_distance_text(suggestion.get("distance_mm")), ) else: message += "\n线槽未接入端子主网络:{0}。".format(carrier_text) isolated = _diagnostic_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) return message def _diagnostic_issue_codes_text(issue_codes): values = [] seen = set() for code in list(issue_codes or []): text = str(code or "").strip() if not text or text in seen: continue seen.add(text) values.append(text) return ", ".join(values) _ROUTING_PATH_NETWORK_ISSUE_LABELS = { "empty_routing_path_network": "布线路径网络为空", "invalid_route_carriers": "路径对象几何无效", "routing_range_only_network": "仅使用布线面兜底", "invalid_terminal_exit_directions": "端子出线方向无效", "invalid_terminal_local_routes": "端子局部路径无效", "route_carriers_outside_boundary": "路径越出柜内边界", "terminals_outside_boundary": "端子越出柜内边界", "long_terminal_accesses": "端子接入过长", "terminal_exit_length_capped": "端子出线长度截断", "terminal_exit_direction_corrected": "端子默认出线方向校正", "unconnected_terminals": "端子未接入", "wire_duct_endpoint_breaks": "线槽端点疑似断开", "wire_ducts_without_terminal_access": "线槽未接入端子主网络", "isolated_network_components": "存在孤立路径网络", } def _diagnostic_issue_labels_text(issue_codes): values = [] seen = set() for code in list(issue_codes or []): text = str(code or "").strip() label = _ROUTING_PATH_NETWORK_ISSUE_LABELS.get(text, text) if not label or label in seen: continue seen.add(label) values.append(label) return "、".join(values) def write_routing_path_network_diagnostic( doc, project_uuid="", terminal_exit_length=20.0, terminal_exit_max_length=DEFAULT_TERMINAL_EXIT_MAX_LENGTH, terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, terminal_access_warning_distance=0.0, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): diagnostic = diagnose_routing_path_network( doc, terminal_exit_length=terminal_exit_length, terminal_exit_max_length=terminal_exit_max_length, terminal_access_max_distance=terminal_access_max_distance, terminal_access_warning_distance=terminal_access_warning_distance, adjoining_duct_tolerance=adjoining_duct_tolerance, ) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_path_network_diagnostics(doc, group) obj = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETRoutingPathNetworkDiagnostic")) obj.Label = "QET Routing Path Network Diagnostic" TerminalObjects.ensure_string_property( obj, "QetDiagnosticKind", PROPERTY_GROUP, "QET diagnostic kind", "RoutingPathNetwork", ) TerminalObjects.ensure_string_property( obj, "QetProjectUuid", PROPERTY_GROUP, "Project UUID", project_uuid, ) TerminalObjects.ensure_bool_property( obj, "QetDiagnosticOk", PROPERTY_GROUP, "QET diagnostic pass state", bool(diagnostic.get("ok", False)), ) TerminalObjects.ensure_string_property( obj, "QetDiagnosticIssueCodes", PROPERTY_GROUP, "QET routing diagnostic issue codes", _diagnostic_issue_codes_text(diagnostic.get("issue_codes", [])), ) TerminalObjects.ensure_string_property( obj, "QetDiagnosticIssueLabels", PROPERTY_GROUP, "QET routing diagnostic issue labels", _diagnostic_issue_labels_text(diagnostic.get("issue_codes", [])), ) TerminalObjects.ensure_string_property( obj, "QetDiagnosticMessage", PROPERTY_GROUP, "QET routing path network diagnostic message", _routing_path_network_diagnostic_message(diagnostic), ) TerminalObjects.ensure_string_property( obj, "QetDiagnosticJson", PROPERTY_GROUP, "QET routing path network diagnostic payload", json.dumps(diagnostic, ensure_ascii=False), ) group.addObject(obj) _highlight_routing_network_diagnostics(doc, diagnostic) try: doc.recompute() except Exception: pass return { "diagnostic": diagnostic, "diagnostic_object": obj, } def carrier_payload(carrier): payload = _carrier_track_payload(carrier) payload["points"] = [_point_payload(point) for point in _carrier_points(carrier)] return payload