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