You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

6772 lines
244 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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)
# 行和列都要生成 carrierDijkstra 才能在网格交点处横竖换向。
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