# FreeCADExchange 3D routing connections. # # 第一版不碰 C++,也不把 3D 走线结果写进数据库。 # 它只读取 FreeCAD 文档里的端子、走线网络和几何障碍, # 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。 import json import itertools import math import os import re import sqlite3 import FreeCAD as App try: import FreeCADGui as Gui except ImportError: Gui = None import RoutingNetwork import TerminalObjects import TemplateSemantics import WiringObjects AUTO_ROUTING_RUNTIME_VERSION = "2026-06-08-runtime-routing-v4" LOCAL_ACCESS_DETOUR_CLEARANCE = 10.0 DEFAULT_OPTIONS = { # 端子出来先走一小段,避免导线贴着设备外壳起步。 "terminal_exit_length": 20.0, # 设备包围盒很大或端子方向朝内时,不允许默认出线无限延长;超限会写入诊断。 "terminal_exit_max_length": 80.0, "lane_axis": "auto", "lane_spacing": 10.0, "lane_max_offset": 30.0, "segment_reuse_penalty": 200.0, # SW/EPLAN 风格路径约束的第一步:可按 carrier 名称/标签/源标签/类型禁止经过。 "forbidden_route_carrier_names": [], "forbidden_route_carrier_labels": [], "forbidden_route_carrier_source_labels": [], "forbidden_route_carrier_kinds": [], "required_route_carrier_names": [], "required_route_carrier_labels": [], "required_route_carrier_source_labels": [], "required_route_carrier_kinds": [], # 线槽网络相关参数。 "use_routing_network": True, "network_entry_max_distance": 1000.0, "network_entry_candidate_limit": 8, # 批量布线默认收窄入口候选,避免真实工程里 入口候选 x 出口候选 x 导线数量 过度放大。 # 设为 0 可关闭批量收窄,继续使用 network_entry_candidate_limit。 "batch_network_entry_candidate_limit": 3, # 批量模式下,柜内候选/无碰撞候选最多把每侧入口扩到这个总数。 # 单根布线不使用该默认值;缺路径重试会按重试候选数临时放宽。 "batch_network_entry_total_candidate_limit": 6, # 单根线因候选裁剪过窄不可达时,用更大的候选数补救一次,避免全量批量都退回慢路径。 "missing_route_retry_candidate_limit": 8, # 第一版批量布线优先保证真实工程能完成;路径仍会做碰撞诊断,必要时可手动开启障碍过滤求路。 "batch_avoid_obstacles": False, # 只对已经发生第三方设备/布局碰撞的少量导线做二次避障,避免全量开启避障拖慢真实工程。 "selective_collision_reroute": True, "selective_collision_reroute_limit": 5, "selective_collision_reroute_allow_fallback": False, "network_entry_distance_cost_factor": 5.0, "terminal_access_warning_distance": 0.0, "route_candidate_collision_penalty": 10000.0, "route_candidate_boundary_penalty": 100000.0, "ignore_endpoint_near_obstacles": True, "adjoining_duct_tolerance": RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, "bend_penalty": 25.0, # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 "carrier_kind_cost_factors": { "WireDuct": 1.0, "WireDuctOpenEnd": 1.0, "WiringCutOut": 1.0, "RoutingPath": 1.0, "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, # RoutingRange 是安装板/柜内面域兜底,不应因为线槽复用惩罚而抢走主线槽。 "RoutingRange": 1000.0, }, # 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。 # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 "obstacle_clearance": 5.0, "local_access_obstacle_scan_margin": 0.0, # 端子出线/入线段通常会贴近端子塑壳或设备外壳,不作为主路径碰撞判定依据。 "ignore_endpoint_collision_segments": True, # 即使批量布线不启用全局避障,也让线槽/主路径到端子的局部接入折线绕开第三方设备。 "avoid_local_access_obstacles": True, # 导入柜体/门板/支架等结构件通常没有 QET 设备绑定;第一版把未绑定结构件视为可穿越结构, # 不把它们计入导线碰撞,避免线槽内导线被柜体 AABB 误报淹没。带 element_uuid 的设备碰撞仍保留。 "auto_ignore_unbound_structural_obstacles": True, # 防止坐标异常或端子离路由网络过远时生成超长接入线,把 FreeCAD # 视图包围盒拉得过大,导致旋转时模型被裁剪到看不见。 "terminal_access_max_distance": 1000.0, # 先把穿过障碍包围盒的路由网络边从 Dijkstra 图中移除;如果没有安全 # 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。 "avoid_obstacles": True, "replace_existing": True, "hide_route_carriers_after_route": True, "wire_style_database_path": "", "preflight_routeability_sample_limit": 0, # 自动布线时如果诊断能明确建议“线槽组件 -> 端子主网络”的桥接点, # 先生成 UserPath 桥再布线,避免真实工程长期退回 RoutingRange 兜底。 "auto_create_diagnostic_bridges": True, # 第一次布线若发现“兜底区域 -> 当前主路径”的缺主路径绕行配对, # 自动补一段 UserPath 桥并重跑一次,让少量剩余碰撞线回到主路径网络。 "auto_create_main_path_detour_bridges": True, # 第一次布线若发现端子接入退回布线面/辅助路径, # 自动补一段到最近主路径的 UserPath 桥并重跑一次。 "auto_create_terminal_access_fallback_bridges": True, # 第一次布线若发现两端已经接到线槽/UserPath 等主路径,但中段仍退回布线面, # 自动补一段主路径目标之间的 UserPath 桥并重跑一次。 "auto_create_main_path_target_bridges": True, } class AutoRoutingError(RuntimeError): pass def _merged_options(options): merged = dict(DEFAULT_OPTIONS) if isinstance(options, dict): merged.update(options) return merged def _route_network_cache(opts): cache = opts.get("__route_network_cache") if isinstance(opts, dict) else None return cache if isinstance(cache, dict) else None def _invalidate_route_network_cache(opts): cache = _route_network_cache(opts) if cache is not None: cache.pop("route_network", None) def _cached_base_route_network(doc, opts): cache = _route_network_cache(opts) cached = cache.get("route_network") if cache is not None else None if isinstance(cached, dict) and int(cached.get("segment_count", 0) or 0) > 0: return cached, True network = RoutingNetwork.build_route_graph( doc, adjoining_duct_tolerance=float((opts or {}).get("adjoining_duct_tolerance", 0.0) or 0.0), ) if cache is not None and int(network.get("segment_count", 0) or 0) > 0: cache["route_network"] = network return network, False def _has_route_constraints(options): opts = options or {} for key in ( "forbidden_route_carrier_names", "forbidden_route_carrier_labels", "forbidden_route_carrier_source_names", "forbidden_route_carrier_source_labels", "forbidden_route_carrier_kinds", "required_route_carrier_names", "required_route_carrier_labels", "required_route_carrier_source_names", "required_route_carrier_source_labels", "required_route_carrier_kinds", ): value = opts.get(key) if isinstance(value, (list, tuple, set)) and any(str(item or "").strip() for item in value): return True if isinstance(value, str) and value.strip(): return True return False def _option_text_list(options, key): value = (options or {}).get(key) if isinstance(value, str): values = [value] elif isinstance(value, (list, tuple, set)): values = list(value) else: values = [] result = [] seen = set() for item in values: text = str(item or "").strip() if not text or text in seen: continue seen.add(text) result.append(text) return result def _route_constraint_payload(options): groups = { "required": { "names": _option_text_list(options, "required_route_carrier_names"), "labels": _option_text_list(options, "required_route_carrier_labels"), "source_names": _option_text_list(options, "required_route_carrier_source_names"), "source_labels": _option_text_list(options, "required_route_carrier_source_labels"), "kinds": _option_text_list(options, "required_route_carrier_kinds"), }, "forbidden": { "names": _option_text_list(options, "forbidden_route_carrier_names"), "labels": _option_text_list(options, "forbidden_route_carrier_labels"), "source_names": _option_text_list(options, "forbidden_route_carrier_source_names"), "source_labels": _option_text_list(options, "forbidden_route_carrier_source_labels"), "kinds": _option_text_list(options, "forbidden_route_carrier_kinds"), }, } return { group: { key: values for key, values in payload.items() if values } for group, payload in groups.items() if any(payload.values()) } _ROUTE_CONSTRAINT_OPTION_KEYS = ( "forbidden_route_carrier_names", "forbidden_route_carrier_labels", "forbidden_route_carrier_source_names", "forbidden_route_carrier_source_labels", "forbidden_route_carrier_kinds", "required_route_carrier_names", "required_route_carrier_labels", "required_route_carrier_source_names", "required_route_carrier_source_labels", "required_route_carrier_kinds", ) def _merge_route_constraint_options(options, extra_options): merged = dict(options or {}) for key in _ROUTE_CONSTRAINT_OPTION_KEYS: values = [] seen = set() for source in (options, extra_options): for item in _option_text_list(source or {}, key): if item in seen: continue seen.add(item) values.append(item) if values: merged[key] = values return merged def _document_route_constraint_options(doc): collector = getattr(RoutingNetwork, "collect_route_constraint_options", None) if not callable(collector): # 运行目录模块偶尔会和 AutoRouting 版本不一致;缺少约束收集函数时退回“无文档级约束”。 return {} try: result = collector(doc) except Exception: return {} return result if isinstance(result, dict) else {} def _vector(point): if isinstance(point, App.Vector): return App.Vector(point.x, point.y, point.z) if isinstance(point, (list, tuple)) and len(point) >= 3: return App.Vector(float(point[0]), float(point[1]), float(point[2])) if isinstance(point, dict): return App.Vector( float(point.get("x", 0.0)), float(point.get("y", 0.0)), float(point.get("z", 0.0)), ) if all(hasattr(point, name) for name in ("x", "y", "z")): return App.Vector(float(point.x), float(point.y), float(point.z)) return App.Vector(0, 0, 0) def _distance(left, right): dx = float(left.x) - float(right.x) dy = float(left.y) - float(right.y) dz = float(left.z) - float(right.z) return (dx * dx + dy * dy + dz * dz) ** 0.5 def _vector_close(left, right, tolerance=0.000001): return _distance(left, right) <= tolerance def _point_payload(point): return { "x": float(point.x), "y": float(point.y), "z": float(point.z), } def _route_length(points): total = 0.0 normalized = [_vector(point) for point in points or []] for index in range(len(normalized) - 1): total += _distance(normalized[index], normalized[index + 1]) return total def _is_finite_point(point): try: return all( math.isfinite(float(getattr(point, axis, 0.0))) for axis in ("x", "y", "z") ) except Exception: return False def _append_unique(points, point): vector = _vector(point) if not _is_finite_point(vector): return if not points or not _vector_close(points[-1], vector): points.append(vector) def _axis_value(point, axis): return float(getattr(point, axis, 0.0)) def _with_axis(point, axis, value): return App.Vector( float(value) if axis == "x" else float(point.x), float(value) if axis == "y" else float(point.y), float(value) if axis == "z" else float(point.z), ) def _auto_lane_axis(route_points): points = [_vector(point) for point in route_points or []] if len(points) < 2: return "y" extents = {"x": 0.0, "y": 0.0, "z": 0.0} for index in range(len(points) - 1): start = points[index] end = points[index + 1] extents["x"] += abs(float(end.x) - float(start.x)) extents["y"] += abs(float(end.y) - float(start.y)) extents["z"] += abs(float(end.z) - float(start.z)) dominant_axis = max(extents, key=lambda axis: extents[axis]) if dominant_axis == "y": return "x" if dominant_axis == "x": return "y" return "x" def _dominant_route_axis(route_points): points = [_vector(point) for point in route_points or []] if len(points) < 2: return "" extents = {"x": 0.0, "y": 0.0, "z": 0.0} for index in range(len(points) - 1): start = points[index] end = points[index + 1] extents["x"] += abs(float(end.x) - float(start.x)) extents["y"] += abs(float(end.y) - float(start.y)) extents["z"] += abs(float(end.z) - float(start.z)) dominant_axis = max(extents, key=lambda axis: extents[axis]) if extents[dominant_axis] <= 0.000001: return "" return dominant_axis def _secondary_lane_axis(route_points, primary_axis): dominant_axis = _dominant_route_axis(route_points) for axis in ("z", "y", "x"): if axis != primary_axis and axis != dominant_axis: return axis for axis in ("z", "y", "x"): if axis != primary_axis: return axis return "" def _lane_offset_for_order(lane_order, lane_direction, lane_spacing, max_offset): primary_offset = float(lane_order) * lane_spacing * lane_direction secondary_order = 0 if max_offset > 0.0 and abs(primary_offset) > max_offset: max_primary_order = max(int(math.floor(max_offset / lane_spacing)), 1) if lane_spacing > 0.0 else 1 secondary_order = max(lane_order - max_primary_order, 0) primary_offset = max_offset if primary_offset > 0.0 else -max_offset return primary_offset, secondary_order def _lane_payload(route_index, options, route_points=None): opts = options or {} lane_axis = (opts.get("lane_axis") or "y").lower() if lane_axis == "auto": lane_axis = _auto_lane_axis(route_points) if lane_axis not in {"x", "y", "z"}: lane_axis = "y" lane_index = max(int(route_index or 0), 0) lane_spacing = float(opts.get("lane_spacing", 0.0) or 0.0) if lane_index <= 0: lane_offset = 0.0 secondary_axis = "" secondary_offset = 0.0 else: lane_order = (lane_index + 1) // 2 lane_direction = 1.0 if lane_index % 2 == 1 else -1.0 max_offset = float(opts.get("lane_max_offset", 0.0) or 0.0) lane_offset, secondary_order = _lane_offset_for_order( lane_order, lane_direction, lane_spacing, max_offset, ) secondary_axis = "" secondary_offset = 0.0 if secondary_order > 0 and lane_spacing > 0.0: secondary_axis = _secondary_lane_axis(route_points, lane_axis) secondary_direction = 1.0 if secondary_order % 2 == 1 else -1.0 secondary_magnitude = ((secondary_order + 1) // 2) * lane_spacing if max_offset > 0.0 and secondary_magnitude > max_offset: secondary_magnitude = max_offset secondary_offset = secondary_magnitude * secondary_direction payload = { "index": lane_index, "axis": lane_axis, "spacing_mm": lane_spacing, "max_offset_mm": float(opts.get("lane_max_offset", 0.0) or 0.0), "offset_mm": lane_offset, } if secondary_axis and abs(secondary_offset) > 0.000001: # 主方向达到上限后,使用第二方向继续错开密集导线,避免多根线在同一路径上完全重合。 payload["secondary_axis"] = secondary_axis payload["secondary_offset_mm"] = secondary_offset return payload def _apply_lane_offset(points, lane): offset = float((lane or {}).get("offset_mm", 0.0) or 0.0) secondary_offset = float((lane or {}).get("secondary_offset_mm", 0.0) or 0.0) if abs(offset) <= 0.000001 and abs(secondary_offset) <= 0.000001: return list(points or []) axis = (lane or {}).get("axis", "y") secondary_axis = (lane or {}).get("secondary_axis", "") result = [] for point in list(points or []): shifted = point if abs(offset) > 0.000001: shifted = _with_axis(shifted, axis, _axis_value(shifted, axis) + offset) if secondary_axis in {"x", "y", "z"} and abs(secondary_offset) > 0.000001: shifted = _with_axis( shifted, secondary_axis, _axis_value(shifted, secondary_axis) + secondary_offset, ) result.append(shifted) return result def _lane_payload_boundary_aware( route_index, options, route_points=None, boundary_violation_count=None, obstacle_hit_count=None, ): lane = _lane_payload(route_index, options, route_points=route_points) opts = options or {} has_boundary_score = callable(boundary_violation_count) has_obstacle_score = callable(obstacle_hit_count) if ( str(opts.get("lane_axis", "auto") or "auto").lower() != "auto" or abs(float(lane.get("offset_mm", 0.0) or 0.0)) <= 0.000001 or (not has_boundary_score and not has_obstacle_score) ): return lane candidates = [] current_axis = str(lane.get("axis", "") or "").strip() or "y" current_offset = float(lane.get("offset_mm", 0.0) or 0.0) current_points = _apply_lane_offset(route_points, lane) current_obstacle_score = int(obstacle_hit_count(current_points) or 0) if has_obstacle_score else 0 offsets = (current_offset, -current_offset) if current_obstacle_score > 0 else (current_offset,) seen = set() for axis in (current_axis, "x", "y", "z"): if axis not in {"x", "y", "z"}: continue for offset in offsets: key = (axis, round(float(offset), 6)) if key in seen: continue seen.add(key) candidate = dict(lane) candidate["axis"] = axis candidate["offset_mm"] = float(offset) points = _apply_lane_offset(route_points, candidate) boundary_score = int(boundary_violation_count(points) or 0) if has_boundary_score else 0 obstacle_score = int(obstacle_hit_count(points) or 0) if has_obstacle_score else 0 candidates.append((boundary_score, obstacle_score, axis, float(offset), candidate)) if not candidates: return lane # lane 自动轴选择优先留在柜内、避开设备;分数相同时保持原先的轴和方向,减少既有工程变化。 dominant_axis = _dominant_route_axis(route_points) candidates.sort( key=lambda item: ( item[0], item[1], 1 if item[2] == dominant_axis else 0, 0 if item[2] == current_axis else 1, 0 if abs(item[3] - current_offset) <= 0.000001 else 1, ) ) return candidates[0][4] def _orthogonal_axis_order(start_point, end_point, preferred_axis=None): axis_order = sorted( ("x", "y", "z"), key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)), reverse=True, ) if preferred_axis in {"x", "y", "z"}: axis_order = [axis for axis in axis_order if axis != preferred_axis] axis_order.append(preferred_axis) return axis_order def _orthogonal_points_for_axis_order(start_point, end_point, axis_order): points = [start_point] current = start_point for axis in axis_order: target = _axis_value(end_point, axis) if abs(_axis_value(current, axis) - target) <= 0.000001: continue current = _with_axis(current, axis, target) _append_unique(points, current) _append_unique(points, end_point) return points def _orthogonal_points(start_point, end_point, preferred_axis=None): if _vector_close(start_point, end_point): return [start_point] # 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。 return _orthogonal_points_for_axis_order( start_point, end_point, _orthogonal_axis_order(start_point, end_point, preferred_axis), ) def _orthogonal_hit_count(points, obstacle_bboxes): hits = 0 if not obstacle_bboxes: return hits for index in range(max(len(points or []) - 1, 0)): start = points[index] end = points[index + 1] for bbox in obstacle_bboxes: if _segment_intersects_bbox(start, end, bbox): hits += 1 break return hits def _segment_hit_bboxes(points, obstacle_bboxes): hit_bboxes = [] seen = set() if not obstacle_bboxes: return hit_bboxes for index in range(max(len(points or []) - 1, 0)): start = points[index] end = points[index + 1] for bbox in obstacle_bboxes: if not _segment_intersects_bbox(start, end, bbox): continue identity = id(bbox) if identity in seen: continue seen.add(identity) hit_bboxes.append(bbox) return hit_bboxes def _bbox_interval_overlap(first_min, first_max, second_min, second_max): return float(first_min) <= float(second_max) and float(second_min) <= float(first_max) def _bbox_overlaps_segment_envelope(start_point, end_point, bbox, margin=0.0): if bbox is None: return False margin = max(float(margin or 0.0), 0.0) try: return ( _bbox_interval_overlap( min(float(start_point.x), float(end_point.x)) - margin, max(float(start_point.x), float(end_point.x)) + margin, float(bbox["xmin"]), float(bbox["xmax"]), ) and _bbox_interval_overlap( min(float(start_point.y), float(end_point.y)) - margin, max(float(start_point.y), float(end_point.y)) + margin, float(bbox["ymin"]), float(bbox["ymax"]), ) and _bbox_interval_overlap( min(float(start_point.z), float(end_point.z)) - margin, max(float(start_point.z), float(end_point.z)) + margin, float(bbox["zmin"]), float(bbox["zmax"]), ) ) except Exception: return True def _filter_obstacle_bboxes_near_polyline(points, obstacle_bboxes, margin=0.0): if not obstacle_bboxes: return [] if len(points or []) < 2: return list(obstacle_bboxes or []) filtered = [] seen = set() for bbox in obstacle_bboxes or []: for index in range(max(len(points or []) - 1, 0)): if not _bbox_overlaps_segment_envelope(points[index], points[index + 1], bbox, margin=margin): continue identity = id(bbox) if identity not in seen: seen.add(identity) filtered.append(bbox) break return filtered def _local_access_obstacle_bboxes(start_point, end_point, obstacle_bboxes, preferred_axis=None, margin=LOCAL_ACCESS_DETOUR_CLEARANCE): if not obstacle_bboxes: return [] # 局部接入段只需要考虑贴近自身正交基线路径的障碍;远处设备留给最终碰撞诊断, # 避免真实机柜中“端子接入 x 全部模型”导致批量布线耗时爆炸。 base_points = _orthogonal_points(start_point, end_point, preferred_axis) return _filter_obstacle_bboxes_near_polyline(base_points, obstacle_bboxes, margin=margin) def _is_local_access_obstacle(obstacle): if not isinstance(obstacle, dict): return False if ( str(obstacle.get("element_uuid", "") or "").strip() or str(obstacle.get("instance_id", "") or "").strip() ): return True parent_refs = obstacle.get("parent_refs", {}) if isinstance(obstacle.get("parent_refs", {}), dict) else {} own_text = " ".join( str(part or "").lower() for part in [ obstacle.get("label", ""), obstacle.get("name", ""), ] ) if any(keyword in own_text for keyword in _DEVICE_COLLISION_KEYWORDS): return True text_parts = [ obstacle.get("label", ""), obstacle.get("name", ""), ] text_parts.extend(list(parent_refs.get("labels", []) or [])) text_parts.extend(list(parent_refs.get("names", []) or [])) text = " ".join(str(part or "").lower() for part in text_parts) structural_import_markers = ( "imported", "qet exchange devices", "qetexchangedevices", "qetcabinet", "linkgroup", "compound", "nauo", ) # 未绑定导入机械件仍会进入最终碰撞诊断,但不参与端子局部绕行; # 否则柜体、铰链、螺丝等大量 AABB 会把每条线的接入计算放大到不可接受。 if any(marker in text for marker in structural_import_markers): return False return True def _axis_detour_values(axis, hit_bboxes, clearance=LOCAL_ACCESS_DETOUR_CLEARANCE): min_key = "{0}min".format(axis) max_key = "{0}max".format(axis) values = [] for bbox in hit_bboxes or []: try: values.append(float(bbox[min_key]) - float(clearance)) values.append(float(bbox[max_key]) + float(clearance)) except Exception: continue return values def _orthogonal_detour_points(start_point, end_point, axis_order, detour_axis, detour_value): points = [start_point] current = start_point if abs(_axis_value(current, detour_axis) - float(detour_value)) > 0.000001: current = _with_axis(current, detour_axis, float(detour_value)) _append_unique(points, current) for axis in axis_order: if axis == detour_axis: continue target = _axis_value(end_point, axis) if abs(_axis_value(current, axis) - target) <= 0.000001: continue current = _with_axis(current, axis, target) _append_unique(points, current) end_axis_value = _axis_value(end_point, detour_axis) if abs(_axis_value(current, detour_axis) - end_axis_value) > 0.000001: current = _with_axis(current, detour_axis, end_axis_value) _append_unique(points, current) _append_unique(points, end_point) return points def _orthogonal_points_with_local_detour(start_point, end_point, obstacle_bboxes, axis_order, best_points, best_hits): hit_bboxes = _segment_hit_bboxes(best_points, obstacle_bboxes) if not hit_bboxes: return best_points active_axes = { axis for axis in ("x", "y", "z") if abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)) > 0.000001 } detour_axes = [axis for axis in ("x", "y", "z") if axis not in active_axes] if not detour_axes: detour_axes = [axis for axis in ("x", "y", "z")] # 设备端子到主路径的接入段常是一条很长的直线;只重排轴顺序时绕不过 # 同轴障碍盒。这里增加一个局部侧向绕行候选,成本低,且不改变主路径网络。 chosen_points = best_points chosen_hits = best_hits for detour_axis in detour_axes: for value in _axis_detour_values(detour_axis, hit_bboxes): candidate_points = _orthogonal_detour_points( start_point, end_point, axis_order, detour_axis, value, ) candidate_hits = _orthogonal_hit_count(candidate_points, obstacle_bboxes) if candidate_hits < chosen_hits: chosen_hits = candidate_hits chosen_points = candidate_points if chosen_hits <= 0: return chosen_points return chosen_points def _orthogonal_points_avoiding_obstacles(start_point, end_point, obstacle_bboxes, preferred_axis=None): base_order = _orthogonal_axis_order(start_point, end_point, preferred_axis) base_points = _orthogonal_points_for_axis_order(start_point, end_point, base_order) if not obstacle_bboxes: return base_points active_axes = [ axis for axis in base_order if abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)) > 0.000001 ] if len(active_axes) <= 1: base_hits = _orthogonal_hit_count(base_points, obstacle_bboxes) if base_hits <= 0: return base_points return _orthogonal_points_with_local_detour( start_point, end_point, obstacle_bboxes, base_order, base_points, base_hits, ) inactive_axes = [axis for axis in base_order if axis not in active_axes] best_points = base_points best_hits = _orthogonal_hit_count(base_points, obstacle_bboxes) if best_hits <= 0: return best_points # 同一入口下,“先走 X”或“先走 Y/Z”可能决定端子接入段是否穿模。 # 这里只重排正交轴顺序,不改变主路径网络和端点绑定语义。 for order in itertools.permutations(active_axes): candidate_order = list(order) + inactive_axes if candidate_order == base_order: continue candidate_points = _orthogonal_points_for_axis_order( start_point, end_point, candidate_order, ) candidate_hits = _orthogonal_hit_count(candidate_points, obstacle_bboxes) if candidate_hits < best_hits: best_hits = candidate_hits best_points = candidate_points if best_hits <= 0: break if best_hits > 0: best_points = _orthogonal_points_with_local_detour( start_point, end_point, obstacle_bboxes, base_order, best_points, best_hits, ) return best_points def _append_orthogonal(points, target_point, preferred_axis=None, obstacle_bboxes=None): if not points: _append_unique(points, target_point) return if obstacle_bboxes: segment = _orthogonal_points_avoiding_obstacles( points[-1], _vector(target_point), obstacle_bboxes, preferred_axis=preferred_axis, ) else: segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis) for point in segment[1:]: _append_unique(points, point) def _collinear_points(first, middle, last): ax = float(middle.x) - float(first.x) ay = float(middle.y) - float(first.y) az = float(middle.z) - float(first.z) bx = float(last.x) - float(middle.x) by = float(last.y) - float(middle.y) bz = float(last.z) - float(middle.z) cross_x = ay * bz - az * by cross_y = az * bx - ax * bz cross_z = ax * by - ay * bx dot = ax * bx + ay * by + az * bz return ( abs(cross_x) <= 0.000001 and abs(cross_y) <= 0.000001 and abs(cross_z) <= 0.000001 and dot >= -0.000001 ) def _route_point_key(point, tolerance=0.001): scale = 1.0 / float(tolerance or 0.001) return ( int(round(float(point.x) * scale)), int(round(float(point.y) * scale)), int(round(float(point.z) * scale)), ) def _simplify_collinear_points(points, preserved_point_keys=None): normalized = [_vector(point) for point in points or [] if _is_finite_point(_vector(point))] if len(normalized) <= 2: return normalized preserved_indices = {0, 1, len(normalized) - 2, len(normalized) - 1} preserved_point_keys = set(preserved_point_keys or []) simplified = [normalized[0]] simplified_indices = [0] for index, point in enumerate(normalized[1:], start=1): _append_unique(simplified, point) if len(simplified_indices) < len(simplified): simplified_indices.append(index) while len(simplified) >= 3 and _collinear_points( simplified[-3], simplified[-2], simplified[-1], ): if ( simplified_indices[-2] in preserved_indices or _route_point_key(simplified[-2]) in preserved_point_keys ): break simplified.pop(-2) simplified_indices.pop(-2) return simplified def _important_route_node_keys(network, path_keys, path_result): edges = network.get("edges", {}) if isinstance(network, dict) else {} important = { key for key in path_keys or [] if len(edges.get(key, []) or []) != 2 } segments = path_result.get("segments", []) if isinstance(path_result, dict) else [] for index in range(1, len(path_keys or []) - 1): previous_segment = segments[index - 1] if index - 1 < len(segments) else {} next_segment = segments[index] if index < len(segments) else {} previous_carrier = (previous_segment.get("carrier") or {}).get("name", "") next_carrier = (next_segment.get("carrier") or {}).get("name", "") if previous_carrier != next_carrier: important.add(path_keys[index]) return important def _offset(point, direction, distance): return App.Vector( float(point.x) + float(direction.x) * float(distance), float(point.y) + float(direction.y) * float(distance), float(point.z) + float(direction.z) * float(distance), ) def _terminal_origin(terminal): return _vector(TerminalObjects.terminal_origin(terminal)) def _terminal_direction(terminal): try: return _vector(TerminalObjects.terminal_direction(terminal)) except Exception: return App.Vector(0, 0, 1) def _project_uuid(doc, start_terminal=None, end_terminal=None): for obj in (start_terminal, end_terminal): value = (getattr(obj, "QetProjectUuid", "") or "").strip() if value: return value try: return (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: return "" def index_terminals(doc): """Return {terminal_uuid: terminal_object} for routable engineering terminals.""" indexed = {} for terminal in _collect_routable_terminals(doc): terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() if terminal_uuid and terminal_uuid not in indexed: indexed[terminal_uuid] = terminal return indexed def _collect_routable_terminals(doc): """Return routable engineering terminals, preserving duplicate QET terminal UUIDs.""" if doc is None: return [] terminals = [] root = None try: root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME) except Exception: root = None if root is not None: terminals.extend(TerminalObjects.collect_terminal_objects(root)) terminals.extend( obj for obj in list(getattr(doc, "Objects", []) or []) if TerminalObjects.is_terminal_object(obj) ) unique = [] seen = set() for terminal in terminals: marker = id(terminal) if marker in seen: continue seen.add(marker) unique.append(terminal) return unique def _terminal_endpoint_value(terminal, property_name): return str(getattr(terminal, property_name, "") or "").strip() def _terminal_uuid_duplicate_summary(terminal_candidates, limit=8): counts = {} samples = [] for terminal in list(terminal_candidates or []): terminal_uuid = _terminal_endpoint_value(terminal, "QetTerminalUuid") if not terminal_uuid: continue counts[terminal_uuid] = counts.get(terminal_uuid, 0) + 1 for terminal_uuid, count in sorted(counts.items()): if count <= 1: continue if len(samples) < limit: samples.append({"terminal_uuid": terminal_uuid, "count": count}) return { "duplicate_terminal_uuid_count": sum(1 for count in counts.values() if count > 1), "duplicate_terminal_uuid_samples": samples, } def _payload_terminal_instance_duplicate_summary(payload, limit=8): counts = {} if isinstance(payload, dict): for device in list(payload.get("devices", []) or []): if not isinstance(device, dict): continue for terminal in list(device.get("terminals", []) or []): if not isinstance(terminal, dict): continue terminal_instance_id = str(terminal.get("terminal_instance_id", "") or "").strip() if not terminal_instance_id: continue counts[terminal_instance_id] = counts.get(terminal_instance_id, 0) + 1 samples = [] for terminal_instance_id, count in sorted(counts.items()): if count <= 1: continue if len(samples) < limit: samples.append( { "terminal_instance_id": terminal_instance_id, "count": count, } ) return { "duplicate_payload_terminal_instance_id_count": sum( 1 for count in counts.values() if count > 1 ), "duplicate_payload_terminal_instance_id_samples": samples, } def _payload_wire_endpoint_refs(payload): refs = [] if not isinstance(payload, dict): return refs for wire in list(payload.get("wires", []) or []): if not isinstance(wire, dict): continue for side in ("start", "end"): terminal_uuid = str(wire.get("{0}_terminal_uuid".format(side), "") or "").strip() if not terminal_uuid: continue refs.append( { "side": side, "wire_uuid": str( wire.get("wire_id", "") or wire.get("wire_uuid", "") or wire.get("id", "") or "" ).strip(), "wire_label": str(wire.get("wire_label", "") or wire.get("wire_mark", "") or "").strip(), "terminal_uuid": terminal_uuid, "element_uuid": str(wire.get("{0}_element_uuid".format(side), "") or "").strip(), "device_instance_id": str( wire.get("{0}_instance_id".format(side), "") or wire.get("{0}_device_instance_id".format(side), "") or "" ).strip(), "terminal_display": str( wire.get("{0}_terminal_display".format(side), "") or wire.get("{0}_terminal_label".format(side), "") or "" ).strip(), } ) return refs def _payload_terminal_display(terminal): if not isinstance(terminal, dict): return "" return str( terminal.get("terminal_display", "") or terminal.get("terminal_label", "") or terminal.get("label", "") or terminal.get("name", "") or "" ).strip() def _payload_endpoint_matches_terminal(endpoint, device, terminal, duplicate_terminal_uuids): terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() if not terminal_uuid or endpoint.get("terminal_uuid") != terminal_uuid: return False endpoint_element = str(endpoint.get("element_uuid", "") or "").strip() terminal_element = str(terminal.get("element_uuid", "") or "").strip() if endpoint_element and terminal_element and endpoint_element != terminal_element: return False endpoint_instance = str(endpoint.get("device_instance_id", "") or "").strip() device_instance = str( device.get("device_instance_id", "") or device.get("instance_id", "") or "" ).strip() if endpoint_instance and device_instance and endpoint_instance != device_instance: return False endpoint_display = _normalized_match_token(endpoint.get("terminal_display", "")) terminal_display = _normalized_match_token(_payload_terminal_display(terminal)) if endpoint_display and terminal_display and endpoint_display != terminal_display: return False if terminal_uuid in duplicate_terminal_uuids and not (endpoint_element or endpoint_instance or endpoint_display): return False return True def _payload_unreferenced_terminal_summary(payload, limit=8): if not isinstance(payload, dict): return { "unreferenced_payload_terminal_count": 0, "unreferenced_payload_terminal_samples": [], } terminal_uuid_counts = {} for device in list(payload.get("devices", []) or []): if not isinstance(device, dict): continue for terminal in list(device.get("terminals", []) or []): if not isinstance(terminal, dict): continue terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() if terminal_uuid: terminal_uuid_counts[terminal_uuid] = terminal_uuid_counts.get(terminal_uuid, 0) + 1 duplicate_terminal_uuids = { terminal_uuid for terminal_uuid, count in terminal_uuid_counts.items() if count > 1 } endpoints = _payload_wire_endpoint_refs(payload) samples = [] count = 0 for device in list(payload.get("devices", []) or []): if not isinstance(device, dict): continue device_instance_id = str( device.get("device_instance_id", "") or device.get("instance_id", "") or "" ).strip() device_label = str( device.get("display_tag", "") or device.get("label", "") or device.get("name", "") or device_instance_id or "" ).strip() for terminal in list(device.get("terminals", []) or []): if not isinstance(terminal, dict): continue terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() if not terminal_uuid: continue if any( _payload_endpoint_matches_terminal(endpoint, device, terminal, duplicate_terminal_uuids) for endpoint in endpoints ): continue count += 1 if len(samples) < int(limit or 0): samples.append( { "device_label": device_label, "device_instance_id": device_instance_id, "element_uuid": str(terminal.get("element_uuid", "") or "").strip(), "terminal_uuid": terminal_uuid, "terminal_instance_id": str(terminal.get("terminal_instance_id", "") or "").strip(), "terminal_display": _payload_terminal_display(terminal), } ) return { "unreferenced_payload_terminal_count": count, "unreferenced_payload_terminal_samples": samples, } def _terminal_endpoint_match(terminal_candidates, item, side, allow_single_fallback=True): terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(side)) result = { "terminal": None, "terminal_uuid": terminal_uuid, "candidate_count": 0, "context_match_count": 0, "ambiguous": False, "reason_code": "", } if not terminal_uuid: result["reason_code"] = "missing_terminal_uuid" return result candidates = [ terminal for terminal in list(terminal_candidates or []) if _terminal_endpoint_value(terminal, "QetTerminalUuid") == terminal_uuid ] result["candidate_count"] = len(candidates) if not candidates: result["reason_code"] = "terminal_uuid_not_found" return result expected_instance_id = _wire_item_value(item, "{0}_instance_id".format(side)) expected_element_uuid = _wire_item_value(item, "{0}_element_uuid".format(side)) if not expected_instance_id and not expected_element_uuid: if len(candidates) == 1 and allow_single_fallback: result["terminal"] = candidates[0] return result result["ambiguous"] = len(candidates) > 1 result["reason_code"] = "ambiguous_terminal_uuid" return result matched = [] for terminal in candidates: instance_match = ( expected_instance_id and _terminal_endpoint_value(terminal, "QetInstanceId") == expected_instance_id ) element_match = ( expected_element_uuid and _terminal_endpoint_value(terminal, "QetElementUuid") == expected_element_uuid ) if instance_match or element_match: score = (4 if instance_match else 0) + (2 if element_match else 0) matched.append((score, terminal)) result["context_match_count"] = len(matched) if not matched: if allow_single_fallback and len(candidates) == 1: result["terminal"] = candidates[0] return result result["reason_code"] = "terminal_uuid_not_in_endpoint_context" return result # 同名 terminal_uuid 在真实 v2 快照里可能重复;优先命中同一 3D 实例,再看 2D 设备。 matched.sort(key=lambda pair: pair[0], reverse=True) best_score = matched[0][0] best_matches = [terminal for score, terminal in matched if score == best_score] if len(best_matches) > 1: display_token = _normalized_match_token( _wire_item_value( item, "{0}_terminal_display".format(side), "{0}_terminal_label".format(side), ) ) if display_token: display_matches = [ terminal for terminal in best_matches if display_token in _terminal_match_tokens(terminal) ] if len(display_matches) == 1: result["terminal"] = display_matches[0] return result result["ambiguous"] = True result["reason_code"] = "ambiguous_terminal_uuid_context" return result result["terminal"] = best_matches[0] return result def _matching_terminal_for_wire_endpoint(terminal_candidates, item, side, allow_single_fallback=True): return _terminal_endpoint_match( terminal_candidates, item, side, allow_single_fallback=allow_single_fallback, ).get("terminal") def _terminal_element_summary(terminals, element_uuid, limit=5): return _terminal_property_summary(terminals, "QetElementUuid", element_uuid, limit=limit) def _terminal_instance_summary(terminals, instance_id, limit=5): return _terminal_property_summary(terminals, "QetInstanceId", instance_id, limit=limit) def _terminal_property_summary(terminals, property_name, expected_value, limit=5): expected = str(expected_value or "").strip() if not expected: return {"count": 0, "samples": []} terminal_values = ( list((terminals or {}).values()) if isinstance(terminals, dict) else list(terminals or []) ) samples = [] count = 0 for terminal in terminal_values: terminal_value = str(getattr(terminal, property_name, "") or "").strip() if terminal_value != expected: continue count += 1 if len(samples) >= limit: continue terminal_uuid = _terminal_endpoint_value(terminal, "QetTerminalUuid") samples.append( { "terminal_uuid": str(terminal_uuid or "").strip(), "label": str(getattr(terminal, "Label", "") or "").strip(), "name": str(getattr(terminal, "Name", "") or "").strip(), "terminal_label": str(getattr(terminal, "QetTerminalLabel", "") or "").strip(), "instance_id": str(getattr(terminal, "QetInstanceId", "") or "").strip(), } ) return {"count": count, "samples": samples} _MISSING_ENDPOINT_REASON_LABELS = { "missing_terminal_uuid": "导线端点缺少 terminal_uuid", "missing_device_binding_metadata": "导线端点缺少 2D/3D 设备绑定信息", "device_not_in_3d_scene": "该 2D 设备未在 FreeCAD 场景中找到", "no_3d_terminals_for_element": "该 2D 设备在 FreeCAD 中没有工程端子", "no_3d_terminals_for_instance": "该 3D 实例在 FreeCAD 中没有工程端子", "terminal_uuid_not_in_element": "同设备存在端子,但没有匹配该 terminal_uuid", "ambiguous_terminal_uuid": "terminal_uuid 重复且导线端点缺少设备上下文,无法安全选择端子", "ambiguous_terminal_uuid_context": "terminal_uuid 重复且设备上下文仍匹配到多个端子,无法安全选择端子", } def _missing_endpoint_reason_code(sample, side): terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() if not terminal_uuid: return "missing_terminal_uuid" match_reason = str(sample.get("{0}_terminal_match_reason_code".format(side), "") or "").strip() if match_reason in ("ambiguous_terminal_uuid", "ambiguous_terminal_uuid_context"): return match_reason element_uuid = str(sample.get("{0}_element_uuid".format(side), "") or "").strip() instance_id = str(sample.get("{0}_instance_id".format(side), "") or "").strip() if not element_uuid and not instance_id: return "missing_device_binding_metadata" if sample.get("{0}_device_in_scene".format(side)) is False: return "device_not_in_3d_scene" element_count = _safe_int(sample.get("{0}_element_terminal_count".format(side), 0)) instance_count = _safe_int(sample.get("{0}_instance_terminal_count".format(side), 0)) if element_count <= 0: return "no_3d_terminals_for_element" if instance_count <= 0 and str(sample.get("{0}_instance_id".format(side), "") or "").strip(): return "no_3d_terminals_for_instance" return "terminal_uuid_not_in_element" def _endpoint_device_summary(doc, instance_id, element_uuid): device_group = None if doc is not None: device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id) if device_group is None: device_group = TerminalObjects.find_device_group(doc, element_uuid) if device_group is None: return {"in_scene": False, "name": "", "label": ""} return { "in_scene": True, "name": str(getattr(device_group, "Name", "") or "").strip(), "label": str(getattr(device_group, "Label", "") or "").strip(), } def _add_missing_endpoint_terminal_context(sample, side, terminals, doc=None): if not isinstance(sample, dict): return sample device_summary = _endpoint_device_summary( doc, sample.get("{0}_instance_id".format(side), ""), sample.get("{0}_element_uuid".format(side), ""), ) sample["{0}_device_in_scene".format(side)] = bool(device_summary.get("in_scene", False)) existing_name = str(sample.get("{0}_device_name".format(side), "") or "").strip() existing_label = str(sample.get("{0}_device_label".format(side), "") or "").strip() sample["{0}_device_name".format(side)] = str(device_summary.get("name", "") or "").strip() or existing_name sample["{0}_device_label".format(side)] = str(device_summary.get("label", "") or "").strip() or existing_label element_uuid = sample.get("{0}_element_uuid".format(side), "") element_summary = _terminal_element_summary(terminals, element_uuid) sample["{0}_element_terminal_count".format(side)] = int(element_summary.get("count", 0) or 0) sample["{0}_element_terminal_samples".format(side)] = list(element_summary.get("samples", []) or []) instance_id = sample.get("{0}_instance_id".format(side), "") instance_summary = _terminal_instance_summary(terminals, instance_id) sample["{0}_instance_terminal_count".format(side)] = int(instance_summary.get("count", 0) or 0) sample["{0}_instance_terminal_samples".format(side)] = list(instance_summary.get("samples", []) or []) reason_code = _missing_endpoint_reason_code(sample, side) sample["{0}_missing_endpoint_reason_code".format(side)] = reason_code sample["{0}_missing_endpoint_reason_label".format(side)] = _MISSING_ENDPOINT_REASON_LABELS.get( reason_code, reason_code, ) return sample def _normalized_match_token(value): return (value or "").strip().lower().replace(" ", "") def _device_group_for_wire_endpoint(doc, instance_id, element_uuid): device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id) if device_group is None: device_group = TerminalObjects.find_device_group(doc, element_uuid) return device_group def _terminal_match_tokens(obj): tokens = [] for value in ( getattr(obj, "QetTemplateSlotName", ""), getattr(obj, "QetTerminalLabel", ""), getattr(obj, "Label", ""), getattr(obj, "Name", ""), ): token = _normalized_match_token(value) if token and token not in tokens: tokens.append(token) return tokens def _slot_match_tokens(slot): tokens = [] for value in ( slot.get("name", ""), slot.get("label", ""), ): token = _normalized_match_token(value) if token and token not in tokens: tokens.append(token) return tokens def _matching_local_terminal(terminal_group, terminal_display, used_objects): local_terminals = [] display_token = _normalized_match_token(terminal_display) for terminal in TerminalObjects.collect_terminal_objects(terminal_group): if terminal in used_objects: continue terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() if not TerminalObjects.is_local_terminal_uuid(terminal_uuid): continue local_terminals.append(terminal) if display_token and display_token in _terminal_match_tokens(terminal): return terminal if not display_token and len(local_terminals) == 1: return local_terminals[0] return None def _matching_template_slot(device_group, terminal_display, used_slot_tokens): display_token = _normalized_match_token(terminal_display) if not display_token: return None for slot in TemplateSemantics.collect_terminal_hints(device_group): slot_tokens = _slot_match_tokens(slot) if display_token not in slot_tokens: continue slot_token = slot_tokens[0] if slot_tokens else "" if slot_token and slot_token in used_slot_tokens: continue return slot return None def _slot_placement(slot): base = slot.get("base") if not isinstance(base, App.Vector): base = App.Vector(0, 0, 0) rotation = App.Rotation() rotation_value = slot.get("rotation") if isinstance(rotation_value, dict): axis = rotation_value.get("axis") angle = rotation_value.get("angle") if isinstance(axis, App.Vector) and angle is not None: try: rotation = App.Rotation(axis, float(angle)) except Exception: rotation = App.Rotation() return App.Placement(base, rotation) def _wire_endpoint_entries(payload): entries = [] seen = set() for item in payload.get("wires", []) or []: if not isinstance(item, dict): continue for prefix in ("start", "end"): terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(prefix)) if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid): continue element_uuid = _wire_item_value(item, "{0}_element_uuid".format(prefix)) instance_id = _wire_item_value(item, "{0}_instance_id".format(prefix)) endpoint_key = (terminal_uuid, element_uuid, instance_id) if endpoint_key in seen: continue seen.add(endpoint_key) entries.append( { "terminal_uuid": terminal_uuid, "element_uuid": element_uuid, "instance_id": instance_id, "terminal_display": _wire_item_value( item, "{0}_terminal_display".format(prefix), "{0}_terminal_label".format(prefix), ), } ) return entries def _payload_device_terminal_groups(payload): groups = {} if not isinstance(payload, dict): return groups for device in list(payload.get("devices", []) or []): if not isinstance(device, dict): continue instance_id = ( str(device.get("device_instance_id", "") or "").strip() or str(device.get("instance_id", "") or "").strip() ) if not instance_id: continue for terminal in list(device.get("terminals", []) or []): if not isinstance(terminal, dict): continue terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() if not terminal_uuid: continue entry = { "instance_id": instance_id, "terminal_uuid": terminal_uuid, "element_uuid": str(terminal.get("element_uuid", "") or "").strip(), "terminal_instance_id": str(terminal.get("terminal_instance_id", "") or "").strip(), "terminal_display": str( terminal.get("terminal_display", "") or terminal.get("terminal_label", "") or "" ).strip(), } groups.setdefault((instance_id, terminal_uuid), []).append(entry) return groups def _pair_payload_terminal_entries_with_objects(entries, terminal_objects): remaining_entries = list(entries or []) remaining_objects = list(terminal_objects or []) pairs = [] for entry in list(remaining_entries): display_token = _normalized_match_token(entry.get("terminal_display", "")) if not display_token: continue matches = [ terminal for terminal in remaining_objects if display_token in _terminal_match_tokens(terminal) ] if len(matches) != 1: continue terminal = matches[0] pairs.append((entry, terminal)) remaining_entries.remove(entry) remaining_objects.remove(terminal) pairs.extend(zip(remaining_entries, remaining_objects)) return pairs def _repair_duplicate_terminal_metadata_from_payload(doc, payload): """Use v2 device terminal rows to repair duplicated terminal_uuid metadata. QET v2 snapshots may describe several physical 3D terminals with the same terminal_uuid. When an older/imported FreeCAD scene copied the first terminal metadata across those objects, routing cannot safely choose an endpoint. 这里仅在“同实例、同 terminal_uuid、数量完全一致”时按显示名/顺序修复, 不额外创建端子,避免把未知设备猜错。 """ if doc is None: return {"repaired": 0, "groups": 0} groups = _payload_device_terminal_groups(payload) if not groups: return {"repaired": 0, "groups": 0} terminals = _collect_routable_terminals(doc) order = {id(terminal): index for index, terminal in enumerate(terminals)} repaired = 0 repaired_groups = 0 project_uuid = str(payload.get("project_uuid", "") or _project_uuid(doc)).strip() if isinstance(payload, dict) else _project_uuid(doc) for (instance_id, terminal_uuid), entries in groups.items(): if len(entries) <= 1: continue candidates = [ terminal for terminal in terminals if _terminal_endpoint_value(terminal, "QetInstanceId") == instance_id and _terminal_endpoint_value(terminal, "QetTerminalUuid") == terminal_uuid ] if len(candidates) != len(entries): continue candidates.sort(key=lambda terminal: order.get(id(terminal), 0)) for entry, terminal in _pair_payload_terminal_entries_with_objects(entries, candidates): terminal_display = entry.get("terminal_display", "") TerminalObjects.set_terminal_semantics( terminal, project_uuid, entry.get("element_uuid", ""), terminal_uuid, instance_id, label=terminal_display or getattr(terminal, "Label", "") or terminal_uuid, slot_name=terminal_display or getattr(terminal, "QetTemplateSlotName", ""), terminal_instance_id=entry.get("terminal_instance_id", ""), ) repaired += 1 repaired_groups += 1 if repaired: try: doc.recompute() except Exception: pass return {"repaired": repaired, "groups": repaired_groups} def _bind_wire_task_terminals(doc, payload): """Promote matching local template terminals to QET terminal UUIDs before routing.""" report = { "bound": 0, "created": 0, "skipped": 0, "warnings": [], } if doc is None or not isinstance(payload, dict): return report project_uuid = (payload.get("project_uuid") or "").strip() if not project_uuid: try: project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: project_uuid = "" used_objects = set() used_slot_tokens = set() for entry in _wire_endpoint_entries(payload): terminal_uuid = entry["terminal_uuid"] endpoint_item = { "start_terminal_uuid": terminal_uuid, "start_element_uuid": entry.get("element_uuid", ""), "start_instance_id": entry.get("instance_id", ""), } if _matching_terminal_for_wire_endpoint( _collect_routable_terminals(doc), endpoint_item, "start", allow_single_fallback=False, ) is not None: continue device_group = _device_group_for_wire_endpoint( doc, entry.get("instance_id", ""), entry.get("element_uuid", ""), ) if device_group is None: report["skipped"] += 1 report["warnings"].append( "端子 {0} 找不到所属 3D 设备实例。".format(terminal_uuid) ) continue instance_id = (getattr(device_group, "QetInstanceId", "") or "").strip() element_uuid = (getattr(device_group, "QetElementUuid", "") or "").strip() terminal_group = TerminalObjects.ensure_terminal_group( doc, device_group, project_uuid=project_uuid, instance_id=instance_id, ) terminal_display = entry.get("terminal_display", "") terminal_obj = _matching_local_terminal(terminal_group, terminal_display, used_objects) if terminal_obj is None: slot = _matching_template_slot(device_group, terminal_display, used_slot_tokens) if slot is None: report["skipped"] += 1 report["warnings"].append( "端子 {0} 没有匹配到模板槽位 {1}。".format( terminal_uuid, terminal_display or "", ) ) continue slot_name = (slot.get("name") or terminal_display or terminal_uuid).strip() terminal_obj = TerminalObjects.create_lcs_object( doc, "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid)), placement=_slot_placement(slot), label=terminal_display or terminal_uuid, ) terminal_group.addObject(terminal_obj) source_obj = slot.get("source_object") if source_obj is not None: try: source_obj.ViewObject.Visibility = False except Exception: pass report["created"] += 1 else: slot_name = (getattr(terminal_obj, "QetTemplateSlotName", "") or terminal_display).strip() report["bound"] += 1 TerminalObjects.set_terminal_semantics( terminal_obj, project_uuid, element_uuid, terminal_uuid, instance_id, label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid, slot_name=slot_name, ) TerminalObjects.hide_engineering_terminal(terminal_obj) used_objects.add(terminal_obj) slot_token = _normalized_match_token(slot_name) if slot_token: used_slot_tokens.add(slot_token) if report["bound"] or report["created"]: try: doc.recompute() except Exception: pass return report def _wire_object_name(start_terminal, end_terminal, wire_uuid=""): if wire_uuid: return "QETRoutedConnection_{0}".format(TerminalObjects.safe_token(wire_uuid)) return "QETRoutedConnection_{0}_{1}".format( TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", "")), TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", "")), ) def _unique_name(doc, base_name): name = TerminalObjects.safe_token(base_name) if doc.getObject(name) is None: return name suffix = 1 while doc.getObject("{0}_{1}".format(name, suffix)) is not None: suffix += 1 return "{0}_{1}".format(name, suffix) def _create_wire_geometry(doc, name, points): # Use a plain Part edge for generated auto wires. Draft wires are convenient for # editing, but in large imported assemblies they can trigger view-provider redraw # glitches while rotating the 3D scene. try: import Part obj = doc.addObject("Part::Feature", name) obj.Shape = Part.makePolygon(points) _set_points(obj, points) return obj except Exception: pass if getattr(App, "ActiveDocument", None) is doc: try: import Draft obj = Draft.make_wire( points, closed=False, placement=None, face=False, support=None, bs2wire=False, ) if obj is not None: try: obj.MakeFace = False except Exception: pass _set_points(obj, points) return obj except Exception: pass obj = doc.addObject("App::FeaturePython", name) _set_points(obj, points) return obj def _set_points(obj, points): try: if "Points" not in getattr(obj, "PropertiesList", []): obj.addProperty("App::PropertyVectorList", "Points", "QET Wiring", "Route points") obj.Points = list(points) except Exception: pass def _set_string(obj, name, value, description="Routing connection property"): TerminalObjects.ensure_string_property(obj, name, "QET Routing", description, value) def _set_bool(obj, name, value, description="Routing connection property"): TerminalObjects.ensure_bool_property(obj, name, "QET Routing", description, value) def _clean_endpoint_metadata(endpoint_metadata): if not isinstance(endpoint_metadata, dict): return {} allowed = ( "start_element_uuid", "start_terminal_display", "start_device_label", "end_element_uuid", "end_terminal_display", "end_device_label", "endpoint_label", ) cleaned = {} for key in allowed: value = str(endpoint_metadata.get(key, "") or "").strip() if value: cleaned[key] = value return cleaned def _set_endpoint_metadata(wire, endpoint_metadata): metadata = _clean_endpoint_metadata(endpoint_metadata) property_names = { "start_element_uuid": "QetStartElementUuid", "start_terminal_display": "QetStartTerminalDisplay", "start_device_label": "QetStartDeviceLabel", "end_element_uuid": "QetEndElementUuid", "end_terminal_display": "QetEndTerminalDisplay", "end_device_label": "QetEndDeviceLabel", "endpoint_label": "QetEndpointLabel", } for key, prop_name in property_names.items(): if key in metadata: _set_string(wire, prop_name, metadata[key], "QET routed wire endpoint metadata") return metadata def _clean_wire_style_payload(wire_style): if not isinstance(wire_style, dict): return {} allowed = ( "id", "project_uuid", "name", "line_color", "line_type", "line_width", "diameter_mm", "area_or_spec", "wire_type", "wire_format", "description", ) payload = {} for key in allowed: value = wire_style.get(key) if value is None: continue if isinstance(value, str): value = value.strip() if not value: continue payload[key] = value return payload def _wire_style_status(wire_style_id, wire_style): style_id = str(wire_style_id or "").strip() if not style_id: return "" return "Resolved" if _clean_wire_style_payload(wire_style) else "Missing" def _route_boundary_payload(route_data): network = route_data.get("network", {}) if isinstance(route_data, dict) else {} if not isinstance(network, dict): network = {} boundary_aware = bool(network.get("boundary_aware", False)) try: violations = int(network.get("route_candidate_boundary_violations", 0) or 0) except Exception: violations = 0 status = "" if boundary_aware: status = "BoundaryWarning" if violations > 0 else "InsideBoundary" return { "boundary_aware": boundary_aware, "boundary_violation_count": max(violations, 0), "boundary_status": status, } def _route_lane_capacity_payload(route_data): lane = route_data.get("lane", {}) if isinstance(route_data, dict) else {} if not isinstance(lane, dict): lane = {} try: lane_index = max(int(lane.get("index", 0) or 0), 0) except Exception: lane_index = 0 try: lane_offset = float(lane.get("offset_mm", 0.0) or 0.0) except Exception: lane_offset = 0.0 try: lane_spacing = float(lane.get("spacing_mm", 0.0) or 0.0) except Exception: lane_spacing = 0.0 try: min_capacity = _route_track_min_capacity(route_data.get("route_track", {})) except Exception: min_capacity = None parallel_wire_count = lane_index + 1 capacity_status = "" if min_capacity is not None: capacity_status = "CapacityWarning" if parallel_wire_count > min_capacity else "WithinCapacity" return { "lane_index": lane_index, "lane_axis": str(lane.get("axis", "") or "").strip(), "lane_offset_mm": lane_offset, "lane_spacing_mm": lane_spacing, "parallel_wire_count": parallel_wire_count, "min_carrier_capacity": min_capacity, "capacity_status": capacity_status, } def _route_access_payload(route_data): network = route_data.get("network", {}) if isinstance(route_data, dict) else {} if not isinstance(network, dict): network = {} def float_value(key, default=0.0): try: return float(network.get(key, default) or default) except Exception: return float(default) def int_value(key, default=1): try: return int(network.get(key, default) or default) except Exception: return int(default) entry_distance = float_value("entry_distance") exit_distance = float_value("exit_distance") warning_distance = float_value("terminal_access_warning_distance") warning_sides = [] if warning_distance > 0.0: if entry_distance > warning_distance: warning_sides.append("entry") if exit_distance > warning_distance: warning_sides.append("exit") access_status = "LongAccessWarning" if warning_sides else "NormalAccess" return { "entry_distance_mm": entry_distance, "exit_distance_mm": exit_distance, "entry_point_mode": str(network.get("entry_point_mode", "") or "").strip(), "exit_point_mode": str(network.get("exit_point_mode", "") or "").strip(), "entry_candidate_rank": int_value("entry_candidate_rank"), "exit_candidate_rank": int_value("exit_candidate_rank"), "access_warning_distance_mm": warning_distance, "access_status": access_status, "warning_sides": warning_sides, } def _route_collision_payload(collisions): hard_count = 0 clearance_count = 0 for collision in collisions or []: if not isinstance(collision, dict): hard_count += 1 continue kind = str(collision.get("collision_kind", "") or "").strip() if kind == "ClearanceWarning": clearance_count += 1 else: hard_count += 1 collision_count = hard_count + clearance_count if hard_count > 0: status = "HardIntersectionWarning" elif clearance_count > 0: status = "ClearanceWarning" else: status = "NoCollision" return { "collision_count": collision_count, "hard_intersection_count": hard_count, "clearance_warning_count": clearance_count, "collision_status": status, } def _route_quality_payload(route_track): carrier_kinds = _route_track_carrier_kinds(route_track) fallback_kinds = [ kind for kind in ("RoutingRange", "AuxiliaryPath") if carrier_kinds.get(kind, 0) ] fallback_labels = _route_warning_carrier_labels(route_track, fallback_kinds, limit=8) return { "quality_status": "FallbackPathWarning" if fallback_kinds else "NormalPath", "fallback_carrier_kinds": fallback_kinds, "fallback_carrier_labels": fallback_labels, } def _route_issue_codes(route_data, collisions): route_track = route_data.get("route_track", {}) if isinstance(route_data, dict) else {} codes = [] def append_once(code, enabled=True): if not enabled or code in codes: return codes.append(code) append_once( "long_terminal_access", _route_access_payload(route_data).get("access_status") == "LongAccessWarning", ) append_once( "collision_warnings", _route_collision_payload(collisions).get("collision_count", 0) > 0, ) collision_payload = _route_collision_payload(collisions) append_once( "hard_intersections", _safe_int(collision_payload.get("hard_intersection_count", 0)) > 0, ) append_once( "clearance_warnings", _safe_int(collision_payload.get("clearance_warning_count", 0)) > 0, ) relation_counts = {} for collision in list(collisions or []): if not isinstance(collision, dict): continue relation = str(collision.get("collision_relation", "") or "").strip() if relation: relation_counts[relation] = relation_counts.get(relation, 0) + 1 append_once( "third_party_device_collisions", _safe_int(relation_counts.get("third_party_device_collision", 0)) > 0, ) append_once( "endpoint_device_collisions", _safe_int(relation_counts.get("endpoint_device_collision", 0)) > 0, ) append_once( "route_quality_warnings", _route_quality_payload(route_track).get("quality_status") == "FallbackPathWarning", ) append_once( "route_capacity_pressure", _route_lane_capacity_payload(route_data).get("capacity_status") == "CapacityWarning", ) append_once( "route_candidate_boundary_violations", _route_boundary_payload(route_data).get("boundary_status") == "BoundaryWarning", ) append_once( "main_path_detour_missing", str(route_data.get("selective_collision_reroute_status", "") or "").strip() == "RejectedFallback", ) network = route_data.get("network", {}) if isinstance(route_data, dict) else {} if isinstance(network, dict): try: obstacle_hits = int(network.get("route_candidate_obstacle_hits", 0) or 0) except Exception: obstacle_hits = 0 append_once("route_candidate_obstacle_hits", obstacle_hits > 0) terminal_access_target_kinds = { str(network.get("start_terminal_access_target_kind", "") or "").strip(), str(network.get("end_terminal_access_target_kind", "") or "").strip(), } append_once( "terminal_access_fallback_targets", bool(terminal_access_target_kinds.intersection({"RoutingRange", "AuxiliaryPath"})), ) return codes def _route_payload(route_data, collisions, wire_style_id="", endpoint_metadata=None, wire_style=None): points = route_data.get("points", []) style_status = _wire_style_status(wire_style_id, wire_style) boundary_payload = _route_boundary_payload(route_data) lane_capacity_payload = _route_lane_capacity_payload(route_data) access_payload = _route_access_payload(route_data) collision_payload = _route_collision_payload(collisions) route_track = route_data.get("route_track", {}) quality_payload = _route_quality_payload(route_track) issue_codes = _route_issue_codes(route_data, collisions) payload = { "algorithm": route_data.get("algorithm", ""), "length_mm": _route_length(points), "wire_style_id": str(wire_style_id or "").strip(), "lane": route_data.get("lane", {}), "points": [_point_payload(point) for point in points], "collision_count": collision_payload["collision_count"], "collisions": collisions, "collision_summary": collision_payload, "network": route_data.get("network", {}), "route_track": route_track, "route_source_labels": _route_source_labels(route_track, limit=8), "route_carrier_names": _route_track_carrier_names(route_track, limit=8), "issue_codes": issue_codes, "issue_labels": [ _routing_diagnostic_issue_label(code) for code in issue_codes ], } if route_data.get("endpoint_access"): payload["endpoint_access"] = route_data.get("endpoint_access", {}) payload["quality"] = quality_payload if boundary_payload["boundary_aware"]: payload["boundary"] = boundary_payload if lane_capacity_payload["capacity_status"]: payload["capacity"] = lane_capacity_payload payload["access"] = access_payload if style_status: payload["wire_style_status"] = style_status selective_status = str(route_data.get("selective_collision_reroute_status", "") or "").strip() if selective_status: selective_payload = { "status": selective_status, "rejected_fallback_kinds": list( route_data.get("selective_collision_reroute_rejected_fallback_kinds", []) or [] ), "rejected_fallback_labels": list( route_data.get("selective_collision_reroute_rejected_fallback_labels", []) or [] ), } payload["selective_collision_reroute"] = { key: value for key, value in selective_payload.items() if value } metadata = _clean_endpoint_metadata(endpoint_metadata) if metadata: payload["endpoint_metadata"] = metadata style_payload = _clean_wire_style_payload(wire_style) if style_payload: payload["wire_style"] = style_payload return payload def _set_routing_connection_metadata( wire, route_data, collisions, wire_style_id="", endpoint_metadata=None, wire_style=None, ): length_mm = _route_length(route_data.get("points", [])) cleaned_endpoint_metadata = _set_endpoint_metadata(wire, endpoint_metadata) cleaned_wire_style = _clean_wire_style_payload(wire_style) style_status = _wire_style_status(wire_style_id, cleaned_wire_style) boundary_payload = _route_boundary_payload(route_data) lane_capacity_payload = _route_lane_capacity_payload(route_data) access_payload = _route_access_payload(route_data) collision_payload = _route_collision_payload(collisions) route_track = route_data.get("route_track", {}) quality_payload = _route_quality_payload(route_track) issue_codes = _route_issue_codes(route_data, collisions) _set_string( wire, "QetRouteAlgorithm", route_data.get("algorithm", ""), "Routing connection algorithm used for this wire", ) _set_string( wire, "QetRouteLengthMm", "{0:.3f}".format(length_mm), "Routing connection length in millimeters", ) _set_string( wire, "QetWireStyleId", str(wire_style_id or "").strip(), "QET wire style ID", ) _set_string( wire, "QetRouteIssueCodes", _diagnostic_issue_codes_text(issue_codes), "Routing issue codes for this wire", ) _set_string( wire, "QetRouteIssueLabels", _diagnostic_issue_labels_text(issue_codes), "Routing issue labels for this wire", ) _set_string( wire, "QetRouteCollisionCount", str(collision_payload["collision_count"]), "Total route collision warning count", ) _set_string( wire, "QetRouteHardIntersectionCount", str(collision_payload["hard_intersection_count"]), "Hard route intersection count", ) _set_string( wire, "QetRouteClearanceWarningCount", str(collision_payload["clearance_warning_count"]), "Route clearance warning count", ) _set_string( wire, "QetRouteCollisionStatus", collision_payload["collision_status"], "Route collision status for this wire", ) _set_string( wire, "QetRouteQualityStatus", quality_payload["quality_status"], "Route quality status for this wire", ) _set_string( wire, "QetRouteFallbackCarrierKinds", ",".join(quality_payload["fallback_carrier_kinds"]), "Fallback route carrier kinds used by this wire", ) _set_string( wire, "QetRouteFallbackCarrierLabels", "、".join(quality_payload["fallback_carrier_labels"]), "Fallback route carrier labels used by this wire", ) _set_string( wire, "QetRouteLaneIndex", str(lane_capacity_payload["lane_index"]), "Shared route lane index", ) _set_string( wire, "QetRouteLaneAxis", lane_capacity_payload["lane_axis"], "Shared route lane offset axis", ) _set_string( wire, "QetRouteLaneOffsetMm", "{0:.3f}".format(lane_capacity_payload["lane_offset_mm"]), "Shared route lane visual offset in millimeters", ) _set_string( wire, "QetRouteLaneSpacingMm", "{0:.3f}".format(lane_capacity_payload["lane_spacing_mm"]), "Shared route lane spacing in millimeters", ) _set_string( wire, "QetRouteParallelWireCount", str(lane_capacity_payload["parallel_wire_count"]), "Parallel wire count implied by this lane", ) if lane_capacity_payload["min_carrier_capacity"] is not None: _set_string( wire, "QetRouteMinCarrierCapacity", str(lane_capacity_payload["min_carrier_capacity"]), "Minimum route carrier capacity used by this wire", ) if lane_capacity_payload["capacity_status"]: _set_string( wire, "QetRouteCapacityStatus", lane_capacity_payload["capacity_status"], "Route capacity status for this wire", ) _set_string( wire, "QetRouteEntryDistanceMm", "{0:.3f}".format(access_payload["entry_distance_mm"]), "Distance from start terminal access point to route network in millimeters", ) _set_string( wire, "QetRouteExitDistanceMm", "{0:.3f}".format(access_payload["exit_distance_mm"]), "Distance from route network to end terminal access point in millimeters", ) _set_string( wire, "QetRouteEntryPointMode", access_payload["entry_point_mode"], "How the start terminal connects to the route network", ) _set_string( wire, "QetRouteExitPointMode", access_payload["exit_point_mode"], "How the end terminal connects to the route network", ) _set_string( wire, "QetRouteEntryCandidateRank", str(access_payload["entry_candidate_rank"]), "Start terminal route-network candidate rank", ) _set_string( wire, "QetRouteExitCandidateRank", str(access_payload["exit_candidate_rank"]), "End terminal route-network candidate rank", ) _set_string( wire, "QetRouteAccessWarningDistanceMm", "{0:.3f}".format(access_payload["access_warning_distance_mm"]), "Terminal access distance warning threshold in millimeters", ) _set_string( wire, "QetRouteAccessStatus", access_payload["access_status"], "Terminal access distance status for this routed wire", ) _set_string( wire, "QetRouteAccessWarningSides", ",".join(access_payload["warning_sides"]), "Terminal sides whose route-network access distance exceeds the warning threshold", ) _set_string( wire, "QetRouteSourceLabels", "、".join(_route_source_labels(route_track, limit=8)), "Route source labels passed through by this wire", ) _set_string( wire, "QetRouteCarrierNames", "、".join(_route_track_carrier_names(route_track, limit=8)), "Route carrier object names passed through by this wire", ) if boundary_payload["boundary_aware"]: _set_bool( wire, "QetRouteBoundaryAware", True, "Whether cabinet boundary scoring participated in this route", ) _set_string( wire, "QetRouteBoundaryStatus", boundary_payload["boundary_status"], "Cabinet boundary status for this routed wire", ) _set_string( wire, "QetRouteBoundaryViolationCount", str(boundary_payload["boundary_violation_count"]), "Number of routed points outside cabinet interior boundary", ) if style_status: _set_string( wire, "QetWireStyleStatus", style_status, "QET wire style lookup status", ) if cleaned_wire_style: _set_string( wire, "QetWireStyleJson", json.dumps(cleaned_wire_style, ensure_ascii=False), "QET wire style resolved from wire_properties", ) # 常用样式字段展开到对象属性,手动测试时不用每次打开 JSON。 for key, prop_name, description in ( ("name", "QetWireStyleName", "QET wire style name"), ("area_or_spec", "QetWireSpecText", "QET wire specification text"), ("line_color", "QetWireColorText", "QET wire color text"), ("line_type", "QetWireLineType", "QET wire line type"), ("wire_type", "QetWireType", "QET wire type"), ("wire_format", "QetWireFormat", "QET wire format"), ("diameter_mm", "QetWireDiameterMm", "QET wire diameter in millimeters"), ("line_width", "QetWireLineWidth", "QET wire view line width"), ): value = cleaned_wire_style.get(key) if value is not None and str(value).strip(): _set_string(wire, prop_name, str(value).strip(), description) _set_string( wire, "QetRouteDiagnosticsJson", json.dumps( _route_payload( route_data, collisions, wire_style_id=wire_style_id, endpoint_metadata=cleaned_endpoint_metadata, wire_style=cleaned_wire_style, ), ensure_ascii=False, ), "Routing connection diagnostics", ) if route_data.get("network"): _set_string( wire, "QetRouteNetworkJson", json.dumps(route_data.get("network", {}), ensure_ascii=False), "Route network metadata used by this wire", ) if route_data.get("route_track"): _set_string( wire, "QetRouteTrackJson", json.dumps(route_data.get("route_track", {}), ensure_ascii=False), "Routing carriers passed through by this wire", ) def build_network_route(start_terminal, end_terminal, route_index=0, options=None, doc=None): opts = _merged_options(options) if not opts.get("use_routing_network", True): return None if doc is None: doc = getattr(start_terminal, "Document", None) or getattr(App, "ActiveDocument", None) if doc is None: return None constraint_options = _merge_route_constraint_options( opts, _document_route_constraint_options(doc), ) connection_point_candidate_cache = opts.get("__connection_point_candidate_cache") if not isinstance(connection_point_candidate_cache, dict): connection_point_candidate_cache = None exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) max_exit_length = max(float(opts.get("terminal_exit_max_length", 0.0) or 0.0), 0.0) start_access_points = RoutingNetwork.terminal_access_path_points_with_network_access( start_terminal, exit_length, max_exit_length=max_exit_length, ) end_access_points = RoutingNetwork.terminal_access_path_points_with_network_access( end_terminal, exit_length, max_exit_length=max_exit_length, ) start_access_diagnostics = RoutingNetwork.terminal_access_diagnostics( start_terminal, exit_length=exit_length, max_exit_length=max_exit_length, ) end_access_diagnostics = RoutingNetwork.terminal_access_diagnostics( end_terminal, exit_length=exit_length, max_exit_length=max_exit_length, ) start_terminal_access_carrier = RoutingNetwork.terminal_access_carrier_for_terminal(start_terminal) end_terminal_access_carrier = RoutingNetwork.terminal_access_carrier_for_terminal(end_terminal) start_origin = start_access_points[0] if start_access_points else _terminal_origin(start_terminal) end_origin = end_access_points[0] if end_access_points else _terminal_origin(end_terminal) start_exit = start_access_points[-1] if start_access_points else _offset(start_origin, _terminal_direction(start_terminal), exit_length) end_exit = end_access_points[-1] if end_access_points else _offset(end_origin, _terminal_direction(end_terminal), exit_length) def clone_route_network(source): cloned = dict(source or {}) cloned["nodes"] = dict((source or {}).get("nodes", {}) or {}) cloned["edges"] = { key: list(value or []) for key, value in ((source or {}).get("edges", {}) or {}).items() } cloned["carriers"] = list((source or {}).get("carriers", []) or []) cloned["bridge_pairs"] = set((source or {}).get("bridge_pairs", set()) or set()) return cloned def build_route_payload( network, start_key, end_key, start_distance, end_distance, start_mode, end_mode, obstacle_aware=False, start_candidate_rank=1, end_candidate_rank=1, ): if start_key == end_key and _distance(start_exit, end_exit) > RoutingNetwork.DEFAULT_NODE_TOLERANCE: return None path_result = RoutingNetwork.shortest_path_with_carriers( network, start_key, end_key, bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0), kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), segment_usage_costs=opts.get("segment_usage_costs", {}), segment_reuse_penalty=float(opts.get("segment_reuse_penalty", 0.0) or 0.0), excluded_transit_carrier_kinds={"TerminalAccess"}, forbidden_carrier_names=constraint_options.get("forbidden_route_carrier_names", []), forbidden_carrier_labels=constraint_options.get("forbidden_route_carrier_labels", []), forbidden_carrier_source_names=constraint_options.get("forbidden_route_carrier_source_names", []), forbidden_carrier_source_labels=constraint_options.get("forbidden_route_carrier_source_labels", []), forbidden_carrier_kinds=constraint_options.get("forbidden_route_carrier_kinds", []), required_carrier_names=constraint_options.get("required_route_carrier_names", []), required_carrier_labels=constraint_options.get("required_route_carrier_labels", []), required_carrier_source_names=constraint_options.get("required_route_carrier_source_names", []), required_carrier_source_labels=constraint_options.get("required_route_carrier_source_labels", []), required_carrier_kinds=constraint_options.get("required_route_carrier_kinds", []), ) path_keys = path_result.get("path", []) if isinstance(path_result, dict) else [] if not path_keys: return None carrier_points = RoutingNetwork.path_points(network, path_keys) if not carrier_points: return None lane = _lane_payload_boundary_aware( route_index, opts, route_points=carrier_points, boundary_violation_count=route_boundary_violation_count, obstacle_hit_count=route_obstacle_hit_count, ) carrier_points = _apply_lane_offset(carrier_points, lane) points = [] for point in start_access_points or [start_origin, start_exit]: _append_unique(points, point) _append_orthogonal( points, carrier_points[0], obstacle_bboxes=local_access_obstacle_bboxes(points[-1], carrier_points[0]), ) for point in carrier_points[1:]: _append_unique(points, point) _append_orthogonal( points, end_exit, obstacle_bboxes=local_access_obstacle_bboxes(points[-1], end_exit), ) for point in reversed(end_access_points or [end_origin, end_exit]): _append_unique(points, point) preserved_point_keys = _important_route_node_keys(network, path_keys, path_result) for access_point in list(start_access_points or []) + list(end_access_points or []): preserved_point_keys.add(_route_point_key(access_point)) points = _simplify_collinear_points(points, preserved_point_keys=preserved_point_keys) return { "algorithm": "network-dijkstra-v1", "points": points, "endpoint_access": { "start_points": [_point_payload(point) for point in start_access_points or []], "end_points": [_point_payload(point) for point in end_access_points or []], "start_diagnostics": start_access_diagnostics, "end_diagnostics": end_access_diagnostics, }, "network": { "carriers": int(network.get("carrier_count", 0)), "segments": int(network.get("segment_count", 0)), "bridged_segments": int(network.get("bridged_segment_count", 0)), "blocked_segments": int(network.get("blocked_segment_count", 0)), "boundary_filtered": bool(network.get("boundary_filtered", False)), "boundary_filtered_segments": int(network.get("boundary_filtered_segment_count", 0) or 0), "nodes": len(network.get("nodes", {})), "entry_distance": float(start_distance or 0.0), "exit_distance": float(end_distance or 0.0), "entry_point_mode": start_mode, "exit_point_mode": end_mode, "entry_candidate_rank": int(start_candidate_rank or 1), "exit_candidate_rank": int(end_candidate_rank or 1), "terminal_access_warning_distance": float( opts.get("terminal_access_warning_distance", 0.0) or 0.0 ), "start_terminal_access_consumed": start_terminal_access_carrier is not None, "end_terminal_access_consumed": end_terminal_access_carrier is not None, "start_terminal_access_carrier": getattr(start_terminal_access_carrier, "Name", ""), "end_terminal_access_carrier": getattr(end_terminal_access_carrier, "Name", ""), "start_terminal_access_label": getattr(start_terminal_access_carrier, "Label", ""), "end_terminal_access_label": getattr(end_terminal_access_carrier, "Label", ""), "start_terminal_access_target_kind": getattr( start_terminal_access_carrier, "QetTerminalAccessTargetKind", "", ), "end_terminal_access_target_kind": getattr( end_terminal_access_carrier, "QetTerminalAccessTargetKind", "", ), "start_terminal_access_target_name": getattr( start_terminal_access_carrier, "QetTerminalAccessTargetName", "", ), "end_terminal_access_target_name": getattr( end_terminal_access_carrier, "QetTerminalAccessTargetName", "", ), "start_terminal_access_target_label": getattr( start_terminal_access_carrier, "QetTerminalAccessTargetLabel", "", ), "end_terminal_access_target_label": getattr( end_terminal_access_carrier, "QetTerminalAccessTargetLabel", "", ), "start_terminal_access_target_rule": getattr( start_terminal_access_carrier, "QetTerminalAccessTargetRule", "", ), "end_terminal_access_target_rule": getattr( end_terminal_access_carrier, "QetTerminalAccessTargetRule", "", ), "start_terminal_access_fallback_target": str( getattr(start_terminal_access_carrier, "QetTerminalAccessFallbackTarget", "") or "" ) == "1", "end_terminal_access_fallback_target": str( getattr(end_terminal_access_carrier, "QetTerminalAccessFallbackTarget", "") or "" ) == "1", "start_terminal_access_avoided_endpoint_device": str( getattr(start_terminal_access_carrier, "QetTerminalAccessAvoidedEndpointDevice", "") or "" ) == "1", "end_terminal_access_avoided_endpoint_device": str( getattr(end_terminal_access_carrier, "QetTerminalAccessAvoidedEndpointDevice", "") or "" ) == "1", "start_terminal_access_target_distance": float( getattr(start_terminal_access_carrier, "QetTerminalAccessTargetDistanceMm", 0.0) or 0.0 ), "end_terminal_access_target_distance": float( getattr(end_terminal_access_carrier, "QetTerminalAccessTargetDistanceMm", 0.0) or 0.0 ), "start_terminal_access_target_component_primary_segments": int( getattr( start_terminal_access_carrier, "QetTerminalAccessTargetComponentPrimarySegments", 0, ) or 0 ), "end_terminal_access_target_component_primary_segments": int( getattr( end_terminal_access_carrier, "QetTerminalAccessTargetComponentPrimarySegments", 0, ) or 0 ), "obstacle_aware": bool(obstacle_aware), "boundary_aware": bool(candidate_boundaries), "route_constraints": _route_constraint_payload(constraint_options), }, "route_track": path_result, "lane": lane, } def route_obstacle_hit_count(points): hits = 0 if not route_candidate_blocked_bboxes: return hits for index in range(max(len(points or []) - 1, 0)): start = points[index] end = points[index + 1] for bbox in _filter_obstacle_bboxes_near_polyline( [start, end], route_candidate_blocked_bboxes, margin=local_access_obstacle_scan_margin, ): if _segment_intersects_bbox(start, end, bbox): hits += 1 break return hits def route_boundary_violation_count(points): if not candidate_boundaries: return 0 violations = 0 for point in points or []: if not _point_inside_any_boundary(point, candidate_boundaries): violations += 1 return violations def route_candidate_identity(candidate): return ( candidate.get("projected_key"), id(candidate.get("carrier")), ) def terminal_access_main_path_target(access_carrier): if access_carrier is None: return {} kind = str(getattr(access_carrier, "QetTerminalAccessTargetKind", "") or "").strip() name = str(getattr(access_carrier, "QetTerminalAccessTargetName", "") or "").strip() label = str(getattr(access_carrier, "QetTerminalAccessTargetLabel", "") or "").strip() if kind not in {"RouteCarrier", "WireDuct", "WireDuctOpenEnd", "WiringCutOut", "UserPath"}: return {} if not (name or label): return {} return {"kind": kind, "name": name, "label": label} def carrier_matches_terminal_access_target(carrier, target): if carrier is None or not isinstance(target, dict) or not target: return False candidate_kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or "RouteCarrier" if target.get("kind") and candidate_kind != target.get("kind"): return False names = { str(getattr(carrier, "Name", "") or "").strip(), str(getattr(carrier, "QetRouteSourceName", "") or "").strip(), } labels = { str(getattr(carrier, "Label", "") or "").strip(), str(getattr(carrier, "QetRouteSourceLabel", "") or "").strip(), } target_name = str(target.get("name", "") or "").strip() target_label = str(target.get("label", "") or "").strip() return bool((target_name and target_name in names) or (target_label and target_label in labels)) def limit_candidates_to_terminal_access_target(access_carrier, candidates): target = terminal_access_main_path_target(access_carrier) if not target: return candidates, False matched = [ candidate for candidate in list(candidates or []) if carrier_matches_terminal_access_target(candidate.get("carrier"), target) ] # 端子接入 carrier 已经记录了“应该接入哪条主路径”。能命中时直接收窄; # 命不中则保留原候选,避免旧工程/旧路径元数据导致完全无法布线。 if matched: return matched, True return candidates, False def connection_point_candidates_for_route_network(route_network, point, limit=0, max_distance=0.0, cacheable=True): if connection_point_candidate_cache is None or not cacheable: return RoutingNetwork.connection_point_candidates( route_network, point, limit=limit, max_distance=max_distance, ) # 批量布线里许多导线共享同一个端子或同一条线槽入口。 # 这里缓存“端点投影到当前路径网络的候选”,不缓存最终路径,避免影响约束/避障/线道评分。 cache_key = ( id(route_network), int(route_network.get("segment_count", 0) or 0) if isinstance(route_network, dict) else 0, len((route_network.get("nodes", {}) or {})) if isinstance(route_network, dict) else 0, _route_point_key(_vector(point)), round(float(max_distance or 0.0), 6), ) cached = connection_point_candidate_cache.get(cache_key) if cached is None: cached = RoutingNetwork.connection_point_candidates( route_network, point, limit=0, max_distance=max_distance, ) connection_point_candidate_cache[cache_key] = [dict(candidate) for candidate in cached] candidates = [dict(candidate) for candidate in cached] max_items = max(int(limit or 0), 0) if max_items: return candidates[:max_items] return candidates def route_candidate_access_hit_count(access_point, candidate): if access_point is None or not local_access_blocked_bboxes: return 0 point = candidate.get("point") if point is None: return 0 nearby_bboxes = local_access_obstacle_bboxes(access_point, point) if not nearby_bboxes: return 0 access_points = _orthogonal_points_avoiding_obstacles( access_point, point, nearby_bboxes, ) return _orthogonal_hit_count(access_points, nearby_bboxes) def select_ranked_entry_candidates(route_network, candidates, limit, access_point=None): ranked = [] seen_ranked = set() for candidate in RoutingNetwork.rank_connection_point_candidates(route_network, candidates): identity = route_candidate_identity(candidate) if identity in seen_ranked: continue seen_ranked.add(identity) ranked.append(candidate) selected = list(ranked[:limit]) if not candidate_boundaries and not route_candidate_blocked_bboxes: return selected seen = {route_candidate_identity(candidate) for candidate in selected} total_limit = int(opts.get("network_entry_candidate_total_limit", 0) or 0) if total_limit > 0: total_limit = max(total_limit, limit) def can_add_extra_candidate(): return total_limit <= 0 or len(selected) < total_limit added_inside = 0 for candidate in ranked[limit:]: if not can_add_extra_candidate(): break point = candidate.get("point") if point is None or not _point_inside_any_boundary(point, candidate_boundaries): continue identity = route_candidate_identity(candidate) if identity in seen: continue # 柜内边界存在时,柜外近路径可能挤掉稍远的柜内路径;额外保留柜内候选再交给评分。 selected.append(candidate) seen.add(identity) added_inside += 1 if added_inside >= limit: break added_clear = 0 for candidate in ranked[limit:]: if not can_add_extra_candidate(): break if route_candidate_access_hit_count(access_point, candidate) > 0: continue identity = route_candidate_identity(candidate) if identity in seen: continue # 同理,近入口若都会穿过设备,稍远的干净入口也要进入评分阶段。 selected.append(candidate) seen.add(identity) added_clear += 1 if added_clear >= limit: break return selected def route_on_network(network, obstacle_aware=False): if network.get("segment_count", 0) <= 0: return None max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) terminal_access_limit = float(opts.get("terminal_access_max_distance", 0.0) or 0.0) # 面板只暴露“端子接入最大距离”,最终求路也要遵守它,避免跨很远直接接入孤立网络。 if terminal_access_limit > 0.0: max_distance = min(max_distance, terminal_access_limit) if max_distance > 0.0 else terminal_access_limit candidate_limit = max(int(opts.get("network_entry_candidate_limit", 8) or 0), 1) start_entry_candidates = connection_point_candidates_for_route_network( network, start_exit, limit=0, max_distance=max_distance, ) limited_start_entry_candidates, start_target_limited = limit_candidates_to_terminal_access_target( start_terminal_access_carrier, start_entry_candidates, ) uses_target_limit = ( start_target_limited or bool(terminal_access_main_path_target(end_terminal_access_carrier)) ) def find_best_route(entry_candidates, limit_end_to_target=True, start_target_limited=False): start_candidate_limit = 1 if start_target_limited else candidate_limit start_candidates = select_ranked_entry_candidates( network, entry_candidates, start_candidate_limit, access_point=start_exit, ) if not start_candidates: return None best_route = None best_score = None entry_distance_cost_factor = float( opts.get("network_entry_distance_cost_factor", 5.0) or 0.0 ) for start_rank, start_candidate in enumerate(start_candidates, start=1): start_network = clone_route_network(network) start_key, start_distance, start_mode = RoutingNetwork.connect_point_candidate_to_network( start_network, start_candidate, ) if start_key is None: continue end_entry_candidates = connection_point_candidates_for_route_network( start_network, end_exit, limit=0, max_distance=max_distance, cacheable=False, ) end_target_limited = False if limit_end_to_target: end_entry_candidates, end_target_limited = limit_candidates_to_terminal_access_target( end_terminal_access_carrier, end_entry_candidates, ) end_candidate_limit = 1 if end_target_limited else candidate_limit end_candidates = select_ranked_entry_candidates( start_network, end_entry_candidates, end_candidate_limit, access_point=end_exit, ) for end_rank, end_candidate in enumerate(end_candidates, start=1): working_network = clone_route_network(start_network) end_key, end_distance, end_mode = RoutingNetwork.connect_point_candidate_to_network( working_network, end_candidate, ) if end_key is None: continue route_data = build_route_payload( working_network, start_key, end_key, start_distance, end_distance, start_mode, end_mode, obstacle_aware=obstacle_aware, start_candidate_rank=start_rank, end_candidate_rank=end_rank, ) if route_data is None: continue route_score = float( (route_data.get("route_track", {}) or {}).get("cost", 0.0) or 0.0 ) route_score += ( float(start_distance or 0.0) + float(end_distance or 0.0) ) * entry_distance_cost_factor obstacle_hits = route_obstacle_hit_count(route_data.get("points", [])) route_score += obstacle_hits * float( opts.get("route_candidate_collision_penalty", 10000.0) or 0.0 ) boundary_violations = route_boundary_violation_count(route_data.get("points", [])) route_score += boundary_violations * float( opts.get("route_candidate_boundary_penalty", 100000.0) or 0.0 ) route_data["network"]["route_candidate_obstacle_hits"] = int(obstacle_hits) route_data["network"]["route_candidate_boundary_violations"] = int( boundary_violations ) route_data["network"]["entry_candidate_score"] = float(route_score) if best_score is None or route_score < best_score: best_score = route_score best_route = route_data return best_route route = find_best_route( limited_start_entry_candidates, limit_end_to_target=True, start_target_limited=start_target_limited, ) if route is not None or not uses_target_limit: return route # 目标主路径可能来自孤立线槽开口或旧工程缓存;目标优先失败后必须退回完整网络候选。 return find_best_route(start_entry_candidates, limit_end_to_target=False, start_target_limited=False) use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True)) use_local_access_obstacle_avoidance = bool(opts.get("avoid_local_access_obstacles", True)) obstacles = [] candidate_obstacles = [] candidate_boundaries = collect_routing_boundaries(doc, options=opts) boundary_bboxes = [ boundary.get("bbox") for boundary in candidate_boundaries if isinstance(boundary, dict) and boundary.get("bbox") ] if use_obstacle_avoidance: obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) if use_obstacle_avoidance or use_local_access_obstacle_avoidance: candidate_options = dict(opts) candidate_options["ignore_endpoint_near_obstacles"] = False candidate_obstacles = collect_obstacles( doc, exclude=[start_terminal, end_terminal], options=candidate_options, ) blocked_bboxes = [obstacle["bbox"] for obstacle in obstacles if obstacle.get("bbox")] candidate_blocked_bboxes = [ obstacle["bbox"] for obstacle in candidate_obstacles if obstacle.get("bbox") ] local_access_blocked_bboxes = [ obstacle["bbox"] for obstacle in candidate_obstacles if obstacle.get("bbox") and _is_local_access_obstacle(obstacle) ] route_candidate_blocked_bboxes = ( candidate_blocked_bboxes if use_obstacle_avoidance else local_access_blocked_bboxes ) local_access_obstacle_scan_margin = max( float(opts.get("local_access_obstacle_scan_margin", 0.0) or 0.0), LOCAL_ACCESS_DETOUR_CLEARANCE, float(opts.get("obstacle_clearance", 0.0) or 0.0), ) def local_access_obstacle_bboxes(start_point, end_point, preferred_axis=None): return _local_access_obstacle_bboxes( start_point, end_point, local_access_blocked_bboxes, preferred_axis=preferred_axis, margin=local_access_obstacle_scan_margin, ) if boundary_bboxes: if blocked_bboxes: boundary_obstacle_network = RoutingNetwork.build_route_graph( doc, blocked_bboxes=blocked_bboxes, allowed_bboxes=boundary_bboxes, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) route_data = route_on_network(boundary_obstacle_network, obstacle_aware=True) if route_data is not None: return route_data boundary_network = RoutingNetwork.build_route_graph( doc, allowed_bboxes=boundary_bboxes, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) route_data = route_on_network(boundary_network, obstacle_aware=False) if route_data is not None: return route_data if blocked_bboxes: obstacle_aware_network = RoutingNetwork.build_route_graph( doc, blocked_bboxes=blocked_bboxes, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) route_data = route_on_network(obstacle_aware_network, obstacle_aware=True) if route_data is not None: return route_data network = opts.get("__base_route_network") if not isinstance(network, dict) or network.get("segment_count", 0) <= 0: network = RoutingNetwork.build_route_graph( doc, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) return route_on_network(network, obstacle_aware=False) def _is_group(obj): try: return bool(obj.isDerivedFrom("App::DocumentObjectGroup")) except Exception: return False def _is_origin_helper(obj): type_id = (getattr(obj, "TypeId", "") or "").lower() name = (getattr(obj, "Name", "") or "").lower() label = (getattr(obj, "Label", "") or "").lower() compact_name = "".join(ch for ch in name if not ch.isdigit()).replace("-", "_") compact_label = "".join(ch for ch in label if not ch.isdigit()).replace("-", "_") helper_names = { "origin", "xy_plane", "xz_plane", "yz_plane", "x_axis", "y_axis", "z_axis", } if "origin" in type_id or compact_name in helper_names: return True return compact_label in helper_names or compact_label.replace(" ", "_") in helper_names def _bbox_payload(obj, clearance=0.0): shape = getattr(obj, "Shape", None) bbox = getattr(shape, "BoundBox", None) if bbox is None: return None return { "xmin": float(bbox.XMin) - clearance, "xmax": float(bbox.XMax) + clearance, "ymin": float(bbox.YMin) - clearance, "ymax": float(bbox.YMax) + clearance, "zmin": float(bbox.ZMin) - clearance, "zmax": float(bbox.ZMax) + clearance, } def _is_routing_boundary(obj): try: return RoutingNetwork.is_routing_boundary(obj) except Exception: return False PASS_THROUGH_OBSTACLE_MODES = {"PassThrough", "WireDuctPassThrough", "SupportSurface"} def _routing_obstacle_mode(obj): return str(getattr(obj, "QetRoutingObstacleMode", "") or "").strip() def _is_auto_detected_support_surface(obj): try: return bool(RoutingNetwork._is_support_surface_candidate(obj)) except Exception: return False def _object_parent_chain(obj, limit=16): chain = [] seen = set() stack = list(getattr(obj, "InList", []) or []) while stack and len(chain) < limit: parent = stack.pop(0) if parent is None or id(parent) in seen: continue seen.add(id(parent)) chain.append(parent) stack.extend(list(getattr(parent, "InList", []) or [])) return chain def _is_route_carrier_geometry(obj): """Return True for imported solids that only visualize generated route carriers.""" if obj is None: return False for parent in _object_parent_chain(obj): parent_name = str(getattr(parent, "Name", "") or "").strip() if parent_name == "QETWiring_02_Carriers": # 中文说明:线槽/UserPath 的源实体可能只是 carrier 组里的显示几何, # 不能反过来作为障碍物,否则导线沿线槽走也会被诊断为碰撞。 return True try: if RoutingNetwork.is_route_carrier(parent): return True except Exception: pass return False def _terminal_route_endpoint_metadata(terminal): payload = { "terminal_name": str(getattr(terminal, "Name", "") or ""), "terminal_label": str(getattr(terminal, "Label", "") or ""), "parent_device_name": "", "parent_device_label": "", "parent_device_instance_id": "", "parent_device_element_uuid": "", } for parent in _object_parent_chain(terminal): instance_id = str(getattr(parent, "QetInstanceId", "") or "").strip() element_uuid = str(getattr(parent, "QetElementUuid", "") or "").strip() if not instance_id and not element_uuid: continue payload["parent_device_name"] = str(getattr(parent, "Name", "") or "") payload["parent_device_label"] = str(getattr(parent, "Label", "") or "") payload["parent_device_instance_id"] = instance_id payload["parent_device_element_uuid"] = element_uuid break return payload def _has_pass_through_obstacle_semantics(obj): if obj is None: return False if _routing_obstacle_mode(obj) in PASS_THROUGH_OBSTACLE_MODES: return True if _is_auto_detected_support_surface(obj): return True for parent in _object_parent_chain(obj): if _routing_obstacle_mode(parent) in PASS_THROUGH_OBSTACLE_MODES: return True if _is_auto_detected_support_surface(parent): return True seen = set() stack = list(getattr(obj, "Group", []) or []) + list(getattr(obj, "OutList", []) or []) while stack: child = stack.pop() if child is None or id(child) in seen: continue seen.add(id(child)) if _routing_obstacle_mode(child) in PASS_THROUGH_OBSTACLE_MODES: # 真实装配里门板/安装板可能是 LinkGroup/Compound 的子对象; # 子对象已作为布线面时,父装配体不能再反过来报导线碰撞。 return True if _is_auto_detected_support_surface(child): # 有些旧 FCStd 只生成了路径 carrier,没有给源对象写入 SupportSurface; # 仍按可识别的支撑面处理,避免柜门/侧盖父装配体成为碰撞误报。 return True stack.extend(list(getattr(child, "Group", []) or [])) stack.extend(list(getattr(child, "OutList", []) or [])) return False def collect_routing_boundaries(doc, options=None): # 柜内边界是 FreeCAD 装配态语义,只用于限制 3D 选路,不写入 2D/3D 数据库。 boundaries = [] for obj in list(getattr(doc, "Objects", []) or []): if _is_group(obj) or _is_origin_helper(obj): continue if not _is_routing_boundary(obj): continue bbox = _bbox_payload(obj, clearance=0.0) if bbox is None: continue boundaries.append( { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), "bbox": bbox, } ) return boundaries def routing_obstacle_mode_summary(doc): summary = {} if doc is None: return summary for obj in list(getattr(doc, "Objects", []) or []): if _is_group(obj) or _is_origin_helper(obj): continue mode = _routing_obstacle_mode(obj) if not mode: continue entry = summary.setdefault(mode, {"count": 0, "samples": []}) entry["count"] += 1 if len(entry["samples"]) < 8: entry["samples"].append( { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), } ) return summary def _collect_group_tree_ids(root): excluded = set() stack = [root] while stack: obj = stack.pop() if obj is None or id(obj) in excluded: continue excluded.add(id(obj)) stack.extend(list(getattr(obj, "Group", []) or [])) return excluded def _expanded_obstacle_exclusion_ids(doc, exclude): excluded = set(id(obj) for obj in (exclude or []) if obj is not None) endpoint_instance_ids = { (getattr(obj, "QetInstanceId", "") or "").strip() for obj in (exclude or []) if obj is not None and (getattr(obj, "QetInstanceId", "") or "").strip() } if not endpoint_instance_ids: return excluded for obj in list(getattr(doc, "Objects", []) or []): instance_id = (getattr(obj, "QetInstanceId", "") or "").strip() parent_instance_ids = { (getattr(parent, "QetInstanceId", "") or "").strip() for parent in list(getattr(obj, "InList", []) or []) if (getattr(parent, "QetInstanceId", "") or "").strip() } if instance_id in endpoint_instance_ids or parent_instance_ids.intersection(endpoint_instance_ids): excluded.update(_collect_group_tree_ids(obj)) excluded.add(id(obj)) return excluded def _obstacle_instance_refs(obj): refs = set() own = str(getattr(obj, "QetInstanceId", "") or "").strip() if own: refs.add(own) for parent in list(getattr(obj, "InList", []) or []): parent_instance = str(getattr(parent, "QetInstanceId", "") or "").strip() if parent_instance: refs.add(parent_instance) return refs def _obstacle_parent_refs(obj): names = [] labels = [] for parent in _object_parent_chain(obj, limit=8): name = str(getattr(parent, "Name", "") or "").strip() label = str(getattr(parent, "Label", "") or "").strip() if name and name not in names: names.append(name) if label and label not in labels: labels.append(label) return {"names": names[:8], "labels": labels[:8]} def _distance_point_to_bbox(point, bbox): squared = 0.0 for axis, min_key, max_key in ( ("x", "xmin", "xmax"), ("y", "ymin", "ymax"), ("z", "zmin", "zmax"), ): value = _axis_value(point, axis) low = float(bbox[min_key]) high = float(bbox[max_key]) if value < low: squared += (low - value) * (low - value) elif value > high: squared += (value - high) * (value - high) return math.sqrt(squared) def _point_inside_bbox(point, bbox, tolerance=0.000001): for axis, min_key, max_key in ( ("x", "xmin", "xmax"), ("y", "ymin", "ymax"), ("z", "zmin", "zmax"), ): value = _axis_value(point, axis) if value < float(bbox[min_key]) - tolerance: return False if value > float(bbox[max_key]) + tolerance: return False return True def _point_inside_any_boundary(point, boundaries): if not boundaries: return True return any( _point_inside_bbox(point, boundary.get("bbox", {}) or {}) for boundary in boundaries if boundary.get("bbox") ) def _obstacle_candidate_cache(doc, options=None): opts = _merged_options(options) clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) candidates = [] if doc is None: return {"clearance": clearance, "candidates": candidates} for obj in list(getattr(doc, "Objects", []) or []): if _has_pass_through_obstacle_semantics(obj): continue if _is_route_carrier_geometry(obj): continue if _is_routing_boundary(obj): continue if _is_group(obj) or _is_origin_helper(obj): continue if TerminalObjects.is_lcs_like(obj) or TerminalObjects.is_terminal_object(obj): continue if RoutingNetwork.is_route_carrier(obj) or WiringObjects.is_routed_wire_object(obj): continue raw_bbox = _bbox_payload(obj, clearance=0.0) bbox = _bbox_payload(obj, clearance=clearance) if bbox is None: continue candidates.append( { "object_id": id(obj), "instance_refs": sorted(_obstacle_instance_refs(obj)), "element_uuid": str(getattr(obj, "QetElementUuid", "") or "").strip(), "instance_id": str(getattr(obj, "QetInstanceId", "") or "").strip(), "parent_refs": _obstacle_parent_refs(obj), "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), "bbox": bbox, "raw_bbox": raw_bbox or bbox, } ) return {"clearance": clearance, "candidates": candidates} def _obstacles_from_candidate_cache(cache, exclude=None, options=None): opts = _merged_options(options) candidates = [] if isinstance(cache, dict): candidates = list(cache.get("candidates", []) or []) excluded_ids = set(id(obj) for obj in (exclude or []) if obj is not None) endpoint_instance_ids = { str(getattr(obj, "QetInstanceId", "") or "").strip() for obj in (exclude or []) if obj is not None and str(getattr(obj, "QetInstanceId", "") or "").strip() } endpoint_points = [] for obj in exclude or []: if obj is not None and TerminalObjects.is_terminal_object(obj): endpoint_points.append(_terminal_origin(obj)) clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) endpoint_clearance = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + clearance obstacles = [] for candidate in candidates: if int(candidate.get("object_id", 0) or 0) in excluded_ids: continue instance_refs = set(str(item or "").strip() for item in candidate.get("instance_refs", []) or [] if str(item or "").strip()) if endpoint_instance_ids and instance_refs.intersection(endpoint_instance_ids): continue bbox = candidate.get("bbox") if bbox is None: continue if bool(opts.get("ignore_endpoint_near_obstacles", True)) and endpoint_points and any( _distance_point_to_bbox(point, bbox) <= endpoint_clearance for point in endpoint_points ): continue obstacles.append( { "name": candidate.get("name", ""), "label": candidate.get("label", ""), "type_id": candidate.get("type_id", ""), "element_uuid": str(candidate.get("element_uuid", "") or "").strip(), "instance_id": str(candidate.get("instance_id", "") or "").strip(), "parent_refs": candidate.get("parent_refs", {}) if isinstance(candidate.get("parent_refs", {}), dict) else {}, "bbox": bbox, "raw_bbox": candidate.get("raw_bbox") or bbox, } ) return obstacles def collect_obstacles(doc, exclude=None, options=None): opts = _merged_options(options) cache = opts.get("__obstacle_candidate_cache") if isinstance(cache, dict): return _obstacles_from_candidate_cache(cache, exclude=exclude, options=opts) excluded = _expanded_obstacle_exclusion_ids(doc, exclude) clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) endpoint_clearance = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + clearance endpoint_points = [] for obj in exclude or []: if obj is not None and TerminalObjects.is_terminal_object(obj): endpoint_points.append(_terminal_origin(obj)) obstacles = [] for obj in list(getattr(doc, "Objects", []) or []): if id(obj) in excluded: continue if _has_pass_through_obstacle_semantics(obj): continue if _is_route_carrier_geometry(obj): continue if _is_routing_boundary(obj): continue if _is_group(obj) or _is_origin_helper(obj): continue if TerminalObjects.is_lcs_like(obj) or TerminalObjects.is_terminal_object(obj): continue if RoutingNetwork.is_route_carrier(obj) or WiringObjects.is_routed_wire_object(obj): continue raw_bbox = _bbox_payload(obj, clearance=0.0) bbox = _bbox_payload(obj, clearance=clearance) if bbox is None: continue if bool(opts.get("ignore_endpoint_near_obstacles", True)) and endpoint_points and any( _distance_point_to_bbox(point, bbox) <= endpoint_clearance for point in endpoint_points ): continue obstacles.append( { "name": getattr(obj, "Name", ""), "label": getattr(obj, "Label", ""), "type_id": getattr(obj, "TypeId", ""), # 中文说明:碰撞诊断要能回到 QET 设备和 FreeCAD 实例,便于现场按 2D/3D 绑定定位。 "element_uuid": str(getattr(obj, "QetElementUuid", "") or "").strip(), "instance_id": str(getattr(obj, "QetInstanceId", "") or "").strip(), "parent_refs": _obstacle_parent_refs(obj), "bbox": bbox, "raw_bbox": raw_bbox or bbox, } ) return obstacles def _segment_intersects_bbox(start, end, bbox): # Slab intersection: 把线段参数化为 start + t*(end-start),t 在 [0, 1] 内相交即命中。 t_min = 0.0 t_max = 1.0 for axis, min_key, max_key in ( ("x", "xmin", "xmax"), ("y", "ymin", "ymax"), ("z", "zmin", "zmax"), ): start_value = _axis_value(start, axis) end_value = _axis_value(end, axis) delta = end_value - start_value low = float(bbox[min_key]) high = float(bbox[max_key]) if abs(delta) <= 0.000001: if start_value < low or start_value > high: return False continue inv = 1.0 / delta near = (low - start_value) * inv far = (high - start_value) * inv if near > far: near, far = far, near t_min = max(t_min, near) t_max = min(t_max, far) if t_min > t_max: return False return True def detect_collisions(points, obstacles, ignored_segment_indices=None): ignored = set(ignored_segment_indices or []) collisions = [] for index in range(max(len(points) - 1, 0)): if index in ignored: continue start = points[index] end = points[index + 1] for obstacle in obstacles: if _segment_intersects_bbox(start, end, obstacle["bbox"]): raw_bbox = obstacle.get("raw_bbox") or obstacle.get("bbox") or {} collision_kind = "HardIntersection" if raw_bbox and not _segment_intersects_bbox(start, end, raw_bbox): collision_kind = "ClearanceWarning" collisions.append( { "segment_index": index, "segment_start": _point_payload(start), "segment_end": _point_payload(end), "collision_kind": collision_kind, "obstacle_name": obstacle.get("name", ""), "obstacle_label": obstacle.get("label", ""), "obstacle_element_uuid": str(obstacle.get("element_uuid", "") or "").strip(), "obstacle_instance_id": str(obstacle.get("instance_id", "") or "").strip(), "obstacle_parent_names": list( (obstacle.get("parent_refs", {}) or {}).get("names", []) or [] ), "obstacle_parent_labels": list( (obstacle.get("parent_refs", {}) or {}).get("labels", []) or [] ), "obstacle_bbox": dict(raw_bbox), "collision_bbox": dict(obstacle.get("bbox", {}) or {}), } ) return collisions def _point_close(first, second, tolerance=0.001): first = _vector(first) second = _vector(second) return _distance(first, second) <= float(tolerance or 0.001) def _point_on_segment(point, start, end, tolerance=0.001): point = _vector(point) start = _vector(start) end = _vector(end) segment_length = _distance(start, end) if segment_length <= float(tolerance or 0.001): return _point_close(point, start, tolerance=tolerance) if _distance(start, point) + _distance(point, end) - segment_length > float(tolerance or 0.001): return False return _collinear_points(start, point, end) def _point_on_polyline(point, polyline, tolerance=0.001): points = [_vector(item) for item in polyline or []] if not points: return False if any(_point_close(point, item, tolerance=tolerance) for item in points): return True for index in range(max(len(points) - 1, 0)): if _point_on_segment(point, points[index], points[index + 1], tolerance=tolerance): return True return False def _route_access_points_from_payload(payload): points = [] if not isinstance(payload, list): return points for item in payload: if isinstance(item, dict): try: points.append(App.Vector(float(item["x"]), float(item["y"]), float(item["z"]))) except Exception: continue return points def _route_track_points_from_payload(route_track): segments = route_track.get("segments", []) if isinstance(route_track, dict) else [] points = [] for segment in list(segments or []): if not isinstance(segment, dict): continue if not points: try: points.append(_vector(segment.get("from", {}))) except Exception: pass try: points.append(_vector(segment.get("to", {}))) except Exception: pass return points def _route_data_with_lane(route_data, start_terminal, end_terminal, lane_index, options=None, doc=None): if not isinstance(route_data, dict): return route_data opts = _merged_options(options) rebuilt = dict(route_data) route_track = dict(route_data.get("route_track", {}) or {}) carrier_points = _route_track_points_from_payload(route_track) if not carrier_points: rebuilt["lane"] = _lane_payload(lane_index, opts, route_points=route_data.get("points", [])) return rebuilt endpoint_access = route_data.get("endpoint_access", {}) if isinstance(route_data.get("endpoint_access", {}), dict) else {} start_access_points = _route_access_points_from_payload(endpoint_access.get("start_points", [])) end_access_points = _route_access_points_from_payload(endpoint_access.get("end_points", [])) exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) start_origin = start_access_points[0] if start_access_points else _terminal_origin(start_terminal) end_origin = end_access_points[0] if end_access_points else _terminal_origin(end_terminal) start_exit = start_access_points[-1] if start_access_points else _offset(start_origin, _terminal_direction(start_terminal), exit_length) end_exit = end_access_points[-1] if end_access_points else _offset(end_origin, _terminal_direction(end_terminal), exit_length) candidate_boundaries = collect_routing_boundaries(doc, options=opts) if doc is not None else [] candidate_obstacles = [] if doc is not None and ( bool(opts.get("avoid_obstacles", True)) or bool(opts.get("avoid_local_access_obstacles", True)) ): candidate_options = dict(opts) candidate_options["ignore_endpoint_near_obstacles"] = False candidate_obstacles = collect_obstacles( doc, exclude=[start_terminal, end_terminal], options=candidate_options, ) candidate_blocked_bboxes = [ obstacle["bbox"] for obstacle in candidate_obstacles if obstacle.get("bbox") ] local_access_blocked_bboxes = [ obstacle["bbox"] for obstacle in candidate_obstacles if obstacle.get("bbox") and _is_local_access_obstacle(obstacle) ] route_candidate_blocked_bboxes = ( candidate_blocked_bboxes if bool(opts.get("avoid_obstacles", True)) else local_access_blocked_bboxes ) scan_margin = max( float(opts.get("local_access_obstacle_scan_margin", 0.0) or 0.0), LOCAL_ACCESS_DETOUR_CLEARANCE, float(opts.get("obstacle_clearance", 0.0) or 0.0), ) def local_access_obstacle_bboxes(start_point, end_point, preferred_axis=None): return _local_access_obstacle_bboxes( start_point, end_point, local_access_blocked_bboxes, preferred_axis=preferred_axis, margin=scan_margin, ) def route_boundary_violation_count(points): if not candidate_boundaries: return 0 return sum(1 for point in points or [] if not _point_inside_any_boundary(point, candidate_boundaries)) def route_obstacle_hit_count(points): hits = 0 if not route_candidate_blocked_bboxes: return hits for index in range(max(len(points or []) - 1, 0)): start = points[index] end = points[index + 1] for bbox in _filter_obstacle_bboxes_near_polyline( [start, end], route_candidate_blocked_bboxes, margin=scan_margin, ): if _segment_intersects_bbox(start, end, bbox): hits += 1 break return hits lane = _lane_payload_boundary_aware( lane_index, opts, route_points=carrier_points, boundary_violation_count=route_boundary_violation_count, obstacle_hit_count=route_obstacle_hit_count, ) shifted_carrier_points = _apply_lane_offset(carrier_points, lane) points = [] for point in start_access_points or [start_origin, start_exit]: _append_unique(points, point) _append_orthogonal( points, shifted_carrier_points[0], obstacle_bboxes=local_access_obstacle_bboxes(points[-1], shifted_carrier_points[0]), ) for point in shifted_carrier_points[1:]: _append_unique(points, point) _append_orthogonal( points, end_exit, obstacle_bboxes=local_access_obstacle_bboxes(points[-1], end_exit), ) for point in reversed(end_access_points or [end_origin, end_exit]): _append_unique(points, point) preserved = {_route_point_key(point) for point in shifted_carrier_points} for access_point in list(start_access_points or []) + list(end_access_points or []): preserved.add(_route_point_key(access_point)) rebuilt["points"] = _simplify_collinear_points(points, preserved_point_keys=preserved) rebuilt["lane"] = lane return rebuilt def _access_collision_segment_indices(points, access_points, from_start=True): route_points = [_vector(point) for point in points or []] access_points = [_vector(point) for point in access_points or []] ignored = set() if len(route_points) < 2 or len(access_points) < 2: return ignored if from_start: indices = range(len(route_points) - 1) else: indices = range(len(route_points) - 2, -1, -1) for index in indices: start = route_points[index] end = route_points[index + 1] if not ( _point_on_polyline(start, access_points) and _point_on_polyline(end, access_points) ): break ignored.add(index) return ignored def _endpoint_collision_segment_indices(points, route_data=None): segment_count = max(len(points or []) - 1, 0) if segment_count <= 0: return set() ignored = {0} if segment_count > 1: ignored.add(segment_count - 1) endpoint_access = route_data.get("endpoint_access", {}) if isinstance(route_data, dict) else {} if isinstance(endpoint_access, dict): # 端子局部出线路径允许穿过/贴近设备壳体;碰撞诊断聚焦主路径中段。 start_access = _route_access_points_from_payload(endpoint_access.get("start_points", [])) end_access = _route_access_points_from_payload(endpoint_access.get("end_points", [])) ignored.update(_access_collision_segment_indices(points, start_access, from_start=True)) ignored.update(_access_collision_segment_indices(points, end_access, from_start=False)) return ignored def _detach_object_from_groups(doc, obj): parents = list(getattr(obj, "InList", []) or []) parents.extend(list(getattr(doc, "Objects", []) or [])) for parent in parents: group = list(getattr(parent, "Group", []) or []) if obj not in group: continue try: if hasattr(parent, "removeObject"): parent.removeObject(obj) else: parent.Group = [child for child in group if child is not obj] except Exception: try: parent.Group = [child for child in group if child is not obj] except Exception: pass def _matching_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=""): matches = [] for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue if wire_uuid: if (getattr(obj, "QetWireUuid", "") or "").strip() != wire_uuid: continue else: same_direction = ( (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == start_uuid and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == end_uuid ) reverse_direction = ( (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == end_uuid and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == start_uuid ) if not same_direction and not reverse_direction: continue matches.append(obj) return matches def _remove_routing_connection_objects(doc, objects): removed = 0 for obj in list(objects or []): try: _detach_object_from_groups(doc, obj) doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _remove_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=""): return _remove_routing_connection_objects( doc, _matching_existing_routing_connections(doc, start_uuid, end_uuid, wire_uuid=wire_uuid), ) def _find_task_by_wire_uuid(doc, wire_uuid): if not wire_uuid: return None try: task_group = doc.getObject("QETWiring_01_Tasks") except Exception: task_group = None if task_group is None: return None for task in list(getattr(task_group, "Group", []) or []): if (getattr(task, "QetWireUuid", "") or "").strip() == wire_uuid: return task return None def _set_task_status(task, status): if task is None: return TerminalObjects.ensure_string_property( task, "RouteStatus", "QET Wiring", "Wire task route status", status, ) def _parse_line_color(value): text = str(value or "").strip() if not text: return None lower = text.lower() named = { "black": (0.0, 0.0, 0.0), "white": (1.0, 1.0, 1.0), "red": (1.0, 0.0, 0.0), "green": (0.0, 0.6, 0.0), "blue": (0.0, 0.35, 1.0), "yellow": (1.0, 0.85, 0.0), "orange": (1.0, 0.55, 0.0), "brown": (0.45, 0.25, 0.1), "gray": (0.5, 0.5, 0.5), "grey": (0.5, 0.5, 0.5), } if lower in named: return named[lower] if lower.isdigit() and len(lower) > 6: try: number = int(lower, 10) except Exception: number = -1 if 0 <= number <= 0xFFFFFFFF: raw = "{0:06x}".format(number & 0xFFFFFF) return tuple(round(int(raw[index:index + 2], 16) / 255.0, 6) for index in (0, 2, 4)) # QET 数据库里的颜色可能带 #,也可能直接保存为 3366CC / 0x3366CC。 raw = lower if raw.startswith("#"): raw = raw[1:] elif raw.startswith("0x"): raw = raw[2:] if raw != lower or (len(raw) in (3, 6, 8) and all(ch in "0123456789abcdef" for ch in raw)): if len(raw) == 8: raw = raw[2:] if len(raw) == 3: raw = "".join(ch * 2 for ch in raw) if len(raw) == 6: try: return tuple(round(int(raw[index:index + 2], 16) / 255.0, 6) for index in (0, 2, 4)) except Exception: return None for prefix, suffix in (("rgb(", ")"), ("rgba(", ")")): if lower.startswith(prefix) and lower.endswith(suffix): text = lower[len(prefix):-len(suffix)] break values = [part.strip() for part in text.replace(";", ",").split(",")] if len(values) >= 3: try: numbers = [float(values[index]) for index in range(3)] except Exception: numbers = [] if len(numbers) == 3: if max(numbers) > 1.0: numbers = [value / 255.0 for value in numbers] return tuple(max(0.0, min(1.0, round(value, 6))) for value in numbers) return None def _wire_style_line_width(wire_style): if not isinstance(wire_style, dict): return None for key in ("line_width", "diameter_mm"): try: width = float(wire_style.get(key, 0) or 0) except Exception: width = 0.0 if width > 0.0: return width area_mm2 = _wire_style_area_mm2(wire_style.get("area_or_spec", "")) if area_mm2 > 0.0: return math.sqrt(4.0 * area_mm2 / math.pi) return None def _wire_style_area_mm2(value): # QET 有时只有 "2.5mm2" 这类规格文本,第一版用它估算显示线宽。 text = str(value or "").strip().lower() if not text: return 0.0 text = text.replace(",", ".").replace("\u00b2", "2") matches = re.findall(r"([0-9]+(?:\.[0-9]+)?)\s*(?:mm\s*(?:2|\^2)|平方)", text) if not matches and re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", text): matches = [text] for match in matches: try: area = float(match) except Exception: area = 0.0 if area > 0.0: return area return 0.0 def _wire_style_color(wire_style): if not isinstance(wire_style, dict): return None return _parse_line_color(wire_style.get("line_color", "")) def _wire_style_draw_style(wire_style): if not isinstance(wire_style, dict): return "Solid" text = str(wire_style.get("line_type", "") or "").strip().lower() if not text: return "Solid" normalized = text.replace("_", "").replace("-", "").replace(" ", "") if normalized in {"dashline", "dashed", "dash", "dashes", "虚线"}: return "Dashed" if normalized in {"dotline", "dotted", "dot", "dots", "点线"}: return "Dotted" if normalized in {"dashdotline", "dashdot", "点划线"}: return "Dashdot" return "Solid" def _set_wire_style_application_metadata(wire, wire_style, line_width, line_color, draw_style): applied = bool(isinstance(wire_style, dict) and wire_style) _set_bool( wire, "QetWireStyleApplied", applied, "Whether the QET wire style has been applied to the visible 3D wire", ) _set_string( wire, "QetAppliedWireLineColor", str((wire_style or {}).get("line_color", "") or ""), "Applied QET wire line color text", ) _set_string( wire, "QetAppliedWireLineWidth", str(line_width), "Applied QET wire line width", ) _set_string( wire, "QetAppliedWireDrawStyle", str(draw_style or "Solid"), "Applied QET wire draw style", ) if line_color is not None: _set_string( wire, "QetAppliedWireLineColorRgb", ",".join(str(value) for value in line_color), "Applied QET wire RGB color", ) def _resolve_wire_style_from_database(wire_style_id, database_path="", project_uuid=""): style_id = str(wire_style_id or "").strip() db_path = str(database_path or "").strip() if not style_id or not db_path: return {} try: connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row except Exception: return {} try: if str(project_uuid or "").strip(): row = connection.execute( """ SELECT * FROM wire_properties WHERE id = ? AND (project_uuid = ? OR project_uuid = '' OR project_uuid IS NULL) ORDER BY CASE WHEN project_uuid = ? THEN 0 ELSE 1 END LIMIT 1 """, (style_id, project_uuid, project_uuid), ).fetchone() else: row = connection.execute( "SELECT * FROM wire_properties WHERE id = ? LIMIT 1", (style_id,), ).fetchone() if row is None: return {} payload = {key: row[key] for key in row.keys()} payload["id"] = str(payload.get("id", style_id)) return _clean_wire_style_payload(payload) except Exception: return {} finally: try: connection.close() except Exception: pass def resolve_wire_style(wire_style_id, options=None, project_uuid=""): style_id = str(wire_style_id or "").strip() if not style_id: return {} opts = options or {} cache = opts.get("__wire_style_cache") cache_key = ( style_id, str(project_uuid or "").strip(), str(opts.get("wire_style_database_path", "") or os.environ.get("QET_WIRE_PROPERTIES_DB", "") or "").strip(), id(opts.get("wire_style_lookup")) if callable(opts.get("wire_style_lookup")) else "", ) if isinstance(cache, dict) and cache_key in cache: return dict(cache.get(cache_key) or {}) lookup = opts.get("wire_style_lookup") if callable(lookup): try: payload = _clean_wire_style_payload(lookup(style_id, project_uuid)) except TypeError: payload = _clean_wire_style_payload(lookup(style_id)) except Exception: payload = {} if isinstance(cache, dict): cache[cache_key] = dict(payload) return payload styles = opts.get("wire_styles") if isinstance(styles, dict): style = styles.get(style_id) if style is None: try: style = styles.get(int(style_id)) except Exception: style = None if isinstance(style, dict): payload = dict(style) payload.setdefault("id", style_id) payload = _clean_wire_style_payload(payload) if isinstance(cache, dict): cache[cache_key] = dict(payload) return payload payload = _resolve_wire_style_from_database( style_id, database_path=opts.get("wire_style_database_path", "") or os.environ.get("QET_WIRE_PROPERTIES_DB", ""), project_uuid=project_uuid, ) if isinstance(cache, dict): cache[cache_key] = dict(payload) return payload def _style_wire(wire, collision_count=0, wire_style=None): line_width = _wire_style_line_width(wire_style) or 5.0 draw_style = _wire_style_draw_style(wire_style) line_color = (1.0, 0.1, 0.0) if collision_count else (_wire_style_color(wire_style) or (0.0, 0.35, 1.0)) try: wire.ViewObject.Visibility = True wire.ViewObject.LineWidth = line_width if hasattr(wire.ViewObject, "DrawStyle"): wire.ViewObject.DrawStyle = draw_style if hasattr(wire.ViewObject, "DisplayMode"): wire.ViewObject.DisplayMode = "Wireframe" wire.ViewObject.LineColor = line_color if hasattr(wire.ViewObject, "ShapeColor"): wire.ViewObject.ShapeColor = line_color except Exception: pass _set_wire_style_application_metadata(wire, wire_style, line_width, line_color, draw_style) def _wire_style_from_routed_wire(wire): text = str(getattr(wire, "QetWireStyleJson", "") or "").strip() if not text: return {} try: payload = json.loads(text) except Exception: return {} return payload if isinstance(payload, dict) else {} def _routed_wire_has_collision_warning(wire): codes = str(getattr(wire, "QetRouteIssueCodes", "") or "").lower() return "collision" in codes def _ensure_routed_wires_visible_and_styled(doc): if doc is None: return 0 shown = 0 for wire in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(wire, "RouteType", "") or "").strip() != "RoutedConnection": continue _style_wire( wire, collision_count=1 if _routed_wire_has_collision_warning(wire) else 0, wire_style=_wire_style_from_routed_wire(wire), ) shown += 1 routed_group = doc.getObject("QETWiring_04_Routed") if routed_group is not None: try: routed_group.ViewObject.Visibility = True except Exception: pass return shown def apply_phase1_acceptance_view(doc): """整理一阶段验收视图:隐藏辅助路径对象,显示并刷新最终导线。 这个入口不重新生成路径、不删除对象、不写数据库,主要用于打开旧工程后把 route carrier 调试网格收起,恢复“只看最终导线”的手动验收状态。 """ hidden_carriers = RoutingNetwork.set_route_carriers_visibility(doc, False) shown_wires = _ensure_routed_wires_visible_and_styled(doc) try: if doc is not None: doc.recompute() except Exception: pass try: if Gui is not None: Gui.updateGui() if hasattr(Gui, "SendMsgToActiveView"): Gui.SendMsgToActiveView("ViewFit") except Exception: pass return { "hidden_route_carriers": hidden_carriers, "shown_routed_wires": shown_wires, "routed_wire_visibility": _routed_wire_visibility_summary(doc), "route_carrier_visibility": _route_carrier_visibility_summary(doc, expected_hidden=True), "wire_style_application": _wire_style_application_summary(doc), } def _object_visibility(obj): try: return bool(obj.ViewObject.Visibility) except Exception: return None def _route_object_sample(obj): return { "name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "kind": str(getattr(obj, "QetRouteCarrierKind", "") or ""), } def _routed_wire_visibility_summary(doc, limit=8): routed = [] for wire in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(wire, "RouteType", "") or "").strip() == "RoutedConnection": routed.append(wire) hidden = [wire for wire in routed if not _object_visibility(wire)] unknown = [wire for wire in routed if _object_visibility(wire) is None] hidden = [wire for wire in routed if _object_visibility(wire) is False] group_visible = False try: routed_group = doc.getObject("QETWiring_04_Routed") if doc is not None else None group_visible = _object_visibility(routed_group) if routed_group is not None else None except Exception: group_visible = None return { "routed": len(routed), "visible": len([wire for wire in routed if _object_visibility(wire) is True]), "hidden": len(hidden), "unknown_visibility": len(unknown), "group_visible": group_visible, "hidden_samples": [_route_object_sample(wire) for wire in hidden[:limit]], "unknown_visibility_samples": [_route_object_sample(wire) for wire in unknown[:limit]], } def _wire_style_application_summary(doc, limit=8): routed = [ wire for wire in list(WiringObjects.iter_routed_wire_objects(doc)) if (getattr(wire, "RouteType", "") or "").strip() == "RoutedConnection" ] expected = [] applied = [] missing = [] styled_black = [] for wire in routed: style_id = str(getattr(wire, "QetWireStyleId", "") or "").strip() style_json = str(getattr(wire, "QetWireStyleJson", "") or "").strip() expects_style = bool(style_id or style_json) is_applied = bool(getattr(wire, "QetWireStyleApplied", False)) if expects_style: expected.append(wire) if is_applied: applied.append(wire) else: missing.append(wire) rgb_text = str(getattr(wire, "QetAppliedWireLineColorRgb", "") or "").strip() # 这里用于回答“黑色线是本来黑色还是未渲染”:有应用元数据且 RGB 为 0,0,0 才算样式黑色。 if is_applied and rgb_text in {"0,0,0", "0.0,0.0,0.0"}: styled_black.append(wire) return { "routed": len(routed), "expected": len(expected), "applied": len(applied), "missing_application": len(missing), "styled_black": len(styled_black), "missing_application_samples": [_route_object_sample(wire) for wire in missing[:limit]], } def _route_carrier_visibility_summary(doc, expected_hidden=True, limit=8): carriers = list(RoutingNetwork.collect_route_carriers(doc)) visible = [carrier for carrier in carriers if _object_visibility(carrier) is True] unknown = [carrier for carrier in carriers if _object_visibility(carrier) is None] kind_counts = {} visible_kind_counts = {} for carrier in carriers: kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "RouteCarrier") kind_counts[kind] = kind_counts.get(kind, 0) + 1 for carrier in visible: kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "RouteCarrier") visible_kind_counts[kind] = visible_kind_counts.get(kind, 0) + 1 return { "expected_hidden": bool(expected_hidden), "total": len(carriers), "visible_after_hide": len(visible), "unknown_visibility": len(unknown), "kind_counts": dict(sorted(kind_counts.items())), "visible_kind_counts": dict(sorted(visible_kind_counts.items())), "visible_samples": [_route_object_sample(carrier) for carrier in visible[:limit]], "unknown_visibility_samples": [_route_object_sample(carrier) for carrier in unknown[:limit]], } def _refresh_routing_view(doc): if Gui is None: return try: if getattr(App, "ActiveDocument", None) is doc: Gui.updateGui() except Exception: pass try: Gui.SendMsgToActiveView("ViewFit") except Exception: pass def _wire_display_label(start_terminal, end_terminal, wire_label="", wire_mark="", wire_uuid="", status=""): base = ( str(wire_label or "").strip() or str(wire_mark or "").strip() or str(wire_uuid or "").strip() or "QET Routed Connection" ) start_label = ( str(getattr(start_terminal, "Label", "") or "").strip() or str(getattr(start_terminal, "Name", "") or "").strip() ) end_label = ( str(getattr(end_terminal, "Label", "") or "").strip() or str(getattr(end_terminal, "Name", "") or "").strip() ) if start_label and end_label: base = "{0}: {1} -> {2}".format(base, start_label, end_label) status_text = str(status or "").strip() if status_text: base = "{0} ({1})".format(base, status_text) return base def route_eplan_connection_between_terminals( doc, start_terminal, end_terminal, route_index=0, options=None, wire_uuid="", wire_label="", net_uuid="", group_uuid="", wire_mark="", wire_mark_is_manual=False, wire_style_id="", endpoint_metadata=None, defer_recompute=False, ): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not TerminalObjects.is_terminal_object(start_terminal): raise AutoRoutingError("Start object is not a routable terminal.") if not TerminalObjects.is_terminal_object(end_terminal): raise AutoRoutingError("End object is not a routable terminal.") if start_terminal == end_terminal: raise AutoRoutingError("Start and end terminal must be different.") opts = _merged_options(options) effective_wire_style_id = str(wire_style_id or opts.get("wire_style_id", "") or "").strip() start_uuid = (getattr(start_terminal, "QetTerminalUuid", "") or "").strip() end_uuid = (getattr(end_terminal, "QetTerminalUuid", "") or "").strip() project_uuid = _project_uuid(doc, start_terminal, end_terminal) if not project_uuid: raise AutoRoutingError("Project UUID is required for routing connections.") wire_style = resolve_wire_style(effective_wire_style_id, options=opts, project_uuid=project_uuid) route_data_override = opts.get("__route_data_override") if isinstance(route_data_override, dict): route_data = dict(route_data_override) else: route_data = build_network_route( start_terminal, end_terminal, route_index=route_index, options=opts, doc=doc, ) if route_data is None: if _has_route_constraints(opts) or _has_route_constraints( _document_route_constraint_options(doc) ): raise AutoRoutingError( "没有满足路径约束的布线路径网络;请检查 required/forbidden 路径规则、线槽和 UserPath 是否连通。" ) raise AutoRoutingError( "没有可用的布线路径网络;请先生成布线布局空间和布线路径网络。" ) points = route_data.get("points", []) if len(points) < 2: raise AutoRoutingError("Routing connection produced fewer than two points.") obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) ignored_collision_segments = set() if opts.get("ignore_endpoint_collision_segments", True): ignored_collision_segments = _endpoint_collision_segment_indices(points, route_data=route_data) collisions = detect_collisions(points, obstacles, ignored_segment_indices=ignored_collision_segments) route_source_labels = _route_source_labels(route_data.get("route_track", {}), limit=4) if route_source_labels: collisions = [ dict(collision, route_source_labels=route_source_labels) for collision in collisions ] collisions = _collisions_with_endpoint_relations( collisions, endpoint_metadata=endpoint_metadata, ) collisions, auto_ignored_collisions = _filter_auto_ignored_collisions(collisions, opts) if auto_ignored_collisions: network_payload = route_data.setdefault("network", {}) if isinstance(network_payload, dict): network_payload["auto_ignored_unbound_structural_obstacle_collisions"] = len(auto_ignored_collisions) status = "CollisionWarning" if collisions else "Routed" existing_replacements = [] if opts.get("replace_existing", True): existing_replacements = _matching_existing_routing_connections( doc, start_uuid, end_uuid, wire_uuid=wire_uuid, ) wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid)) wire = None removed_existing = 0 try: wire = _create_wire_geometry(doc, wire_name, points) wire.Label = _wire_display_label( start_terminal, end_terminal, wire_label=wire_label, wire_mark=wire_mark, wire_uuid=wire_uuid, status=status, ) WiringObjects.set_routed_wire_semantics( wire, project_uuid, wire_uuid, wire_label or wire_mark or wire_uuid, start_uuid, end_uuid, (getattr(start_terminal, "QetInstanceId", "") or "").strip(), (getattr(end_terminal, "QetInstanceId", "") or "").strip(), route_type="RoutedConnection", route_status=status, route_mode="EplanRoute", net_uuid=net_uuid, group_uuid=group_uuid, wire_mark=wire_mark, wire_mark_is_manual=wire_mark_is_manual, ) _set_routing_connection_metadata( wire, route_data, collisions, wire_style_id=effective_wire_style_id, endpoint_metadata=endpoint_metadata, wire_style=wire_style, ) routed_group = WiringObjects.ensure_routed_group(doc, project_uuid) if wire not in getattr(routed_group, "Group", []): routed_group.addObject(wire) try: routed_group.ViewObject.Visibility = True except Exception: pass _style_wire(wire, collision_count=len(collisions), wire_style=wire_style) task = _find_task_by_wire_uuid(doc, wire_uuid) _set_task_status(task, status) except Exception: if wire is not None: _remove_routing_connection_objects(doc, [wire]) raise if existing_replacements: removed_existing = _remove_routing_connection_objects(doc, existing_replacements) if removed_existing != len(existing_replacements): if wire is not None: _remove_routing_connection_objects(doc, [wire]) raise AutoRoutingError("Failed to replace existing routed connection.") if not defer_recompute: try: doc.recompute() except Exception: pass return { "wire": wire, "wire_object_label": str(getattr(wire, "Label", "") or "").strip(), "route_status": status, "wire_style_status": _wire_style_status(effective_wire_style_id, wire_style), "wire_style": _clean_wire_style_payload(wire_style), "algorithm": route_data.get("algorithm", ""), "network": route_data.get("network", {}), "route_track": route_data.get("route_track", {}), "points": points, "lane": route_data.get("lane", {}), "length_mm": _route_length(points), "collision_count": len(collisions), "collisions": collisions, "auto_ignored_collisions": auto_ignored_collisions, "replaced_routed_connections": removed_existing, } def _wire_item_value(item, *names): if not isinstance(item, dict): return "" for name in names: value = item.get(name, "") if value is not None and str(value).strip(): return str(value).strip() return "" def _payload_device_index(payload): index = {"by_element": {}, "by_instance": {}} if not isinstance(payload, dict): return index for device in list(payload.get("devices", []) or []): if not isinstance(device, dict): continue element_uuid = str(device.get("element_uuid", "") or "").strip() instance_id = ( str(device.get("instance_id", "") or "").strip() or str(device.get("device_instance_id", "") or "").strip() ) if element_uuid and element_uuid not in index["by_element"]: index["by_element"][element_uuid] = device if instance_id and instance_id not in index["by_instance"]: index["by_instance"][instance_id] = device # v2 快照按 3D 设备实例组织,2D element_uuid 在端子数组里。 for terminal in list(device.get("terminals", []) or []): if not isinstance(terminal, dict): continue terminal_element_uuid = str(terminal.get("element_uuid", "") or "").strip() if terminal_element_uuid and terminal_element_uuid not in index["by_element"]: index["by_element"][terminal_element_uuid] = device return index def _payload_device_for_endpoint(device_index, item, side): if not isinstance(device_index, dict): return {} element_uuid = _wire_item_value(item, "{0}_element_uuid".format(side)) instance_id = _wire_item_value(item, "{0}_instance_id".format(side)) if instance_id: device = (device_index.get("by_instance", {}) or {}).get(instance_id) if isinstance(device, dict): return device if element_uuid: device = (device_index.get("by_element", {}) or {}).get(element_uuid) if isinstance(device, dict): return device return {} def _payload_device_value(device, *names): if not isinstance(device, dict): return "" for name in names: value = device.get(name, "") if (value is None or not str(value).strip()) and name == "instance_id": value = device.get("device_instance_id", "") if value is not None and str(value).strip(): return str(value).strip() return "" def _collision_relation(sample): if not isinstance(sample, dict): return "unknown_collision_relation" obstacle_element_uuid = str(sample.get("obstacle_element_uuid", "") or "").strip() if not obstacle_element_uuid: return "unbound_obstacle_collision" endpoint_element_uuids = { str(sample.get("start_element_uuid", "") or "").strip(), str(sample.get("end_element_uuid", "") or "").strip(), } endpoint_element_uuids.discard("") if obstacle_element_uuid in endpoint_element_uuids: return "endpoint_device_collision" return "third_party_device_collision" def _is_auto_ignorable_unbound_structural_collision(collision): if not isinstance(collision, dict): return False if str(collision.get("obstacle_element_uuid", "") or "").strip(): return False relation = str(collision.get("collision_relation", "") or "").strip() if relation and relation != "unbound_obstacle_collision": return False own_text = " ".join( [ str(collision.get("obstacle_label", "") or ""), str(collision.get("obstacle_name", "") or ""), ] ).lower() if any(keyword in own_text for keyword in _DEVICE_COLLISION_KEYWORDS): return False text_parts = [ collision.get("obstacle_label", ""), collision.get("obstacle_name", ""), ] text_parts.extend(list(collision.get("obstacle_parent_labels", []) or [])) text_parts.extend(list(collision.get("obstacle_parent_names", []) or [])) text = " ".join(str(part or "").lower() for part in text_parts) if not any(keyword in text for keyword in _STRUCTURAL_COLLISION_KEYWORDS): return False imported_context = " ".join( str(part or "").lower() for part in list(collision.get("obstacle_parent_labels", []) or []) + list(collision.get("obstacle_parent_names", []) or []) ) imported_markers = ( "qet exchange devices", "qetexchangedevices", "qetcabinet", "linkgroup", "compound", "nauo", ) return any(marker in imported_context for marker in imported_markers) def _filter_auto_ignored_collisions(collisions, options=None): if not bool((options or {}).get("auto_ignore_unbound_structural_obstacles", True)): return list(collisions or []), [] kept = [] ignored = [] for collision in list(collisions or []): if _is_auto_ignorable_unbound_structural_collision(collision): ignored.append(collision) else: kept.append(collision) return kept, ignored def _result_collision_count(result): if not isinstance(result, dict): return 0 try: return int(result.get("collision_count", 0) or 0) except Exception: return len(list(result.get("collisions", []) or [])) def _result_third_party_collision_count(result, item): if not isinstance(result, dict): return 0 count = 0 for collision in list(result.get("collisions", []) or []): if not isinstance(collision, dict): continue sample = dict(collision) sample.update( { "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "end_element_uuid": _wire_item_value(item, "end_element_uuid"), } ) if _collision_relation(sample) == "third_party_device_collision": count += 1 return count def _collisions_with_endpoint_relations(collisions, endpoint_metadata=None): metadata = _clean_endpoint_metadata(endpoint_metadata) if not metadata: return list(collisions or []) enriched = [] for collision in list(collisions or []): if not isinstance(collision, dict): enriched.append(collision) continue sample = dict(collision) sample.update( { "start_element_uuid": metadata.get("start_element_uuid", ""), "end_element_uuid": metadata.get("end_element_uuid", ""), } ) sample["collision_relation"] = _collision_relation(sample) enriched.append(sample) return enriched def _route_lane_key(start_uuid, end_uuid): endpoints = sorted( value for value in ( str(start_uuid or "").strip(), str(end_uuid or "").strip(), ) if value ) return tuple(endpoints) def _route_segment_key(segment): if not isinstance(segment, dict): return None carrier = segment.get("carrier") or {} carrier_name = str(carrier.get("name", "") or "").strip() from_key = tuple(segment.get("from_key", []) or []) to_key = tuple(segment.get("to_key", []) or []) if not from_key or not to_key: return None return ( carrier_name, tuple(sorted((from_key, to_key))), ) def _route_segment_keys(result): route_track = result.get("route_track", {}) if isinstance(result, dict) else {} return _route_track_segment_keys(route_track) def _route_track_segment_keys(route_track): segments = route_track.get("segments", []) if isinstance(route_track, dict) else [] keys = [] for segment in segments or []: # 虚拟桥接段不是真实线槽/路径共路,不能触发并行 lane 递增或复用惩罚。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue key = _route_segment_key(segment) if key is not None: keys.append(key) return keys def _incoming_wire_uuids(wires): wire_uuids = set() for item in wires or []: if not isinstance(item, dict): continue wire_uuid = _wire_item_value(item, "wire_id", "wire_uuid", "id") if wire_uuid: wire_uuids.add(wire_uuid) return wire_uuids def _existing_routed_segment_usage(doc, excluded_wire_uuids=None): excluded_wire_uuids = set(excluded_wire_uuids or []) usage = {} for wire in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(wire, "RouteType", "") or "").strip() != "RoutedConnection": continue wire_uuid = (getattr(wire, "QetWireUuid", "") or "").strip() if wire_uuid and wire_uuid in excluded_wire_uuids: continue try: route_track = json.loads((getattr(wire, "QetRouteTrackJson", "") or "").strip() or "{}") except Exception: route_track = {} for segment_key in _route_track_segment_keys(route_track): usage[segment_key] = usage.get(segment_key, 0) + 1 return usage def bind_wire_task_terminals_from_payload(doc, payload): """Bind local template terminals to QET terminal UUIDs without creating wires.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") binding_report = _bind_wire_task_terminals(doc, payload) terminals = index_terminals(doc) wires = payload.get("wires", []) or [] endpoints = _wire_endpoint_entries(payload) binding_report.update( { "total_wires": len(wires), "endpoint_terminals": len(endpoints), "available_terminals": len(terminals), "local_terminals": sum( 1 for terminal_uuid in terminals if TerminalObjects.is_local_terminal_uuid(terminal_uuid) ), } ) return binding_report def format_terminal_binding_report(report): message = "工程端子检查/绑定完成:更新 {0} 个,新建 {1} 个,跳过 {2} 个;当前端子 {3} 个,本地端子 {4} 个。".format( report.get("bound", 0), report.get("created", 0), report.get("skipped", 0), report.get("available_terminals", 0), report.get("local_terminals", 0), ) warnings = report.get("warnings", []) or [] if warnings: message += "\n首个问题:{0}".format(warnings[0]) if report.get("total_wires", 0) <= 0: message += "\n没有导线任务,无法按 QET terminal_uuid 绑定工程端子。" return message def _wire_style_database_status(database_path): path = str(database_path or "").strip() status = { "path": path, "status": "NotConfigured" if not path else "Available", "has_wire_properties_table": False, "wire_properties_count": None, } if not path: return status if not os.path.exists(path): status["status"] = "Missing" return status try: connection = sqlite3.connect(path) except Exception as exc: status["status"] = "Unreadable" status["error"] = str(exc) return status try: row = connection.execute( """ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'wire_properties' LIMIT 1 """ ).fetchone() if row is None: status["status"] = "NoWirePropertiesTable" else: status["has_wire_properties_table"] = True # 现场排障需要区分“库存在但没有任何导线样式”和“样式 ID 查不到”。 count_row = connection.execute("SELECT COUNT(*) FROM wire_properties").fetchone() status["wire_properties_count"] = int((count_row or [0])[0] or 0) if status["wire_properties_count"] <= 0: status["status"] = "EmptyWirePropertiesTable" except Exception as exc: status["status"] = "Unreadable" status["error"] = str(exc) finally: try: connection.close() except Exception: pass return status def _normalized_filesystem_path(path): text = str(path or "").strip() if not text: return "" try: return os.path.normcase(os.path.abspath(text)) except Exception: return os.path.normcase(text) def _wire_style_ids_from_wires(wires): style_ids = [] seen = set() for item in wires or []: if not isinstance(item, dict): continue style_id = _wire_item_value(item, "wire_style_id") if not style_id or style_id in seen: continue seen.add(style_id) style_ids.append(style_id) return style_ids def _wire_style_database_resolves_style_ids(database_path, style_ids, project_uuid=""): requested_ids = [str(item or "").strip() for item in (style_ids or []) if str(item or "").strip()] if not requested_ids: return True for style_id in requested_ids: if not _resolve_wire_style_from_database(style_id, database_path=database_path, project_uuid=project_uuid): return False return True def _discover_wire_style_database_path_from_json_path( json_path, project_uuid="", style_ids=None, exclude_paths=None, ): try: directory = os.path.dirname(os.path.abspath(str(json_path or ""))) except Exception: return "" if not directory: return "" search_dirs = [] for base in ( directory, os.path.dirname(directory), os.path.dirname(os.path.dirname(directory)), ): if not base or base in search_dirs: continue search_dirs.append(base) data_dir = os.path.join(base, "datafiles") if data_dir not in search_dirs: search_dirs.append(data_dir) parent = os.path.dirname(directory) for base in (parent, os.path.dirname(parent)): if not base: continue try: for name in sorted(os.listdir(base)): data_dir = os.path.join(base, name, "datafiles") if os.path.isdir(data_dir) and data_dir not in search_dirs: search_dirs.append(data_dir) except Exception: pass candidates = [] for search_dir in search_dirs: try: names = os.listdir(search_dir) except Exception: continue priority = {"project-local.db": 0, "project-local.sqlite": 1} for name in names: lower = name.lower() if lower in priority or lower.endswith((".sqlite", ".sqlite3", ".db")): candidates.append((priority.get(lower, 10), lower, os.path.join(search_dir, name))) excluded = { _normalized_filesystem_path(path) for path in (exclude_paths or []) if str(path or "").strip() } for _priority, _name, candidate in sorted(candidates): if _normalized_filesystem_path(candidate) in excluded: continue # 只自动采用确实含 wire_properties 的库,避免把其它业务库误当成导线样式库。 candidate_status = _wire_style_database_status(candidate) if candidate_status.get("status", "") != "Available": continue if not _wire_style_database_resolves_style_ids( candidate, style_ids=style_ids, project_uuid=project_uuid, ): continue return candidate return "" def _context_wire_style_database_path(project_uuid="", style_ids=None, exclude_paths=None): summary = getattr(App, "_qet_exchange_summary", None) if not isinstance(summary, dict): return "" style_path = str(summary.get("wire_style_database_path", "") or "").strip() excluded = { _normalized_filesystem_path(path) for path in (exclude_paths or []) if str(path or "").strip() } if ( style_path and _normalized_filesystem_path(style_path) not in excluded and _wire_style_database_status(style_path).get("status", "") == "Available" and _wire_style_database_resolves_style_ids( style_path, style_ids=style_ids, project_uuid=project_uuid, ) ): return style_path return _discover_wire_style_database_path_from_json_path( summary.get("json_path", ""), project_uuid=project_uuid, style_ids=style_ids, exclude_paths=exclude_paths, ) def _apply_wire_style_database_option(opts, payload, doc=None): if not isinstance(opts, dict): return opts project_uuid = "" style_ids = [] if isinstance(payload, dict): project_uuid = str(payload.get("project_uuid", "") or "").strip() style_ids = _wire_style_ids_from_wires(payload.get("wires", []) or []) if isinstance(payload, dict): for payload_key in ("wire_style_database_path", "project_database_path", "database_path"): payload_db_path = str(payload.get(payload_key, "") or "").strip() if payload_db_path and not str(opts.get("wire_style_database_path", "") or "").strip(): opts["wire_style_database_path"] = payload_db_path break configured_path = str(opts.get("wire_style_database_path", "") or "").strip() if configured_path: configured_status = _wire_style_database_status(configured_path) if configured_status.get("status", "") != "Available": fallback_path = _context_wire_style_database_path( project_uuid=project_uuid, style_ids=style_ids, exclude_paths=[configured_path], ) if not fallback_path: fallback_path = _document_wire_style_database_path( doc, project_uuid=project_uuid, style_ids=style_ids, exclude_paths=[configured_path], ) if fallback_path: opts["wire_style_database_fallback_from"] = configured_path opts["wire_style_database_path"] = fallback_path return opts context_db_path = _context_wire_style_database_path(project_uuid=project_uuid, style_ids=style_ids) if context_db_path and not str(opts.get("wire_style_database_path", "") or "").strip(): opts["wire_style_database_path"] = context_db_path if not str(opts.get("wire_style_database_path", "") or "").strip(): document_db_path = _document_wire_style_database_path( doc, project_uuid=project_uuid, style_ids=style_ids, ) if document_db_path: opts["wire_style_database_path"] = document_db_path return opts def _payload_wires_have_style_ids(payload): if not isinstance(payload, dict) or not isinstance(payload.get("wires"), list): return False for item in payload.get("wires") or []: if isinstance(item, dict) and _wire_item_value(item, "wire_style_id"): return True return False def _context_exchange_json_path(): summary = getattr(App, "_qet_exchange_summary", None) if isinstance(summary, dict): json_path = str(summary.get("json_path", "") or "").strip() if json_path: return json_path return os.environ.get("QET_2D_TO_3D_JSON", "").strip() def _document_exchange_json_path(doc): filename = str(getattr(doc, "FileName", "") or "").strip() if not filename: return "" try: directory = os.path.dirname(os.path.abspath(filename)) except Exception: return "" if not directory: return "" if os.path.basename(directory).lower() == ".qet_freecad": json_path = os.path.join(directory, "2d_to_3d.json") if os.path.exists(json_path): return json_path return filename def _document_wire_style_database_path(doc, project_uuid="", style_ids=None, exclude_paths=None): json_path = _document_exchange_json_path(doc) if not json_path: return "" return _discover_wire_style_database_path_from_json_path( json_path, project_uuid=project_uuid, style_ids=style_ids, exclude_paths=exclude_paths, ) def _load_context_exchange_payload(): json_path = _context_exchange_json_path() if not json_path: return {} try: with open(json_path, "r", encoding="utf-8") as handle: payload = json.load(handle) except Exception: return {} return payload if isinstance(payload, dict) else {} def _load_document_exchange_payload(doc): json_path = _document_exchange_json_path(doc) if not json_path or os.path.splitext(json_path)[1].lower() != ".json": return {} try: with open(json_path, "r", encoding="utf-8") as handle: payload = json.load(handle) except Exception: return {} return payload if isinstance(payload, dict) else {} def _context_payload_matches_project(payload, context_payload): payload_project_uuid = "" if isinstance(payload, dict): payload_project_uuid = str(payload.get("project_uuid", "") or "").strip() context_project_uuid = "" if isinstance(context_payload, dict): context_project_uuid = str(context_payload.get("project_uuid", "") or "").strip() if payload_project_uuid and context_project_uuid and payload_project_uuid != context_project_uuid: # 手动打开其它 FCStd 后,全局 QET 会话可能仍指向旧 JSON;项目不一致时禁止回补。 return False return True def _load_context_payload_with_wire_styles(payload): if _payload_wires_have_style_ids(payload): return payload context_payload = _load_context_exchange_payload() if not _payload_wires_have_style_ids(context_payload): return payload if not _context_payload_matches_project(payload, context_payload): return payload # 当前 FreeCAD 会话可能早于样式字段加载;只在磁盘 JSON 确实有样式时回补。 result = dict(context_payload) devices = list(result.get("devices", []) or []) if devices: result["__context_devices_json_path"] = _context_exchange_json_path() result["__context_device_count"] = len(devices) return result def _load_context_payload_with_devices(payload): if not isinstance(payload, dict): return payload if isinstance(payload.get("devices"), list) and payload.get("devices"): return payload json_path = _context_exchange_json_path() context_payload = _load_context_exchange_payload() devices = list(context_payload.get("devices", []) or []) if isinstance(context_payload, dict) else [] if not devices or not _context_payload_matches_project(payload, context_payload): return payload # 只补设备列表,保留当前 FCStd 任务导线,避免用磁盘 JSON 覆盖用户正在测试的任务对象。 merged = dict(payload) merged["devices"] = devices merged["__context_devices_json_path"] = json_path merged["__context_device_count"] = len(devices) return merged def _load_document_payload_with_devices(doc, payload): if not isinstance(payload, dict): return payload if isinstance(payload.get("devices"), list) and payload.get("devices"): return payload json_path = _document_exchange_json_path(doc) document_payload = _load_document_exchange_payload(doc) devices = list(document_payload.get("devices", []) or []) if isinstance(document_payload, dict) else [] if not devices or not _context_payload_matches_project(payload, document_payload): return payload # 只补设备列表,保留当前 FCStd 任务导线,避免用磁盘 JSON 覆盖用户正在测试的任务对象。 merged = dict(payload) merged["devices"] = devices merged["__context_devices_json_path"] = json_path merged["__context_device_count"] = len(devices) return merged def _preflight_wire_payload(doc, payload): doc_project_uuid = _project_uuid(doc) payload_project_uuid = "" if isinstance(payload, dict): payload_project_uuid = str(payload.get("project_uuid", "") or "").strip() if doc_project_uuid and payload_project_uuid and doc_project_uuid != payload_project_uuid: task_payload = _wire_tasks_payload(doc) return task_payload, list(task_payload.get("wires") or []), "tasks" payload = _load_context_payload_with_wire_styles(payload) payload = _load_context_payload_with_devices(payload) payload = _load_document_payload_with_devices(doc, payload) if isinstance(payload, dict) and isinstance(payload.get("wires"), list): return payload, list(payload.get("wires") or []), "payload" task_payload = _wire_tasks_payload(doc) task_payload = _load_context_payload_with_devices(task_payload) task_payload = _load_document_payload_with_devices(doc, task_payload) return task_payload, list(task_payload.get("wires") or []), "tasks" def _payload_matches_document_project(doc, payload): if not isinstance(payload, dict): return False doc_project_uuid = _project_uuid(doc) payload_project_uuid = str(payload.get("project_uuid", "") or "").strip() if doc_project_uuid and payload_project_uuid and doc_project_uuid != payload_project_uuid: return False return True def _append_preflight_issue(report, code, message, severity="warning", count=1, samples=None): if code in report.get("issue_codes", []): return issue = { "severity": severity, "code": code, "message": message, "count": int(count or 0), } if samples: issue["samples"] = list(samples) report["issues"].append(issue) report["issue_codes"].append(code) def _append_preflight_path_network_issues(report, diagnostic): if not isinstance(diagnostic, dict): return preflight_blocking_codes = { "route_carriers_outside_boundary", "terminals_outside_boundary", } for issue in _dict_items(diagnostic.get("issues", []) or []): code = str(issue.get("code", "") or "").strip() if not code or code not in preflight_blocking_codes: continue samples = [] if code == "route_carriers_outside_boundary": samples = diagnostic.get("route_carriers_outside_boundary", []) or [] elif code == "terminals_outside_boundary": samples = diagnostic.get("terminals_outside_boundary", []) or [] _append_preflight_issue( report, code, _routing_path_network_issue_label(code), severity=issue.get("severity", "warning"), count=issue.get("count", 1), samples=samples, ) def _wire_style_preflight_summary(wires, options, project_uuid, database_status): summary = { "with_style_id": 0, "without_style_id": 0, "resolved": 0, "missing": 0, "unique_style_ids": [], "missing_style_ids": [], "missing_samples": [], } style_ids = [] seen_ids = set() for item in wires: if not isinstance(item, dict): continue style_id = _wire_item_value(item, "wire_style_id") if not style_id: summary["without_style_id"] += 1 continue summary["with_style_id"] += 1 if style_id not in seen_ids: seen_ids.add(style_id) style_ids.append(style_id) summary["unique_style_ids"] = list(style_ids) if not style_ids: return summary can_lookup = ( callable((options or {}).get("wire_style_lookup")) or isinstance((options or {}).get("wire_styles"), dict) or bool(database_status.get("has_wire_properties_table", False)) ) if not can_lookup: summary["missing"] = summary["with_style_id"] summary["missing_style_ids"] = list(style_ids) for item in wires: if not isinstance(item, dict): continue style_id = _wire_item_value(item, "wire_style_id") if style_id and len(summary["missing_samples"]) < 8: summary["missing_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_style_id": style_id, } ) return summary resolved_ids = set() missing_ids = set() for style_id in style_ids: style = resolve_wire_style(style_id, options=options, project_uuid=project_uuid) if isinstance(style, dict) and style: resolved_ids.add(style_id) else: missing_ids.add(style_id) for item in wires: if not isinstance(item, dict): continue style_id = _wire_item_value(item, "wire_style_id") if not style_id: continue if style_id in resolved_ids: summary["resolved"] += 1 else: summary["missing"] += 1 if len(summary["missing_samples"]) < 8: summary["missing_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_style_id": style_id, } ) summary["missing_style_ids"] = sorted(missing_ids) return summary def _routing_boundary_summary(doc, options=None): boundaries = collect_routing_boundaries(doc, options=options) return { "count": len(boundaries), "samples": [ { "name": item.get("name", ""), "label": item.get("label", ""), "type_id": item.get("type_id", ""), } for item in boundaries[:8] ], } def _routing_runtime_capabilities(): route_constraint_collector = callable( getattr(RoutingNetwork, "collect_route_constraint_options", None) ) return { "ok": bool(route_constraint_collector), "route_constraint_collector": bool(route_constraint_collector), } def _preflight_routeability_summary(doc, wires, terminals, options=None): opts = options or {} try: sample_limit = max( int(opts.get("preflight_routeability_sample_limit", DEFAULT_OPTIONS["preflight_routeability_sample_limit"]) or 0), 0, ) except Exception: sample_limit = int(DEFAULT_OPTIONS["preflight_routeability_sample_limit"]) summary = { "checked": 0, "sample_limit": sample_limit, "eligible_wires": 0, "unchecked_wires": 0, "unrouteable_wires": 0, "unrouteable_samples": [], } if sample_limit <= 0: return summary # terminal_uuid 在当前 v2 快照里可能重复;预检抽样必须和正式布线一样, # 优先按设备实例、2D 元件和端子显示名消歧,避免把 B1 误判成同 UUID 的 A1。 terminal_candidates = list(opts.get("__terminal_candidates", []) or []) for item in wires or []: if not isinstance(item, dict): continue start_uuid = _wire_item_value(item, "start_terminal_uuid") end_uuid = _wire_item_value(item, "end_terminal_uuid") if terminal_candidates: start_terminal = _terminal_endpoint_match( terminal_candidates, item, "start", ).get("terminal") end_terminal = _terminal_endpoint_match( terminal_candidates, item, "end", ).get("terminal") else: start_terminal = terminals.get(start_uuid) end_terminal = terminals.get(end_uuid) if start_terminal is None or end_terminal is None: continue summary["eligible_wires"] += 1 if summary["checked"] >= sample_limit: continue summary["checked"] += 1 try: route_data = build_network_route( start_terminal, end_terminal, route_index=0, options=opts, doc=doc, ) except Exception as exc: route_data = None error_text = str(exc) else: error_text = "" if route_data is not None: continue summary["unrouteable_wires"] += 1 if len(summary["unrouteable_samples"]) < 8: summary["unrouteable_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_object_label": _wire_item_value(item, "wire_label", "wire_mark", "wire_id", "wire_uuid", "id"), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "error": error_text or "无法在当前路径网络中连通两端。", } ) summary["unchecked_wires"] = max( int(summary.get("eligible_wires", 0) or 0) - int(summary.get("checked", 0) or 0), 0, ) return summary def preflight_eplan_connections(doc, payload=None, options=None): """Check whether a real QET project is ready for first-version auto routing.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) opts.setdefault("__route_network_cache", {}) source_payload, wires, source = _preflight_wire_payload(doc, payload) _apply_wire_style_database_option(opts, source_payload, doc=doc) opts.setdefault("__wire_style_cache", {}) opts.setdefault("__connection_point_candidate_cache", {}) project_uuid = str(source_payload.get("project_uuid", "") or _project_uuid(doc)).strip() terminal_metadata_repair = _repair_duplicate_terminal_metadata_from_payload(doc, source_payload) terminals = index_terminals(doc) terminal_candidates = _collect_routable_terminals(doc) duplicate_terminal_summary = _terminal_uuid_duplicate_summary(terminal_candidates) payload_terminal_instance_duplicates = _payload_terminal_instance_duplicate_summary(source_payload) unreferenced_payload_terminals = _payload_unreferenced_terminal_summary(source_payload) local_terminal_count = sum( 1 for terminal in terminal_candidates if TerminalObjects.is_local_terminal_uuid( _terminal_endpoint_value(terminal, "QetTerminalUuid") ) ) report = { "ok": True, "source": source, "runtime_version": AUTO_ROUTING_RUNTIME_VERSION, "project_uuid": project_uuid, "total_wires": len(wires), "available_terminals": len(terminal_candidates), "available_terminal_objects": len(terminal_candidates), "unique_terminal_uuids": len(terminals), "local_terminals": local_terminal_count, "repaired_duplicate_terminal_metadata": terminal_metadata_repair.get("repaired", 0), "repaired_duplicate_terminal_metadata_groups": terminal_metadata_repair.get("groups", 0), "duplicate_terminal_uuid_count": duplicate_terminal_summary["duplicate_terminal_uuid_count"], "duplicate_terminal_uuid_samples": duplicate_terminal_summary["duplicate_terminal_uuid_samples"], "duplicate_payload_terminal_instance_id_count": payload_terminal_instance_duplicates[ "duplicate_payload_terminal_instance_id_count" ], "duplicate_payload_terminal_instance_id_samples": payload_terminal_instance_duplicates[ "duplicate_payload_terminal_instance_id_samples" ], "unreferenced_payload_terminal_count": unreferenced_payload_terminals[ "unreferenced_payload_terminal_count" ], "unreferenced_payload_terminal_samples": unreferenced_payload_terminals[ "unreferenced_payload_terminal_samples" ], "route_network_carriers": 0, "route_network_segments": 0, "route_network_nodes": 0, "batch_network_entry_candidate_limit": int( opts.get("batch_network_entry_candidate_limit", 0) or 0 ), "batch_network_entry_total_candidate_limit": int( opts.get("batch_network_entry_total_candidate_limit", 0) or 0 ), "missing_route_retry_candidate_limit": int( opts.get("missing_route_retry_candidate_limit", 0) or 0 ), "missing_route_retries": 0, "batch_avoid_obstacles": bool(opts.get("batch_avoid_obstacles", False)), "missing_endpoint_uuids": [], "missing_endpoint_samples": [], "routeability_checked": 0, "routeability_sample_limit": int( opts.get("preflight_routeability_sample_limit", DEFAULT_OPTIONS["preflight_routeability_sample_limit"]) or 0 ), "routeability_eligible_wires": 0, "routeability_unchecked_wires": 0, "unrouteable_wires": 0, "unrouteable_samples": [], "routing_sources": {}, "routing_boundaries": {}, "routing_obstacle_modes": {}, "routing_path_network_diagnostic": {}, "runtime_capabilities": _routing_runtime_capabilities(), "wire_style_database": {}, "wire_style_database_fallback_from": str(opts.get("wire_style_database_fallback_from", "") or "").strip(), "wire_style": {}, "issues": [], "issue_codes": [], } try: network = RoutingNetwork.build_route_graph( doc, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) report["route_network_carriers"] = int(network.get("carrier_count", 0) or 0) report["route_network_segments"] = int(network.get("segment_count", 0) or 0) report["route_network_nodes"] = len(network.get("nodes", {}) or {}) except Exception as exc: report["route_network_error"] = str(exc) _append_preflight_issue( report, "route_network_error", "布线路径网络构建失败。", severity="error", ) try: report["routing_sources"] = RoutingNetwork.routing_source_summary(doc) except Exception as exc: report["routing_sources"] = {"error": str(exc)} try: report["routing_boundaries"] = _routing_boundary_summary(doc, options=opts) except Exception as exc: report["routing_boundaries"] = {"count": 0, "error": str(exc), "samples": []} try: report["routing_obstacle_modes"] = routing_obstacle_mode_summary(doc) except Exception as exc: report["routing_obstacle_modes"] = {"error": str(exc)} try: path_diagnostic = RoutingNetwork.diagnose_routing_path_network( doc, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) report["routing_path_network_diagnostic"] = _compact_routing_path_network_diagnostic(path_diagnostic) _append_preflight_path_network_issues(report, report["routing_path_network_diagnostic"]) except Exception as exc: report["routing_path_network_diagnostic"] = { "ok": False, "issue_count": 1, "issue_codes": ["routing_path_network_diagnostic_error"], "issues": [ { "severity": "warning", "code": "routing_path_network_diagnostic_error", "count": 1, } ], "error": str(exc), } _append_preflight_issue( report, "routing_path_network_diagnostic_error", "路径网络诊断失败。", severity="warning", ) if _safe_int(report.get("duplicate_terminal_uuid_count", 0)) > 0: _append_preflight_issue( report, "duplicate_3d_terminal_uuids", "3D工程端子 UUID 重复;FreeCAD 会优先按设备实例、2D 设备 UUID 和端子显示名消歧,缺少这些上下文时可能无法稳定匹配。", severity="warning", count=_safe_int(report.get("duplicate_terminal_uuid_count", 0)), samples=report.get("duplicate_terminal_uuid_samples", []), ) if _safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)) > 0: _append_preflight_issue( report, "duplicate_payload_terminal_instance_ids", "2d_to_3d.json 中 terminal_instance_id 存在重复,FreeCAD 会临时生成稳定 3D 端子实例 ID 消歧。", severity="warning", count=_safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)), samples=report.get("duplicate_payload_terminal_instance_id_samples", []), ) if _safe_int(report.get("unreferenced_payload_terminal_count", 0)) > 0: _append_preflight_issue( report, "payload_terminals_without_wires", "2d_to_3d.json 中存在没有被任何 wires[] 端点引用的设备端子;这可能是未接线端子,也可能是 QET 少导出了导线任务。", severity="warning", count=_safe_int(report.get("unreferenced_payload_terminal_count", 0)), samples=report.get("unreferenced_payload_terminal_samples", []), ) missing_endpoint_uuids = set() for item in wires: if not isinstance(item, dict): continue start_uuid = _wire_item_value(item, "start_terminal_uuid") end_uuid = _wire_item_value(item, "end_terminal_uuid") start_match = _terminal_endpoint_match(terminal_candidates, item, "start") end_match = _terminal_endpoint_match(terminal_candidates, item, "end") start_found = start_match.get("terminal") is not None end_found = end_match.get("terminal") is not None if start_found and end_found: continue for terminal_uuid, found in ((start_uuid, start_found), (end_uuid, end_found)): if terminal_uuid and not found: missing_endpoint_uuids.add(terminal_uuid) if len(report["missing_endpoint_samples"]) < 8: sample = { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), # 预检阶段同样没有 3D 导线对象,这里记录最接近对象标题的任务显示名。 "wire_object_label": _wire_item_value(item, "wire_label", "wire_mark", "wire_id", "wire_uuid", "id"), "start_terminal_uuid": start_uuid, "start_found": start_found, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_instance_id": _wire_item_value(item, "start_instance_id"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_terminal_candidate_count": int(start_match.get("candidate_count", 0) or 0), "start_terminal_context_match_count": int(start_match.get("context_match_count", 0) or 0), "start_terminal_match_reason_code": str(start_match.get("reason_code", "") or ""), "start_terminal_match_ambiguous": bool(start_match.get("ambiguous", False)), "end_terminal_uuid": end_uuid, "end_found": end_found, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_instance_id": _wire_item_value(item, "end_instance_id"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_terminal_candidate_count": int(end_match.get("candidate_count", 0) or 0), "end_terminal_context_match_count": int(end_match.get("context_match_count", 0) or 0), "end_terminal_match_reason_code": str(end_match.get("reason_code", "") or ""), "end_terminal_match_ambiguous": bool(end_match.get("ambiguous", False)), } if not start_found: _add_missing_endpoint_terminal_context(sample, "start", terminal_candidates, doc=doc) if not end_found: _add_missing_endpoint_terminal_context(sample, "end", terminal_candidates, doc=doc) report["missing_endpoint_samples"].append(sample) report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) if report["route_network_segments"] > 0: routeability_options = dict(opts) routeability_options["__terminal_candidates"] = terminal_candidates routeability = _preflight_routeability_summary(doc, wires, terminals, options=routeability_options) report["routeability_checked"] = int(routeability.get("checked", 0) or 0) report["routeability_sample_limit"] = int(routeability.get("sample_limit", 0) or 0) report["routeability_eligible_wires"] = int(routeability.get("eligible_wires", 0) or 0) report["routeability_unchecked_wires"] = int(routeability.get("unchecked_wires", 0) or 0) report["unrouteable_wires"] = int(routeability.get("unrouteable_wires", 0) or 0) report["unrouteable_samples"] = list(routeability.get("unrouteable_samples", []) or []) database_status = _wire_style_database_status( opts.get("wire_style_database_path", "") or os.environ.get("QET_WIRE_PROPERTIES_DB", "") ) report["wire_style_database"] = database_status report["wire_style"] = _wire_style_preflight_summary( wires, opts, project_uuid, database_status, ) if report["total_wires"] <= 0: _append_preflight_issue(report, "no_wire_tasks", "没有导线任务。", severity="error") if report["available_terminals"] <= 0: _append_preflight_issue(report, "no_available_terminals", "没有可用工程端子。", severity="error") runtime_capabilities = report.get("runtime_capabilities", {}) if isinstance(runtime_capabilities, dict) and not runtime_capabilities.get( "route_constraint_collector", False ): _append_preflight_issue( report, "runtime_route_constraint_collector_missing", "运行模块缺少路径约束收集函数,请同步 FreeCADExchange 运行目录并重启 FreeCAD。", severity="error", ) if report["route_network_segments"] <= 0: _append_preflight_issue( report, "no_route_network", "没有可用布线路径网络。", severity="error", ) routing_sources = report.get("routing_sources", {}) if isinstance(routing_sources, dict): candidate_sources = int(routing_sources.get("candidate_sources", 0) or 0) route_carriers = int(routing_sources.get("route_carriers", 0) or 0) if candidate_sources <= 0 and route_carriers <= 0: _append_preflight_issue( report, "no_routing_sources", "未识别到线槽、布线面或用户路径源;请先装配/标记线槽、安装板或草图路径。", severity="error", ) elif route_carriers <= 0: _append_preflight_issue( report, "routing_sources_not_generated", "已识别到布线源,但还没有生成可用路径 carrier;请先生成布线路径网络。", severity="error", count=candidate_sources, samples=routing_sources.get("candidate_samples", []), ) if report["missing_endpoint_uuids"]: _append_preflight_issue( report, "missing_endpoints", "部分导线端点没有匹配到 3D 工程端子。", severity="error", count=len(report["missing_endpoint_uuids"]), samples=report["missing_endpoint_samples"], ) if report["unrouteable_wires"] > 0: _append_preflight_issue( report, "unrouteable_wires", "部分导线端点存在,但当前路径网络无法连通。", severity="error", count=report["unrouteable_wires"], samples=report["unrouteable_samples"], ) if report["wire_style"].get("with_style_id", 0) > 0: style_db_status = database_status.get("status", "") if style_db_status == "NotConfigured": _append_preflight_issue(report, "wire_style_database_not_configured", "导线样式库未配置。") elif style_db_status == "Missing": _append_preflight_issue(report, "wire_style_database_missing", "导线样式库文件不存在。") elif style_db_status == "NoWirePropertiesTable": _append_preflight_issue(report, "wire_style_database_no_table", "导线样式库缺少 wire_properties 表。") elif style_db_status == "EmptyWirePropertiesTable": _append_preflight_issue(report, "wire_style_database_empty", "导线样式库 wire_properties 表为空。") elif style_db_status == "Unreadable": _append_preflight_issue(report, "wire_style_database_unreadable", "导线样式库无法读取。") if report["wire_style"].get("missing", 0) > 0: _append_preflight_issue( report, "missing_wire_styles", "部分导线样式 ID 无法在 wire_properties 中解析。", count=report["wire_style"].get("missing", 0), samples=report["wire_style"].get("missing_samples", []), ) if report["wire_style"].get("without_style_id", 0) > 0: _append_preflight_issue( report, "wires_without_style_id", "部分导线未带 wire_style_id,将使用默认显示样式。", count=report["wire_style"].get("without_style_id", 0), ) report["ok"] = not bool(report["issues"]) return report def _wire_style_database_status_text(status): labels = { "Available": "可用", "NotConfigured": "未配置", "Missing": "文件不存在", "NoWirePropertiesTable": "缺少 wire_properties 表", "EmptyWirePropertiesTable": "wire_properties 为空", "Unreadable": "无法读取", } return labels.get(str(status or "").strip(), str(status or "").strip() or "未知") def format_eplan_routing_preflight_report(report): if not isinstance(report, dict): return "布线准备度:无法读取预检报告。" message = "布线准备度:{0}。".format("可执行" if report.get("ok") else "未通过") source_label = { "payload": "QET 会话交换数据", "tasks": "当前 FreeCAD 文档任务", }.get(str(report.get("source", "") or "").strip(), "") if source_label: message += "\n导线来源:{0}。".format(source_label) runtime_version = str(report.get("runtime_version", "") or "").strip() if runtime_version: message += "\n运行版本:{0}。".format(runtime_version) message += "\n导线任务:{0} 条;工程端子:{1} 个;本地端子:{2} 个。".format( report.get("total_wires", 0), report.get("available_terminals", 0), report.get("local_terminals", 0), ) message += "\n路径网络:{0} 段({1} 条 carrier / {2} 节点)。".format( report.get("route_network_segments", 0), report.get("route_network_carriers", 0), report.get("route_network_nodes", 0), ) routing_sources = report.get("routing_sources", {}) if isinstance(routing_sources, dict) and routing_sources: candidate_sources = int(routing_sources.get("candidate_sources", 0) or 0) if candidate_sources <= 0: message += "\n布线源:未识别到线槽/布线面/用户路径。" else: message += "\n布线源:线槽 {0} 个,布线面 {1} 个,穿线孔 {2} 个,用户路径 {3} 个;已生成 carrier {4} 条。".format( routing_sources.get("wire_duct_sources", 0), routing_sources.get("support_surface_sources", 0), routing_sources.get("wiring_cut_out_sources", 0), routing_sources.get("user_path_sources", 0), routing_sources.get("route_carriers", 0), ) routing_boundaries = report.get("routing_boundaries", {}) if isinstance(routing_boundaries, dict) and routing_boundaries: boundary_count = int(routing_boundaries.get("count", 0) or 0) if boundary_count <= 0: message += "\n柜内边界:未标记。" else: message += "\n柜内边界:{0} 个。".format(boundary_count) routing_obstacle_modes = report.get("routing_obstacle_modes", {}) if isinstance(routing_obstacle_modes, dict): pass_through = routing_obstacle_modes.get("PassThrough", {}) if isinstance(pass_through, dict): pass_through_count = int(pass_through.get("count", 0) or 0) if pass_through_count > 0: message += "\n忽略碰撞对象:{0} 个。".format(pass_through_count) path_diagnostic = report.get("routing_path_network_diagnostic", {}) if isinstance(path_diagnostic, dict) and int(path_diagnostic.get("issue_count", 0) or 0) > 0: issue_labels = [ _routing_path_network_issue_label(code) for code in list(path_diagnostic.get("issue_codes", []) or [])[:3] ] message += "\n路径网络检查提示:{0}。".format("、".join(issue_labels) if issue_labels else "存在问题") outside_sources = _dict_items(path_diagnostic.get("route_carriers_outside_boundary", []) or []) if outside_sources: sample = outside_sources[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} carrier_text = carrier.get("label") or carrier.get("name") or "未知路径对象" message += " 越界路径:{0} {1} 个越界点。".format( carrier_text, _safe_int(sample.get("outside_point_count", 0)), ) outside_terminals = _dict_items(path_diagnostic.get("terminals_outside_boundary", []) or []) if outside_terminals: sample = outside_terminals[0] message += " 越界端子:{0} {1} 个越界点。".format( _diagnostic_terminal_text(sample), _safe_int(sample.get("outside_point_count", 0)), ) runtime_capabilities = report.get("runtime_capabilities", {}) if isinstance(runtime_capabilities, dict) and not runtime_capabilities.get( "route_constraint_collector", True ): message += "\n运行模块能力:路径约束收集函数缺失,请同步运行目录并重启 FreeCAD。" database_status = report.get("wire_style_database", {}) if isinstance(database_status, dict): message += "\n导线样式库:{0}".format( _wire_style_database_status_text(database_status.get("status", "")) ) path = str(database_status.get("path", "") or "").strip() if path: message += ",{0}".format(path) fallback_from = str(report.get("wire_style_database_fallback_from", "") or "").strip() if fallback_from: message += "(从备用库恢复,原库:{0})".format(fallback_from) message += "。" wire_style = report.get("wire_style", {}) if isinstance(wire_style, dict): parts = [] for key, label in ( ("resolved", "已解析"), ("missing", "缺失样式"), ("without_style_id", "未设置样式"), ): try: value = int(wire_style.get(key, 0) or 0) except Exception: value = 0 if value > 0: parts.append("{0} {1} 条".format(label, value)) if parts: message += "\n导线样式:{0}。".format(",".join(parts)) duplicate_terminal_uuid_count = _safe_int(report.get("duplicate_terminal_uuid_count", 0)) if duplicate_terminal_uuid_count > 0: message += "\n3D工程端子 UUID 重复:{0} 组".format(duplicate_terminal_uuid_count) samples = [item for item in list(report.get("duplicate_terminal_uuid_samples", []) or []) if isinstance(item, dict)] if samples: sample = samples[0] message += ",示例 {0} 出现 {1} 次".format( sample.get("terminal_uuid", "未知端子"), _safe_int(sample.get("count", 0)), ) message += ";需要依赖设备实例、2D 设备 UUID 或端子显示名消歧。" unreferenced_count = _safe_int(report.get("unreferenced_payload_terminal_count", 0)) if unreferenced_count > 0: message += "\n未被 wires[] 引用的端子:{0} 个".format(unreferenced_count) samples = [item for item in list(report.get("unreferenced_payload_terminal_samples", []) or []) if isinstance(item, dict)] if samples: sample = samples[0] device_text = str(sample.get("device_label", "") or sample.get("device_instance_id", "") or "未知设备") terminal_text = str(sample.get("terminal_display", "") or sample.get("terminal_uuid", "") or "未知端子") message += ",示例 {0}/{1}".format(device_text, terminal_text) message += "。" issues = [item for item in list(report.get("issues", []) or []) if isinstance(item, dict)] if issues: message += "\n预检问题:{0}。".format( ";".join(str(item.get("message", "") or item.get("code", "")) for item in issues[:5]) ) missing_samples = list(report.get("missing_endpoint_samples", []) or []) if missing_samples: sample = missing_samples[0] wire_text = _wire_object_sample_text(sample) if wire_text and wire_text != "未知导线": message += "\n端点缺失示例:导线 {0},{1}。".format( wire_text, _endpoint_pair_text(sample), ) else: message += "\n端点缺失示例:{0}。".format(_endpoint_pair_text(sample)) detail_text = _missing_endpoint_detail_text(sample) if detail_text: message += "\n端点缺失明细:{0}。".format(detail_text) style_samples = [] if isinstance(wire_style, dict): style_samples = list(wire_style.get("missing_samples", []) or []) if style_samples: sample = style_samples[0] message += "\n样式缺失示例:导线 {0},样式 {1}。".format( _wire_sample_text(sample), sample.get("wire_style_id", ""), ) routeability_checked = int(report.get("routeability_checked", 0) or 0) routeability_unchecked = int(report.get("routeability_unchecked_wires", 0) or 0) if routeability_checked > 0 or routeability_unchecked > 0: message += "\n可达性抽样:已检查 {0} 条".format(routeability_checked) if routeability_unchecked > 0: message += ",未检查 {0} 条".format(routeability_unchecked) message += "。" unrouteable_samples = list(report.get("unrouteable_samples", []) or []) if unrouteable_samples: sample = unrouteable_samples[0] message += "\n导线不可达示例:导线 {0},{1};原因:{2}".format( _wire_sample_text(sample), _endpoint_pair_text(sample), sample.get("error", "当前路径网络无法连通。"), ) message += "。" return message def route_eplan_connections_from_payload(doc, payload, options=None, prepared_layout=None): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") payload = _load_context_payload_with_wire_styles(payload) payload = _load_context_payload_with_devices(payload) payload = _load_document_payload_with_devices(doc, payload) opts = _merged_options(options) _apply_wire_style_database_option(opts, payload, doc=doc) opts.setdefault("__wire_style_cache", {}) opts.setdefault("__connection_point_candidate_cache", {}) terminal_metadata_repair = _repair_duplicate_terminal_metadata_from_payload(doc, payload) terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) terminals = index_terminals(doc) terminal_candidates = _collect_routable_terminals(doc) duplicate_terminal_summary = _terminal_uuid_duplicate_summary(terminal_candidates) payload_terminal_instance_duplicates = _payload_terminal_instance_duplicate_summary(payload) local_terminal_count = sum( 1 for terminal in terminal_candidates if TerminalObjects.is_local_terminal_uuid( _terminal_endpoint_value(terminal, "QetTerminalUuid") ) ) wires = payload.get("wires", []) or [] payload_devices = _payload_device_index(payload) project_uuid_value = str(payload.get("project_uuid", "") or _project_uuid(doc)).strip() report = { "project_uuid": project_uuid_value, "runtime_version": AUTO_ROUTING_RUNTIME_VERSION, "total_wires": len(wires), "available_terminals": len(terminal_candidates), "available_terminal_objects": len(terminal_candidates), "unique_terminal_uuids": len(terminals), "local_terminals": local_terminal_count, "repaired_duplicate_terminal_metadata": terminal_metadata_repair.get("repaired", 0), "repaired_duplicate_terminal_metadata_groups": terminal_metadata_repair.get("groups", 0), "duplicate_terminal_uuid_count": duplicate_terminal_summary["duplicate_terminal_uuid_count"], "duplicate_terminal_uuid_samples": duplicate_terminal_summary["duplicate_terminal_uuid_samples"], "duplicate_payload_terminal_instance_id_count": payload_terminal_instance_duplicates[ "duplicate_payload_terminal_instance_id_count" ], "duplicate_payload_terminal_instance_id_samples": payload_terminal_instance_duplicates[ "duplicate_payload_terminal_instance_id_samples" ], "auto_bound_terminals": terminal_binding_report["bound"], "auto_created_terminals": terminal_binding_report["created"], "auto_terminal_binding_warnings": terminal_binding_report["warnings"], "routed": 0, "collision_warnings": 0, "replaced_routed_connections": 0, "total_length_mm": 0.0, "skipped_missing_terminal": 0, "skipped_missing_route_network": 0, "skipped_invalid": 0, "terminal_access_warning_distance": float( opts.get( "terminal_access_warning_distance", RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE, ) or 0.0 ), "batch_network_entry_candidate_limit": int( opts.get("batch_network_entry_candidate_limit", 0) or 0 ), "batch_network_entry_total_candidate_limit": int( opts.get("batch_network_entry_total_candidate_limit", 0) or 0 ), "missing_route_retry_candidate_limit": int( opts.get("missing_route_retry_candidate_limit", 0) or 0 ), "missing_route_retries": 0, "batch_avoid_obstacles": bool(opts.get("batch_avoid_obstacles", False)), "selective_collision_reroute": bool(opts.get("selective_collision_reroute", True)), "selective_collision_reroute_limit": int( opts.get("selective_collision_reroute_limit", 0) or 0 ), "selective_collision_reroute_allow_fallback": bool( opts.get("selective_collision_reroute_allow_fallback", False) ), "selective_collision_reroute_attempts": 0, "selective_collision_reroutes": 0, "selective_collision_reroute_no_improvement": 0, "selective_collision_reroute_rejected_fallback": 0, "selective_collision_reroute_errors": 0, "missing_endpoint_uuids": [], "missing_endpoint_samples": [], "missing_route_network_samples": [], "collision_samples": [], "errors": [], "error_samples": [], "route_status_counts": {}, "wire_style_status_counts": {}, "wire_style_database_path": str(opts.get("wire_style_database_path", "") or "").strip(), "wire_style_database_fallback_from": str(opts.get("wire_style_database_fallback_from", "") or "").strip(), "context_devices_loaded": bool(str(payload.get("__context_devices_json_path", "") or "").strip()), "context_device_count": _safe_int(payload.get("__context_device_count", 0)), "context_devices_json_path": str(payload.get("__context_devices_json_path", "") or "").strip(), "routing_sources": {}, "routes": [], } if isinstance(prepared_layout, dict): report["prepared_layout"] = prepared_layout route_network = {} route_network_reused = False cache = _route_network_cache(opts) cached_route_network = cache.get("route_network") if cache is not None else None if isinstance(cached_route_network, dict) and int(cached_route_network.get("segment_count", 0) or 0) > 0: route_network = cached_route_network route_network_reused = True else: try: route_network = RoutingNetwork.build_route_graph( doc, adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) if cache is not None and int(route_network.get("segment_count", 0) or 0) > 0: cache["route_network"] = route_network except Exception as exc: route_network = {} report["route_network_error"] = str(exc) report["route_network_reused"] = bool(route_network_reused) report["route_network_carriers"] = int(route_network.get("carrier_count", 0) or 0) report["route_network_segments"] = int(route_network.get("segment_count", 0) or 0) report["route_network_nodes"] = len(route_network.get("nodes", {}) or {}) report["route_network_carrier_kind_counts"] = _route_network_carrier_kind_counts(route_network) try: report["routing_sources"] = RoutingNetwork.routing_source_summary(doc) except Exception as exc: report["routing_sources"] = {"error": str(exc)} has_route_network = report["route_network_segments"] > 0 obstacle_candidate_cache = _obstacle_candidate_cache(doc, options=opts) report["batch_obstacle_candidates"] = len( obstacle_candidate_cache.get("candidates", []) or [] ) missing_endpoint_uuids = set() segment_usage_costs = _existing_routed_segment_usage( doc, excluded_wire_uuids=_incoming_wire_uuids(wires), ) lane_indexes_by_pair = {} # 已存在的 RoutedConnection 也要占用显示 lane;否则增量布线时新线会从 lane 0 开始贴到旧线上。 lane_indexes_by_segment = { segment_key: max(int(usage_count or 0), 0) for segment_key, usage_count in segment_usage_costs.items() if int(usage_count or 0) > 0 } def add_status(status): key = str(status or "").strip() or "Unknown" report["route_status_counts"][key] = report["route_status_counts"].get(key, 0) + 1 def add_wire_style_status(status): key = str(status or "").strip() if not key: return report["wire_style_status_counts"][key] = ( report["wire_style_status_counts"].get(key, 0) + 1 ) def set_item_task_status(item, status): wire_uuid = _wire_item_value(item, "wire_id", "wire_uuid", "id") if not wire_uuid: return _set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), status) def missing_route_network_sample(item, start_uuid, end_uuid, error_text=""): start_payload_device = _payload_device_for_endpoint(payload_devices, item, "start") end_payload_device = _payload_device_for_endpoint(payload_devices, item, "end") sample = { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), # 失败样例没有真实导线对象,这里保留任务侧最接近对象标题的显示名,方便手工复盘。 "wire_object_label": _wire_item_value(item, "wire_label", "wire_mark", "wire_id", "wire_uuid", "id"), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label", "start_display_tag") or _payload_device_value(start_payload_device, "display_tag", "label", "name"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label", "end_display_tag") or _payload_device_value(end_payload_device, "display_tag", "label", "name"), "endpoint_label": _wire_item_value(item, "endpoint_label"), } if error_text: sample["error"] = error_text return sample def add_missing_route_network_sample(item, start_uuid, end_uuid, error_text=""): if len(report["missing_route_network_samples"]) >= 8: return report["missing_route_network_samples"].append( missing_route_network_sample(item, start_uuid, end_uuid, error_text=error_text) ) def is_missing_route_network_error(error_text): text = str(error_text or "") return ( "没有可用的布线路径网络" in text or "没有满足路径约束的布线路径网络" in text or "No route path" in text ) def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata): route_options = dict(opts) route_options["avoid_obstacles"] = bool(opts.get("batch_avoid_obstacles", False)) if isinstance(item, dict) and "__avoid_obstacles_override" in item: route_options["avoid_obstacles"] = bool(item.get("__avoid_obstacles_override")) if isinstance(item, dict) and "__replace_existing_override" in item: route_options["replace_existing"] = bool(item.get("__replace_existing_override")) if isinstance(item, dict) and "__route_data_override" in item: route_options["__route_data_override"] = item.get("__route_data_override") if isinstance(route_network, dict) and route_network.get("segment_count", 0) > 0: route_options["__base_route_network"] = route_network route_options["__obstacle_candidate_cache"] = obstacle_candidate_cache batch_candidate_limit = int(opts.get("batch_network_entry_candidate_limit", 0) or 0) batch_total_candidate_limit = int(opts.get("batch_network_entry_total_candidate_limit", 0) or 0) override_candidate_limit = 0 if isinstance(item, dict): override_candidate_limit = int(item.get("__network_entry_candidate_limit_override", 0) or 0) if override_candidate_limit > 0: route_options["network_entry_candidate_limit"] = override_candidate_limit route_options["network_entry_candidate_total_limit"] = max( int(route_options.get("network_entry_candidate_total_limit", 0) or 0), override_candidate_limit, ) elif batch_candidate_limit > 0: current_candidate_limit = int( route_options.get("network_entry_candidate_limit", batch_candidate_limit) or batch_candidate_limit ) route_options["network_entry_candidate_limit"] = min( current_candidate_limit, batch_candidate_limit, ) if batch_total_candidate_limit > 0 and int(route_options.get("network_entry_candidate_total_limit", 0) or 0) <= 0: route_options["network_entry_candidate_total_limit"] = max( batch_total_candidate_limit, route_options["network_entry_candidate_limit"], ) if isinstance(item, dict) and "__segment_usage_costs" in item: route_options["segment_usage_costs"] = item.get("__segment_usage_costs", {}) if isinstance(item, dict): for key in ( "forbidden_route_carrier_names", "forbidden_route_carrier_labels", "forbidden_route_carrier_source_names", "forbidden_route_carrier_source_labels", "forbidden_route_carrier_kinds", "required_route_carrier_names", "required_route_carrier_labels", "required_route_carrier_source_names", "required_route_carrier_source_labels", "required_route_carrier_kinds", ): if key in item: route_options[key] = item.get(key) return route_eplan_connection_between_terminals( doc, start_terminal, end_terminal, route_index=route_lane_index, options=route_options, wire_uuid=_wire_item_value(item, "wire_id", "wire_uuid", "id"), wire_label=_wire_item_value(item, "wire_label", "wire_mark"), net_uuid=_wire_item_value(item, "net_uuid"), group_uuid=_wire_item_value(item, "group_uuid"), wire_mark=_wire_item_value(item, "wire_mark"), wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)), wire_style_id=_wire_item_value(item, "wire_style_id"), endpoint_metadata=endpoint_metadata, defer_recompute=True, ) for item in wires: if not isinstance(item, dict): report["skipped_invalid"] += 1 add_status("Invalid") continue start_uuid = _wire_item_value(item, "start_terminal_uuid") end_uuid = _wire_item_value(item, "end_terminal_uuid") start_match = _terminal_endpoint_match(terminal_candidates, item, "start") end_match = _terminal_endpoint_match(terminal_candidates, item, "end") start_terminal = start_match.get("terminal") end_terminal = end_match.get("terminal") if start_terminal is None or end_terminal is None: report["skipped_missing_terminal"] += 1 add_status("MissingTerminal") set_item_task_status(item, "MissingTerminal") for terminal_uuid, terminal in ((start_uuid, start_terminal), (end_uuid, end_terminal)): if terminal_uuid and terminal is None: missing_endpoint_uuids.add(terminal_uuid) # 这里只保留少量样例,避免面板状态被大量导线任务刷屏。 if len(report["missing_endpoint_samples"]) < 8: start_payload_device = _payload_device_for_endpoint(payload_devices, item, "start") end_payload_device = _payload_device_for_endpoint(payload_devices, item, "end") sample = { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), # 这里还没生成 3D 导线对象,保留最接近对象标题的任务显示名。 "wire_object_label": _wire_item_value(item, "wire_label", "wire_mark", "wire_id", "wire_uuid", "id"), "start_terminal_uuid": start_uuid, "start_found": start_terminal is not None, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_instance_id": _wire_item_value(item, "start_instance_id") or _payload_device_value(start_payload_device, "instance_id"), "start_device_label": _wire_item_value(item, "start_device_label", "start_display_tag") or _payload_device_value(start_payload_device, "display_tag", "label", "name"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_terminal_candidate_count": int(start_match.get("candidate_count", 0) or 0), "start_terminal_context_match_count": int(start_match.get("context_match_count", 0) or 0), "start_terminal_match_reason_code": str(start_match.get("reason_code", "") or ""), "start_terminal_match_ambiguous": bool(start_match.get("ambiguous", False)), "end_terminal_uuid": end_uuid, "end_found": end_terminal is not None, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_instance_id": _wire_item_value(item, "end_instance_id") or _payload_device_value(end_payload_device, "instance_id"), "end_device_label": _wire_item_value(item, "end_device_label", "end_display_tag") or _payload_device_value(end_payload_device, "display_tag", "label", "name"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_terminal_candidate_count": int(end_match.get("candidate_count", 0) or 0), "end_terminal_context_match_count": int(end_match.get("context_match_count", 0) or 0), "end_terminal_match_reason_code": str(end_match.get("reason_code", "") or ""), "end_terminal_match_ambiguous": bool(end_match.get("ambiguous", False)), } if start_terminal is None: _add_missing_endpoint_terminal_context(sample, "start", terminal_candidates, doc=doc) if end_terminal is None: _add_missing_endpoint_terminal_context(sample, "end", terminal_candidates, doc=doc) report["missing_endpoint_samples"].append(sample) continue if not has_route_network: report["skipped_missing_route_network"] += 1 add_status("MissingRouteNetwork") set_item_task_status(item, "MissingRouteNetwork") add_missing_route_network_sample(item, start_uuid, end_uuid) continue lane_key = _route_lane_key(start_uuid, end_uuid) route_lane_index = lane_indexes_by_pair.get(lane_key, 0) result = None route_segment_keys = [] try: start_payload_device = _payload_device_for_endpoint(payload_devices, item, "start") end_payload_device = _payload_device_for_endpoint(payload_devices, item, "end") endpoint_metadata = { "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label", "start_display_tag") or _payload_device_value(start_payload_device, "display_tag", "label", "name"), "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label", "end_display_tag") or _payload_device_value(end_payload_device, "display_tag", "label", "name"), "endpoint_label": _wire_item_value(item, "endpoint_label"), } result = create_route( route_lane_index, dict(item, __segment_usage_costs=segment_usage_costs), start_terminal, end_terminal, endpoint_metadata, ) route_segment_keys = _route_segment_keys(result) shared_lane_index = max( [lane_indexes_by_segment.get(key, 0) for key in route_segment_keys] or [0] ) final_lane_index = max(route_lane_index, shared_lane_index) if final_lane_index != route_lane_index: initial_wire = result.get("wire") if isinstance(result, dict) else None lane_route_data = _route_data_with_lane( result, start_terminal, end_terminal, final_lane_index, options=opts, doc=doc, ) if initial_wire is not None: _remove_routing_connection_objects(doc, [initial_wire]) try: result = create_route( final_lane_index, dict( item, __segment_usage_costs=segment_usage_costs, __route_data_override=lane_route_data, ), start_terminal, end_terminal, endpoint_metadata, ) except Exception: if initial_wire is not None: _remove_routing_connection_objects(doc, [initial_wire]) raise route_segment_keys = _route_segment_keys(result) selective_limit = int(opts.get("selective_collision_reroute_limit", 0) or 0) selective_attempts = int(report.get("selective_collision_reroute_attempts", 0) or 0) if ( bool(opts.get("selective_collision_reroute", True)) and not bool(opts.get("batch_avoid_obstacles", False)) and selective_attempts < selective_limit and _result_third_party_collision_count(result, item) > 0 ): report["selective_collision_reroute_attempts"] += 1 original_result = result original_wire = result.get("wire") if isinstance(result, dict) else None original_collision_count = _result_collision_count(result) try: retry_result = create_route( int(result.get("lane", {}).get("index", final_lane_index) or final_lane_index), dict( item, __segment_usage_costs=segment_usage_costs, __avoid_obstacles_override=True, __replace_existing_override=False, ), start_terminal, end_terminal, endpoint_metadata, ) retry_collision_count = _result_collision_count(retry_result) retry_quality = _route_quality_payload(retry_result.get("route_track", {})) retry_uses_fallback = ( retry_quality.get("quality_status") == "FallbackPathWarning" ) allow_fallback = bool( opts.get("selective_collision_reroute_allow_fallback", False) ) if retry_collision_count < original_collision_count and ( allow_fallback or not retry_uses_fallback ): if original_wire is not None: _remove_routing_connection_objects(doc, [original_wire]) result = retry_result route_segment_keys = _route_segment_keys(result) report["selective_collision_reroutes"] += 1 else: retry_wire = retry_result.get("wire") if isinstance(retry_result, dict) else None if retry_wire is not None: _remove_routing_connection_objects(doc, [retry_wire]) result = original_result if retry_uses_fallback and not allow_fallback: detour_path = _create_main_path_detour_user_path_from_retry( doc, retry_result, original_result, project_uuid=project_uuid_value, ) result["selective_collision_reroute_status"] = "RejectedFallback" result["selective_collision_reroute_rejected_fallback"] = True result["selective_collision_reroute_rejected_fallback_kinds"] = list( retry_quality.get("fallback_carrier_kinds", []) or [] ) result["selective_collision_reroute_rejected_fallback_labels"] = list( retry_quality.get("fallback_carrier_labels", []) or [] ) if detour_path is not None: result["auto_main_path_detour_user_path"] = getattr(detour_path, "Name", "") report["selective_collision_reroute_rejected_fallback"] += 1 else: result["selective_collision_reroute_status"] = "NoImprovement" report["selective_collision_reroute_no_improvement"] += 1 if original_wire is not None: _set_routing_connection_metadata( original_wire, result, result.get("collisions", []), wire_style_id=_wire_item_value(item, "wire_style_id"), endpoint_metadata=endpoint_metadata, wire_style=result.get("wire_style", {}), ) _set_task_status(_find_task_by_wire_uuid(doc, _wire_item_value(item, "wire_id", "wire_uuid", "id")), result["route_status"]) except Exception: report["selective_collision_reroute_errors"] += 1 result = original_result _set_task_status(_find_task_by_wire_uuid(doc, _wire_item_value(item, "wire_id", "wire_uuid", "id")), result["route_status"]) except Exception as exc: error_text = str(exc) if is_missing_route_network_error(error_text): retry_limit = int(opts.get("missing_route_retry_candidate_limit", 0) or 0) active_limit = int(opts.get("batch_network_entry_candidate_limit", 0) or 0) retry_succeeded = False if retry_limit > max(active_limit, 0): try: result = create_route( route_lane_index, dict( item, __segment_usage_costs=segment_usage_costs, __network_entry_candidate_limit_override=retry_limit, ), start_terminal, end_terminal, endpoint_metadata, ) route_segment_keys = _route_segment_keys(result) report["missing_route_retries"] += 1 retry_succeeded = True except Exception as retry_exc: error_text = str(retry_exc) if retry_succeeded: pass else: # 路径网络存在但两端无法连通时,按缺路径网络处理,避免被普通 Error 淹没。 report["skipped_missing_route_network"] += 1 add_status("MissingRouteNetwork") set_item_task_status(item, "MissingRouteNetwork") add_missing_route_network_sample(item, start_uuid, end_uuid, error_text=error_text) continue else: report["errors"].append(error_text) add_status("Error") set_item_task_status(item, "Error") if len(report["error_samples"]) < 8: report["error_samples"].append( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), # 这个样例对应的是任务记录,不一定已经生成 FreeCAD 导线对象。 "wire_object_label": _wire_item_value(item, "wire_label", "wire_mark", "wire_id", "wire_uuid", "id"), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": _wire_item_value(item, "start_device_label"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": _wire_item_value(item, "end_device_label"), "endpoint_label": _wire_item_value(item, "endpoint_label"), "error": error_text, } ) continue lane_indexes_by_pair[lane_key] = max( lane_indexes_by_pair.get(lane_key, 0), int(result.get("lane", {}).get("index", 0) or 0) + 1, ) for segment_key in route_segment_keys: lane_indexes_by_segment[segment_key] = max( lane_indexes_by_segment.get(segment_key, 0), int(result.get("lane", {}).get("index", 0) or 0) + 1, ) segment_usage_costs[segment_key] = segment_usage_costs.get(segment_key, 0) + 1 if result["route_status"] == "CollisionWarning": report["collision_warnings"] += 1 report["replaced_routed_connections"] += int( result.get("replaced_routed_connections", 0) or 0 ) add_status(result["route_status"]) add_wire_style_status(result.get("wire_style_status", "")) route_collision_samples = [] route_source_labels = _route_source_labels(result.get("route_track", {}), limit=4) for collision in list(result.get("collisions", []) or [])[:3]: sample = dict(collision) sample.update( { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_object_label": result.get("wire_object_label", ""), "start_terminal_uuid": start_uuid, "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "end_terminal_uuid": end_uuid, "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "route_source_labels": route_source_labels, } ) sample["collision_relation"] = _collision_relation(sample) route_collision_samples.append(sample) if len(report["collision_samples"]) < 8: report["collision_samples"].append(sample) report["routed"] += 1 route_length = float(result.get("length_mm", 0.0) or 0.0) report["total_length_mm"] += route_length start_endpoint_route_metadata = _terminal_route_endpoint_metadata(start_terminal) end_endpoint_route_metadata = _terminal_route_endpoint_metadata(end_terminal) route_record = { "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), "wire_object_label": result.get("wire_object_label", ""), "wire_style_id": _wire_item_value(item, "wire_style_id"), "wire_style_status": result.get("wire_style_status", ""), "start_terminal_uuid": start_uuid, "start_terminal_name": start_endpoint_route_metadata.get("terminal_name", ""), "start_terminal_label": start_endpoint_route_metadata.get("terminal_label", ""), "start_element_uuid": _wire_item_value(item, "start_element_uuid"), "start_terminal_display": _wire_item_value(item, "start_terminal_display"), "start_device_label": endpoint_metadata.get("start_device_label", ""), "start_parent_device_name": start_endpoint_route_metadata.get("parent_device_name", ""), "start_parent_device_label": start_endpoint_route_metadata.get("parent_device_label", ""), "start_parent_device_instance_id": start_endpoint_route_metadata.get( "parent_device_instance_id", "", ), "start_parent_device_element_uuid": start_endpoint_route_metadata.get( "parent_device_element_uuid", "", ), "end_terminal_uuid": end_uuid, "end_terminal_name": end_endpoint_route_metadata.get("terminal_name", ""), "end_terminal_label": end_endpoint_route_metadata.get("terminal_label", ""), "end_element_uuid": _wire_item_value(item, "end_element_uuid"), "end_terminal_display": _wire_item_value(item, "end_terminal_display"), "end_device_label": endpoint_metadata.get("end_device_label", ""), "end_parent_device_name": end_endpoint_route_metadata.get("parent_device_name", ""), "end_parent_device_label": end_endpoint_route_metadata.get("parent_device_label", ""), "end_parent_device_instance_id": end_endpoint_route_metadata.get( "parent_device_instance_id", "", ), "end_parent_device_element_uuid": end_endpoint_route_metadata.get( "parent_device_element_uuid", "", ), "endpoint_label": _wire_item_value(item, "endpoint_label"), "algorithm": result["algorithm"], "route_status": result["route_status"], "length_mm": route_length, "lane": result.get("lane", {}), "network": result.get("network", {}), "route_track": result.get("route_track", {}), "collision_count": result["collision_count"], "collisions": route_collision_samples, "collision_samples": route_collision_samples, } selective_status = str(result.get("selective_collision_reroute_status", "") or "").strip() if selective_status: route_record["selective_collision_reroute_status"] = selective_status route_record["selective_collision_reroute_rejected_fallback_kinds"] = list( result.get("selective_collision_reroute_rejected_fallback_kinds", []) or [] ) route_record["selective_collision_reroute_rejected_fallback_labels"] = list( result.get("selective_collision_reroute_rejected_fallback_labels", []) or [] ) if str(result.get("auto_main_path_detour_user_path", "") or "").strip(): route_record["auto_main_path_detour_user_path"] = str( result.get("auto_main_path_detour_user_path", "") or "" ).strip() route_record["issue_codes"] = _route_issue_codes(route_record, route_collision_samples) route_record["issue_labels"] = [ _routing_diagnostic_issue_label(code) for code in route_record["issue_codes"] ] if isinstance(result.get("wire_style"), dict) and result.get("wire_style"): route_record["wire_style"] = dict(result.get("wire_style") or {}) report["routes"].append(route_record) if report["routed"] > 0: try: doc.recompute() except Exception: pass report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) missing_terminal_summary = _batch_missing_terminal_summary(report, doc=doc) if _safe_int(missing_terminal_summary.get("skipped_missing_terminal", 0)) > 0: # 原始 report 也保留结构化缺端子分组,便于面板和调试脚本不用再解析中文文本。 report["missing_terminal_summary"] = missing_terminal_summary _raise_main_path_detour_capacities_from_report(doc, report) report["route_path_usage"] = _route_path_usage_summary(report) report["top_collision_obstacles"] = _top_collision_obstacles(report) _attach_routing_path_network_diagnostic_if_needed(doc, report, opts) report["issue_codes"] = _routing_connection_batch_issue_codes(report) _attach_main_path_detour_report_summary(doc, report) _write_routing_connection_batch_diagnostic(doc, report) return report def _missing_endpoint_label(sample, side): terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() element_uuid = str(sample.get("{0}_element_uuid".format(side), "") or "").strip() device_label = str(sample.get("{0}_device_label".format(side), "") or "").strip() terminal_display = str(sample.get("{0}_terminal_display".format(side), "") or "").strip() if device_label and terminal_display: label = "{0}/{1}".format(device_label, terminal_display) elif device_label: label = device_label elif element_uuid and terminal_display: label = "{0}/{1}".format(element_uuid, terminal_display) elif terminal_display: label = terminal_display elif element_uuid: label = element_uuid else: return terminal_uuid if terminal_uuid and terminal_uuid != label: return "{0} ({1})".format(label, terminal_uuid) return label def _missing_endpoint_side_summary(sample): missing_sides = [] if sample.get("start_found") is False: missing_sides.append("起点") if sample.get("end_found") is False: missing_sides.append("终点") if not missing_sides: return "" if len(missing_sides) == 2: return "(缺失:两端)" return "(缺失:{0})".format(missing_sides[0]) def _wire_sample_text(sample): return ( str(sample.get("wire", "") or "").strip() or str(sample.get("wire_label", "") or "").strip() or str(sample.get("wire_uuid", "") or "").strip() or "未知导线" ) def _wire_object_sample_text(sample): # 需要用户回到 FreeCAD 树目录定位对象时,优先显示对象 Label;普通统计仍用导线号保持简洁。 return str(sample.get("wire_object_label", "") or "").strip() or _wire_sample_text(sample) def _endpoint_pair_text(sample): endpoint_label = str(sample.get("endpoint_label", "") or "").strip() if endpoint_label: return endpoint_label return "{0} -> {1}".format( _missing_endpoint_label(sample, "start"), _missing_endpoint_label(sample, "end"), ) def _missing_endpoint_detail_text(sample): if not isinstance(sample, dict): return "" parts = [] for side, label in (("start", "起点"), ("end", "终点")): if sample.get("{0}_found".format(side)) is not False: continue element_uuid = str(sample.get("{0}_element_uuid".format(side), "") or "").strip() instance_id = str(sample.get("{0}_instance_id".format(side), "") or "").strip() device_label = str(sample.get("{0}_device_label".format(side), "") or "").strip() terminal_display = str(sample.get("{0}_terminal_display".format(side), "") or "").strip() terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() fields = [] if device_label: fields.append("device={0}".format(device_label)) if element_uuid: fields.append("element={0}".format(element_uuid)) if instance_id: fields.append("instance={0}".format(instance_id)) if terminal_display: fields.append("terminal={0}".format(terminal_display)) if terminal_uuid: fields.append("uuid={0}".format(terminal_uuid)) if "{0}_element_terminal_count".format(side) in sample: fields.append( "FreeCAD同设备端子={0}".format( _safe_int(sample.get("{0}_element_terminal_count".format(side), 0)) ) ) if "{0}_instance_terminal_count".format(side) in sample: fields.append( "FreeCAD同实例端子={0}".format( _safe_int(sample.get("{0}_instance_terminal_count".format(side), 0)) ) ) reason_label = str(sample.get("{0}_missing_endpoint_reason_label".format(side), "") or "").strip() if reason_label: fields.append("原因={0}".format(reason_label)) if fields: parts.append("{0} {1}".format(label, ", ".join(fields))) return ";".join(parts) def _missing_endpoint_reason_counts_from_samples(samples): counts = {} for sample in list(samples or []): if not isinstance(sample, dict): continue for side in ("start", "end"): if sample.get("{0}_found".format(side)) is not False: continue reason_code = str(sample.get("{0}_missing_endpoint_reason_code".format(side), "") or "").strip() if not reason_code: continue counts[reason_code] = counts.get(reason_code, 0) + 1 return counts def _missing_endpoint_reason_hint_text(reason_counts): if not isinstance(reason_counts, dict): return "" parts = [] if _safe_int(reason_counts.get("missing_device_binding_metadata", 0)) > 0: parts.append( "QET 导线端点缺少 element_uuid,FreeCAD 无法判断缺失端子属于哪个 2D 设备;" "第一版不要求 start/end_instance_id" ) if _safe_int(reason_counts.get("device_not_in_3d_scene", 0)) > 0: parts.append("部分导线引用的设备未在当前 FreeCAD 场景中找到,请先检查设备导入、装配和 2D/3D 绑定") if not parts: return "" return ";".join(parts) + "。" def _route_source_labels(route_track, limit=5): labels = [] seen = set() if not isinstance(route_track, dict): return labels for segment in route_track.get("segments", []) or []: # 自动桥接段是虚拟连通边,路径示例只展示真实经过的源对象。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue label = ( str(carrier.get("source_label", "") or "").strip() or str(carrier.get("source_name", "") or "").strip() or str(carrier.get("label", "") or "").strip() or str(carrier.get("name", "") or "").strip() ) source_path_index = str(carrier.get("source_path_index", "") or "").strip() if label and source_path_index: label = "{0}(路径{1})".format(label, source_path_index) if not label or label in seen: continue seen.add(label) labels.append(label) if len(labels) >= int(limit or 0): break return labels def _route_source_sample_text(report): for route in report.get("routes", []) or []: if not isinstance(route, dict): continue labels = _route_source_labels(route.get("route_track", {})) if not labels: continue return "路径示例:导线 {0} 经过 {1}。".format( _wire_sample_text(route), "、".join(labels), ) return "" def _route_warning_carrier_labels(route_track, warning_kinds, limit=4): labels = [] seen = set() if not isinstance(route_track, dict): return labels warning_kind_set = { str(kind or "").strip() for kind in (warning_kinds or []) if str(kind or "").strip() } for segment in route_track.get("segments", []) or []: if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue kind = str(carrier.get("kind", "") or "").strip() if kind not in warning_kind_set: continue label = ( str(carrier.get("source_label", "") or "").strip() or str(carrier.get("source_name", "") or "").strip() or str(carrier.get("label", "") or "").strip() or str(carrier.get("name", "") or "").strip() ) if not label or label in seen: continue seen.add(label) labels.append(label) if len(labels) >= int(limit or 0): break return labels def _fallback_route_source_label_counts(report, limit=6): counts = {} for route in report.get("routes", []) or []: if not isinstance(route, dict): continue route_track = route.get("route_track", {}) quality = _route_quality_payload(route_track) if quality.get("quality_status") != "FallbackPathWarning": continue labels = _route_warning_carrier_labels( route_track, quality.get("fallback_carrier_kinds", []), limit=8, ) if not labels: labels = ["未命名布线面/辅助路径"] for label in labels: counts[label] = counts.get(label, 0) + 1 return { key: counts[key] for key in sorted(counts, key=lambda item: (-counts[item], item))[: int(limit or 0)] } def _route_network_metric_max(report, key): maximum = 0 for route in report.get("routes", []) or []: if not isinstance(route, dict): continue if key == "bridged_segments": route_track = route.get("route_track", {}) if isinstance(route_track, dict) and key in route_track: try: maximum = max(maximum, int(route_track.get(key, 0) or 0)) except Exception: pass continue network = route.get("network", {}) if not isinstance(network, dict): continue try: maximum = max(maximum, int(network.get(key, 0) or 0)) except Exception: continue return maximum def _route_lane_summary(report): max_lane_index = 0 lane_spacing = 0.0 lane_max_offset = 0.0 for route in report.get("routes", []) or []: if not isinstance(route, dict): continue lane = route.get("lane", {}) if not isinstance(lane, dict): continue try: lane_index = int(lane.get("index", 0) or 0) except Exception: lane_index = 0 if lane_index <= max_lane_index: continue max_lane_index = lane_index try: lane_spacing = float(lane.get("spacing_mm", 0.0) or 0.0) except Exception: lane_spacing = 0.0 try: lane_max_offset = float(lane.get("max_offset_mm", 0.0) or 0.0) except Exception: lane_max_offset = 0.0 if max_lane_index <= 0: return {} return { "max_lane_index": max_lane_index, "spacing_mm": lane_spacing, "max_offset_mm": lane_max_offset, } def _route_track_min_capacity(route_track): if not isinstance(route_track, dict): return None capacities = [] for segment in route_track.get("segments", []) or []: # 自动桥接段是虚拟连通边,不代表真实线槽截面,不能参与容量最小值计算。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue try: capacity = int(float(carrier.get("capacity", 0) or 0)) except Exception: capacity = 0 if capacity > 0: capacities.append(capacity) if not capacities: return None return min(capacities) def _route_track_bottleneck_carriers(route_track, min_capacity=None, limit=8): if not isinstance(route_track, dict): return { "names": [], "kinds": [], "source_labels": [], } try: target_capacity = int(float(min_capacity)) if min_capacity is not None else _route_track_min_capacity(route_track) except Exception: target_capacity = _route_track_min_capacity(route_track) if target_capacity is None or target_capacity <= 0: return { "names": [], "kinds": [], "source_labels": [], } max_items = int(limit or 0) names = [] kinds = [] source_labels = [] seen_names = set() seen_kinds = set() seen_sources = set() for segment in route_track.get("segments", []) or []: # 只看真实路径段;虚拟桥接段不代表实际线槽/路径容量。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue try: capacity = int(float(carrier.get("capacity", 0) or 0)) except Exception: capacity = 0 if capacity != target_capacity: continue name = str(carrier.get("name", "") or "").strip() if name and name not in seen_names: seen_names.add(name) names.append(name) kind = str(carrier.get("kind", "") or "").strip() if kind and kind not in seen_kinds: seen_kinds.add(kind) kinds.append(kind) label = ( str(carrier.get("source_label", "") or "").strip() or str(carrier.get("source_name", "") or "").strip() or str(carrier.get("label", "") or "").strip() or name ) source_path_index = str(carrier.get("source_path_index", "") or "").strip() if label and source_path_index: label = "{0}(路径{1})".format(label, source_path_index) if label and label not in seen_sources: seen_sources.add(label) source_labels.append(label) if max_items > 0 and len(names) >= max_items and len(source_labels) >= max_items: break return { "names": names[:max_items] if max_items > 0 else names, "kinds": kinds[:max_items] if max_items > 0 else kinds, "source_labels": source_labels[:max_items] if max_items > 0 else source_labels, } def _route_capacity_pressure_summary(report): samples = _route_capacity_pressure_samples(report, limit=0) if not samples: return {} pressure = max( samples, key=lambda item: int(item.get("max_parallel_wires", 0) or 0), ) return { "count": len(samples), "max_parallel_wires": pressure.get("max_parallel_wires", 0), "min_capacity": pressure.get("min_capacity", 0), "sample": pressure, } def _route_capacity_pressure_samples(report, limit=8): samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue lane = route.get("lane", {}) if not isinstance(lane, dict): continue try: max_parallel_wires = int(lane.get("index", 0) or 0) + 1 except Exception: max_parallel_wires = 1 route_capacity = _route_track_min_capacity(route.get("route_track", {})) if route_capacity is None or max_parallel_wires <= route_capacity: continue if max_samples <= 0 or len(samples) < max_samples: route_track = route.get("route_track", {}) bottlenecks = _route_track_bottleneck_carriers( route_track, min_capacity=route_capacity, limit=4, ) samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "wire": _wire_sample_text(route), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "max_parallel_wires": max_parallel_wires, "min_capacity": route_capacity, "lane_index": int(lane.get("index", 0) or 0), "carrier_names": _route_track_carrier_names(route_track, limit=4), "route_source_labels": _route_source_labels(route_track, limit=4), "bottleneck_carrier_names": bottlenecks["names"], "bottleneck_carrier_kinds": bottlenecks["kinds"], "bottleneck_route_source_labels": bottlenecks["source_labels"], } ) return samples def _show_candidate_debug_warnings(report): if not isinstance(report, dict): return False return bool( report.get("show_candidate_debug_warnings") or report.get("show_route_debug_warnings") ) _ROUTE_QUALITY_WARNING_KIND_LABELS = { "RoutingRange": "布线面", "AuxiliaryPath": "辅助路径", } def _route_track_carrier_kinds(route_track): if not isinstance(route_track, dict): return {} counts = {} has_segment_list = isinstance(route_track.get("segments"), list) raw_segments = route_track.get("segments", []) segments = raw_segments or [] for segment in segments: # 虚拟桥接段只表示网络连通,不代表导线真实经过该类型 carrier。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue kind = str(carrier.get("kind", "") or "").strip() if kind: counts[kind] = counts.get(kind, 0) + 1 if counts: return counts if has_segment_list: return {} carrier_kinds = route_track.get("carrier_kinds", {}) if isinstance(carrier_kinds, dict) and carrier_kinds: return { str(key): value for key, value in carrier_kinds.items() if str(key).strip() } return {} def _route_track_carrier_names(route_track, limit=8): if not isinstance(route_track, dict): return [] names = [] seen = set() has_segment_list = isinstance(route_track.get("segments"), list) for segment in route_track.get("segments", []) or []: # 诊断样例只列真实经过的 carrier;虚拟桥接段不显示为源路径对象。 if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue name = str(carrier.get("name", "") or "").strip() if not name or name in seen: continue seen.add(name) names.append(name) if len(names) >= int(limit or 0): return names if has_segment_list: return names return list(route_track.get("carrier_names", []) or [])[: int(limit or 0)] _MAIN_ROUTE_USAGE_KINDS = { "WireDuct", "WireDuctOpenEnd", "UserPath", "WiringCutOut", } def _route_path_usage_summary(report): summary = { "main_path_routes": 0, "fallback_routes": 0, } for route in report.get("routes", []) or []: if not isinstance(route, dict): continue carrier_kinds = _route_track_carrier_kinds(route.get("route_track", {})) if any(carrier_kinds.get(kind, 0) for kind in _MAIN_ROUTE_USAGE_KINDS): summary["main_path_routes"] += 1 if any(carrier_kinds.get(kind, 0) for kind in _ROUTE_QUALITY_WARNING_KIND_LABELS): summary["fallback_routes"] += 1 return summary def _terminal_access_usage_summary(report): summary = { "routes": 0, "both_endpoints_consumed": 0, "one_endpoint_consumed": 0, "no_endpoint_consumed": 0, "start_consumed": 0, "end_consumed": 0, } for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue start_consumed = bool(network.get("start_terminal_access_consumed", False)) end_consumed = bool(network.get("end_terminal_access_consumed", False)) summary["routes"] += 1 if start_consumed: summary["start_consumed"] += 1 if end_consumed: summary["end_consumed"] += 1 if start_consumed and end_consumed: summary["both_endpoints_consumed"] += 1 elif start_consumed or end_consumed: summary["one_endpoint_consumed"] += 1 else: summary["no_endpoint_consumed"] += 1 return summary def _terminal_access_target_kind_counts(report): counts = {} for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue for key in ("start_terminal_access_target_kind", "end_terminal_access_target_kind"): kind = str(network.get(key, "") or "").strip() if not kind: continue counts[kind] = counts.get(kind, 0) + 1 preferred_order = { "WireDuct": 0, "WireDuctOpenEnd": 1, "UserPath": 2, "WiringCutOut": 3, "RoutingPath": 4, "RoutingRange": 10, "AuxiliaryPath": 11, } return { key: counts[key] for key in sorted(counts, key=lambda item: (preferred_order.get(item, 100), -counts[item], item)) } def _terminal_access_fallback_target_count(report): counts = _terminal_access_target_kind_counts(report) return sum(_safe_int(counts.get(kind, 0)) for kind in ("RoutingRange", "AuxiliaryPath")) def _terminal_access_fallback_target_samples(report, limit=8): samples = [] for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue for endpoint in ("start", "end"): kind = str(network.get("{0}_terminal_access_target_kind".format(endpoint), "") or "").strip() if kind not in {"RoutingRange", "AuxiliaryPath"}: continue sample = { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "endpoint": endpoint, "target_kind": kind, "target_name": str(network.get("{0}_terminal_access_target_name".format(endpoint), "") or ""), "target_label": str(network.get("{0}_terminal_access_target_label".format(endpoint), "") or ""), "target_distance": float( network.get("{0}_terminal_access_target_distance".format(endpoint), 0.0) or 0.0 ), } optional_fields = { "terminal_uuid": str(route.get("{0}_terminal_uuid".format(endpoint), "") or ""), "terminal_name": str(route.get("{0}_terminal_name".format(endpoint), "") or ""), "terminal_label": str( route.get("{0}_terminal_label".format(endpoint), "") or route.get("{0}_terminal_display".format(endpoint), "") or "" ), "parent_device_name": str( route.get("{0}_parent_device_name".format(endpoint), "") or route.get("{0}_device_name".format(endpoint), "") or "" ), "parent_device_label": str( route.get("{0}_parent_device_label".format(endpoint), "") or route.get("{0}_device_label".format(endpoint), "") or "" ), "parent_device_instance_id": str( route.get("{0}_parent_device_instance_id".format(endpoint), "") or route.get("{0}_instance_id".format(endpoint), "") or "" ), "parent_device_element_uuid": str( route.get("{0}_parent_device_element_uuid".format(endpoint), "") or route.get("{0}_element_uuid".format(endpoint), "") or "" ), } sample.update({key: value for key, value in optional_fields.items() if value}) samples.append(sample) if int(limit or 0) <= 0: return samples return samples[: int(limit or 0)] def _terminal_access_fallback_targets(report): if not isinstance(report, dict): return False if _route_network_main_path_carriers(report) <= 0: return False return _terminal_access_fallback_target_count(report) > 0 def _terminal_access_fallback_targets_text(report): if not _terminal_access_fallback_targets(report): return "" text = ( "端子接入退回布线面:当前有线槽/UserPath/过线孔主路径 {0} 条," "但仍有 {1} 个端子接入目标为 RoutingRange/辅助路径。" ).format( _route_network_main_path_carriers(report), _terminal_access_fallback_target_count(report), ) samples = _terminal_access_fallback_target_samples(report, limit=1) if samples: sample = samples[0] endpoint_text = "起点" if sample.get("endpoint") == "start" else "终点" wire_text = str(sample.get("wire_label", "") or sample.get("wire_uuid", "") or "未知导线") target_text = str(sample.get("target_label", "") or sample.get("target_kind", "") or "布线面") text += "示例 {0} {1}接入到 {2},距离 {3:.1f}mm。".format( wire_text, endpoint_text, target_text, float(sample.get("target_distance", 0.0) or 0.0), ) text += "请检查端子附近是否缺少 UserPath/线槽桥接,或主路径是否被孤立。" return text def _route_network_carrier_kind_counts(network): counts = {} if not isinstance(network, dict): return counts for carrier in list(network.get("carriers", []) or []): kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or "RoutingPath" counts[kind] = counts.get(kind, 0) + 1 return { key: counts[key] for key in sorted(counts) } def _report_route_network_carrier_kind_counts(report): if not isinstance(report, dict): return {} counts = report.get("route_network_carrier_kind_counts", {}) if not isinstance(counts, dict): return {} result = {} for key, value in counts.items(): text = str(key or "").strip() count = _safe_int(value) if text and count > 0: result[text] = count return result _ROUTE_NETWORK_KIND_SUMMARY_LABELS = { "WireDuct": "线槽路径", "WireDuctOpenEnd": "线槽开口", "UserPath": "用户路径", "WiringCutOut": "过线孔", "TerminalAccess": "端子接入", "RoutingRange": "布线面", "AuxiliaryPath": "辅助路径", } def _route_network_carrier_kind_summary_text(report): counts = _report_route_network_carrier_kind_counts(report) if not counts: return "" # 这里展示的是当前可用路径网络,不是本次新生成 carrier 的数量。 ordered_kinds = ( "WireDuct", "WireDuctOpenEnd", "UserPath", "WiringCutOut", "TerminalAccess", "RoutingRange", "AuxiliaryPath", ) parts = [] used = set() for kind in ordered_kinds: count = _safe_int(counts.get(kind, 0)) if count <= 0: continue used.add(kind) parts.append("{0} {1} 条".format(_ROUTE_NETWORK_KIND_SUMMARY_LABELS[kind], count)) for kind, value in sorted(counts.items()): if kind in used: continue count = _safe_int(value) if count > 0: parts.append("{0} {1} 条".format(kind, count)) return ",".join(parts) def _route_network_main_path_carriers(report): counts = _report_route_network_carrier_kind_counts(report) return sum(_safe_int(counts.get(kind, 0)) for kind in _MAIN_ROUTE_USAGE_KINDS) def _main_path_not_used(report): if not isinstance(report, dict): return False if _safe_int(report.get("routed", 0)) <= 0: return False usage = _route_path_usage_summary(report) return ( _safe_int(usage.get("main_path_routes", 0)) <= 0 and _safe_int(usage.get("fallback_routes", 0)) > 0 ) def _main_path_not_used_text(report): if not _main_path_not_used(report): return "" usage = _route_path_usage_summary(report) fallback_routes = _safe_int(usage.get("fallback_routes", 0)) main_path_carriers = _route_network_main_path_carriers(report) if main_path_carriers > 0: return ( "主路径未采用:当前有线槽/UserPath/过线孔路径 {0} 条,但本批次 {1} 条导线都走了布线面/辅助路径。" ).format(main_path_carriers, fallback_routes) return ( "未使用线槽或用户主路径:本批次 {0} 条导线都走了布线面/辅助路径;" "请先生成线槽、UserPath 或过线孔主路径。" ).format(fallback_routes) def _main_path_underused(report): if not isinstance(report, dict): return False if _safe_int(report.get("routed", 0)) <= 0: return False if _route_network_main_path_carriers(report) <= 0: return False usage = _route_path_usage_summary(report) main_path_routes = _safe_int(usage.get("main_path_routes", 0)) fallback_routes = _safe_int(usage.get("fallback_routes", 0)) if main_path_routes <= 0 or fallback_routes <= 0: return False return fallback_routes >= main_path_routes * 2 def _main_path_underused_text(report): if not _main_path_underused(report): return "" usage = _route_path_usage_summary(report) main_path_routes = _safe_int(usage.get("main_path_routes", 0)) fallback_routes = _safe_int(usage.get("fallback_routes", 0)) routed = max(1, _safe_int(report.get("routed", main_path_routes + fallback_routes))) main_path_carriers = _route_network_main_path_carriers(report) text = ( "主路径使用率过低:当前有线槽/UserPath/过线孔路径 {0} 条," "本批次 {1} 条导线中只有 {2} 条使用主路径,{3} 条仍走布线面/辅助路径。" ).format(main_path_carriers, routed, main_path_routes, fallback_routes) fallback_counts = _fallback_route_source_label_counts(report, limit=3) if fallback_counts: fallback_text = ",".join("{0} {1} 条".format(label, count) for label, count in fallback_counts.items()) text += " 主要兜底路径:{0}。".format(fallback_text) text += " 建议补 UserPath/线槽到端子区域的桥接,或标记柜内边界,避免整体退回柜板布线面。" return text def _has_routing_path_network_diagnostic(report): if not isinstance(report, dict): return False diagnostic = report.get("routing_path_network_diagnostic", {}) if not isinstance(diagnostic, dict) or not diagnostic: return False return ( "ok" in diagnostic or bool(diagnostic.get("issue_codes")) or bool(diagnostic.get("issues")) or bool(diagnostic.get("summary")) ) def _attach_routing_path_network_diagnostic_if_needed(doc, report, opts): if doc is None or not isinstance(report, dict): return # 缺路径网络时也要补诊断。否则用户只能看到 MissingRouteNetwork, # 看不到是没有线槽/UserPath,还是路径源没有生成 carrier。 should_attach = _main_path_not_used(report) or _safe_int( report.get("skipped_missing_route_network", 0) ) > 0 if not should_attach: return if _has_routing_path_network_diagnostic(report): return try: # 批量布线已经发现“有线但没走主路径”时,补充一次网络诊断,直接给出线槽/端子接入的根因。 report["routing_path_network_diagnostic"] = _compact_routing_path_network_diagnostic( RoutingNetwork.diagnose_routing_path_network( doc, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) ) except Exception as exc: report["routing_path_network_diagnostic"] = { "ok": False, "issue_count": 1, "issue_codes": ["routing_path_network_diagnostic_error"], "issues": [ { "severity": "warning", "code": "routing_path_network_diagnostic_error", "count": 1, } ], "summary": {}, "error": str(exc), } def _route_quality_warning_summary(report): warning_count = 0 sample = None for warning in _route_quality_warning_samples(report, limit=0): warning_labels = list(warning.get("carrier_labels", []) or []) if not warning_labels: continue warning_count += 1 if sample is None: sample = { "wire": warning.get("wire_label") or warning.get("wire_uuid") or "未知导线", "labels": warning_labels, "route_carrier_labels": list(warning.get("route_carrier_labels", []) or []), } if warning_count <= 0: return {} return { "count": warning_count, "sample": sample or {}, } def _route_quality_warning_samples(report, limit=8): samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue carrier_kinds = _route_track_carrier_kinds(route.get("route_track", {})) warning_kinds = [ kind for kind in _ROUTE_QUALITY_WARNING_KIND_LABELS if carrier_kinds.get(kind, 0) ] if not warning_kinds: continue if max_samples <= 0 or len(samples) < max_samples: route_track = route.get("route_track", {}) samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "carrier_kinds": warning_kinds, "carrier_labels": [ _ROUTE_QUALITY_WARNING_KIND_LABELS.get(kind, kind) for kind in warning_kinds ], "route_carrier_labels": _route_warning_carrier_labels( route_track, warning_kinds, ), } ) return samples def _long_network_entry_warning_samples(report, limit=8): try: warning_distance = float( report.get( "terminal_access_warning_distance", RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE, ) or 0.0 ) except Exception: warning_distance = float(RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE) if warning_distance <= 0.0: return [] samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue long_parts = [] warning_sides = [] for side, label, key in ( ("entry", "起点", "entry_distance"), ("exit", "终点", "exit_distance"), ): try: distance = float(network.get(key, 0.0) or 0.0) except Exception: distance = 0.0 if distance > warning_distance: warning_sides.append(side) long_parts.append("{0}接入 {1:.1f} mm".format(label, distance)) if not long_parts: continue if max_samples <= 0 or len(samples) < max_samples: samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "wire": _wire_sample_text(route), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "entry_distance": float(network.get("entry_distance", 0.0) or 0.0), "exit_distance": float(network.get("exit_distance", 0.0) or 0.0), "warning_sides": warning_sides, "warning_parts": long_parts, "warning_distance": float(warning_distance), "route_source_labels": _route_source_labels( route.get("route_track", {}), limit=4, ), } ) return samples def _long_network_entry_summary(report): samples = _long_network_entry_warning_samples(report, limit=1) if not samples: return {} return { "count": len(_long_network_entry_warning_samples(report, limit=0)), "sample": samples[0], "warning_distance": float(samples[0].get("warning_distance", 0.0) or 0.0), } def _route_candidate_obstacle_warning_samples(report, limit=8): samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue try: hits = int(network.get("route_candidate_obstacle_hits", 0) or 0) except Exception: hits = 0 if hits <= 0: continue if max_samples <= 0 or len(samples) < max_samples: samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "wire": _wire_sample_text(route), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "hits": hits, "route_source_labels": _route_source_labels( route.get("route_track", {}), limit=4, ), } ) return samples def _route_candidate_obstacle_warning_summary(report): samples = _route_candidate_obstacle_warning_samples(report, limit=1) if not samples: return {} return { "count": len(_route_candidate_obstacle_warning_samples(report, limit=0)), "sample": samples[0], } def _route_candidate_boundary_warning_samples(report, limit=8): samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue try: violations = int(network.get("route_candidate_boundary_violations", 0) or 0) except Exception: violations = 0 if violations <= 0: continue if max_samples <= 0 or len(samples) < max_samples: samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "wire": _wire_sample_text(route), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "violations": violations, "route_source_labels": _route_source_labels( route.get("route_track", {}), limit=4, ), } ) return samples def _route_candidate_boundary_warning_summary(report): samples = _route_candidate_boundary_warning_samples(report, limit=1) if not samples: return {} return { "count": len(_route_candidate_boundary_warning_samples(report, limit=0)), "sample": samples[0], } def _route_constraint_samples(report, limit=8): samples = [] max_samples = int(limit or 0) for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue constraints = network.get("route_constraints", {}) if not isinstance(constraints, dict) or not constraints: continue required = _route_constraint_group(constraints.get("required", {})) forbidden = _route_constraint_group(constraints.get("forbidden", {})) if not required and not forbidden: continue if max_samples <= 0 or len(samples) < max_samples: samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "wire": _wire_sample_text(route), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "required": required, "forbidden": forbidden, } ) return samples def _route_constraint_group(group): if not isinstance(group, dict): return {} result = {} for key in ("names", "labels", "source_names", "source_labels", "kinds"): values = [] seen = set() for item in list(group.get(key, []) or []): text = str(item or "").strip() if not text or text in seen: continue seen.add(text) values.append(text) if values: result[key] = values return result def _route_constraint_summary(report): samples = _route_constraint_samples(report, limit=1) if not samples: return {} return { "count": len(_route_constraint_samples(report, limit=0)), "sample": samples[0], } def _route_constraint_group_text(group): if not isinstance(group, dict): return "" parts = [] # 中文报告优先显示最容易和 FreeCAD 面板/对象标签对应的标签字段。 has_source_labels = bool(list(group.get("source_labels", []) or [])) for key, label in ( ("labels", ""), ("names", "名称 "), ("source_names", "源名称 "), ("source_labels", "源标签 "), ("kinds", "类型 "), ): if key == "source_names" and has_source_labels: continue values = list(group.get(key, []) or []) if values: parts.append("{0}{1}".format(label, "、".join(str(item) for item in values))) return ";".join(parts) _COLLISION_KIND_LABELS = { "HardIntersection": "硬碰撞", "ClearanceWarning": "安全间隙", } _COLLISION_RELATION_LABELS = { "third_party_device_collision": "第三方设备/布局", "endpoint_device_collision": "端点设备", "unbound_obstacle_collision": "未绑定障碍物", "unknown_collision_relation": "未知关系", } _STRUCTURAL_COLLISION_KEYWORDS = ( "cabinet", "door", "cover", "panel", "bracket", "support", "shell", "柜", "门", "盖", "板", "支架", "壳", ) _DEVICE_COLLISION_KEYWORDS = ( "device", "mccb", "breaker", "terminal", "relay", "设备", "断路器", "端子", "继电器", ) def _collision_kind_counts(report): counts = {} for sample in _collision_samples_for_report(report): kind = str(sample.get("collision_kind", "") or "").strip() or "HardIntersection" counts[kind] = counts.get(kind, 0) + 1 return counts def _collision_relation_counts(report): counts = {} for sample in _collision_samples_for_report(report): relation = str(sample.get("collision_relation", "") or "").strip() if not relation: continue counts[relation] = counts.get(relation, 0) + 1 return counts def _collision_samples_for_report(report): route_samples = [] for route in report.get("routes", []) or []: if not isinstance(route, dict): continue for sample in list(route.get("collision_samples", []) or []): if isinstance(sample, dict): route_samples.append(sample) if route_samples: return route_samples return [ sample for sample in list(report.get("collision_samples", []) or []) if isinstance(sample, dict) ] def _collision_kind_summary_text(counts): if not isinstance(counts, dict) or not counts: return "" parts = [] for key in ("HardIntersection", "ClearanceWarning"): value = int(counts.get(key, 0) or 0) if value > 0: parts.append("{0} {1} 处".format(_COLLISION_KIND_LABELS.get(key, key), value)) for key, value in sorted(counts.items()): if key in _COLLISION_KIND_LABELS: continue value = int(value or 0) if value > 0: parts.append("{0} {1} 处".format(key, value)) return ",".join(parts) def _collision_relation_summary_text(counts): if not isinstance(counts, dict) or not counts: return "" parts = [] for key in ( "third_party_device_collision", "endpoint_device_collision", "unbound_obstacle_collision", "unknown_collision_relation", ): value = int(counts.get(key, 0) or 0) if value > 0: parts.append("{0} {1} 处".format(_COLLISION_RELATION_LABELS.get(key, key), value)) for key, value in sorted(counts.items()): if key in _COLLISION_RELATION_LABELS: continue value = int(value or 0) if value > 0: parts.append("{0} {1} 处".format(key, value)) return ",".join(parts) def _collision_reroute_recommendation(report): relation_counts = _collision_relation_counts(report) third_party_count = _safe_int(relation_counts.get("third_party_device_collision", 0)) endpoint_count = _safe_int(relation_counts.get("endpoint_device_collision", 0)) if third_party_count <= 0 and endpoint_count <= 0: return {} if third_party_count > 0: return { "strategy": "selective_local_reroute_or_user_path", "global_avoid_obstacles_recommended": False, "reason": ( "第三方设备/布局碰撞 {0} 处,优先对碰撞导线做局部二次避障;" "如果局部绕行仍失败,再补用户路径或调整装配。全量避障会显著放大真实工程计算量。" ).format(third_party_count), } return { "strategy": "review_terminal_local_access", "global_avoid_obstacles_recommended": False, "reason": ( "端点设备碰撞 {0} 处,优先检查端子局部出线路径、端子朝向和设备模型接线点。" ).format(endpoint_count), } def _collision_reroute_recommendation_text(report): recommendation = _collision_reroute_recommendation(report) reason = str(recommendation.get("reason", "") or "").strip() if not reason: return "" strategy = str(recommendation.get("strategy", "") or "").strip() if strategy == "selective_local_reroute_or_user_path": return "优先对第三方设备/布局碰撞做局部二次避障;局部绕行失败时再补用户路径或调整装配,避免全量避障拖慢真实工程。" if strategy == "review_terminal_local_access": return "优先检查端子局部出线路径、端子朝向和设备模型接线点。" return reason def _selective_collision_reroute_summary_text(report): attempts = _safe_int(report.get("selective_collision_reroute_attempts", 0)) accepted = _safe_int(report.get("selective_collision_reroutes", 0)) rejected_fallback = _safe_int(report.get("selective_collision_reroute_rejected_fallback", 0)) no_improvement = _safe_int(report.get("selective_collision_reroute_no_improvement", 0)) errors = _safe_int(report.get("selective_collision_reroute_errors", 0)) if attempts <= 0: return "" parts = [ "尝试 {0} 条".format(attempts), "接受 {0} 条".format(accepted), ] if rejected_fallback > 0: parts.append("拒绝辅助路径 {0} 条".format(rejected_fallback)) if no_improvement > 0: parts.append("无改善 {0} 条".format(no_improvement)) if errors > 0: parts.append("失败 {0} 条".format(errors)) text = "局部避障:{0}".format(",".join(parts)) if rejected_fallback > 0: text += ";拒绝的绕行会退回布线面/辅助路径,请补主路径/UserPath 或调整装配" return text def _top_collision_obstacle_summary_text(report, limit=3): obstacles = _top_collision_obstacles(report, limit=limit) if not obstacles: return "" return _top_collision_obstacle_items_text(obstacles) def _main_path_detour_bridge_pair_text(detour_summary, limit=3): if not isinstance(detour_summary, dict): return "" pair_counts = detour_summary.get("bridge_pair_counts", {}) if not isinstance(pair_counts, dict) or not pair_counts: return "" items = [] for pair, count in sorted(pair_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0])))[: int(limit or 3)]: pair_text = str(pair or "").strip() count_value = _safe_int(count) if pair_text and count_value > 0: items.append("{0} {1} 条".format(pair_text, count_value)) return "、".join(items) def _top_collision_resolution_summary_text(report, limit=3): obstacles = _top_collision_obstacles(report, limit=limit) parts = [] for item in obstacles: label = str(item.get("label", "") or "").strip() hint = str(item.get("resolution_hint_label", "") or "").strip() if label and hint: parts.append("{0}:{1}".format(label, hint)) return ";".join(parts) def _collision_resolution_summary(report, limit=8): obstacles = _top_collision_obstacles(report, limit=limit) counts = {} samples = {} for item in obstacles: code = str(item.get("resolution_hint_code", "") or "").strip() if not code: continue counts[code] = counts.get(code, 0) + 1 samples.setdefault(code, []) if len(samples[code]) < 5: samples[code].append( { "label": item.get("label", ""), "name": item.get("name", ""), "count": int(item.get("count", 0) or 0), "parent_labels": list(item.get("parent_labels", []) or []), "parent_names": list(item.get("parent_names", []) or []), "resolution_hint_label": item.get("resolution_hint_label", ""), } ) if not counts: return {} structural_count = int(counts.get("review_pass_through_structural_obstacle", 0) or 0) device_count = int(counts.get("review_device_or_layout_collision", 0) or 0) action_parts = [] if structural_count > 0: action_parts.append( "先处理 {0} 个疑似结构件碰撞候选:确认后可标记 PassThrough".format(structural_count) ) if device_count > 0: action_parts.append( "另有 {0} 个疑似设备/装配碰撞需要补路径或调整装配".format(device_count) ) recommended_action = ";".join(action_parts) if recommended_action: recommended_action += "。" return { "counts": counts, "samples": samples, "recommended_action": recommended_action, } def _collision_resolution_counts(report): summary = _collision_resolution_summary(report, limit=999999) counts = summary.get("counts", {}) if isinstance(summary, dict) else {} return counts if isinstance(counts, dict) else {} def _top_collision_obstacle_items_text(obstacles): parts = [] for item in obstacles: label = str(item.get("label", "") or "").strip() parent_labels = [ str(parent or "").strip() for parent in list(item.get("parent_labels", []) or []) if str(parent or "").strip() ] display = label if parent_labels: display = "{0}({1})".format(label, parent_labels[0]) parts.append("{0} {1} 处".format(display, item.get("count", 0))) return ",".join(parts) def _collision_obstacle_resolution_hint(item): if not isinstance(item, dict): return { "code": "review_device_or_layout_collision", "label": "疑似设备/安装区域碰撞,优先补柜内路径或调整装配", } text_parts = [ item.get("label", ""), item.get("name", ""), ] own_text = " ".join(str(part or "").lower() for part in text_parts) if any(keyword in own_text for keyword in _DEVICE_COLLISION_KEYWORDS): return { "code": "review_device_or_layout_collision", "label": "疑似设备/安装区域碰撞,优先补柜内路径或调整装配", } text_parts.extend(list(item.get("parent_labels", []) or [])) text_parts.extend(list(item.get("parent_names", []) or [])) text = " ".join(str(part or "").lower() for part in text_parts) if any(keyword in text for keyword in _STRUCTURAL_COLLISION_KEYWORDS): return { "code": "review_pass_through_structural_obstacle", "label": "疑似柜体/门板/支架结构,确认可穿越后标记忽略碰撞", } return { "code": "review_device_or_layout_collision", "label": "疑似设备/安装区域碰撞,优先补柜内路径或调整装配", } def _top_collision_obstacles(report, limit=3): groups = {} for sample in _collision_samples_for_report(report): label = ( str(sample.get("obstacle_label", "") or "").strip() or str(sample.get("obstacle_name", "") or "").strip() ) if not label: continue name = str(sample.get("obstacle_name", "") or "").strip() key = (label, name) group = groups.setdefault( key, { "label": label, "name": name, "count": 0, "collision_kind_counts": {}, "collision_relation_counts": {}, "parent_labels": [], "parent_names": [], }, ) group["count"] += 1 element_uuid = str(sample.get("obstacle_element_uuid", "") or "").strip() if element_uuid and not group.get("element_uuid"): group["element_uuid"] = element_uuid instance_id = str(sample.get("obstacle_instance_id", "") or "").strip() if instance_id and not group.get("instance_id"): group["instance_id"] = instance_id kind = str(sample.get("collision_kind", "") or "").strip() or "HardIntersection" group["collision_kind_counts"][kind] = group["collision_kind_counts"].get(kind, 0) + 1 relation = str(sample.get("collision_relation", "") or "").strip() if relation: group["collision_relation_counts"][relation] = ( group["collision_relation_counts"].get(relation, 0) + 1 ) for parent_label in list(sample.get("obstacle_parent_labels", []) or []): parent_label = str(parent_label or "").strip() if parent_label and parent_label not in group["parent_labels"]: group["parent_labels"].append(parent_label) for parent_name in list(sample.get("obstacle_parent_names", []) or []): parent_name = str(parent_name or "").strip() if parent_name and parent_name not in group["parent_names"]: group["parent_names"].append(parent_name) if not groups: return [] items = sorted( groups.values(), key=lambda item: (-int(item.get("count", 0) or 0), item.get("label", ""), item.get("name", "")), )[: int(limit or 3)] for item in items: if not item.get("collision_relation_counts"): item.pop("collision_relation_counts", None) hint = _collision_obstacle_resolution_hint(item) item["resolution_hint_code"] = hint["code"] item["resolution_hint_label"] = hint["label"] return items def _wire_style_status_counts(report): counts = {} if isinstance(report.get("wire_style_status_counts"), dict): for key, value in (report.get("wire_style_status_counts") or {}).items(): status = str(key or "").strip() if not status: continue try: amount = int(value or 0) except Exception: amount = 0 if amount > 0: counts[status] = counts.get(status, 0) + amount if counts: return counts for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue status = str(route.get("wire_style_status", "") or "").strip() if status: counts[status] = counts.get(status, 0) + 1 return counts def _wire_style_status_summary_text(counts): if not isinstance(counts, dict) or not counts: return "" labels = { "Missing": "缺失", "Resolved": "已解析", } parts = [] # 缺失样式最影响手动测试判断,所以中文报告里优先显示。 for key in ("Missing", "Resolved"): value = int(counts.get(key, 0) or 0) if value > 0: parts.append("{0} {1} 条".format(labels.get(key, key), value)) for key, value in sorted(counts.items()): if key in labels: continue value = int(value or 0) if value > 0: parts.append("{0} {1} 条".format(key, value)) return ",".join(parts) def _wire_style_status_samples(report, status="Missing", limit=8): wanted = str(status or "").strip() samples = [] max_samples = int(limit or 0) for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue if str(route.get("wire_style_status", "") or "").strip() != wanted: continue if max_samples > 0 and len(samples) >= max_samples: break samples.append( { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire": _wire_sample_text(route), "wire_style_id": route.get("wire_style_id", ""), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "start_terminal_display": route.get("start_terminal_display", ""), "end_terminal_display": route.get("end_terminal_display", ""), } ) return samples def _has_routing_attempt_without_results(report): if not isinstance(report, dict): return False if _safe_int(report.get("total_wires", 0)) <= 0: return False if _safe_int(report.get("routed", 0)) > 0: return False status_counts = report.get("route_status_counts", {}) has_status = isinstance(status_counts, dict) and any( _safe_int(value) > 0 for value in status_counts.values() ) return bool( has_status or _safe_int(report.get("skipped_missing_terminal", 0)) > 0 or _safe_int(report.get("skipped_missing_route_network", 0)) > 0 or _safe_int(report.get("skipped_invalid", 0)) > 0 or bool(report.get("errors")) or bool(report.get("error_samples")) ) def _route_status_count(report, status): if not isinstance(report, dict): return 0 counts = report.get("route_status_counts", {}) if not isinstance(counts, dict): return 0 return _safe_int(counts.get(status, 0)) def _route_status_counts_payload(report): if not isinstance(report, dict): return {} counts = report.get("route_status_counts", {}) if not isinstance(counts, dict): return {} payload = {} for key, value in counts.items(): text = str(key or "").strip() count = _safe_int(value) if text and count > 0: payload[text] = count return payload def _route_status_counts_text(counts): if not isinstance(counts, dict) or not counts: return "" labels = { "Routed": "正常", "CollisionWarning": "碰撞告警", "Error": "错误", "MissingTerminal": "缺失端子", "MissingRouteNetwork": "缺少布线路径网络", "Invalid": "无效任务", } parts = [] for key in ("Routed", "CollisionWarning", "Error", "MissingTerminal", "MissingRouteNetwork", "Invalid"): value = _safe_int(counts.get(key, 0)) if value > 0: parts.append("{0} {1} 条".format(labels[key], value)) for key in sorted(counts): if key in labels: continue value = _safe_int(counts.get(key, 0)) if value > 0: parts.append("{0} {1} 条".format(key, value)) return ",".join(parts) def _has_routing_error_status(report): if not isinstance(report, dict): return False return ( bool(report.get("errors")) or bool(report.get("error_samples")) or _route_status_count(report, "Error") > 0 ) def format_eplan_connection_route_report(report): message = "批量生成布线连接完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( report.get("routed", 0), report.get("collision_warnings", 0), report.get("skipped_missing_terminal", 0), ) if _safe_int(report.get("total_wires", 0)) <= 0: message += "\n没有导线任务:请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中已有导线任务。" status_counts = report.get("route_status_counts", {}) if isinstance(status_counts, dict) and status_counts: status_labels = { "Routed": "正常", "CollisionWarning": "碰撞告警", "Error": "错误", "MissingTerminal": "缺失端子", "MissingRouteNetwork": "缺少布线路径网络", "Invalid": "无效任务", } def status_count_value(value): try: return int(value or 0) except Exception: return 0 status_parts = [] for key in ( "Routed", "CollisionWarning", "Error", "MissingTerminal", "MissingRouteNetwork", "Invalid", ): value = status_count_value(status_counts.get(key, 0)) if value > 0: status_parts.append("{0} {1} 条".format(status_labels[key], value)) for key, value in sorted(status_counts.items()): value = status_count_value(value) if key in status_labels or value <= 0: continue status_parts.append("{0} {1} 条".format(key, value)) if status_parts: message += "\n结果状态:{0}。".format(",".join(status_parts)) if _has_routing_attempt_without_results(report): message += "\n未生成有效导线:本次只有路径承载/诊断对象,未生成 RoutedConnection 导线。" style_status_text = _wire_style_status_summary_text(_wire_style_status_counts(report)) if style_status_text: message += "\n导线样式:{0}。".format(style_status_text) missing_style_samples = _wire_style_status_samples(report, status="Missing", limit=1) if missing_style_samples: sample = missing_style_samples[0] message += " 示例导线 {0} 样式 {1}。".format( sample.get("wire", "未知导线"), sample.get("wire_style_id", ""), ) style_database_path = str(report.get("wire_style_database_path", "") or "").strip() if style_database_path: fallback_from = str(report.get("wire_style_database_fallback_from", "") or "").strip() if fallback_from: message += "\n导线样式库:{0}(从备用库恢复,原库:{1})。".format( style_database_path, fallback_from, ) else: message += "\n导线样式库:{0}。".format(style_database_path) prepared_layout = report.get("prepared_layout") if isinstance(prepared_layout, dict): message += "\n布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。".format( prepared_layout.get("wire_duct_carriers", 0), prepared_layout.get("surface_carriers", 0), prepared_layout.get("terminal_access_carriers", 0), ) route_network_text = _route_network_carrier_kind_summary_text(report) if route_network_text: message += "\n当前路径网络:{0}。".format(route_network_text) auto_bridges = report.get("auto_diagnostic_bridges", {}) if isinstance(auto_bridges, dict): created_count = _safe_int(auto_bridges.get("created_count", 0)) if created_count > 0: message += "\n自动诊断桥接:生成 UserPath {0} 条。".format(created_count) unconnected_targets = _safe_int( auto_bridges.get("unconnected_terminal_access_bridge_targets", 0) ) unconnected_created = _safe_int( auto_bridges.get("unconnected_terminal_access_user_path_bridges", 0) ) if unconnected_targets > 0 or unconnected_created > 0: message += " 未接入端子接入段 {0} 个,生成 {1} 条。".format( unconnected_targets, unconnected_created, ) pair_labels = [ str(label or "").strip() for label in list( auto_bridges.get("unconnected_terminal_access_bridge_pair_labels", []) or [] ) if str(label or "").strip() ] if pair_labels: message += " 配对:{0}。".format("、".join(pair_labels[:3])) auto_detour_bridges = report.get("auto_main_path_detour_bridges", {}) if isinstance(auto_detour_bridges, dict): created_count = _safe_int(auto_detour_bridges.get("created_count", 0)) if created_count > 0: reroute_text = "并重跑布线" if bool(auto_detour_bridges.get("rerouted", False)) else "" message += "\n自动主路径补桥:生成 UserPath {0} 条{1}。".format(created_count, reroute_text) pair_labels = [ str(label or "").strip() for label in list(auto_detour_bridges.get("created_pair_labels", []) or []) if str(label or "").strip() ] if pair_labels: message += " 配对:{0}。".format("、".join(pair_labels[:3])) auto_terminal_bridges = report.get("auto_terminal_access_fallback_bridges", {}) if isinstance(auto_terminal_bridges, dict): created_count = _safe_int(auto_terminal_bridges.get("created_count", 0)) if created_count > 0: reroute_text = "并重跑布线" if bool(auto_terminal_bridges.get("rerouted", False)) else "" message += "\n自动端子接入补桥:生成 UserPath {0} 条{1}。".format(created_count, reroute_text) pair_labels = [ str(label or "").strip() for label in list(auto_terminal_bridges.get("created_pair_labels", []) or []) if str(label or "").strip() ] if pair_labels: message += " 配对:{0}。".format("、".join(pair_labels[:3])) path_diagnostic = report.get("routing_path_network_diagnostic", {}) if isinstance(path_diagnostic, dict) and int(path_diagnostic.get("issue_count", 0) or 0) > 0: issue_labels = [ _routing_path_network_issue_label(code) for code in list(path_diagnostic.get("issue_codes", []) or [])[:3] ] message += "\n路径网络检查提示:{0}。".format("、".join(issue_labels) if issue_labels else "存在问题") outside_sources = _dict_items(path_diagnostic.get("route_carriers_outside_boundary", []) or []) if outside_sources: sample = outside_sources[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} carrier_text = carrier.get("label") or carrier.get("name") or "未知路径对象" message += " 越界路径:{0} {1} 个越界点。".format( carrier_text, _safe_int(sample.get("outside_point_count", 0)), ) outside_terminals = _dict_items(path_diagnostic.get("terminals_outside_boundary", []) or []) if outside_terminals: sample = outside_terminals[0] message += " 越界端子:{0} {1} 个越界点。".format( _diagnostic_terminal_text(sample), _safe_int(sample.get("outside_point_count", 0)), ) if report.get("skipped_missing_route_network", 0) > 0: message += "\n缺少或未连通布线路径网络:{0} 条导线已跳过。请检查线槽/UserPath/布线面是否已生成 carrier、两端是否接入同一网络,以及路径约束是否过严。".format( report.get("skipped_missing_route_network", 0) ) routing_sources = report.get("routing_sources", {}) if isinstance(routing_sources, dict) and routing_sources: candidate_sources = _safe_int(routing_sources.get("candidate_sources", 0)) route_carriers = _safe_int(routing_sources.get("route_carriers", 0)) if candidate_sources <= 0 and route_carriers <= 0: message += " 未识别到线槽、布线面或用户路径源。" elif route_carriers <= 0: message += " 已识别到布线源 {0} 个,但还没有生成可用路径 carrier。".format( candidate_sources ) route_sample = (report.get("missing_route_network_samples") or [None])[0] if route_sample: message += "\n缺路径网络示例:导线 {0},{1}。".format( _wire_sample_text(route_sample), _endpoint_pair_text(route_sample), ) # 示例带上原始失败原因,手动测试时可以直接判断是空网络、端点不连通还是距离阈值限制。 route_error = str(route_sample.get("error", "") or "").strip() if route_error: message += "原因:{0}。".format(route_error) retry_count = _safe_int(report.get("missing_route_retries", 0)) if retry_count > 0: retry_limit = _safe_int(report.get("missing_route_retry_candidate_limit", 0)) if retry_limit > 0: message += "\n候选放宽重试:{0} 条导线通过候选上限 {1} 的补救重试完成布线。".format( retry_count, retry_limit, ) else: message += "\n候选放宽重试:{0} 条导线通过补救重试完成布线。".format( retry_count ) total_length_mm = float(report.get("total_length_mm", 0.0) or 0.0) if total_length_mm > 0.0: message += "\n布线连接总长度:{0:.1f} mm。".format(total_length_mm) replaced_routed_connections = int(report.get("replaced_routed_connections", 0) or 0) if replaced_routed_connections > 0: message += "\n已替换旧布线连接:{0} 条。".format(replaced_routed_connections) hidden_route_carriers = int(report.get("hidden_route_carriers", 0) or 0) if hidden_route_carriers > 0: message += "\n已隐藏走线路径辅助对象:{0} 条。".format(hidden_route_carriers) duplicate_payload_terminal_instances = _safe_int( report.get("duplicate_payload_terminal_instance_id_count", 0) ) if duplicate_payload_terminal_instances > 0: message += "\n输入端子实例ID重复:{0} 组,已按设备实例/端子显示名在 FreeCAD 侧临时消歧。".format( duplicate_payload_terminal_instances ) wire_visibility = report.get("routed_wire_visibility", {}) if isinstance(wire_visibility, dict): hidden_wires = _safe_int(wire_visibility.get("hidden", 0)) if hidden_wires > 0: message += "\n导线可见性异常:{0} 条 RoutedConnection 仍不可见。".format(hidden_wires) style_application = report.get("wire_style_application", {}) if isinstance(style_application, dict): missing_application = _safe_int(style_application.get("missing_application", 0)) styled_black = _safe_int(style_application.get("styled_black", 0)) if styled_black > 0: message += "\n黑色导线:{0} 条来自 wire_properties 样式,属于已解析并已应用的黑色线。".format( styled_black ) if missing_application > 0: message += "\n导线样式实际应用异常:{0} 条导线有样式 ID/样式数据但未渲染到 ViewObject。".format( missing_application ) carrier_visibility = report.get("route_carrier_visibility", {}) if isinstance(carrier_visibility, dict) and bool(carrier_visibility.get("expected_hidden")): visible_carriers = _safe_int(carrier_visibility.get("visible_after_hide", 0)) if visible_carriers > 0: message += "\n辅助路径显示异常:{0} 条 route carrier 仍可见。".format(visible_carriers) bridged_segments = _route_network_metric_max(report, "bridged_segments") blocked_segments = _route_network_metric_max(report, "blocked_segments") network_parts = [] if bridged_segments > 0: network_parts.append("自动桥接 {0} 段相邻/投影主路径".format(bridged_segments)) if blocked_segments > 0: network_parts.append("避障屏蔽 {0} 段".format(blocked_segments)) if network_parts: message += "\n路径网络:{0}。".format(",".join(network_parts)) lane_summary = _route_lane_summary(report) if lane_summary: lane_text = "并行错位:最大 lane {0},间距 {1:.1f} mm".format( lane_summary.get("max_lane_index", 0), float(lane_summary.get("spacing_mm", 0.0) or 0.0), ) max_offset = float(lane_summary.get("max_offset_mm", 0.0) or 0.0) if max_offset > 0.0: lane_text += ",最大偏移 {0:.1f} mm".format(max_offset) message += "\n{0}。".format(lane_text) capacity_pressure = _route_capacity_pressure_summary(report) if _show_candidate_debug_warnings(report) else {} if capacity_pressure: message += "\n容量提示:最大并行线数 {0},路径最小容量 {1}。".format( capacity_pressure.get("max_parallel_wires", 0), capacity_pressure.get("min_capacity", 0), ) sample = capacity_pressure.get("sample", {}) if isinstance(sample, dict) and sample.get("wire"): message += " 示例导线 {0}".format(sample.get("wire")) route_labels = list(sample.get("route_source_labels", []) or []) carrier_names = list(sample.get("carrier_names", []) or []) bottleneck_labels = list(sample.get("bottleneck_route_source_labels", []) or []) bottleneck_names = list(sample.get("bottleneck_carrier_names", []) or []) bottleneck_paths = bottleneck_labels or bottleneck_names if bottleneck_paths: message += ",瓶颈路径 {0}".format("、".join(bottleneck_paths)) path_labels = route_labels or carrier_names if path_labels: message += ",路径 {0}".format("、".join(path_labels)) message += "。" collision_kind_text = _collision_kind_summary_text(_collision_kind_counts(report)) if collision_kind_text: message += "\n碰撞分类:{0}。".format(collision_kind_text) collision_relation_text = _collision_relation_summary_text(_collision_relation_counts(report)) if collision_relation_text: message += "\n碰撞关系:{0}。".format(collision_relation_text) reroute_text = _collision_reroute_recommendation_text(report) if reroute_text: message += "\n后续处理:{0}".format(reroute_text) selective_reroute_text = _selective_collision_reroute_summary_text(report) if selective_reroute_text: message += "\n{0}。".format(selective_reroute_text) detour_summary = report.get("main_path_detour_missing_summary", {}) if isinstance(detour_summary, dict): detour_count = _safe_int(detour_summary.get("wire_count", 0)) if detour_count > 0: message += "\n缺主路径绕行:{0} 条".format(detour_count) label_counts = detour_summary.get("rejected_fallback_label_counts", {}) location_items = [] if isinstance(label_counts, dict) and label_counts: for label, count in sorted(label_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0])))[:5]: label_text = str(label or "").strip() count_value = _safe_int(count) if label_text and count_value > 0: location_items.append("{0} {1} 条".format(label_text, count_value)) if location_items: message += ",需补路径位置:{0}".format("、".join(location_items)) bridge_pair_text = _main_path_detour_bridge_pair_text(detour_summary) if bridge_pair_text: message += ";补路配对:{0}".format(bridge_pair_text) message += "。" top_collision_obstacles = _top_collision_obstacle_summary_text(report) if top_collision_obstacles: message += "\n碰撞高发对象:{0}。".format(top_collision_obstacles) collision_resolution = _top_collision_resolution_summary_text(report) if collision_resolution: message += "\n碰撞处理建议:{0}。".format(collision_resolution) candidate_ranks = [] for route in report.get("routes", []) or []: if not isinstance(route, dict): continue network = route.get("network", {}) if not isinstance(network, dict): continue try: entry_rank = int(network.get("entry_candidate_rank", 0) or 0) except Exception: entry_rank = 0 try: exit_rank = int(network.get("exit_candidate_rank", 0) or 0) except Exception: exit_rank = 0 if entry_rank > 1 or exit_rank > 1: candidate_ranks.append((entry_rank, exit_rank)) if candidate_ranks: entry_rank, exit_rank = candidate_ranks[0] parts = [] if entry_rank > 1: parts.append("起点第 {0} 个".format(entry_rank)) if exit_rank > 1: parts.append("终点第 {0} 个".format(exit_rank)) message += "\n接入候选:{0}。".format(",".join(parts)) long_entry_warning = _long_network_entry_summary(report) if long_entry_warning: sample = long_entry_warning.get("sample", {}) route_text = "" route_labels = list(sample.get("route_source_labels", []) or []) if route_labels: route_text = ",路径 {0}".format("、".join(route_labels)) # 最终导线接入距离过长时,通常意味着设备附近缺少局部路径或主路径离端子太远。 message += "\n接入距离提示:{0} 条导线起点/终点接入过长,示例导线 {1} {2}{3},可能存在悬空或跨距过长。".format( long_entry_warning.get("count", 0), sample.get("wire", "未知导线"), ",".join(sample.get("warning_parts", []) or []), route_text, ) obstacle_entry_warning = ( _route_candidate_obstacle_warning_summary(report) if _show_candidate_debug_warnings(report) else {} ) if obstacle_entry_warning: sample = obstacle_entry_warning.get("sample", {}) route_text = "" route_labels = list(sample.get("route_source_labels", []) or []) if route_labels: route_text = ",路径 {0}".format("、".join(route_labels)) message += "\n接入避障提示:{0} 条导线候选路径仍穿过障碍,示例导线 {1} {2} 处{3}。请补 UserPath/线槽或移动设备。".format( obstacle_entry_warning.get("count", 0), sample.get("wire", "未知导线"), sample.get("hits", 0), route_text, ) boundary_warning = _route_candidate_boundary_warning_summary(report) if boundary_warning: sample = boundary_warning.get("sample", {}) route_text = "" route_labels = list(sample.get("route_source_labels", []) or []) if route_labels: route_text = ",路径 {0}".format("、".join(route_labels)) message += "\n导线越出柜内区域:{0} 条,示例导线 {1} {2} 个越界点{3}。".format( boundary_warning.get("count", 0), sample.get("wire", "未知导线"), sample.get("violations", 0), route_text, ) message += "\n柜内边界提示:{0} 条导线最终路径仍越出柜内区域,示例导线 {1} {2} 个越界点{3}。请补柜内 UserPath/线槽或调整柜内边界。".format( boundary_warning.get("count", 0), sample.get("wire", "未知导线"), sample.get("violations", 0), route_text, ) constraint_summary = _route_constraint_summary(report) if constraint_summary: sample = constraint_summary.get("sample", {}) constraint_parts = [] required_text = _route_constraint_group_text(sample.get("required", {})) forbidden_text = _route_constraint_group_text(sample.get("forbidden", {})) if required_text: constraint_parts.append("必须经过 {0}".format(required_text)) if forbidden_text: constraint_parts.append("禁止经过 {0}".format(forbidden_text)) sample_text = ",".join(constraint_parts) if constraint_parts else "存在路径约束" message += "\n路径约束提示:{0} 条导线应用必经/禁经规则,示例导线 {1} {2}。".format( constraint_summary.get("count", 0), sample.get("wire", "未知导线"), sample_text, ) route_source_sample = _route_source_sample_text(report) if route_source_sample: message += "\n{0}".format(route_source_sample) path_usage = _route_path_usage_summary(report) main_path_routes = _safe_int(path_usage.get("main_path_routes", 0)) fallback_routes = _safe_int(path_usage.get("fallback_routes", 0)) if main_path_routes > 0 or fallback_routes > 0: message += "\n路径采用:线槽/主路径 {0} 条,布线面/辅助路径 {1} 条。".format( main_path_routes, fallback_routes, ) terminal_access_usage = _terminal_access_usage_summary(report) if _safe_int(terminal_access_usage.get("routes", 0)) > 0: message += "\n端子接入采用:两端接入 {0} 条,一端接入 {1} 条,未接入 {2} 条。".format( _safe_int(terminal_access_usage.get("both_endpoints_consumed", 0)), _safe_int(terminal_access_usage.get("one_endpoint_consumed", 0)), _safe_int(terminal_access_usage.get("no_endpoint_consumed", 0)), ) terminal_access_target_counts = _terminal_access_target_kind_counts(report) if terminal_access_target_counts: target_text = ",".join( "{0} {1} 个".format(kind, count) for kind, count in terminal_access_target_counts.items() ) message += "\n端子接入目标:{0}。".format(target_text) fallback_target_text = _terminal_access_fallback_targets_text(report) if fallback_target_text: message += "\n{0}".format(fallback_target_text) main_path_text = _main_path_not_used_text(report) if main_path_text: message += "\n{0}".format(main_path_text) main_path_underused_text = _main_path_underused_text(report) if main_path_underused_text: message += "\n{0}".format(main_path_underused_text) quality_warning = _route_quality_warning_summary(report) if quality_warning: message += "\n路径质量提示:{0} 条导线使用布线面/辅助路径,可能没有完全优先进入线槽。".format( quality_warning.get("count", 0) ) sample = quality_warning.get("sample", {}) if isinstance(sample, dict) and sample.get("wire") and sample.get("labels"): message += " 示例 {0} 使用{1}".format( sample.get("wire"), "、".join(sample.get("labels", []) or []), ) route_carrier_labels = list(sample.get("route_carrier_labels", []) or []) if route_carrier_labels: message += ":{0}".format("、".join(route_carrier_labels)) message += "。" errors = report.get("errors", []) or [] if errors: message += "\n首个错误:{0}".format(str(errors[0])) error_sample = (report.get("error_samples") or [None])[0] if error_sample: message += "\n错误示例:导线 {0},{1}:{2}".format( _wire_sample_text(error_sample), _endpoint_pair_text(error_sample), error_sample.get("error", ""), ) collision_sample = (report.get("collision_samples") or [None])[0] if collision_sample: obstacle_text = ( collision_sample.get("obstacle_label") or collision_sample.get("obstacle_name") or "未知对象" ) wire_text = _wire_object_sample_text(collision_sample) route_text = "" route_labels = list(collision_sample.get("route_source_labels", []) or []) if route_labels: route_text = ",路径 {0}".format("、".join(route_labels)) if collision_sample.get("collision_kind") == "ClearanceWarning": message += "\n碰撞示例:导线 {0} 进入 {1} 的安全间隙{2}。".format( wire_text, obstacle_text, route_text, ) else: message += "\n碰撞示例:导线 {0} 碰到 {1}{2}。".format( wire_text, obstacle_text, route_text, ) auto_bound = report.get("auto_bound_terminals", 0) auto_created = report.get("auto_created_terminals", 0) if auto_bound or auto_created: message += "\n已按导线任务绑定 3D 工程端子:更新 {0} 个,新建 {1} 个。".format( auto_bound, auto_created, ) missing_reason_counts = {} if report.get("skipped_missing_terminal", 0) > 0: missing_reason_counts = _missing_endpoint_reason_counts_from_samples(report.get("missing_endpoint_samples", [])) if report.get("routed", 0) == 0 and report.get("skipped_missing_terminal", 0) > 0: message += ( "\n端子匹配失败:当前 3D 可布线端子 {0} 个,其中本地模板端子 {1} 个;" "导线任务引用的 QET terminal_uuid 没有绑定到这些 3D 工程端子。" ).format( report.get("available_terminals", 0), report.get("local_terminals", 0), ) if report.get("local_terminals", 0) > 0: message += " 请先从 QET 重新导入/更新工程端子,使端子 UUID 不再是 local:...。" missing_reason_hint = _missing_endpoint_reason_hint_text(missing_reason_counts) if missing_reason_hint: message += "\n缺端子原因提示:{0}".format(missing_reason_hint) missing_terminal_summary = _batch_missing_terminal_summary(report) missing_device_groups_text = _missing_terminal_device_groups_text( missing_terminal_summary.get("device_groups", []) ) if missing_device_groups_text: message += "\n需补端子设备:{0}。".format(missing_device_groups_text) sample = (report.get("missing_endpoint_samples") or [None])[0] if sample: endpoint_text = "{0} -> {1}{2}".format( _missing_endpoint_label(sample, "start"), _missing_endpoint_label(sample, "end"), _missing_endpoint_side_summary(sample), ) wire_text = _wire_object_sample_text(sample) if wire_text and wire_text != "未知导线": message += "\n缺失示例:导线 {0},{1}".format(wire_text, endpoint_text) else: message += "\n缺失示例:{0}".format(endpoint_text) detail_text = _missing_endpoint_detail_text(sample) if detail_text: message += "\n缺失明细:{0}。".format(detail_text) return message def _clear_routing_preflight_diagnostics(doc): group = WiringObjects.ensure_diagnostic_group(doc, _project_uuid(doc)) removed = 0 for obj in list(getattr(group, "Group", []) or []): if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "RoutingPreflight": continue try: group.removeObject(obj) except Exception: try: group.Group = [ candidate for candidate in list(getattr(group, "Group", []) or []) if candidate is not obj ] except Exception: pass try: if doc.getObject(getattr(obj, "Name", "")) is not None: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _compact_routing_preflight_report(report, sample_limit=8): if not isinstance(report, dict): return {} limit = max(int(sample_limit or 0), 0) payload = {} for key in ( "ok", "source", "runtime_version", "project_uuid", "total_wires", "available_terminals", "local_terminals", "route_network_carriers", "route_network_segments", "route_network_nodes", "route_network_error", "routeability_checked", "routeability_sample_limit", "routeability_eligible_wires", "routeability_unchecked_wires", "unrouteable_wires", ): if key in report: payload[key] = report.get(key) issue_codes = list(report.get("issue_codes", []) or []) payload["issue_codes"] = issue_codes[:50] issues = [item for item in list(report.get("issues", []) or []) if isinstance(item, dict)] payload["issues"] = issues[:limit] payload["issue_count"] = len(issues) missing_endpoint_uuids = list(report.get("missing_endpoint_uuids", []) or []) payload["missing_endpoint_uuid_count"] = len(missing_endpoint_uuids) payload["missing_endpoint_uuids"] = missing_endpoint_uuids[:50] missing_samples = list(report.get("missing_endpoint_samples", []) or []) payload["missing_endpoint_samples"] = missing_samples[:limit] payload["missing_endpoint_samples_count"] = len(missing_samples) payload["duplicate_terminal_uuid_count"] = _safe_int( report.get("duplicate_terminal_uuid_count", 0) ) duplicate_terminal_uuid_samples = list(report.get("duplicate_terminal_uuid_samples", []) or []) payload["duplicate_terminal_uuid_samples"] = duplicate_terminal_uuid_samples[:limit] payload["duplicate_terminal_uuid_samples_count"] = len(duplicate_terminal_uuid_samples) payload["duplicate_payload_terminal_instance_id_count"] = _safe_int( report.get("duplicate_payload_terminal_instance_id_count", 0) ) duplicate_payload_terminal_instance_id_samples = list( report.get("duplicate_payload_terminal_instance_id_samples", []) or [] ) payload["duplicate_payload_terminal_instance_id_samples"] = ( duplicate_payload_terminal_instance_id_samples[:limit] ) payload["duplicate_payload_terminal_instance_id_samples_count"] = len( duplicate_payload_terminal_instance_id_samples ) payload["unreferenced_payload_terminal_count"] = _safe_int( report.get("unreferenced_payload_terminal_count", 0) ) unreferenced_samples = list(report.get("unreferenced_payload_terminal_samples", []) or []) payload["unreferenced_payload_terminal_samples"] = unreferenced_samples[:limit] payload["unreferenced_payload_terminal_samples_count"] = len(unreferenced_samples) unrouteable_samples = list(report.get("unrouteable_samples", []) or []) payload["unrouteable_samples"] = unrouteable_samples[:limit] payload["unrouteable_samples_count"] = len(unrouteable_samples) for key in ( "routing_sources", "routing_boundaries", "routing_obstacle_modes", "routing_path_network_diagnostic", "runtime_capabilities", "wire_style_database", "wire_style", ): value = report.get(key) if isinstance(value, dict): payload[key] = dict(value) payload["issue_labels"] = [ _routing_diagnostic_issue_label(code) for code in payload.get("issue_codes", []) ] payload["diagnostic_payload"] = "compact-routing-preflight-v1" return payload 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) def _diagnostic_issue_labels_text(issue_codes): values = [] seen = set() for code in list(issue_codes or []): label = _routing_diagnostic_issue_label(code) if not label or label in seen: continue seen.add(label) values.append(label) return "、".join(values) def write_routing_preflight_diagnostic(doc, report): if doc is None or not isinstance(report, dict): return None project_uuid = str(report.get("project_uuid", "") or _project_uuid(doc)).strip() group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_preflight_diagnostics(doc) compact_payload = _compact_routing_preflight_report(report) # 预检报告会被用户反复刷新,只保留压缩后的最新一次结果,便于在树中排障。 diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETRoutingPreflightDiagnostic")) diagnostic.Label = "QET Routing Preflight Diagnostic" _set_string(diagnostic, "QetDiagnosticKind", "RoutingPreflight", "QET diagnostic kind") _set_string(diagnostic, "QetProjectUuid", project_uuid, "Project UUID") _set_bool(diagnostic, "QetDiagnosticOk", bool(report.get("ok", False)), "QET diagnostic pass state") _set_string( diagnostic, "QetDiagnosticIssueCodes", _diagnostic_issue_codes_text(compact_payload.get("issue_codes", [])), "QET routing diagnostic issue codes", ) _set_string( diagnostic, "QetDiagnosticIssueLabels", _diagnostic_issue_labels_text(compact_payload.get("issue_codes", [])), "QET routing diagnostic issue labels", ) _set_string( diagnostic, "QetDiagnosticMessage", format_eplan_routing_preflight_report(report), "QET routing preflight diagnostic message", ) _set_string( diagnostic, "QetDiagnosticJson", json.dumps(compact_payload, ensure_ascii=False), "QET routing preflight diagnostic payload", ) group.addObject(diagnostic) return diagnostic _ROUTING_DIAGNOSTIC_KINDS = ( "RoutingPreflight", "RoutingPathNetwork", "RoutingConnectionBatch", ) _ROUTING_DIAGNOSTIC_ISSUE_LABELS = { "no_wire_tasks": "没有导线任务", "no_available_terminals": "没有工程端子", "missing_endpoints": "缺失端点", "unrouteable_wires": "导线不可达", "no_routed_connections": "未生成有效导线", "missing_terminals": "端子匹配失败", "no_route_network": "缺少路径网络", "missing_route_network": "缺少路径网络", "no_routing_sources": "缺少布线源", "routing_sources_not_generated": "布线源未生成路径网络", "wire_style_database_not_configured": "导线样式库未配置", "wire_style_database_missing": "导线样式库文件不存在", "wire_style_database_no_table": "导线样式库缺少 wire_properties", "wire_style_database_empty": "导线样式库为空", "wire_style_database_unreadable": "导线样式库无法读取", "runtime_route_constraint_collector_missing": "运行模块缺少路径约束收集函数", "missing_wire_styles": "缺失导线样式", "wires_without_style_id": "导线未设置样式", "empty_routing_path_network": "布线路径网络为空", "invalid_route_carriers": "路径对象几何无效", "routing_range_only_network": "仅使用布线面兜底", "main_path_not_used": "未使用线槽或用户主路径", "main_path_underused": "主路径使用率过低", "terminal_access_fallback_targets": "端子接入退回布线面", "invalid_terminal_exit_directions": "端子出线方向无效", "invalid_terminal_local_routes": "端子局部路径无效", "route_carriers_outside_boundary": "路径越出柜内边界", "terminals_outside_boundary": "端子越出柜内边界", "long_terminal_accesses": "端子接入过长", "long_terminal_access": "端子接入过长", "unconnected_terminals": "端子未接入", "wire_duct_endpoint_breaks": "线槽端点疑似断开", "isolated_network_components": "存在孤立路径网络", "routing_errors": "布线计算错误", "collision_warnings": "碰撞告警", "hard_intersections": "硬穿模", "clearance_warnings": "间隙不足", "structural_collision_candidates": "结构件碰撞候选", "device_or_layout_collisions": "设备/布局碰撞", "third_party_device_collisions": "第三方设备/布局碰撞", "endpoint_device_collisions": "端点设备碰撞", "main_path_detour_missing": "缺少主路径绕行空间", "route_quality_warnings": "路径质量告警", "route_candidate_obstacle_hits": "候选路径碰撞风险", "route_candidate_boundary_violations": "候选路径越出柜内边界", "route_capacity_pressure": "路径容量压力", "routed_wires_not_visible": "导线生成后不可见", "wire_styles_not_applied": "导线样式未实际应用", "route_carriers_still_visible": "辅助路径对象仍可见", "diagnostic_json_empty": "诊断 JSON 为空", "diagnostic_json_invalid": "诊断 JSON 无效", "routed_wire_diagnostics_missing": "导线诊断缺失", "routed_wire_diagnostics_invalid": "导线诊断 JSON 无效", "missing_device_binding_metadata": "端点缺少绑定信息", "device_not_in_3d_scene": "3D场景缺少设备", "no_3d_terminals_for_element": "设备缺少工程端子", "no_3d_terminals_for_instance": "实例缺少工程端子", "terminal_uuid_not_in_element": "端子UUID不匹配", "duplicate_3d_terminal_uuids": "3D端子UUID重复", "duplicate_payload_terminal_instance_ids": "输入端子实例ID重复", "payload_terminals_without_wires": "输入端子未被导线引用", } def _routing_diagnostic_issue_label(code): text = str(code or "").strip() return _ROUTING_DIAGNOSTIC_ISSUE_LABELS.get(text, text or "未知问题") def _read_diagnostic_payload(obj): text = str(getattr(obj, "QetDiagnosticJson", "") or "").strip() if not text: return {}, False try: payload = json.loads(text) except Exception: return {}, True return payload if isinstance(payload, dict) else {}, False def _issue_codes_from_text(text): codes = [] for token in re.split(r"[,;,;\s]+", str(text or "")): code = token.strip() if code and code not in codes: codes.append(code) return codes def _diagnostic_issue_codes(entry): payload = entry.get("payload", {}) if isinstance(entry, dict) else {} codes = [] for code in _issue_codes_from_text(entry.get("issue_codes_text", "")): if code not in codes: codes.append(code) if isinstance(payload, dict): for code in list(payload.get("issue_codes", []) or []): text = str(code or "").strip() if text and text not in codes: codes.append(text) if entry.get("json_invalid") and "diagnostic_json_invalid" not in codes: codes.append("diagnostic_json_invalid") if entry.get("json_empty") and "diagnostic_json_empty" not in codes: codes.append("diagnostic_json_empty") return codes def _routed_wire_diagnostic_gaps(doc, limit=8): missing_samples = [] invalid_samples = [] missing_count = 0 invalid_count = 0 if doc is None: return {"count": 0, "samples": [], "invalid_count": 0, "invalid_samples": []} for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue diagnostic_text = str(getattr(obj, "QetRouteDiagnosticsJson", "") or "").strip() if diagnostic_text: try: payload = json.loads(diagnostic_text) if isinstance(payload, dict): continue except Exception: pass invalid_count += 1 if len(invalid_samples) < int(limit or 0): invalid_samples.append( { "name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "wire_uuid": str(getattr(obj, "QetWireUuid", "") or ""), "route_status": str(getattr(obj, "RouteStatus", "") or ""), } ) continue missing_count += 1 if len(missing_samples) >= int(limit or 0): continue missing_samples.append( { "name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "wire_uuid": str(getattr(obj, "QetWireUuid", "") or ""), "route_status": str(getattr(obj, "RouteStatus", "") or ""), } ) return { "count": missing_count, "samples": missing_samples, "invalid_count": invalid_count, "invalid_samples": invalid_samples, } def _routed_wire_issue_summary(doc, limit=8): total_count = 0 issue_wire_count = 0 issue_code_counts = {} samples = [] if doc is None: return { "total_wire_count": 0, "issue_wire_count": 0, "issue_code_counts": {}, "samples": [], } for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue total_count += 1 issue_codes = _issue_codes_from_text(getattr(obj, "QetRouteIssueCodes", "")) if not issue_codes: continue issue_wire_count += 1 for code in issue_codes: issue_code_counts[code] = issue_code_counts.get(code, 0) + 1 if len(samples) < int(limit or 0): samples.append( { "name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "wire_uuid": str(getattr(obj, "QetWireUuid", "") or ""), "issue_codes": issue_codes, "issue_labels": [_routing_diagnostic_issue_label(code) for code in issue_codes], } ) return { "total_wire_count": total_count, "issue_wire_count": issue_wire_count, "issue_code_counts": { key: issue_code_counts[key] for key in sorted(issue_code_counts) }, "samples": samples, } def _main_path_detour_missing_summary(doc, limit=8): wire_count = 0 rejected_labels = [] seen_labels = set() rejected_label_counts = {} rejected_kind_counts = {} current_route_source_label_counts = {} bridge_pair_counts = {} samples = [] if doc is None: return { "wire_count": 0, "rejected_fallback_labels": [], "rejected_fallback_label_counts": {}, "rejected_fallback_kind_counts": {}, "current_route_source_label_counts": {}, "bridge_pair_counts": {}, "samples": [], } for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue issue_codes = _issue_codes_from_text(getattr(obj, "QetRouteIssueCodes", "")) if "main_path_detour_missing" not in issue_codes: continue wire_count += 1 sample = { "name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "wire_uuid": str(getattr(obj, "QetWireUuid", "") or ""), "rejected_fallback_labels": [], "rejected_fallback_kinds": [], "current_route_source_labels": [], } diagnostic_text = str(getattr(obj, "QetRouteDiagnosticsJson", "") or "").strip() if diagnostic_text: try: diagnostic = json.loads(diagnostic_text) except Exception: diagnostic = {} reroute = diagnostic.get("selective_collision_reroute", {}) if isinstance(diagnostic, dict) else {} if isinstance(reroute, dict): for kind in list(reroute.get("rejected_fallback_kinds", []) or []): kind = str(kind or "").strip() if not kind: continue rejected_kind_counts[kind] = rejected_kind_counts.get(kind, 0) + 1 if kind not in sample["rejected_fallback_kinds"]: sample["rejected_fallback_kinds"].append(kind) for label in list(reroute.get("rejected_fallback_labels", []) or []): label = str(label or "").strip() if not label: continue rejected_label_counts[label] = rejected_label_counts.get(label, 0) + 1 if label not in seen_labels: seen_labels.add(label) rejected_labels.append(label) if label not in sample["rejected_fallback_labels"]: sample["rejected_fallback_labels"].append(label) route_track_text = str(getattr(obj, "QetRouteTrackJson", "") or "").strip() if route_track_text: try: route_track = json.loads(route_track_text) except Exception: route_track = {} current_labels = _route_source_labels(route_track, limit=8) sample["current_route_source_labels"] = current_labels for label in current_labels: current_route_source_label_counts[label] = current_route_source_label_counts.get(label, 0) + 1 # 这里不自动建桥,只给出“兜底区域 -> 当前主路径”的人工补路方向。 for rejected_label in sample["rejected_fallback_labels"]: for current_label in current_labels: pair_key = "{0} -> {1}".format(rejected_label, current_label) bridge_pair_counts[pair_key] = bridge_pair_counts.get(pair_key, 0) + 1 if len(samples) < int(limit or 0): samples.append(sample) return { "wire_count": wire_count, "rejected_fallback_labels": rejected_labels, "rejected_fallback_label_counts": { key: rejected_label_counts[key] for key in sorted(rejected_label_counts) }, "rejected_fallback_kind_counts": { key: rejected_kind_counts[key] for key in sorted(rejected_kind_counts) }, "current_route_source_label_counts": { key: current_route_source_label_counts[key] for key in sorted(current_route_source_label_counts) }, "bridge_pair_counts": { key: bridge_pair_counts[key] for key in sorted(bridge_pair_counts) }, "samples": samples, } def _backfill_missing_endpoint_reason_context(sample, terminals, doc=None): if not isinstance(sample, dict): return sample result = dict(sample) for side in ("start", "end"): if bool(result.get("{0}_found".format(side), False)): continue reason_code = str(result.get("{0}_missing_endpoint_reason_code".format(side), "") or "").strip() reason_label = str(result.get("{0}_missing_endpoint_reason_label".format(side), "") or "").strip() if reason_code or reason_label: continue _add_missing_endpoint_terminal_context(result, side, terminals, doc=doc) return result def _batch_missing_terminal_summary(batch_payload, doc=None): if not isinstance(batch_payload, dict): return { "skipped_missing_terminal": 0, "sample_wire_count": 0, "missing_endpoint_count": 0, "reason_code_counts": {}, "reason_label_counts": {}, "device_groups": [], "samples": [], } skipped = _safe_int(batch_payload.get("skipped_missing_terminal", 0)) samples = [] reason_code_counts = {} reason_label_counts = {} device_groups = {} missing_endpoint_count = 0 terminals = index_terminals(doc) if doc is not None else {} for sample in list(batch_payload.get("missing_endpoint_samples", []) or []): if not isinstance(sample, dict): continue sample = _backfill_missing_endpoint_reason_context(sample, terminals, doc=doc) samples.append(sample) for side in ("start", "end"): if sample.get("{0}_found".format(side)) is not False: continue terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() reason_code = str(sample.get("{0}_missing_endpoint_reason_code".format(side), "") or "").strip() reason_label = str(sample.get("{0}_missing_endpoint_reason_label".format(side), "") or "").strip() if not terminal_uuid and not reason_code and not reason_label: continue missing_endpoint_count += 1 if terminal_uuid and not reason_code and not reason_label: reason_code = "unknown" reason_label = "缺端点原因未记录" if reason_code: reason_code_counts[reason_code] = reason_code_counts.get(reason_code, 0) + 1 if reason_label: reason_label_counts[reason_label] = reason_label_counts.get(reason_label, 0) + 1 device_group_key = _missing_endpoint_device_group_key(sample, side) group = device_groups.setdefault( device_group_key, { "device_label": device_group_key[0], "device_name": device_group_key[1], "instance_id": device_group_key[2], "element_uuid": device_group_key[3], "missing_endpoint_count": 0, "terminal_uuids": [], "terminal_displays": [], "reason_code_counts": {}, "reason_label_counts": {}, "wire_uuids": [], "wire_labels": [], }, ) group["missing_endpoint_count"] += 1 _append_once(group["terminal_uuids"], terminal_uuid) _append_once( group["terminal_displays"], str(sample.get("{0}_terminal_display".format(side), "") or "").strip(), ) _append_once(group["wire_uuids"], str(sample.get("wire_uuid", "") or "").strip()) _append_once(group["wire_labels"], str(sample.get("wire_label", "") or "").strip()) if reason_code: group["reason_code_counts"][reason_code] = group["reason_code_counts"].get(reason_code, 0) + 1 if reason_label: group["reason_label_counts"][reason_label] = group["reason_label_counts"].get(reason_label, 0) + 1 grouped_devices = sorted( device_groups.values(), key=lambda item: ( -int(item.get("missing_endpoint_count", 0) or 0), str(item.get("device_label", "") or ""), str(item.get("device_name", "") or ""), str(item.get("element_uuid", "") or ""), ), ) return { "skipped_missing_terminal": skipped, "sample_wire_count": len(samples), "missing_endpoint_count": missing_endpoint_count, "reason_code_counts": { key: reason_code_counts[key] for key in sorted(reason_code_counts) }, "reason_label_counts": { key: reason_label_counts[key] for key in sorted(reason_label_counts) }, "device_groups": grouped_devices[:8], "samples": samples[:8], } def _append_once(values, value): text = str(value or "").strip() if text and text not in values: values.append(text) def _missing_endpoint_device_group_key(sample, side): device_label = str(sample.get("{0}_device_label".format(side), "") or "").strip() device_name = str(sample.get("{0}_device_name".format(side), "") or "").strip() instance_id = str(sample.get("{0}_instance_id".format(side), "") or "").strip() element_uuid = str(sample.get("{0}_element_uuid".format(side), "") or "").strip() display = device_label or device_name or instance_id or element_uuid or "未知设备" return (display, device_name, instance_id, element_uuid) def _missing_terminal_device_groups_text(groups, limit=3): parts = [] for group in list(groups or [])[: int(limit or 3)]: if not isinstance(group, dict): continue label = str(group.get("device_label", "") or group.get("device_name", "") or "未知设备").strip() count = _safe_int(group.get("missing_endpoint_count", 0)) if not label or count <= 0: continue displays = [str(item or "").strip() for item in list(group.get("terminal_displays", []) or []) if str(item or "").strip()] uuids = [str(item or "").strip() for item in list(group.get("terminal_uuids", []) or []) if str(item or "").strip()] terminal_text = "、".join(displays[:3] or uuids[:3]) if terminal_text: parts.append("{0} 缺 {1} 处({2})".format(label, count, terminal_text)) else: parts.append("{0} 缺 {1} 处".format(label, count)) return ",".join(parts) def _routing_diagnostic_recommended_actions(summary): if not isinstance(summary, dict): return [] actions = [] def add(action): if action and action not in actions: actions.append(action) missing_summary = summary.get("batch_missing_terminal_summary", {}) if isinstance(missing_summary, dict) and _safe_int(missing_summary.get("skipped_missing_terminal", 0)) > 0: reason_counts = missing_summary.get("reason_code_counts", {}) if not isinstance(reason_counts, dict): reason_counts = {} if _safe_int(reason_counts.get("missing_device_binding_metadata", 0)) > 0: add("检查 QET 导线端点是否提供 element_uuid 和 terminal_uuid(第一版不要求 start/end_instance_id)") if _safe_int(reason_counts.get("device_not_in_3d_scene", 0)) > 0: add("检查缺失 3D 设备是否已导入、装配并完成 2D/3D 绑定") if ( _safe_int(reason_counts.get("no_3d_terminals_for_element", 0)) > 0 or _safe_int(reason_counts.get("no_3d_terminals_for_instance", 0)) > 0 ): add("点击“选择缺端子设备”定位需要补工程端子的设备") if _safe_int(reason_counts.get("terminal_uuid_not_in_element", 0)) > 0: add("点击“选择缺端子候选端子”核对 terminal_uuid 与脚号绑定") if _safe_int(reason_counts.get("unknown", 0)) > 0: add("重新生成布线连接,刷新缺端子原因诊断") if not reason_counts: add("点击“选择缺端子设备”定位需要补工程端子的设备") wire_issues = summary.get("routed_wire_issue_summary", {}) issue_counts = wire_issues.get("issue_code_counts", {}) if isinstance(wire_issues, dict) else {} if isinstance(wire_issues, dict) and _safe_int(wire_issues.get("issue_wire_count", 0)) > 0: add("点击“选择异常导线”定位带问题码的导线") if isinstance(issue_counts, dict) and ( _safe_int(issue_counts.get("long_terminal_access", 0)) > 0 or _safe_int(issue_counts.get("long_terminal_accesses", 0)) > 0 ): add("点击“选择长接入端子/设备”检查设备高度和局部出线路径") if isinstance(issue_counts, dict) and ( _safe_int(issue_counts.get("route_candidate_boundary_violations", 0)) > 0 or _safe_int(issue_counts.get("boundary_warning", 0)) > 0 ): add("点击“选择越界导线”定位越出柜内区域的导线及其路径") add("检查柜内边界和 UserPath,必要时补柜内主路径") if isinstance(issue_counts, dict) and _safe_int(issue_counts.get("route_capacity_pressure", 0)) > 0: add("检查路径容量,必要时补备用路径或提高线槽容量") if isinstance(issue_counts, dict) and _safe_int(issue_counts.get("main_path_detour_missing", 0)) > 0: add("点击“选择缺主路径导线”定位需要补 UserPath 或主路径桥接的导线") issue_codes = set(str(code or "").strip() for code in list(summary.get("issue_codes", []) or [])) hard_collision_count = _safe_int(issue_counts.get("hard_intersections", 0)) if isinstance(issue_counts, dict) else 0 clearance_collision_count = _safe_int(issue_counts.get("clearance_warnings", 0)) if isinstance(issue_counts, dict) else 0 if "main_path_detour_missing" in issue_codes: add("点击“选择缺主路径导线”定位需要补 UserPath 或主路径桥接的导线") add("点击“选择缺主路径线路径”对照当前实际路径") detour_summary = summary.get("main_path_detour_missing_summary", {}) if isinstance(detour_summary, dict) and list(detour_summary.get("rejected_fallback_labels", []) or []): add("点击“选择缺主路径补路位置”快速定位汇总需补区域") add("选中缺主路径导线后点击“选择拒绝兜底路径”查看需补路径位置") if "terminal_access_fallback_targets" in issue_codes: add("优先补端子附近到线槽/UserPath 的接入桥,避免端子接入退回布线面") add("点击“按诊断建议生成桥接”尝试自动补端子退回目标到最近主路径的 UserPath 桥") diagnostics = summary.get("diagnostics", {}) or {} batch_payload = ((diagnostics.get("RoutingConnectionBatch", {}) or {}).get("payload", {}) or {}) path_payload = ((diagnostics.get("RoutingPathNetwork", {}) or {}).get("payload", {}) or {}) has_batch_samples = isinstance(batch_payload, dict) and list( batch_payload.get("terminal_access_fallback_target_samples", []) or [] ) has_path_samples = isinstance(path_payload, dict) and list( path_payload.get("terminal_access_fallback_targets", []) or [] ) if has_batch_samples or has_path_samples: add("按端子接入退回布线面示例定位设备侧缺口,再重新生成布线路径网络") if "unconnected_terminals" in issue_codes: add("点击“选择未接入端子”定位未接入路由网络或接入距离超限的端子") diagnostics = summary.get("diagnostics", {}) or {} path_payload = ((diagnostics.get("RoutingPathNetwork", {}) or {}).get("payload", {}) or {}) unconnected_samples = ( list(path_payload.get("unconnected_terminals", []) or []) if isinstance(path_payload, dict) else [] ) has_bridgeable_unconnected = any( isinstance(sample, dict) and str(sample.get("access_carrier", "") or "").strip() and ( str(sample.get("nearest_network_carrier_name", "") or "").strip() or str(sample.get("nearest_network_carrier_label", "") or "").strip() ) for sample in unconnected_samples ) if has_bridgeable_unconnected: add("点击“按诊断建议生成桥接”尝试自动补未接入端子接入段到最近路径的 UserPath 桥") add("补端子附近 UserPath/线槽入口,或确认设备装配位置和端子接入最大距离") if ( "terminal_exit_direction_corrected" in issue_codes or "terminal_exit_length_capped" in issue_codes or "invalid_terminal_exit_directions" in issue_codes or "invalid_terminal_local_routes" in issue_codes ): add("点击“选择出线问题端子”定位方向校正、长度截断、显式方向无效或局部路径无效的端子") add("复查设备模板 CPoint/LCS 出线方向,必要时设置端子局部出线路径") if "invalid_terminal_exit_directions" in issue_codes: add("检查 QetTerminalExitDirectionJson,必要时用“选中端子设置出线方向”重写显式方向") if "invalid_terminal_local_routes" in issue_codes: add("检查 QetTerminalLocalRoutePointsJson,必要时用“选中端子设置局部出线”重写局部路径") collision_count = 0 batch_payload = ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) if isinstance(batch_payload, dict): collision_count = _safe_int(batch_payload.get("collision_warnings", 0)) if ( "collision_warnings" in issue_codes or collision_count > 0 or _safe_int(issue_counts.get("collision_warnings", 0)) > 0 ): if "hard_intersections" in issue_codes or hard_collision_count > 0: add("硬穿模:优先补 UserPath/线槽主路径或调整装配,不能直接忽略") if "clearance_warnings" in issue_codes or clearance_collision_count > 0: add("间隙不足:核对设备安全间隙、线槽/UserPath位置,必要时补路径或调整装配") collision_resolution = summary.get("batch_collision_resolution_summary", {}) if isinstance(collision_resolution, dict): counts = collision_resolution.get("counts", {}) if isinstance(counts, dict): structural_count = _safe_int(counts.get("review_pass_through_structural_obstacle", 0)) device_count = _safe_int(counts.get("review_device_or_layout_collision", 0)) if structural_count > 0: add( "先处理 {0} 个疑似结构件碰撞候选:确认后可标记 PassThrough".format( structural_count ) ) if device_count > 0: add( "另有 {0} 个疑似设备/装配碰撞需要补路径或调整装配".format( device_count ) ) top_obstacles = list(summary.get("batch_top_collision_obstacles", []) or []) has_parent_refs = any( isinstance(item, dict) and (list(item.get("parent_names", []) or []) or list(item.get("parent_labels", []) or [])) for item in top_obstacles ) add("点击“选择高发碰撞对象”和“选择碰撞导线”核对穿模位置") if has_parent_refs: add("点击“选择碰撞父装配”确认结构件后再标记忽略碰撞") return actions def collect_routing_diagnostic_summary(doc): """Collect the latest routing diagnostics into one FreeCAD-side summary.""" project_uuid = _project_uuid(doc) if doc is not None else "" summary = { "project_uuid": project_uuid, "ok": False, "diagnostic_count": 0, "diagnostics": {}, "missing_diagnostic_kinds": list(_ROUTING_DIAGNOSTIC_KINDS), "issue_codes": [], "issue_labels": [], "messages": [], "runtime_version": "", "batch_route_path_usage": {}, "batch_route_status_counts": {}, "batch_top_collision_obstacles": [], "routed_wire_diagnostic_gaps": {"count": 0, "samples": []}, "routed_wire_issue_summary": { "total_wire_count": 0, "issue_wire_count": 0, "issue_code_counts": {}, "samples": [], }, "main_path_detour_missing_summary": { "wire_count": 0, "rejected_fallback_labels": [], "rejected_fallback_kind_counts": {}, "samples": [], }, "batch_missing_terminal_summary": { "skipped_missing_terminal": 0, "sample_wire_count": 0, "missing_endpoint_count": 0, "reason_code_counts": {}, "reason_label_counts": {}, "samples": [], }, "recommended_actions": [], } if doc is None: return summary try: group = doc.getObject("QETWiring_05_Diagnostics") except Exception: group = None if group is None: return summary diagnostics = {} for obj in list(getattr(group, "Group", []) or []): kind = str(getattr(obj, "QetDiagnosticKind", "") or "").strip() if kind not in _ROUTING_DIAGNOSTIC_KINDS: continue diagnostic_json_text = str(getattr(obj, "QetDiagnosticJson", "") or "").strip() payload, json_invalid = _read_diagnostic_payload(obj) entry = { "object_name": str(getattr(obj, "Name", "") or ""), "label": str(getattr(obj, "Label", "") or ""), "kind": kind, "project_uuid": str(getattr(obj, "QetProjectUuid", "") or "").strip(), "ok": bool(getattr(obj, "QetDiagnosticOk", False)), "issue_codes_text": str(getattr(obj, "QetDiagnosticIssueCodes", "") or "").strip(), "message": str(getattr(obj, "QetDiagnosticMessage", "") or "").strip(), "payload": payload, "json_invalid": json_invalid, # 真实项目里可能残留旧版空诊断对象;汇总时要提醒用户重新运行对应检查。 "json_empty": not diagnostic_json_text, } # 每类诊断只保留最新对象;正常流程会先清旧对象,这里只是兜底。 diagnostics[kind] = entry issue_codes = [] messages = [] runtime_versions = {} for kind in _ROUTING_DIAGNOSTIC_KINDS: entry = diagnostics.get(kind) if not entry: continue if not project_uuid and entry.get("project_uuid"): project_uuid = entry.get("project_uuid", "") if entry.get("message"): messages.append(entry.get("message")) payload = entry.get("payload") if isinstance(payload, dict): runtime_version = str(payload.get("runtime_version", "") or "").strip() if runtime_version: runtime_versions[kind] = runtime_version for code in _diagnostic_issue_codes(entry): if code not in issue_codes: issue_codes.append(code) batch_payload = {} batch_entry = diagnostics.get("RoutingConnectionBatch") if isinstance(batch_entry, dict) and isinstance(batch_entry.get("payload"), dict): batch_payload = batch_entry.get("payload") or {} missing_kinds = [kind for kind in _ROUTING_DIAGNOSTIC_KINDS if kind not in diagnostics] if batch_entry: # “生成布线连接”是最终入口;它已经覆盖预检结果,不应因未单独点预检而判失败。 missing_kinds = [kind for kind in missing_kinds if kind != "RoutingPreflight"] embedded_network = batch_payload.get("routing_path_network_diagnostic") if isinstance(embedded_network, dict): # 批量报告内嵌路径网络诊断时,不再要求额外的 RoutingPathNetwork 独立对象。 missing_kinds = [kind for kind in missing_kinds if kind != "RoutingPathNetwork"] routed_wire_gaps = _routed_wire_diagnostic_gaps(doc) routed_wire_issues = _routed_wire_issue_summary(doc) missing_terminal_summary = _batch_missing_terminal_summary(batch_payload, doc=doc) main_path_detour_missing = _main_path_detour_missing_summary(doc) if _safe_int(missing_terminal_summary.get("skipped_missing_terminal", 0)) > 0: if "missing_terminals" not in issue_codes: issue_codes.append("missing_terminals") if ( _safe_int(missing_terminal_summary.get("missing_endpoint_count", 0)) > 0 and "missing_endpoints" not in issue_codes ): issue_codes.append("missing_endpoints") if _has_routing_error_status(batch_payload) and "routing_errors" not in issue_codes: issue_codes.append("routing_errors") if routed_wire_gaps.get("count", 0) > 0 and "routed_wire_diagnostics_missing" not in issue_codes: issue_codes.append("routed_wire_diagnostics_missing") if routed_wire_gaps.get("invalid_count", 0) > 0 and "routed_wire_diagnostics_invalid" not in issue_codes: issue_codes.append("routed_wire_diagnostics_invalid") if ( _safe_int(main_path_detour_missing.get("wire_count", 0)) > 0 and "main_path_detour_missing" not in issue_codes ): issue_codes.append("main_path_detour_missing") all_present_ok = bool(diagnostics) and not missing_kinds and all( bool(entry.get("ok", False)) for entry in diagnostics.values() ) summary.update( { "project_uuid": project_uuid, "ok": all_present_ok and not issue_codes, "diagnostic_count": len(diagnostics), "diagnostics": diagnostics, "missing_diagnostic_kinds": missing_kinds, "issue_codes": issue_codes, "issue_labels": [_routing_diagnostic_issue_label(code) for code in issue_codes], "messages": messages, # 批量布线最能代表本次生成结果;没有批量结果时再用预检/路径网络版本辅助排查。 "runtime_version": ( runtime_versions.get("RoutingConnectionBatch") or runtime_versions.get("RoutingPreflight") or runtime_versions.get("RoutingPathNetwork") or "" ), "batch_route_path_usage": ( dict(batch_payload.get("route_path_usage") or {}) if isinstance(batch_payload.get("route_path_usage"), dict) else {} ), "batch_route_status_counts": _route_status_counts_payload(batch_payload), "batch_top_collision_obstacles": ( list(batch_payload.get("top_collision_obstacles") or []) if isinstance(batch_payload.get("top_collision_obstacles"), list) else [] ), "batch_collision_resolution_summary": ( dict(batch_payload.get("collision_resolution_summary") or {}) if isinstance(batch_payload.get("collision_resolution_summary"), dict) else {} ), "routed_wire_diagnostic_gaps": routed_wire_gaps, "routed_wire_issue_summary": routed_wire_issues, "main_path_detour_missing_summary": main_path_detour_missing, "batch_missing_terminal_summary": missing_terminal_summary, } ) summary["recommended_actions"] = _routing_diagnostic_recommended_actions(summary) return summary def format_routing_diagnostic_summary(summary): if not isinstance(summary, dict): return "汇总诊断失败:诊断结果无效。" status = "通过" if summary.get("ok") else "未通过" message = "汇总诊断:{0},诊断对象 {1} 个。".format( status, _safe_int(summary.get("diagnostic_count", 0)), ) missing = list(summary.get("missing_diagnostic_kinds", []) or []) if missing: message += " 未生成:{0}。".format("、".join(missing)) labels = list(summary.get("issue_labels", []) or []) if labels: message += " 问题:{0}。".format("、".join(labels[:8])) status_text = _route_status_counts_text(summary.get("batch_route_status_counts", {})) if status_text: message += "\n结果状态:{0}。".format(status_text) runtime_version = str(summary.get("runtime_version", "") or "").strip() if runtime_version: message += " 运行版本:{0}。".format(runtime_version) path_usage = summary.get("batch_route_path_usage", {}) if isinstance(path_usage, dict): main_path_routes = _safe_int(path_usage.get("main_path_routes", 0)) fallback_routes = _safe_int(path_usage.get("fallback_routes", 0)) if main_path_routes > 0 or fallback_routes > 0: message += "\n路径采用:线槽/主路径 {0} 条,布线面/辅助路径 {1} 条。".format( main_path_routes, fallback_routes, ) wire_issues = summary.get("routed_wire_issue_summary", {}) if isinstance(wire_issues, dict): issue_wire_count = _safe_int(wire_issues.get("issue_wire_count", 0)) total_wire_count = _safe_int(wire_issues.get("total_wire_count", 0)) if issue_wire_count > 0: message += "\n异常导线:{0}/{1} 条".format(issue_wire_count, total_wire_count) counts = wire_issues.get("issue_code_counts", {}) if isinstance(counts, dict) and counts: items = [] for code, count in sorted(counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0])))[:5]: label = _routing_diagnostic_issue_label(code) count_value = _safe_int(count) if label and count_value > 0: items.append("{0} {1} 条".format(label, count_value)) if items: message += "({0})".format("、".join(items)) message += "。" detour_summary = summary.get("main_path_detour_missing_summary", {}) if isinstance(detour_summary, dict): detour_count = _safe_int(detour_summary.get("wire_count", 0)) if detour_count > 0: message += "\n缺主路径绕行:{0} 条".format(detour_count) label_counts = detour_summary.get("rejected_fallback_label_counts", {}) location_items = [] if isinstance(label_counts, dict) and label_counts: for label, count in sorted(label_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0])))[:5]: label_text = str(label or "").strip() count_value = _safe_int(count) if label_text and count_value > 0: location_items.append("{0} {1} 条".format(label_text, count_value)) if not location_items: labels = [ str(label or "").strip() for label in list(detour_summary.get("rejected_fallback_labels", []) or []) if str(label or "").strip() ] location_items = labels[:5] if location_items: message += ",需补路径位置:{0}".format("、".join(location_items)) bridge_pair_text = _main_path_detour_bridge_pair_text(detour_summary) if bridge_pair_text: message += ";补路配对:{0}".format(bridge_pair_text) message += "。" missing_terminal_summary = summary.get("batch_missing_terminal_summary", {}) if isinstance(missing_terminal_summary, dict): skipped_missing = _safe_int(missing_terminal_summary.get("skipped_missing_terminal", 0)) if skipped_missing > 0: message += "\n缺端子:{0} 条".format(skipped_missing) label_counts = missing_terminal_summary.get("reason_label_counts", {}) if isinstance(label_counts, dict) and label_counts: items = [] for label, count in sorted(label_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0])))[:5]: label_text = str(label or "").strip() count_value = _safe_int(count) if label_text and count_value > 0: items.append("{0} {1} 处".format(label_text, count_value)) if items: message += "({0})".format("、".join(items)) message += "。" device_groups_text = _missing_terminal_device_groups_text( missing_terminal_summary.get("device_groups", []) ) if device_groups_text: message += "\n需补端子设备:{0}。".format(device_groups_text) actions = [str(item or "").strip() for item in list(summary.get("recommended_actions", []) or []) if str(item or "").strip()] if actions: message += "\n建议:{0}。".format(";".join(actions[:5])) top_collision_obstacles = [] for item in list(summary.get("batch_top_collision_obstacles", []) or [])[:3]: if not isinstance(item, dict): continue label = str(item.get("label", "") or "").strip() count = _safe_int(item.get("count", 0)) if label and count > 0: top_collision_obstacles.append(item) if top_collision_obstacles: message += "\n碰撞高发对象:{0}。".format( _top_collision_obstacle_items_text(top_collision_obstacles) ) collision_resolution = summary.get("batch_collision_resolution_summary", {}) if isinstance(collision_resolution, dict): resolution_action = str(collision_resolution.get("recommended_action", "") or "").strip() if resolution_action: message += "\n碰撞分类建议:{0}".format(resolution_action) messages = [str(item or "").strip() for item in list(summary.get("messages", []) or []) if str(item or "").strip()] if messages: message += "\n最近诊断:{0}".format("\n".join(messages[:3])) wire_gaps = summary.get("routed_wire_diagnostic_gaps", {}) if isinstance(wire_gaps, dict): gap_count = _safe_int(wire_gaps.get("count", 0)) if gap_count > 0: message += "\n导线诊断缺失:{0} 条".format(gap_count) samples = list(wire_gaps.get("samples", []) or []) if samples: sample = samples[0] sample_label = str(sample.get("label", "") or sample.get("name", "") or "").strip() if sample_label: message += ",示例 {0}".format(sample_label) message += "。" invalid_count = _safe_int(wire_gaps.get("invalid_count", 0)) if invalid_count > 0: message += "\n导线诊断 JSON 无效:{0} 条".format(invalid_count) samples = list(wire_gaps.get("invalid_samples", []) or []) if samples: sample = samples[0] sample_label = str(sample.get("label", "") or sample.get("name", "") or "").strip() if sample_label: message += ",示例 {0}".format(sample_label) message += "。" return message def _clear_routing_diagnostic_summary_objects(doc): group = WiringObjects.ensure_diagnostic_group(doc, _project_uuid(doc)) removed = 0 for obj in list(getattr(group, "Group", []) or []): if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "RoutingDiagnosticSummary": continue try: group.removeObject(obj) except Exception: try: group.Group = [ candidate for candidate in list(getattr(group, "Group", []) or []) if candidate is not obj ] except Exception: pass try: if doc.getObject(getattr(obj, "Name", "")) is not None: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def write_routing_diagnostic_summary(doc, summary=None): if doc is None: return None payload = summary if isinstance(summary, dict) else collect_routing_diagnostic_summary(doc) project_uuid = str(payload.get("project_uuid", "") or _project_uuid(doc)).strip() group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_diagnostic_summary_objects(doc) # 汇总对象用于手动测试复盘:把三类诊断的最新状态固定到树目录里。 diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETRoutingDiagnosticSummary")) diagnostic.Label = "QET Routing Diagnostic Summary" _set_string(diagnostic, "QetDiagnosticKind", "RoutingDiagnosticSummary", "QET diagnostic kind") _set_string(diagnostic, "QetProjectUuid", project_uuid, "Project UUID") _set_bool(diagnostic, "QetDiagnosticOk", bool(payload.get("ok", False)), "QET diagnostic pass state") _set_string( diagnostic, "QetDiagnosticIssueCodes", _diagnostic_issue_codes_text(payload.get("issue_codes", [])), "QET routing diagnostic issue codes", ) _set_string( diagnostic, "QetDiagnosticIssueLabels", _diagnostic_issue_labels_text(payload.get("issue_codes", [])), "QET routing diagnostic issue labels", ) _set_string( diagnostic, "QetDiagnosticMessage", format_routing_diagnostic_summary(payload), "QET routing diagnostic summary message", ) _set_string( diagnostic, "QetDiagnosticJson", json.dumps(payload, ensure_ascii=False), "QET routing diagnostic summary payload", ) group.addObject(diagnostic) return diagnostic def _clear_routing_connection_batch_diagnostics(doc): group = WiringObjects.ensure_diagnostic_group(doc, _project_uuid(doc)) removed = 0 for obj in list(getattr(group, "Group", []) or []): if (getattr(obj, "QetDiagnosticKind", "") or "").strip() != "RoutingConnectionBatch": continue try: group.removeObject(obj) except Exception: try: group.Group = [ candidate for candidate in list(getattr(group, "Group", []) or []) if candidate is not obj ] except Exception: pass try: if doc.getObject(getattr(obj, "Name", "")) is not None: doc.removeObject(obj.Name) removed += 1 except Exception: pass return removed def _compact_route_sample(route): route_track = route.get("route_track", {}) if isinstance(route, dict) else {} if not isinstance(route_track, dict): route_track = {} access_payload = _route_access_payload(route) collision_payload = _route_collision_payload(route.get("collisions", [])) quality_payload = _route_quality_payload(route_track) lane_capacity_payload = _route_lane_capacity_payload(route) boundary_payload = _route_boundary_payload(route) issue_codes = _route_issue_codes(route, route.get("collisions", [])) sample = { "wire_uuid": route.get("wire_uuid", ""), "wire_label": route.get("wire_label", ""), "wire_object_label": route.get("wire_object_label", ""), "start_terminal_uuid": route.get("start_terminal_uuid", ""), "start_element_uuid": route.get("start_element_uuid", ""), "start_terminal_display": route.get("start_terminal_display", ""), "end_terminal_uuid": route.get("end_terminal_uuid", ""), "end_element_uuid": route.get("end_element_uuid", ""), "end_terminal_display": route.get("end_terminal_display", ""), "endpoint_label": route.get("endpoint_label", ""), "route_status": route.get("route_status", ""), "wire_style_id": route.get("wire_style_id", ""), "wire_style_status": route.get("wire_style_status", ""), "length_mm": route.get("length_mm", 0.0), "lane": route.get("lane", {}), "algorithm": route.get("algorithm", ""), "collision_count": route.get("collision_count", 0), "carrier_kinds": _route_track_carrier_kinds(route_track), "carrier_names": _route_track_carrier_names(route_track, limit=8), "route_source_labels": _route_source_labels(route_track, limit=8), "access": access_payload, "collision_summary": collision_payload, "quality": quality_payload, "issue_codes": issue_codes, "issue_labels": [ _routing_diagnostic_issue_label(code) for code in issue_codes ], } if lane_capacity_payload["capacity_status"]: sample["capacity"] = lane_capacity_payload if boundary_payload["boundary_aware"]: sample["boundary"] = boundary_payload selective_status = str(route.get("selective_collision_reroute_status", "") or "").strip() if selective_status: sample["selective_collision_reroute"] = { "status": selective_status, "rejected_fallback_kinds": list( route.get("selective_collision_reroute_rejected_fallback_kinds", []) or [] ), "rejected_fallback_labels": list( route.get("selective_collision_reroute_rejected_fallback_labels", []) or [] ), } if isinstance(route.get("wire_style"), dict) and route.get("wire_style"): sample["wire_style"] = dict(route.get("wire_style") or {}) network = route.get("network", {}) if isinstance(network, dict): bridged_segments = network.get("bridged_segments", 0) if "bridged_segments" in route_track: bridged_segments = route_track.get("bridged_segments", 0) sample["network"] = { "carriers": network.get("carriers", 0), "segments": network.get("segments", 0), "bridged_segments": bridged_segments, "blocked_segments": network.get("blocked_segments", 0), "entry_distance": network.get("entry_distance", 0.0), "exit_distance": network.get("exit_distance", 0.0), "entry_candidate_rank": network.get("entry_candidate_rank", 1), "exit_candidate_rank": network.get("exit_candidate_rank", 1), "entry_candidate_score": network.get("entry_candidate_score", 0.0), "route_candidate_obstacle_hits": network.get("route_candidate_obstacle_hits", 0), "boundary_aware": bool(network.get("boundary_aware", False)), "route_candidate_boundary_violations": network.get( "route_candidate_boundary_violations", 0 ), "route_constraints": network.get("route_constraints", {}), } sample["network"]["terminal_access"] = { "start_consumed": bool(network.get("start_terminal_access_consumed", False)), "end_consumed": bool(network.get("end_terminal_access_consumed", False)), "start_carrier": str(network.get("start_terminal_access_carrier", "") or ""), "end_carrier": str(network.get("end_terminal_access_carrier", "") or ""), "start_label": str(network.get("start_terminal_access_label", "") or ""), "end_label": str(network.get("end_terminal_access_label", "") or ""), "start_target_kind": str(network.get("start_terminal_access_target_kind", "") or ""), "end_target_kind": str(network.get("end_terminal_access_target_kind", "") or ""), "start_target_name": str(network.get("start_terminal_access_target_name", "") or ""), "end_target_name": str(network.get("end_terminal_access_target_name", "") or ""), "start_target_label": str(network.get("start_terminal_access_target_label", "") or ""), "end_target_label": str(network.get("end_terminal_access_target_label", "") or ""), "start_target_distance": float( network.get("start_terminal_access_target_distance", 0.0) or 0.0 ), "end_target_distance": float( network.get("end_terminal_access_target_distance", 0.0) or 0.0 ), "start_target_component_primary_segments": int( network.get("start_terminal_access_target_component_primary_segments", 0) or 0 ), "end_target_component_primary_segments": int( network.get("end_terminal_access_target_component_primary_segments", 0) or 0 ), } return sample def _route_sample_priority(route, index): if not isinstance(route, dict): return (1, index) issue_count = len(_route_issue_codes(route, route.get("collisions", []))) if issue_count <= 0: return (1, index) return (0, -issue_count, index) def _routing_connection_batch_issue_codes(report): if not isinstance(report, dict): return [] collision_resolution_counts = _collision_resolution_counts(report) collision_relation_counts = _collision_relation_counts(report) missing_endpoint_reason_counts = _missing_endpoint_reason_counts_from_samples( report.get("missing_endpoint_samples", []) ) checks = ( ("no_wire_tasks", _safe_int(report.get("total_wires", 0)) <= 0), ("no_routed_connections", _has_routing_attempt_without_results(report)), ("missing_terminals", _safe_int(report.get("skipped_missing_terminal", 0)) > 0), ( "missing_device_binding_metadata", _safe_int(missing_endpoint_reason_counts.get("missing_device_binding_metadata", 0)) > 0, ), ( "device_not_in_3d_scene", _safe_int(missing_endpoint_reason_counts.get("device_not_in_3d_scene", 0)) > 0, ), ( "no_3d_terminals_for_element", _safe_int(missing_endpoint_reason_counts.get("no_3d_terminals_for_element", 0)) > 0, ), ( "no_3d_terminals_for_instance", _safe_int(missing_endpoint_reason_counts.get("no_3d_terminals_for_instance", 0)) > 0, ), ( "terminal_uuid_not_in_element", _safe_int(missing_endpoint_reason_counts.get("terminal_uuid_not_in_element", 0)) > 0, ), ( "duplicate_payload_terminal_instance_ids", _safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)) > 0, ), ( "missing_route_network", _safe_int(report.get("skipped_missing_route_network", 0)) > 0, ), ("routing_errors", _has_routing_error_status(report)), ("collision_warnings", _safe_int(report.get("collision_warnings", 0)) > 0), ( "structural_collision_candidates", _safe_int(collision_resolution_counts.get("review_pass_through_structural_obstacle", 0)) > 0, ), ( "device_or_layout_collisions", _safe_int(collision_resolution_counts.get("review_device_or_layout_collision", 0)) > 0, ), ( "third_party_device_collisions", _safe_int(collision_relation_counts.get("third_party_device_collision", 0)) > 0, ), ( "endpoint_device_collisions", _safe_int(collision_relation_counts.get("endpoint_device_collision", 0)) > 0, ), ( "main_path_detour_missing", _safe_int(report.get("selective_collision_reroute_rejected_fallback", 0)) > 0, ), ("missing_wire_styles", bool(_wire_style_status_samples(report, status="Missing", limit=1))), ( "route_quality_warnings", bool(_route_quality_warning_samples(report, limit=1)), ), ("main_path_not_used", _main_path_not_used(report)), ("main_path_underused", _main_path_underused(report)), ("terminal_access_fallback_targets", _terminal_access_fallback_targets(report)), ( "long_terminal_access", bool(_long_network_entry_warning_samples(report, limit=1)), ), ( "route_candidate_boundary_violations", bool(_route_candidate_boundary_warning_samples(report, limit=1)), ), ( "routed_wires_not_visible", _safe_int((report.get("routed_wire_visibility") or {}).get("hidden", 0)) > 0, ), ( "wire_styles_not_applied", _safe_int((report.get("wire_style_application") or {}).get("missing_application", 0)) > 0, ), ( "route_carriers_still_visible", bool((report.get("route_carrier_visibility") or {}).get("expected_hidden")) and _safe_int((report.get("route_carrier_visibility") or {}).get("visible_after_hide", 0)) > 0, ), ) issue_codes = [code for code, enabled in checks if enabled] # 候选避障命中与容量压力用于算法调试/质量观察;只要最终导线已生成且无真实碰撞, # 它们不应让批量诊断失败,否则手动验收会把“候选评分过程”误读成最终布线问题。 return issue_codes def _routed_route_issue_summary_from_report(report): issue_counts = {} issue_wire_count = 0 total_wire_count = 0 samples = [] if not isinstance(report, dict): return { "issue_wire_count": 0, "total_wire_count": 0, "issue_code_counts": {}, "samples": [], } for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue total_wire_count += 1 codes = [ str(code or "").strip() for code in list(route.get("issue_codes", []) or []) if str(code or "").strip() ] if not codes: continue issue_wire_count += 1 for code in codes: issue_counts[code] = issue_counts.get(code, 0) + 1 if len(samples) < 8: samples.append( { "wire_uuid": route.get("wire_uuid", ""), "label": route.get("wire_object_label", "") or route.get("wire_label", ""), "issue_codes": codes, } ) return { "issue_wire_count": issue_wire_count, "total_wire_count": total_wire_count, "issue_code_counts": dict(sorted(issue_counts.items())), "samples": samples, } def _main_path_detour_missing_summary_from_report(report, limit=8): # 批量布线刚完成时,测试或某些运行路径可能还没把导线对象完整挂入分组; # 这里直接从本次 routes[] 生成同一份补路摘要,避免面板报告漏掉缺主路径位置。 wire_count = 0 rejected_labels = [] seen_labels = set() rejected_label_counts = {} rejected_kind_counts = {} current_route_source_label_counts = {} bridge_pair_counts = {} samples = [] if not isinstance(report, dict): return { "wire_count": 0, "rejected_fallback_labels": [], "rejected_fallback_label_counts": {}, "rejected_fallback_kind_counts": {}, "current_route_source_label_counts": {}, "bridge_pair_counts": {}, "samples": [], } for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue issue_codes = [ str(code or "").strip() for code in list(route.get("issue_codes", []) or []) if str(code or "").strip() ] if "main_path_detour_missing" not in issue_codes: continue wire_count += 1 sample = { "name": "", "label": str(route.get("wire_object_label", "") or route.get("wire_label", "") or ""), "wire_uuid": str(route.get("wire_uuid", "") or ""), "rejected_fallback_labels": [], "rejected_fallback_kinds": [], "current_route_source_labels": [], } for kind in list(route.get("selective_collision_reroute_rejected_fallback_kinds", []) or []): kind = str(kind or "").strip() if not kind: continue rejected_kind_counts[kind] = rejected_kind_counts.get(kind, 0) + 1 if kind not in sample["rejected_fallback_kinds"]: sample["rejected_fallback_kinds"].append(kind) for label in list(route.get("selective_collision_reroute_rejected_fallback_labels", []) or []): label = str(label or "").strip() if not label: continue rejected_label_counts[label] = rejected_label_counts.get(label, 0) + 1 if label not in seen_labels: seen_labels.add(label) rejected_labels.append(label) if label not in sample["rejected_fallback_labels"]: sample["rejected_fallback_labels"].append(label) current_labels = _route_source_labels(route.get("route_track", {}), limit=8) sample["current_route_source_labels"] = current_labels for label in current_labels: current_route_source_label_counts[label] = current_route_source_label_counts.get(label, 0) + 1 for rejected_label in sample["rejected_fallback_labels"]: for current_label in current_labels: pair_key = "{0} -> {1}".format(rejected_label, current_label) bridge_pair_counts[pair_key] = bridge_pair_counts.get(pair_key, 0) + 1 if len(samples) < int(limit or 0): samples.append(sample) return { "wire_count": wire_count, "rejected_fallback_labels": rejected_labels, "rejected_fallback_label_counts": { key: rejected_label_counts[key] for key in sorted(rejected_label_counts) }, "rejected_fallback_kind_counts": { key: rejected_kind_counts[key] for key in sorted(rejected_kind_counts) }, "current_route_source_label_counts": { key: current_route_source_label_counts[key] for key in sorted(current_route_source_label_counts) }, "bridge_pair_counts": { key: bridge_pair_counts[key] for key in sorted(bridge_pair_counts) }, "samples": samples, } def _attach_main_path_detour_report_summary(doc, report): if not isinstance(report, dict): return report detour_summary = _main_path_detour_missing_summary_from_report(report) if _safe_int(detour_summary.get("wire_count", 0)) <= 0: detour_summary = _main_path_detour_missing_summary(doc) report["main_path_detour_missing_summary"] = detour_summary action_summary = { "issue_codes": list(report.get("issue_codes", []) or []), "main_path_detour_missing_summary": detour_summary, "routed_wire_issue_summary": _routed_route_issue_summary_from_report(report), "batch_top_collision_obstacles": list(report.get("top_collision_obstacles", []) or []), "batch_collision_resolution_summary": _collision_resolution_summary(report), "diagnostics": { "RoutingConnectionBatch": { "payload": report, } }, } report["recommended_actions"] = _routing_diagnostic_recommended_actions(action_summary) return report def _find_route_bridge_sources_by_name_or_label(doc, name="", label=""): refs = [] seen_names = set() name = str(name or "").strip() label = str(label or "").strip() if doc is None: return refs if name: obj = doc.getObject(name) if obj is not None: refs.append(obj) seen_names.add(getattr(obj, "Name", "")) for candidate in list(getattr(doc, "Objects", []) or []): candidate_name = getattr(candidate, "Name", "") if candidate_name in seen_names: continue candidate_label = str(getattr(candidate, "Label", "") or "").strip() route_source_name = str(getattr(candidate, "QetRouteSourceName", "") or "").strip() route_source_label = str(getattr(candidate, "QetRouteSourceLabel", "") or "").strip() if ( (label and (candidate_label == label or route_source_label == label)) or (name and (candidate_name == name or route_source_name == name)) ): refs.append(candidate) seen_names.add(candidate_name) return refs def _route_bridge_label(source, carrier, fallback): return ( getattr(source, "QetRouteSourceLabel", "") or getattr(source, "Label", "") or getattr(carrier, "QetRouteSourceLabel", "") or getattr(carrier, "Label", "") or getattr(carrier, "Name", "") or fallback ) def _is_auto_ignorable_unbound_structural_obstacle(obstacle): if not isinstance(obstacle, dict): return False if ( str(obstacle.get("element_uuid", "") or "").strip() or str(obstacle.get("instance_id", "") or "").strip() ): return False parent_refs = obstacle.get("parent_refs", {}) if isinstance(obstacle.get("parent_refs", {}), dict) else {} own_text = " ".join( str(part or "").lower() for part in [ obstacle.get("label", ""), obstacle.get("name", ""), ] ) if any(keyword in own_text for keyword in _DEVICE_COLLISION_KEYWORDS): return False text_parts = [ obstacle.get("label", ""), obstacle.get("name", ""), ] text_parts.extend(list(parent_refs.get("labels", []) or [])) text_parts.extend(list(parent_refs.get("names", []) or [])) text = " ".join(str(part or "").lower() for part in text_parts) return any(keyword in text for keyword in _STRUCTURAL_COLLISION_KEYWORDS) def _route_bridge_obstacles(doc, left_source, right_source, left_carrier, right_carrier): options = { "obstacle_clearance": float(DEFAULT_OPTIONS.get("obstacle_clearance", 5.0) or 0.0), "ignore_endpoint_near_obstacles": False, } obstacles = collect_obstacles( doc, exclude=[left_source, right_source, left_carrier, right_carrier], options=options, ) if not bool(DEFAULT_OPTIONS.get("auto_ignore_unbound_structural_obstacles", True)): return obstacles # 桥接路径在柜内生成,未绑定的柜体/安装框 AABB 往往包住整柜; # 它们不能阻止线槽/UserPath 之间的局部连通,但真实设备仍保留为硬障碍。 return [ obstacle for obstacle in obstacles if not _is_auto_ignorable_unbound_structural_obstacle(obstacle) ] def _bridge_points_avoiding_obstacles(left_point, right_point, obstacles): bboxes = [item.get("bbox") for item in list(obstacles or []) if isinstance(item.get("bbox"), dict)] if not bboxes: return [left_point, right_point] # 自动桥接是布线路径网络的一部分,不能为了连通两条线槽而直接穿过设备。 points = _orthogonal_points_avoiding_obstacles(left_point, right_point, bboxes) if detect_collisions(points, obstacles): return [] return _simplify_collinear_points(points) def _create_user_path_bridge_between_objects_avoiding_obstacles( doc, left_source, right_source, project_uuid="", bridge_kind="MainPathDetourBridge", ): best = RoutingNetwork.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) <= RoutingNetwork.DEFAULT_NODE_TOLERANCE: return [] try: if RoutingNetwork._route_bridge_already_exists(doc, left_point, right_point): return [] except Exception: pass obstacles = _route_bridge_obstacles(doc, left_source, right_source, left, right) points = _bridge_points_avoiding_obstacles(left_point, right_point, obstacles) if len(points) < 2: return [] left_label = _route_bridge_label(left_source, left, "Path A") right_label = _route_bridge_label(right_source, right, "Path B") carrier = RoutingNetwork.create_route_carrier( doc, points, label="QET User Bridge {0} -> {1}".format(left_label, right_label), project_uuid=project_uuid, kind=RoutingNetwork.ROUTE_CARRIER_KIND_USER_PATH, capacity=min( int(getattr(left, "QetRouteCarrierCapacity", 1) or 1), int(getattr(right, "QetRouteCarrierCapacity", 1) or 1), ), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeKind", "QET Routing", "QET route bridge kind", str(bridge_kind or "MainPathDetourBridge"), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgePairLabel", "QET Routing", "Human readable source pair for this generated bridge", "{0} -> {1}".format(left_label, right_label), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeLeftSourceName", "QET Routing", "Left/source object name for this generated bridge", getattr(left_source, "Name", "") or getattr(left, "QetRouteSourceName", "") or getattr(left, "Name", ""), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeRightSourceName", "QET Routing", "Right/source object name for this generated bridge", getattr(right_source, "Name", "") or getattr(right, "QetRouteSourceName", "") or getattr(right, "Name", ""), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeLeftSourceLabel", "QET Routing", "Left/source object label for this generated bridge", left_label, ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeRightSourceLabel", "QET Routing", "Right/source object label for this generated bridge", right_label, ) return [carrier] def _create_main_path_detour_bridges_from_report(doc, report, project_uuid=""): detour_summary = report.get("main_path_detour_missing_summary", {}) if isinstance(report, dict) else {} pair_counts = detour_summary.get("bridge_pair_counts", {}) if isinstance(detour_summary, dict) else {} if not isinstance(pair_counts, dict): pair_counts = {} created = [] missing_pairs = [] duplicates = 0 for pair_text, _count in sorted(pair_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0]))): pair_text = str(pair_text or "").strip() if " -> " not in pair_text: continue left_label, right_label = [part.strip() for part in pair_text.split(" -> ", 1)] if not left_label or not right_label: continue left_matches = _find_route_bridge_sources_by_name_or_label(doc, name=left_label, label=left_label) right_matches = _find_route_bridge_sources_by_name_or_label(doc, name=right_label, label=right_label) if not left_matches or not right_matches: missing_pairs.append(pair_text) continue new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( doc, left_matches[0], right_matches[0], project_uuid=project_uuid, ) if new_bridges: created.extend(new_bridges) else: duplicates += 1 return { "enabled": True, "pairs": len(pair_counts), "created_count": len(created), "duplicates": duplicates, "missing_pairs": missing_pairs, "created_pair_labels": [ getattr(bridge, "QetRouteBridgePairLabel", "") for bridge in created ], "rerouted": False, } def _result_route_points(result): points = [] if isinstance(result, dict): for point in list(result.get("points", []) or []): try: points.append(_vector(point)) except Exception: continue if points: return points wire = result.get("wire") for point in list(getattr(wire, "Points", []) or []): try: points.append(_vector(point)) except Exception: continue return points def _route_points_equal(left_points, right_points, tolerance=0.001): left = [_vector(point) for point in list(left_points or [])] right = [_vector(point) for point in list(right_points or [])] if len(left) != len(right): return False return all(_distance(left[index], right[index]) <= float(tolerance or 0.001) for index in range(len(left))) def _set_route_carrier_capacity(carrier, capacity): try: setter = getattr(RoutingNetwork, "_set_route_carrier_capacity_value") setter(carrier, capacity) return except Exception: pass try: if "QetRouteCarrierCapacity" not in getattr(carrier, "PropertiesList", []): carrier.addProperty( "App::PropertyInteger", "QetRouteCarrierCapacity", "QET Routing", "How many routed wires can reuse this carrier segment before detouring is preferred", ) carrier.QetRouteCarrierCapacity = max(int(capacity or 1), 1) except Exception: pass def _find_existing_main_path_detour_user_path(doc, points): target_points = _simplify_collinear_points(points) if len(target_points) < 2: return None for carrier in RoutingNetwork.collect_route_carriers(doc): if str(getattr(carrier, "QetRouteBridgeKind", "") or "").strip() != "MainPathDetourPath": continue if _route_points_equal(getattr(carrier, "Points", []) or [], target_points): return carrier return None def _create_main_path_detour_user_path_from_retry(doc, retry_result, original_result, project_uuid=""): points = _simplify_collinear_points(_result_route_points(retry_result)) if len(points) < 2: return None lane_capacity = max(int(((retry_result or {}).get("lane", {}) or {}).get("index", 0) or 0) + 1, 1) existing = _find_existing_main_path_detour_user_path(doc, points) if existing is not None: current_capacity = int(getattr(existing, "QetRouteCarrierCapacity", 1) or 1) _set_route_carrier_capacity(existing, max(current_capacity + 1, lane_capacity)) return existing retry_quality = _route_quality_payload(retry_result.get("route_track", {})) fallback_labels = list(retry_quality.get("fallback_carrier_labels", []) or []) current_labels = _route_source_labels(original_result.get("route_track", {}), limit=4) if isinstance(original_result, dict) else [] left_label = str(fallback_labels[0] if fallback_labels else "FallbackPath") right_label = str(current_labels[0] if current_labels else "MainPath") carrier = RoutingNetwork.create_route_carrier( doc, points, label="QET Auto Main Path Detour {0} -> {1}".format(left_label, right_label), project_uuid=project_uuid, kind=RoutingNetwork.ROUTE_CARRIER_KIND_USER_PATH, capacity=lane_capacity, ) # 这是把已验证的避障折线固化为正式 UserPath,不是放开 RoutingRange 兜底。 TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeKind", "QET Routing", "QET route bridge kind", "MainPathDetourPath", ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgePairLabel", "QET Routing", "Human readable source pair for this generated detour path", "{0} -> {1}".format(left_label, right_label), ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeLeftSourceLabel", "QET Routing", "Left/fallback source label for this generated detour path", left_label, ) TerminalObjects.ensure_string_property( carrier, "QetRouteBridgeRightSourceLabel", "QET Routing", "Right/current source label for this generated detour path", right_label, ) return carrier def _main_path_detour_wire_uuids_from_report(report): wire_uuids = [] seen = set() for route in list((report or {}).get("routes", []) or []): if not isinstance(route, dict): continue issue_codes = {str(code or "").strip() for code in list(route.get("issue_codes", []) or [])} if "main_path_detour_missing" not in issue_codes: continue wire_uuid = str(route.get("wire_uuid", "") or "").strip() if not wire_uuid or wire_uuid in seen: continue seen.add(wire_uuid) wire_uuids.append(wire_uuid) return wire_uuids def _terminal_access_fallback_wire_uuids_from_report(report): wire_uuids = [] seen = set() for sample in _terminal_access_fallback_target_samples(report, limit=0): if not isinstance(sample, dict): continue wire_uuid = str(sample.get("wire_uuid", "") or "").strip() if not wire_uuid or wire_uuid in seen: continue seen.add(wire_uuid) wire_uuids.append(wire_uuid) return wire_uuids def _create_terminal_access_fallback_bridges_from_report(doc, report, project_uuid=""): samples = [] if isinstance(report, dict): samples = [ item for item in list(report.get("terminal_access_fallback_target_samples", []) or []) if isinstance(item, dict) ] if not samples: samples = [ item for item in list(report.get("terminal_access_fallback_targets", []) or []) if isinstance(item, dict) ] if not samples: samples = _terminal_access_fallback_target_samples(report, limit=0) main_path_kinds = {"WireDuct", "WireDuctOpenEnd", "UserPath", "WiringCutOut", "RoutingPath"} main_candidates = [ carrier for carrier in RoutingNetwork.collect_route_carriers(doc) if str(getattr(carrier, "QetRouteCarrierKind", "") or "").strip() in main_path_kinds ] created = [] duplicates = 0 missing_refs = [] seen_targets = set() for sample in samples: if not isinstance(sample, dict): continue target_kind = str(sample.get("target_kind", "") or "").strip() if target_kind not in {"RoutingRange", "AuxiliaryPath"}: continue target_name = str(sample.get("target_name", "") or "").strip() target_label = str(sample.get("target_label", "") or "").strip() target_matches = _find_route_bridge_sources_by_name_or_label(doc, name=target_name, label="") if not target_matches: target_matches = _find_route_bridge_sources_by_name_or_label(doc, name=target_label, label=target_label) if not target_matches: missing_ref = target_name or target_label or target_kind if missing_ref and missing_ref not in missing_refs: missing_refs.append(missing_ref) continue target = target_matches[0] target_ref = getattr(target, "Name", "") or target_name or target_label if target_ref in seen_targets: continue seen_targets.add(target_ref) best_main = None best_distance = None recommended_main_name = str(sample.get("nearest_main_path_name", "") or "").strip() recommended_main_label = str(sample.get("nearest_main_path_label", "") or "").strip() if recommended_main_name or recommended_main_label: recommended_matches = _find_route_bridge_sources_by_name_or_label( doc, name=recommended_main_name, label=recommended_main_label, ) for candidate in recommended_matches: if candidate is target: continue if str(getattr(candidate, "QetRouteCarrierKind", "") or "").strip() not in main_path_kinds: continue bridge_candidate = RoutingNetwork.nearest_route_bridge_candidate_between_objects( doc, target, candidate, ) if not isinstance(bridge_candidate, dict): continue best_main = candidate best_distance = float(bridge_candidate.get("distance_mm", 0.0) or 0.0) break for candidate in main_candidates: if best_main is not None: break if candidate is target: continue bridge_candidate = RoutingNetwork.nearest_route_bridge_candidate_between_objects( doc, target, candidate, ) if not isinstance(bridge_candidate, dict): continue distance = float(bridge_candidate.get("distance_mm", 0.0) or 0.0) if best_distance is None or distance < best_distance: best_distance = distance best_main = candidate if best_main is None: missing_ref = "{0} -> 主路径".format(target_label or target_name or target_kind) if missing_ref not in missing_refs: missing_refs.append(missing_ref) continue new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( doc, target, best_main, project_uuid=project_uuid, bridge_kind="TerminalAccessFallbackBridge", ) if new_bridges: created.extend(new_bridges) else: duplicates += 1 return { "enabled": True, "targets": len(seen_targets), "created_count": len(created), "duplicates": duplicates, "missing_targets": missing_refs, "created_pair_labels": [ getattr(bridge, "QetRouteBridgePairLabel", "") for bridge in created ], "rerouted": False, } def _main_path_target_bridge_kind_set(): return {"WireDuct", "WireDuctOpenEnd", "UserPath", "WiringCutOut", "RoutingPath"} def _create_main_path_target_bridges_from_report(doc, report, project_uuid=""): """Bridge two main-path targets when a wire still detours through RoutingRange.""" if not isinstance(report, dict): return { "enabled": True, "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "wire_uuids": [], "rerouted": False, } main_path_kinds = _main_path_target_bridge_kind_set() seen_pairs = set() wire_uuids = [] created = [] duplicates = 0 missing_pairs = [] for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue quality = _route_quality_payload(route.get("route_track", {})) if not quality.get("fallback_carrier_kinds"): continue network = route.get("network", {}) if isinstance(route.get("network", {}), dict) else {} start_kind = str(network.get("start_terminal_access_target_kind", "") or "").strip() end_kind = str(network.get("end_terminal_access_target_kind", "") or "").strip() if start_kind not in main_path_kinds or end_kind not in main_path_kinds: continue start_name = str(network.get("start_terminal_access_target_name", "") or "").strip() end_name = str(network.get("end_terminal_access_target_name", "") or "").strip() start_label = str(network.get("start_terminal_access_target_label", "") or "").strip() end_label = str(network.get("end_terminal_access_target_label", "") or "").strip() if not (start_name or start_label) or not (end_name or end_label): continue pair_key = tuple(sorted(((start_name or start_label), (end_name or end_label)))) if len(set(pair_key)) < 2: continue wire_uuid = str(route.get("wire_uuid", "") or "").strip() if pair_key in seen_pairs: # 同一对线槽/UserPath 目标只需要补一条桥,但这对目标下的所有导线都应该重跑。 if wire_uuid and wire_uuid not in wire_uuids: wire_uuids.append(wire_uuid) continue seen_pairs.add(pair_key) start_matches = _find_route_bridge_sources_by_name_or_label( doc, name=start_name, label=start_label, ) end_matches = _find_route_bridge_sources_by_name_or_label( doc, name=end_name, label=end_label, ) if not start_matches or not end_matches: missing_pairs.append("{0} -> {1}".format(start_label or start_name, end_label or end_name)) continue new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( doc, start_matches[0], end_matches[0], project_uuid=project_uuid, bridge_kind="MainPathTargetBridge", ) if new_bridges: created.extend(new_bridges) if wire_uuid and wire_uuid not in wire_uuids: wire_uuids.append(wire_uuid) else: duplicates += 1 return { "enabled": True, "pairs": len(seen_pairs), "created_count": len(created), "duplicates": duplicates, "missing_pairs": missing_pairs, "created_pair_labels": [ getattr(bridge, "QetRouteBridgePairLabel", "") for bridge in created ], "wire_uuids": wire_uuids, "rerouted": False, } def _terminal_access_target_ref(access_carrier): if access_carrier is None: return {} kind = str(getattr(access_carrier, "QetTerminalAccessTargetKind", "") or "").strip() name = str(getattr(access_carrier, "QetTerminalAccessTargetName", "") or "").strip() label = str(getattr(access_carrier, "QetTerminalAccessTargetLabel", "") or "").strip() if kind not in _main_path_target_bridge_kind_set(): return {} if not (name or label): return {} return { "kind": kind, "name": name, "label": label, "key": name or label, } def _create_main_path_target_bridges_from_payload(doc, payload, project_uuid=""): """Pre-create bridges between the two main-path targets used by wire endpoints. 先补桥再布线,避免一批导线先退回 RoutingRange,随后又因为补桥重跑。 """ summary = { "enabled": True, "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "wire_uuids": [], "rerouted": False, "precreated_count": 0, } if doc is None or not isinstance(payload, dict): return summary payload = _load_context_payload_with_devices(payload) payload = _load_document_payload_with_devices(doc, payload) wires = list(payload.get("wires", []) or []) if not wires: return summary _repair_duplicate_terminal_metadata_from_payload(doc, payload) terminal_candidates = _collect_routable_terminals(doc) seen_pairs = set() created = [] duplicates = 0 missing_pairs = [] wire_uuids = [] for item in wires: if not isinstance(item, dict): continue start_terminal = _terminal_endpoint_match(terminal_candidates, item, "start").get("terminal") end_terminal = _terminal_endpoint_match(terminal_candidates, item, "end").get("terminal") if start_terminal is None or end_terminal is None: continue start_target = _terminal_access_target_ref( RoutingNetwork.terminal_access_carrier_for_terminal(start_terminal) ) end_target = _terminal_access_target_ref( RoutingNetwork.terminal_access_carrier_for_terminal(end_terminal) ) if not start_target or not end_target: continue pair_key = tuple(sorted((start_target["key"], end_target["key"]))) if len(set(pair_key)) < 2: continue wire_uuid = _wire_item_uuid(item) if wire_uuid and wire_uuid not in wire_uuids: wire_uuids.append(wire_uuid) if pair_key in seen_pairs: continue seen_pairs.add(pair_key) start_matches = _find_route_bridge_sources_by_name_or_label( doc, name=start_target.get("name", ""), label=start_target.get("label", ""), ) end_matches = _find_route_bridge_sources_by_name_or_label( doc, name=end_target.get("name", ""), label=end_target.get("label", ""), ) if not start_matches or not end_matches: missing_pairs.append( "{0} -> {1}".format( start_target.get("label") or start_target.get("name"), end_target.get("label") or end_target.get("name"), ) ) continue new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( doc, start_matches[0], end_matches[0], project_uuid=project_uuid, bridge_kind="MainPathTargetBridge", ) if new_bridges: created.extend(new_bridges) else: duplicates += 1 summary["pairs"] = len(seen_pairs) summary["created_count"] = len(created) summary["precreated_count"] = len(created) summary["duplicates"] = duplicates summary["missing_pairs"] = missing_pairs summary["created_pair_labels"] = [ getattr(bridge, "QetRouteBridgePairLabel", "") for bridge in created ] summary["wire_uuids"] = wire_uuids return summary def _wire_item_uuid(item): if not isinstance(item, dict): return "" return str(_wire_item_value(item, "wire_id", "wire_uuid", "id") or "").strip() def _payload_subset_for_wire_uuids(payload, wire_uuids): if not isinstance(payload, dict): return {} wanted = {str(wire_uuid or "").strip() for wire_uuid in list(wire_uuids or []) if str(wire_uuid or "").strip()} if not wanted: return {} subset = dict(payload) subset["wires"] = [ dict(item) for item in list(payload.get("wires", []) or []) if _wire_item_uuid(item) in wanted ] return subset if subset["wires"] else {} def _append_unique_text(values, value): result = [ str(item or "").strip() for item in list(values or []) if str(item or "").strip() ] text = str(value or "").strip() if text and text not in result: result.append(text) return result def _same_main_path_target_retry_payload(payload, report): """Build a retry payload for wires whose two accesses target main paths. 这类线在真实柜体里应优先沿线槽/UserPath/线槽开口等主路径走;如果第一次结果 仍退回 RoutingRange/AuxiliaryPath,就临时加“必经目标主路径、禁止兜底面”的约束重试。 """ summary = { "enabled": True, "wire_uuids": [], "target_names": [], "target_labels": [], } if not isinstance(payload, dict) or not isinstance(report, dict): return {}, summary main_path_kinds = _main_path_target_bridge_kind_set() constraints_by_wire = {} for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue quality = _route_quality_payload(route.get("route_track", {})) if not quality.get("fallback_carrier_kinds"): continue network = route.get("network", {}) if isinstance(route.get("network", {}), dict) else {} start_kind = str(network.get("start_terminal_access_target_kind", "") or "").strip() end_kind = str(network.get("end_terminal_access_target_kind", "") or "").strip() if start_kind not in main_path_kinds or end_kind not in main_path_kinds: continue start_name = str(network.get("start_terminal_access_target_name", "") or "").strip() end_name = str(network.get("end_terminal_access_target_name", "") or "").strip() start_label = str(network.get("start_terminal_access_target_label", "") or "").strip() end_label = str(network.get("end_terminal_access_target_label", "") or "").strip() if not (start_name or start_label) or not (end_name or end_label): continue wire_uuid = str(route.get("wire_uuid", "") or "").strip() if not wire_uuid: continue constraints_by_wire[wire_uuid] = { "target_names": _append_unique_text([start_name], end_name), "target_labels": _append_unique_text([start_label], end_label), } if wire_uuid not in summary["wire_uuids"]: summary["wire_uuids"].append(wire_uuid) for target_name in constraints_by_wire[wire_uuid]["target_names"]: if target_name and target_name not in summary["target_names"]: summary["target_names"].append(target_name) for target_label in constraints_by_wire[wire_uuid]["target_labels"]: if target_label and target_label not in summary["target_labels"]: summary["target_labels"].append(target_label) if not constraints_by_wire: return {}, summary retry_payload = dict(payload) retry_wires = [] for item in list(payload.get("wires", []) or []): if not isinstance(item, dict): continue wire_uuid = _wire_item_uuid(item) constraint = constraints_by_wire.get(wire_uuid) if not constraint: continue retry_item = dict(item) retry_item["forbidden_route_carrier_kinds"] = _append_unique_text( _append_unique_text(retry_item.get("forbidden_route_carrier_kinds", []), "RoutingRange"), "AuxiliaryPath", ) for target_name in list(constraint.get("target_names", []) or []): retry_item["required_route_carrier_names"] = _append_unique_text( retry_item.get("required_route_carrier_names", []), target_name, ) for target_label in list(constraint.get("target_labels", []) or []): retry_item["required_route_carrier_labels"] = _append_unique_text( retry_item.get("required_route_carrier_labels", []), target_label, ) retry_wires.append(retry_item) if not retry_wires: return {}, summary retry_payload["wires"] = retry_wires return retry_payload, summary def _recompute_route_report_after_route_replacement(doc, report): routes = [route for route in list((report or {}).get("routes", []) or []) if isinstance(route, dict)] route_status_counts = {} wire_style_status_counts = {} collision_samples = [] total_length = 0.0 collision_warnings = 0 rejected_fallback = 0 for route in routes: status = str(route.get("route_status", "") or "").strip() or "Unknown" route_status_counts[status] = route_status_counts.get(status, 0) + 1 style_status = str(route.get("wire_style_status", "") or "").strip() if style_status: wire_style_status_counts[style_status] = wire_style_status_counts.get(style_status, 0) + 1 if status == "CollisionWarning": collision_warnings += 1 if str(route.get("selective_collision_reroute_status", "") or "").strip() == "RejectedFallback": rejected_fallback += 1 total_length += float(route.get("length_mm", 0.0) or 0.0) for collision in list(route.get("collisions", []) or [])[:3]: if len(collision_samples) >= 8: break sample = dict(collision) if isinstance(collision, dict) else {} if sample: sample.setdefault("wire_uuid", route.get("wire_uuid", "")) sample.setdefault("wire_label", route.get("wire_label", "")) sample.setdefault("wire_object_label", route.get("wire_object_label", "")) collision_samples.append(sample) for status, count in ( ("MissingTerminal", _safe_int(report.get("skipped_missing_terminal", 0))), ("MissingRouteNetwork", _safe_int(report.get("skipped_missing_route_network", 0))), ("Invalid", _safe_int(report.get("skipped_invalid", 0))), ("Error", len(list(report.get("errors", []) or []))), ): if count > 0: route_status_counts[status] = route_status_counts.get(status, 0) + count report["routes"] = routes report["routed"] = len(routes) report["collision_warnings"] = collision_warnings report["total_length_mm"] = total_length report["route_status_counts"] = route_status_counts report["wire_style_status_counts"] = wire_style_status_counts report["collision_samples"] = collision_samples report["selective_collision_reroute_rejected_fallback"] = rejected_fallback report["route_path_usage"] = _route_path_usage_summary(report) report["top_collision_obstacles"] = _top_collision_obstacles(report) report["issue_codes"] = _routing_connection_batch_issue_codes(report) _attach_main_path_detour_report_summary(doc, report) return report def _merge_retry_routes_into_report(doc, report, retry_report, retry_prefix="main_path_detour"): if not isinstance(report, dict) or not isinstance(retry_report, dict): return report retry_routes = { str(route.get("wire_uuid", "") or "").strip(): route for route in list(retry_report.get("routes", []) or []) if isinstance(route, dict) and str(route.get("wire_uuid", "") or "").strip() } if not retry_routes: return report merged_routes = [] replaced = 0 for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue wire_uuid = str(route.get("wire_uuid", "") or "").strip() if wire_uuid in retry_routes: merged_routes.append(retry_routes[wire_uuid]) replaced += 1 else: merged_routes.append(route) report["routes"] = merged_routes prefix = str(retry_prefix or "main_path_detour").strip() or "main_path_detour" report["{0}_retry_wires".format(prefix)] = len(retry_routes) report["{0}_retry_replaced_routes".format(prefix)] = replaced return _recompute_route_report_after_route_replacement(doc, report) def _raise_main_path_detour_capacities_from_report(doc, report): if doc is None or not isinstance(report, dict): return 0 updated = 0 for route in list(report.get("routes", []) or []): if not isinstance(route, dict): continue lane_capacity = max(int(((route.get("lane", {}) or {}).get("index", 0) or 0)) + 1, 1) route_track = route.get("route_track", {}) if not isinstance(route_track, dict): continue for segment in list(route_track.get("segments", []) or []): if not isinstance(segment, dict): continue carrier_payload = segment.get("carrier", {}) if not isinstance(carrier_payload, dict): continue carrier_name = str(carrier_payload.get("name", "") or "").strip() if not carrier_name: continue carrier = doc.getObject(carrier_name) if carrier is None: continue if str(getattr(carrier, "QetRouteBridgeKind", "") or "").strip() != "MainPathDetourPath": continue current_capacity = int(getattr(carrier, "QetRouteCarrierCapacity", 1) or 1) if current_capacity < lane_capacity: _set_route_carrier_capacity(carrier, lane_capacity) current_capacity = lane_capacity updated += 1 if int(carrier_payload.get("capacity", 1) or 1) < current_capacity: carrier_payload["capacity"] = current_capacity if updated > 0: report["auto_main_path_detour_capacity_updates"] = updated return updated def _compact_routing_connection_batch_report(report, sample_limit=8): if not isinstance(report, dict): return {} limit = max(int(sample_limit or 0), 0) payload = {} scalar_keys = ( "total_wires", "available_terminals", "local_terminals", "auto_bound_terminals", "auto_created_terminals", "routed", "collision_warnings", "replaced_routed_connections", "total_length_mm", "skipped_missing_terminal", "skipped_missing_route_network", "skipped_invalid", "route_network_carriers", "route_network_segments", "route_network_nodes", "route_network_error", "batch_network_entry_candidate_limit", "missing_route_retry_candidate_limit", "missing_route_retries", "batch_avoid_obstacles", "selective_collision_reroute", "selective_collision_reroute_limit", "selective_collision_reroute_allow_fallback", "selective_collision_reroute_attempts", "selective_collision_reroutes", "selective_collision_reroute_no_improvement", "selective_collision_reroute_rejected_fallback", "selective_collision_reroute_errors", "batch_obstacle_candidates", "wire_style_database_path", "wire_style_database_fallback_from", "context_devices_loaded", "context_device_count", "context_devices_json_path", "runtime_version", "hidden_route_carriers", "duplicate_payload_terminal_instance_id_count", "routing_method", "routing_path_network_updated", ) for key in scalar_keys: if key in report: payload[key] = report.get(key) payload["issue_codes"] = _routing_connection_batch_issue_codes(report) payload["issue_labels"] = [ _routing_diagnostic_issue_label(code) for code in payload["issue_codes"] ] if isinstance(report.get("prepared_layout"), dict): payload["prepared_layout"] = report.get("prepared_layout") if isinstance(report.get("routing_path_network_diagnostic"), dict): payload["routing_path_network_diagnostic"] = report.get("routing_path_network_diagnostic") if isinstance(report.get("auto_diagnostic_bridges"), dict): payload["auto_diagnostic_bridges"] = dict(report.get("auto_diagnostic_bridges") or {}) if isinstance(report.get("auto_main_path_target_bridges"), dict): payload["auto_main_path_target_bridges"] = dict(report.get("auto_main_path_target_bridges") or {}) if isinstance(report.get("same_main_path_target_retry"), dict): payload["same_main_path_target_retry"] = dict(report.get("same_main_path_target_retry") or {}) if isinstance(report.get("auto_main_path_detour_bridges"), dict): payload["auto_main_path_detour_bridges"] = dict(report.get("auto_main_path_detour_bridges") or {}) if isinstance(report.get("auto_terminal_access_fallback_bridges"), dict): payload["auto_terminal_access_fallback_bridges"] = dict( report.get("auto_terminal_access_fallback_bridges") or {} ) for key in ( "routed_wire_visibility", "wire_style_application", "route_carrier_visibility", ): if isinstance(report.get(key), dict): payload[key] = dict(report.get(key) or {}) if isinstance(report.get("route_status_counts"), dict): payload["route_status_counts"] = dict(report.get("route_status_counts") or {}) carrier_kind_counts = _report_route_network_carrier_kind_counts(report) if carrier_kind_counts: payload["route_network_carrier_kind_counts"] = carrier_kind_counts payload["route_network_main_path_carriers"] = _route_network_main_path_carriers(report) wire_style_status_counts = _wire_style_status_counts(report) if wire_style_status_counts: payload["wire_style_status_counts"] = wire_style_status_counts missing_wire_style_samples = _wire_style_status_samples(report, status="Missing", limit=limit) payload["missing_wire_style_samples"] = missing_wire_style_samples payload["missing_wire_style_samples_count"] = len( _wire_style_status_samples(report, status="Missing", limit=0) ) missing_endpoint_uuids = list(report.get("missing_endpoint_uuids", []) or []) payload["missing_endpoint_uuid_count"] = len(missing_endpoint_uuids) payload["missing_endpoint_uuids"] = missing_endpoint_uuids[:50] missing_terminal_summary = _batch_missing_terminal_summary(report) if _safe_int(missing_terminal_summary.get("skipped_missing_terminal", 0)) > 0: payload["missing_terminal_summary"] = missing_terminal_summary for key in ( "auto_terminal_binding_warnings", "duplicate_payload_terminal_instance_id_samples", "missing_endpoint_samples", "missing_route_network_samples", "collision_samples", "error_samples", "errors", ): values = list(report.get(key, []) or []) payload[key] = values[:limit] payload["{0}_count".format(key)] = len(values) payload["collision_kind_counts"] = _collision_kind_counts(report) collision_relation_counts = _collision_relation_counts(report) if collision_relation_counts: payload["collision_relation_counts"] = collision_relation_counts collision_reroute_recommendation = _collision_reroute_recommendation(report) if collision_reroute_recommendation: payload["collision_reroute_recommendation"] = collision_reroute_recommendation payload["top_collision_obstacles"] = _top_collision_obstacles(report, limit=limit) collision_resolution_summary = _collision_resolution_summary(report, limit=limit) if collision_resolution_summary: payload["collision_resolution_summary"] = collision_resolution_summary if isinstance(report.get("main_path_detour_missing_summary"), dict): payload["main_path_detour_missing_summary"] = report.get("main_path_detour_missing_summary") if isinstance(report.get("recommended_actions"), list): payload["recommended_actions"] = list(report.get("recommended_actions") or []) routes = [route for route in list(report.get("routes", []) or []) if isinstance(route, dict)] payload["route_count"] = len(routes) prioritized_routes = [ route for _index, route in sorted( enumerate(routes), key=lambda item: _route_sample_priority(item[1], item[0]), ) ] payload["route_samples"] = [_compact_route_sample(route) for route in prioritized_routes[:limit]] payload["route_sample_count"] = len(payload["route_samples"]) payload["route_path_usage"] = _route_path_usage_summary(report) payload["terminal_access_usage"] = _terminal_access_usage_summary(report) payload["terminal_access_target_kind_counts"] = _terminal_access_target_kind_counts(report) payload["terminal_access_fallback_target_count"] = len( _terminal_access_fallback_target_samples(report, limit=0) ) payload["terminal_access_fallback_target_samples"] = _terminal_access_fallback_target_samples( report, limit=limit, ) payload["main_path_usage"] = { "main_path_carriers": _route_network_main_path_carriers(report), "underused": _main_path_underused(report), "fallback_source_label_counts": _fallback_route_source_label_counts(report, limit=limit), } route_quality_warnings = _route_quality_warning_samples(report, limit=limit) payload["route_quality_warning_count"] = len(_route_quality_warning_samples(report, limit=0)) payload["route_quality_warning_samples"] = route_quality_warnings entry_distance_warnings = _long_network_entry_warning_samples(report, limit=limit) payload["route_entry_distance_warning_count"] = len( _long_network_entry_warning_samples(report, limit=0) ) payload["route_entry_distance_warning_samples"] = entry_distance_warnings candidate_obstacle_warnings = _route_candidate_obstacle_warning_samples(report, limit=limit) payload["route_candidate_obstacle_warning_count"] = len( _route_candidate_obstacle_warning_samples(report, limit=0) ) payload["route_candidate_obstacle_warning_samples"] = candidate_obstacle_warnings candidate_boundary_warnings = _route_candidate_boundary_warning_samples(report, limit=limit) payload["route_candidate_boundary_warning_count"] = len( _route_candidate_boundary_warning_samples(report, limit=0) ) payload["route_candidate_boundary_warning_samples"] = candidate_boundary_warnings # 给界面/诊断 JSON 一个更工程化的别名,避免用户只看到算法内部的 candidate 命名。 payload["wire_outside_boundary_count"] = payload["route_candidate_boundary_warning_count"] payload["wire_outside_boundary_samples"] = candidate_boundary_warnings route_constraint_samples = _route_constraint_samples(report, limit=limit) payload["route_constraint_warning_count"] = len(_route_constraint_samples(report, limit=0)) payload["route_constraint_warning_samples"] = route_constraint_samples capacity_pressure_warnings = _route_capacity_pressure_samples(report, limit=limit) payload["route_capacity_pressure_warning_count"] = len( _route_capacity_pressure_samples(report, limit=0) ) payload["route_capacity_pressure_warning_samples"] = capacity_pressure_warnings payload["diagnostic_payload"] = "compact-routing-connection-batch-v1" return payload def _write_routing_connection_batch_diagnostic(doc, report): if doc is None or not isinstance(report, dict): return None project_uuid = _project_uuid(doc) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_connection_batch_diagnostics(doc) compact_payload = _compact_routing_connection_batch_report(report) diagnostic = doc.addObject("App::DocumentObjectGroup", _unique_name(doc, "QETRoutingConnectionDiagnostic")) diagnostic.Label = "QET Routing Connection Diagnostic" _set_string(diagnostic, "QetDiagnosticKind", "RoutingConnectionBatch", "QET diagnostic kind") _set_string(diagnostic, "QetProjectUuid", project_uuid, "Project UUID") _set_bool( diagnostic, "QetDiagnosticOk", not bool(_routing_connection_batch_issue_codes(report)), "QET diagnostic pass state", ) _set_string( diagnostic, "QetDiagnosticIssueCodes", _diagnostic_issue_codes_text(compact_payload.get("issue_codes", [])), "QET routing diagnostic issue codes", ) _set_string( diagnostic, "QetDiagnosticIssueLabels", _diagnostic_issue_labels_text(compact_payload.get("issue_codes", [])), "QET routing diagnostic issue labels", ) _set_string( diagnostic, "QetDiagnosticMessage", format_eplan_connection_route_report(report), "QET routing connection batch diagnostic message", ) _set_string( diagnostic, "QetDiagnosticJson", json.dumps(compact_payload, ensure_ascii=False), "QET routing connection batch diagnostic payload", ) group.addObject(diagnostic) return diagnostic def _iter_wire_tasks(doc): try: task_group = doc.getObject("QETWiring_01_Tasks") except Exception: task_group = None if task_group is None: return [] return [ task for task in list(getattr(task_group, "Group", []) or []) if (getattr(task, "RouteType", "") or "").strip() == "Task" ] def _wire_tasks_payload(doc): payload = {"project_uuid": _project_uuid(doc), "wires": []} for task in _iter_wire_tasks(doc): payload["wires"].append( { "wire_id": (getattr(task, "QetWireUuid", "") or "").strip(), "wire_label": (getattr(task, "QetWireLabel", "") or "").strip(), "wire_mark": (getattr(task, "QetWireMark", "") or "").strip(), "wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)), "wire_style_id": (getattr(task, "QetWireStyleId", "") or "").strip(), "net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(), "group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(), "start_element_uuid": (getattr(task, "QetStartElementUuid", "") or "").strip(), "start_instance_id": (getattr(task, "QetStartInstanceId", "") or "").strip(), "start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(), "start_terminal_display": (getattr(task, "QetStartTerminalDisplay", "") or "").strip(), "start_device_label": (getattr(task, "QetStartDeviceLabel", "") or "").strip(), "end_element_uuid": (getattr(task, "QetEndElementUuid", "") or "").strip(), "end_instance_id": (getattr(task, "QetEndInstanceId", "") or "").strip(), "end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(), "end_terminal_display": (getattr(task, "QetEndTerminalDisplay", "") or "").strip(), "end_device_label": (getattr(task, "QetEndDeviceLabel", "") or "").strip(), "endpoint_label": (getattr(task, "QetEndpointLabel", "") or "").strip(), } ) return payload def bind_wire_task_terminals_from_tasks(doc): return bind_wire_task_terminals_from_payload(doc, _wire_tasks_payload(doc)) def _direct_task_routing_path_network_diagnostic(doc, opts): try: return _compact_routing_path_network_diagnostic( RoutingNetwork.diagnose_routing_path_network( doc, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) ) except Exception as exc: return { "ok": False, "issue_count": 1, "issue_codes": ["routing_path_network_diagnostic_error"], "issues": [ { "severity": "warning", "code": "routing_path_network_diagnostic_error", "count": 1, } ], "summary": {}, "error": str(exc), } def _diagnostic_bridge_summary_from_report(result, enabled=True): if not isinstance(result, dict): result = {} created = list(result.get("created", []) or []) return { "enabled": bool(enabled), "suggestions": int(result.get("suggestions", 0) or 0), "created_count": len(created), "duplicates": int(result.get("duplicates", 0) or 0), "stale_suggestions": int(result.get("stale_suggestions", 0) or 0), "unconnected_terminal_access_bridge_targets": int( result.get("unconnected_terminal_access_bridge_targets", 0) or 0 ), "unconnected_terminal_access_user_path_bridges": int( result.get("unconnected_terminal_access_user_path_bridges", 0) or 0 ), "unconnected_terminal_access_bridge_duplicates": int( result.get("unconnected_terminal_access_bridge_duplicates", 0) or 0 ), "unconnected_terminal_access_bridge_pair_labels": list( result.get("unconnected_terminal_access_bridge_pair_labels", []) or [] ), } def _direct_task_auto_diagnostic_bridge_report(doc, opts): diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) bridge_report = _diagnostic_bridge_summary_from_report( {}, enabled=bool(opts.get("auto_create_diagnostic_bridges", True)), ) if not bridge_report["enabled"]: return diagnostic, bridge_report try: result = RoutingNetwork.create_user_path_bridges_from_diagnostic_suggestions( doc, diagnostic, project_uuid=_project_uuid(doc), ) created = list(result.get("created", []) or []) if isinstance(result, dict) else [] bridge_report = _diagnostic_bridge_summary_from_report(result, enabled=True) if created: # 任务直连入口没有“更新路径网络”前置步骤;桥接创建后补一次诊断,让报告反映桥接后的网络状态。 diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) except Exception as exc: bridge_report = _diagnostic_bridge_summary_from_report({}, enabled=True) bridge_report["error"] = str(exc) return diagnostic, bridge_report def route_eplan_connection_tasks(doc, options=None, prepared_layout=None): opts = _merged_options(options) routing_path_network_diagnostic = {} auto_diagnostic_bridges = {} if not bool(opts.get("__skip_task_auto_diagnostic_bridges", False)): routing_path_network_diagnostic, auto_diagnostic_bridges = ( _direct_task_auto_diagnostic_bridge_report(doc, opts) ) payload = _wire_tasks_payload(doc) report = route_eplan_connections_from_payload(doc, payload, options=opts, prepared_layout=prepared_layout) report_changed = False if ( isinstance(routing_path_network_diagnostic, dict) and routing_path_network_diagnostic and not _has_routing_path_network_diagnostic(report) ): report["routing_path_network_diagnostic"] = routing_path_network_diagnostic report_changed = True if isinstance(auto_diagnostic_bridges, dict) and auto_diagnostic_bridges: report["auto_diagnostic_bridges"] = auto_diagnostic_bridges report_changed = True if report_changed: report["issue_codes"] = _routing_connection_batch_issue_codes(report) _write_routing_connection_batch_diagnostic(doc, report) return report def prepare_eplan_layout_space(doc, project_uuid=""): """Prepare the FreeCAD document as an EPLAN-style layout space. This step marks layout-space source objects and wiring buckets, but does not generate the routing path network. In EPLAN terms, the layout space is the 3D installation context in which the network is later generated. """ if doc is None: raise AutoRoutingError("No FreeCAD document is available.") target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" return RoutingNetwork.prepare_layout_space_sources_from_document( doc, project_uuid=target_project_uuid, ) def generate_eplan_routing_path_network(doc, project_uuid="", options=None, selection_ex=None): """Generate the routing path network for the current layout space.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" return RoutingNetwork.create_routing_path_network_from_document( doc, project_uuid=target_project_uuid, selection_ex=selection_ex, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) def check_eplan_routing_path_network(doc, project_uuid="", options=None): """Write and return routing path network diagnostics for the layout space.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) if not target_project_uuid: try: target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() except Exception: target_project_uuid = "" result = RoutingNetwork.write_routing_path_network_diagnostic( doc, project_uuid=target_project_uuid, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), ) diagnostic = result.get("diagnostic", {}) if isinstance(result, dict) else {} return { "diagnostic": diagnostic, "diagnostic_object": result.get("diagnostic_object") if isinstance(result, dict) else None, "ok": bool(diagnostic.get("ok", False)) if isinstance(diagnostic, dict) else False, "issue_count": len(diagnostic.get("issues", []) or []) if isinstance(diagnostic, dict) else 0, "issue_codes": list(diagnostic.get("issue_codes", []) or []) if isinstance(diagnostic, dict) else [], } def _compact_routing_path_network_diagnostic(diagnostic): if not isinstance(diagnostic, dict): return {} issues = _dict_items(diagnostic.get("issues", []) or []) payload = { "ok": bool(diagnostic.get("ok", False)), "issue_count": len(issues), "issue_codes": [str(issue.get("code", "") or "") for issue in issues if issue.get("code", "")], "issues": [ { "severity": issue.get("severity", ""), "code": issue.get("code", ""), "count": issue.get("count", 0), } for issue in issues ], "summary": diagnostic.get("summary", {}) if isinstance(diagnostic.get("summary", {}), dict) else {}, } outside_carriers = _dict_items(diagnostic.get("route_carriers_outside_boundary", []) or []) if outside_carriers: payload["route_carriers_outside_boundary"] = [ { "carrier": item.get("carrier", {}) if isinstance(item.get("carrier", {}), dict) else {}, "outside_point_count": _safe_int(item.get("outside_point_count", 0)), "outside_points": list(item.get("outside_points", []) or [])[:3], } for item in outside_carriers[:5] ] outside_terminals = _dict_items(diagnostic.get("terminals_outside_boundary", []) or []) if outside_terminals: payload["terminals_outside_boundary"] = [ { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "outside_point_count": _safe_int(item.get("outside_point_count", 0)), "outside_points": list(item.get("outside_points", []) or [])[:3], } for item in outside_terminals[:5] ] unconnected = _dict_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: payload["unconnected_terminals"] = [ _compact_unconnected_terminal_sample(item) for item in unconnected[:8] ] long_accesses = _dict_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: payload["long_terminal_accesses"] = [ { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "terminal_origin": item.get("terminal_origin", {}), "parent_device_name": item.get("parent_device_name", ""), "parent_device_label": item.get("parent_device_label", ""), "parent_device_instance_id": item.get("parent_device_instance_id", ""), "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), "access_carrier": item.get("access_carrier", ""), "terminal_access_length_mm": item.get("terminal_access_length_mm", 0.0), "terminal_access_warning_distance_mm": item.get("terminal_access_warning_distance_mm", 0.0), "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), "target_kind": item.get("target_kind", ""), "target_name": item.get("target_name", ""), "target_label": item.get("target_label", ""), "target_rule": item.get("target_rule", ""), "target_distance_mm": item.get("target_distance_mm", 0.0), "nearest_main_path_kind": item.get("nearest_main_path_kind", ""), "nearest_main_path_name": item.get("nearest_main_path_name", ""), "nearest_main_path_label": item.get("nearest_main_path_label", ""), "nearest_main_path_distance_mm": item.get("nearest_main_path_distance_mm", 0.0), "nearest_main_path_over_max_distance": bool( item.get("nearest_main_path_over_max_distance", False) ), "endpoint_device_avoided": bool(item.get("endpoint_device_avoided", False)), "terminal_access_dominant_axis": item.get("terminal_access_dominant_axis", ""), "terminal_access_axis_lengths_mm": item.get("terminal_access_axis_lengths_mm", {}), "terminal_access_points": list(item.get("terminal_access_points", []) or [])[:6], } for item in long_accesses[:5] ] capped_exits = _dict_items(diagnostic.get("capped_terminal_exits", []) or []) if capped_exits: payload["capped_terminal_exits"] = [ _compact_terminal_exit_diagnostic_sample(item) for item in capped_exits[:8] ] corrected_exits = _dict_items(diagnostic.get("corrected_terminal_exits", []) or []) if corrected_exits: payload["corrected_terminal_exits"] = [ _compact_terminal_exit_diagnostic_sample(item) for item in corrected_exits[:8] ] invalid_exit_directions = _dict_items(diagnostic.get("invalid_terminal_exit_directions", []) or []) if invalid_exit_directions: payload["invalid_terminal_exit_directions"] = [ _compact_terminal_metadata_issue_sample(item) for item in invalid_exit_directions[:8] ] invalid_local_routes = _dict_items(diagnostic.get("invalid_terminal_local_routes", []) or []) if invalid_local_routes: payload["invalid_terminal_local_routes"] = [ _compact_terminal_metadata_issue_sample(item) for item in invalid_local_routes[:8] ] wire_duct_components = _dict_items(diagnostic.get("wire_ducts_without_terminal_access", []) or []) if wire_duct_components: payload["wire_ducts_without_terminal_access"] = [ { "index": item.get("index"), "nodes": _safe_int(item.get("nodes", 0)), "segments": _safe_int(item.get("segments", 0)), "carrier_kinds": item.get("carrier_kinds", {}) if isinstance(item.get("carrier_kinds", {}), dict) else {}, "carrier_names": list(item.get("carrier_names", []) or [])[:8], "bridge_suggestion": item.get("bridge_suggestion", {}) if isinstance(item.get("bridge_suggestion", {}), dict) else {}, } for item in wire_duct_components[:5] ] terminal_access_fallbacks = _dict_items(diagnostic.get("terminal_access_fallback_targets", []) or []) if terminal_access_fallbacks: payload["terminal_access_fallback_targets"] = [ _compact_terminal_access_quality_sample(item) for item in terminal_access_fallbacks[:8] ] terminal_access_endpoint_avoidance = _dict_items( diagnostic.get("terminal_access_endpoint_device_avoidance", []) or [] ) if terminal_access_endpoint_avoidance: payload["terminal_access_endpoint_device_avoidance"] = [ _compact_terminal_access_quality_sample(item) for item in terminal_access_endpoint_avoidance[:8] ] return payload def _compact_unconnected_terminal_sample(item): return { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "terminal_origin": item.get("terminal_origin", {}), "parent_device_name": item.get("parent_device_name", ""), "parent_device_label": item.get("parent_device_label", ""), "parent_device_instance_id": item.get("parent_device_instance_id", ""), "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), "access_carrier": item.get("access_carrier", ""), "nearest_network_distance_mm": item.get("nearest_network_distance_mm"), "nearest_network_point": item.get("nearest_network_point"), "nearest_network_carrier_kind": item.get("nearest_network_carrier_kind", ""), "nearest_network_carrier_name": item.get("nearest_network_carrier_name", ""), "nearest_network_carrier_label": item.get("nearest_network_carrier_label", ""), "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), "terminal_exit_length_mm": item.get("terminal_exit_length_mm", 0.0), "terminal_exit_point": item.get("terminal_exit_point", {}), "terminal_access_dominant_axis": item.get("terminal_access_dominant_axis", ""), "terminal_access_axis_lengths_mm": item.get("terminal_access_axis_lengths_mm", {}), "terminal_access_points": list(item.get("terminal_access_points", []) or [])[:6], "code": item.get("code", ""), } def _compact_terminal_access_quality_sample(item): return { "access_carrier_name": item.get("access_carrier_name", ""), "access_carrier_label": item.get("access_carrier_label", ""), "terminal_name": item.get("terminal_name", ""), "terminal_label": item.get("terminal_label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "parent_device_name": item.get("parent_device_name", ""), "parent_device_label": item.get("parent_device_label", ""), "parent_device_instance_id": item.get("parent_device_instance_id", ""), "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), "target_kind": item.get("target_kind", ""), "target_name": item.get("target_name", ""), "target_label": item.get("target_label", ""), "target_rule": item.get("target_rule", ""), "target_distance_mm": item.get("target_distance_mm", 0.0), "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), "nearest_main_path_kind": item.get("nearest_main_path_kind", ""), "nearest_main_path_name": item.get("nearest_main_path_name", ""), "nearest_main_path_label": item.get("nearest_main_path_label", ""), "nearest_main_path_distance_mm": item.get("nearest_main_path_distance_mm", 0.0), "nearest_main_path_over_max_distance": bool(item.get("nearest_main_path_over_max_distance", False)), "endpoint_device_avoided": bool(item.get("endpoint_device_avoided", False)), "endpoint_device_bbox": item.get("endpoint_device_bbox", {}), "access_length_mm": item.get("access_length_mm", 0.0), "access_points": list(item.get("access_points", []) or [])[:6], } def _compact_terminal_metadata_issue_sample(item): payload = { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "parent_device_name": item.get("parent_device_name", ""), "parent_device_label": item.get("parent_device_label", ""), "parent_device_instance_id": item.get("parent_device_instance_id", ""), "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), "property_name": item.get("property_name", ""), "reason": item.get("reason", ""), "message": item.get("message", ""), "raw_sample": item.get("raw_sample", ""), } for key in ("local_route_end_point", "endpoint_device_bbox", "valid_point_count"): if key in item: payload[key] = item.get(key) return payload def _compact_terminal_exit_diagnostic_sample(item): return { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), "instance_id": item.get("instance_id", ""), "parent_device_name": item.get("parent_device_name", ""), "parent_device_label": item.get("parent_device_label", ""), "parent_device_instance_id": item.get("parent_device_instance_id", ""), "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), "exit_rule": item.get("exit_rule", ""), "exit_direction_source": item.get("exit_direction_source", ""), "exit_direction": item.get("exit_direction", {}), "original_exit_direction": item.get("original_exit_direction", {}), "exit_direction_corrected": bool(item.get("exit_direction_corrected", False)), "origin": item.get("origin", {}), "exit_point": item.get("exit_point", {}), "local_route_used": bool(item.get("local_route_used", False)), "local_route_point_count": _safe_int(item.get("local_route_point_count", 0)), "requested_exit_length_mm": item.get("requested_exit_length_mm", 0.0), "actual_exit_length_mm": item.get("actual_exit_length_mm", 0.0), "max_exit_length_mm": item.get("max_exit_length_mm", 0.0), "device_exit_required_length_mm": item.get("device_exit_required_length_mm", 0.0), "original_device_exit_required_length_mm": item.get("original_device_exit_required_length_mm", 0.0), "exit_length_capped": bool(item.get("exit_length_capped", False)), "device_bbox_detected": bool(item.get("device_bbox_detected", False)), } _PATH_NETWORK_ISSUE_LABELS = { "empty_routing_path_network": "布线路径网络为空", "invalid_route_carriers": "路径对象几何无效", "routing_range_only_network": "仅使用布线面兜底", "invalid_terminal_local_routes": "端子局部路径无效", "route_carriers_outside_boundary": "路径越出柜内边界", "terminals_outside_boundary": "端子越出柜内边界", "long_terminal_accesses": "端子接入过长", "terminal_exit_length_capped": "端子出线长度截断", "terminal_exit_direction_corrected": "端子默认出线方向校正", "terminal_access_fallback_targets": "端子接入退回布线面", "terminal_access_endpoint_device_avoidance": "端子接入避让端点设备", "unconnected_terminals": "端子未接入", "wire_duct_endpoint_breaks": "线槽端点疑似断开", "wire_ducts_without_terminal_access": "线槽未接入端子主网络", "isolated_network_components": "存在孤立路径网络", } def _routing_path_network_issue_label(code): return _PATH_NETWORK_ISSUE_LABELS.get(str(code or ""), str(code or "未知问题")) def _format_distance_mm(value): try: return "{0:.1f} mm".format(float(value)) except Exception: return "未知距离" def _format_point_text(point): if not isinstance(point, dict): return "未知位置" try: return "({0:.1f}, {1:.1f}, {2:.1f})".format( float(point.get("x", 0.0)), float(point.get("y", 0.0)), float(point.get("z", 0.0)), ) except Exception: return "未知位置" def _diagnostic_terminal_text(item): if not isinstance(item, dict): return "未知端子" terminal_uuid = str(item.get("terminal_uuid", "") or "").strip() label = str(item.get("label", "") or "").strip() name = str(item.get("name", "") or "").strip() # 报告优先给人看的 FreeCAD 对象名,同时保留 terminal_uuid 便于和 QET 数据对应。 display = label if label and label != terminal_uuid else name if display and terminal_uuid and display != terminal_uuid: return "{0}({1})".format(display, terminal_uuid) return terminal_uuid or display or "未知端子" def _diagnostic_nearest_network_carrier_text(item): if not isinstance(item, dict): return "" label = str(item.get("nearest_network_carrier_label", "") or "").strip() name = str(item.get("nearest_network_carrier_name", "") or "").strip() kind = str(item.get("nearest_network_carrier_kind", "") or "").strip() text = label or name if not text: return "" if kind: return "{0}({1})".format(text, kind) return text def _dict_items(value): if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def _safe_int(value, fallback=0): try: return int(value or 0) except Exception: return int(fallback or 0) def format_routing_path_network_report(diagnostic): """Return an actionable Chinese summary for routing path network diagnostics.""" if not isinstance(diagnostic, dict): return "布线路径网络检查失败:诊断结果无效。" summary = diagnostic.get("summary", {}) if isinstance(diagnostic.get("summary", {}), dict) else {} issues = _dict_items(diagnostic.get("issues", []) or []) if not issues: message = "布线路径网络检查通过:{0} 条 carrier / {1} 段 / {2} 个节点。".format( summary.get("carriers", 0), summary.get("segments", 0), summary.get("nodes", 0), ) bridged_segments = int(summary.get("bridged_segments", 0) or 0) if bridged_segments > 0: message += " 自动桥接 {0} 段相邻/投影主路径。".format(bridged_segments) return message message = "布线路径网络检查发现 {0} 类问题。".format(len(issues)) empty_network = any(issue.get("code") == "empty_routing_path_network" for issue in issues) if empty_network: message += "\n布线路径网络为空:没有可用路径段。请先生成线槽、UserPath、过线孔或布线面路径。" unconnected = _dict_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: sample = unconnected[0] nearest_carrier = _diagnostic_nearest_network_carrier_text(sample) nearest_carrier_clause = ",最近路径 {0}".format(nearest_carrier) if nearest_carrier else "" message += "\n端子未接入:{0},距离最近网络 {1}{2},当前端子接入最大距离 {3}。请重新生成布线路径网络,或补一段线槽/辅助路径到该端子。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("nearest_network_distance_mm")), nearest_carrier_clause, _format_distance_mm(sample.get("terminal_access_max_distance_mm")), ) possible_breaks = _dict_items(diagnostic.get("possible_breaks", []) or []) if possible_breaks: sample = possible_breaks[0] carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} carrier_text = carrier.get("label") or carrier.get("name") or "未知线槽" message += "\n线槽端点疑似断开:{0} @ {1}。请补齐相邻线槽、开口或辅助路径。".format( carrier_text, _format_point_text(sample.get("point")), ) wire_duct_components = _dict_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, _format_distance_mm(suggestion.get("distance_mm")), ) else: message += "\n线槽未接入端子主网络:{0}。请用 UserPath/线槽开口/桥接路径把线槽接到端子接入所在的主网络。".format( carrier_text ) invalid_carriers = _dict_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 {} carrier_text = carrier.get("label") or carrier.get("name") or "未知路径对象" message += "\n路径对象几何无效:{0},有效点不足。请重新生成该 UserPath/线槽路径或检查 Points。".format( carrier_text ) outside_carriers = _dict_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 {} carrier_text = carrier.get("label") or carrier.get("name") or "未知路径对象" message += "\n路径越出柜内边界:{0},越界点 {1} 个。请把该线槽/UserPath 调整到柜内,或重新标记正确的柜内边界。".format( carrier_text, _safe_int(sample.get("outside_point_count", 0)), ) outside_terminals = _dict_items(diagnostic.get("terminals_outside_boundary", []) or []) if outside_terminals: sample = outside_terminals[0] message += "\n端子越出柜内边界:{0},越界点 {1} 个。请确认设备已经装配到柜内,或重新标记正确的柜内边界。".format( _diagnostic_terminal_text(sample), _safe_int(sample.get("outside_point_count", 0)), ) long_accesses = _dict_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: sample = long_accesses[0] target_label = str(sample.get("target_label", "") or sample.get("target_name", "") or "").strip() target_kind = str(sample.get("target_kind", "") or "").strip() target_clause = "" if target_label or target_kind: target_text = target_label or "未知目标" if target_kind: target_text = "{0}({1})".format(target_text, target_kind) target_clause = ",目标 {0}".format(target_text) message += "\n端子接入过长:{0},接入段 {1}{2},建议补设备局部路径、移动设备或补一段 UserPath/线槽靠近端子。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("terminal_access_length_mm")), target_clause, ) invalid_exit_directions = _dict_items(diagnostic.get("invalid_terminal_exit_directions", []) or []) if invalid_exit_directions: sample = invalid_exit_directions[0] message += "\n端子出线方向无效:{0},字段 {1}。请检查模板端子出线方向或 QetTerminalExitDirectionJson。".format( _diagnostic_terminal_text(sample), sample.get("property_name", "未知字段"), ) invalid_local_routes = _dict_items(diagnostic.get("invalid_terminal_local_routes", []) or []) if invalid_local_routes: sample = invalid_local_routes[0] reason = str(sample.get("reason", "") or "").strip() reason_clause = ",原因 {0}".format(reason) if reason else "" message += "\n端子局部路径无效:{0},字段 {1}{2}。请检查模板端子局部路径或 QetTerminalLocalRoutePointsJson。".format( _diagnostic_terminal_text(sample), sample.get("property_name", "未知字段"), reason_clause, ) corrected_exits = _dict_items(diagnostic.get("corrected_terminal_exits", []) or []) if corrected_exits: sample = corrected_exits[0] message += "\n端子默认出线方向已校正:{0},原方向 {1},采用方向 {2}。建议复查设备模板端子 LCS 或补明确局部出线路径。".format( _diagnostic_terminal_text(sample), _format_point_text(sample.get("original_exit_direction")), _format_point_text(sample.get("exit_direction")), ) capped_exits = _dict_items(diagnostic.get("capped_terminal_exits", []) or []) if capped_exits: sample = capped_exits[0] message += "\n端子出线长度截断:{0},实际 {1} / 上限 {2},设备出线需求 {3}。建议检查父设备包围盒、端子方向或局部出线路径。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("actual_exit_length_mm")), _format_distance_mm(sample.get("max_exit_length_mm")), _format_distance_mm(sample.get("device_exit_required_length_mm")), ) routing_range_only = diagnostic.get("routing_range_only_network", {}) if isinstance(routing_range_only, dict) and routing_range_only: message += "\n当前路径网络仅使用布线面兜底:RoutingRange {0} 条,主路径 0 条。建议补线槽、UserPath 或过线孔作为柜内主路径。".format( int(routing_range_only.get("routing_range_carriers", 0) or 0) ) isolated = _dict_items(diagnostic.get("isolated_components", []) or []) if isolated: sample = isolated[0] carriers = sample.get("carrier_labels") or sample.get("carrier_names") or [] carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知 carrier" message += "\n存在孤立路径网络:{0}。请用线槽/辅助路径把孤立网络接入主网络。".format(carrier_text) if not (empty_network or unconnected or possible_breaks or wire_duct_components or invalid_carriers or outside_carriers or outside_terminals or long_accesses or invalid_exit_directions or invalid_local_routes or corrected_exits or capped_exits or routing_range_only or isolated): first_issue = issues[0] message += "\n首个问题:{0} ({1})。".format( first_issue.get("code", "unknown"), first_issue.get("count", 0), ) return message def update_eplan_routing_path_network(doc, project_uuid="", options=None, selection_ex=None): """Update the routing path network before EPLAN-style Route.""" return generate_eplan_routing_path_network( doc, project_uuid=project_uuid, options=options, selection_ex=selection_ex, ) def _refresh_terminal_access_after_route_network_change(doc, project_uuid="", options=None): """Refresh only TerminalAccess carriers after auto-created bridge paths. 自动补 UserPath/桥接路径后,端子接入段可能还指向旧线槽。这里不重建 线槽、布线面等整套网络,只重算 TerminalAccess,让端子重新选择最近且 更合适的主路径,兼顾质量和耗时。 """ if doc is None: return {"refreshed": False, "terminal_access_carriers": 0} opts = options if isinstance(options, dict) else _merged_options(options) _invalidate_route_network_cache(opts) try: carriers = RoutingNetwork.create_terminal_access_carriers_from_document( doc, project_uuid=project_uuid, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), ) finally: _invalidate_route_network_cache(opts) return { "refreshed": True, "terminal_access_carriers": len(carriers), } def route_eplan_connections( doc, payload=None, options=None, project_uuid="", selection_ex=None, update_network=True, ): """Route QET wire tasks through the EPLAN-style routing path network.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) opts.setdefault("__route_network_cache", {}) terminal_access_refreshes = [] def refresh_terminal_access(reason): refresh = {"reason": reason, "refreshed": False, "terminal_access_carriers": 0} try: refresh.update( _refresh_terminal_access_after_route_network_change( doc, project_uuid=(project_uuid or _project_uuid(doc)), options=opts, ) ) if isinstance(prepared_network, dict): prepared_network["terminal_access_carriers"] = int( refresh.get("terminal_access_carriers", 0) or 0 ) except Exception as exc: refresh["error"] = str(exc) terminal_access_refreshes.append(refresh) return refresh prepared_network = None if update_network: prepared_network = update_eplan_routing_path_network( doc, project_uuid=project_uuid, options=opts, selection_ex=selection_ex, ) target_payload = payload if target_payload is None: candidate_payload = getattr(App, "_qet_exchange_payload", None) if _payload_matches_document_project(doc, candidate_payload): target_payload = candidate_payload effective_route_payload = target_payload if isinstance(target_payload, dict) and target_payload.get("wires") else None precreated_main_path_target_bridges = { "enabled": bool(opts.get("auto_create_main_path_target_bridges", True)), "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "wire_uuids": [], "rerouted": False, "precreated_count": 0, } if ( bool(opts.get("auto_create_main_path_target_bridges", True)) and isinstance(effective_route_payload, dict) and effective_route_payload.get("wires") ): try: precreated_main_path_target_bridges = _create_main_path_target_bridges_from_payload( doc, effective_route_payload, project_uuid=(project_uuid or _project_uuid(doc)), ) if int(precreated_main_path_target_bridges.get("created_count", 0) or 0) > 0: refresh_terminal_access("precreated_main_path_target_bridges") except Exception as exc: precreated_main_path_target_bridges["error"] = str(exc) diagnostic_route_network = None try: diagnostic_route_network, _diagnostic_route_network_reused = _cached_base_route_network(doc, opts) except Exception: diagnostic_route_network = None routing_path_network_diagnostic = {} auto_diagnostic_bridges = { "enabled": bool(opts.get("auto_create_diagnostic_bridges", True)), "suggestions": 0, "created_count": 0, "duplicates": 0, "stale_suggestions": 0, "unconnected_terminal_access_bridge_targets": 0, "unconnected_terminal_access_user_path_bridges": 0, "unconnected_terminal_access_bridge_duplicates": 0, "unconnected_terminal_access_bridge_pair_labels": [], } try: routing_path_network_diagnostic = _compact_routing_path_network_diagnostic( RoutingNetwork.diagnose_routing_path_network( doc, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), route_network=diagnostic_route_network, ) ) except Exception as exc: routing_path_network_diagnostic = { "ok": False, "issue_count": 1, "issue_codes": ["routing_path_network_diagnostic_error"], "issues": [ { "severity": "error", "code": "routing_path_network_diagnostic_error", "count": 1, } ], "summary": {}, "error": str(exc), } if bool(opts.get("auto_create_diagnostic_bridges", True)): try: bridge_report = RoutingNetwork.create_user_path_bridges_from_diagnostic_suggestions( doc, routing_path_network_diagnostic, project_uuid=(project_uuid or _project_uuid(doc)), ) created = list(bridge_report.get("created", []) or []) if isinstance(bridge_report, dict) else [] auto_diagnostic_bridges = _diagnostic_bridge_summary_from_report(bridge_report, enabled=True) if created: _invalidate_route_network_cache(opts) if update_network: prepared_network = update_eplan_routing_path_network( doc, project_uuid=project_uuid, options=opts, selection_ex=selection_ex, ) else: refresh_terminal_access("auto_diagnostic_bridges") try: diagnostic_route_network, _diagnostic_route_network_reused = _cached_base_route_network(doc, opts) except Exception: diagnostic_route_network = None routing_path_network_diagnostic = _compact_routing_path_network_diagnostic( RoutingNetwork.diagnose_routing_path_network( doc, terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), route_network=diagnostic_route_network, ) ) except Exception as exc: auto_diagnostic_bridges = _diagnostic_bridge_summary_from_report({}, enabled=True) auto_diagnostic_bridges["error"] = str(exc) if isinstance(target_payload, dict) and target_payload.get("wires"): report = route_eplan_connections_from_payload( doc, target_payload, options=opts, prepared_layout=prepared_network, ) else: task_route_options = dict(opts) task_route_options["__skip_task_auto_diagnostic_bridges"] = True effective_route_payload = _wire_tasks_payload(doc) if bool(opts.get("auto_create_main_path_target_bridges", True)): try: precreated_main_path_target_bridges = _create_main_path_target_bridges_from_payload( doc, effective_route_payload, project_uuid=(project_uuid or _project_uuid(doc)), ) if int(precreated_main_path_target_bridges.get("created_count", 0) or 0) > 0: refresh_terminal_access("precreated_task_main_path_target_bridges") except Exception as exc: precreated_main_path_target_bridges["error"] = str(exc) report = route_eplan_connection_tasks( doc, options=task_route_options, prepared_layout=prepared_network, ) auto_main_path_target_bridges = dict(precreated_main_path_target_bridges) if bool(opts.get("auto_create_main_path_target_bridges", True)): try: post_main_path_target_bridges = _create_main_path_target_bridges_from_report( doc, report, project_uuid=(project_uuid or _project_uuid(doc)), ) auto_main_path_target_bridges["pairs"] = int(auto_main_path_target_bridges.get("pairs", 0) or 0) + int( post_main_path_target_bridges.get("pairs", 0) or 0 ) auto_main_path_target_bridges["created_count"] = int( auto_main_path_target_bridges.get("created_count", 0) or 0 ) + int(post_main_path_target_bridges.get("created_count", 0) or 0) auto_main_path_target_bridges["postcreated_count"] = int( post_main_path_target_bridges.get("created_count", 0) or 0 ) auto_main_path_target_bridges["duplicates"] = int( auto_main_path_target_bridges.get("duplicates", 0) or 0 ) + int(post_main_path_target_bridges.get("duplicates", 0) or 0) auto_main_path_target_bridges["missing_pairs"] = list(auto_main_path_target_bridges.get("missing_pairs", []) or []) + list( post_main_path_target_bridges.get("missing_pairs", []) or [] ) auto_main_path_target_bridges["created_pair_labels"] = list(auto_main_path_target_bridges.get("created_pair_labels", []) or []) + list( post_main_path_target_bridges.get("created_pair_labels", []) or [] ) auto_main_path_target_bridges["wire_uuids"] = _append_unique_text( auto_main_path_target_bridges.get("wire_uuids", []), "", ) for wire_uuid in list(post_main_path_target_bridges.get("wire_uuids", []) or []): auto_main_path_target_bridges["wire_uuids"] = _append_unique_text( auto_main_path_target_bridges.get("wire_uuids", []), wire_uuid, ) if int(post_main_path_target_bridges.get("created_count", 0) or 0) > 0: retry_wire_uuids = list(post_main_path_target_bridges.get("wire_uuids", []) or []) refresh_terminal_access("postcreated_main_path_target_bridges") retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, retry_payload, options=opts, prepared_layout=prepared_network, ) report = _merge_retry_routes_into_report( doc, report, retry_report, retry_prefix="main_path_target", ) auto_main_path_target_bridges["retry_wires"] = len(retry_payload.get("wires", []) or []) auto_main_path_target_bridges["retry_replaced_routes"] = int( report.get("main_path_target_retry_replaced_routes", 0) or 0 ) auto_main_path_target_bridges["rerouted"] = True else: auto_main_path_target_bridges["retry_wires"] = 0 auto_main_path_target_bridges["retry_replaced_routes"] = 0 auto_main_path_target_bridges["rerouted"] = False else: auto_main_path_target_bridges.setdefault("retry_wires", 0) auto_main_path_target_bridges.setdefault("retry_replaced_routes", 0) auto_main_path_target_bridges.setdefault("rerouted", False) except Exception as exc: auto_main_path_target_bridges = { "enabled": True, "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "wire_uuids": [], "rerouted": False, "precreated_count": int(precreated_main_path_target_bridges.get("precreated_count", 0) or 0), "error": str(exc), } same_main_path_target_retry = { "enabled": True, "wire_uuids": [], "target_names": [], "target_labels": [], "retry_wires": 0, "retry_replaced_routes": 0, "rerouted": False, } try: retry_payload, same_main_path_target_retry = _same_main_path_target_retry_payload( effective_route_payload, report, ) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, retry_payload, options=opts, prepared_layout=prepared_network, ) report = _merge_retry_routes_into_report( doc, report, retry_report, retry_prefix="same_main_path_target", ) same_main_path_target_retry["retry_wires"] = len(retry_payload.get("wires", []) or []) same_main_path_target_retry["retry_replaced_routes"] = int( report.get("same_main_path_target_retry_replaced_routes", 0) or 0 ) same_main_path_target_retry["rerouted"] = bool( same_main_path_target_retry["retry_replaced_routes"] > 0 ) else: same_main_path_target_retry.setdefault("retry_wires", 0) same_main_path_target_retry.setdefault("retry_replaced_routes", 0) same_main_path_target_retry.setdefault("rerouted", False) except Exception as exc: same_main_path_target_retry = { "enabled": True, "wire_uuids": [], "target_names": [], "target_labels": [], "retry_wires": 0, "retry_replaced_routes": 0, "rerouted": False, "error": str(exc), } auto_main_path_detour_bridges = { "enabled": bool(opts.get("auto_create_main_path_detour_bridges", True)), "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "rerouted": False, } if bool(opts.get("auto_create_main_path_detour_bridges", True)): try: auto_main_path_detour_bridges = _create_main_path_detour_bridges_from_report( doc, report, project_uuid=(project_uuid or _project_uuid(doc)), ) if int(auto_main_path_detour_bridges.get("created_count", 0) or 0) > 0: retry_wire_uuids = _main_path_detour_wire_uuids_from_report(report) refresh_terminal_access("auto_main_path_detour_bridges") retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, retry_payload, options=opts, prepared_layout=prepared_network, ) report = _merge_retry_routes_into_report(doc, report, retry_report) auto_main_path_detour_bridges["retry_wires"] = len(retry_payload.get("wires", []) or []) auto_main_path_detour_bridges["retry_replaced_routes"] = int( report.get("main_path_detour_retry_replaced_routes", 0) or 0 ) auto_main_path_detour_bridges["rerouted"] = True else: auto_main_path_detour_bridges["retry_wires"] = 0 auto_main_path_detour_bridges["retry_replaced_routes"] = 0 auto_main_path_detour_bridges["rerouted"] = False except Exception as exc: auto_main_path_detour_bridges = { "enabled": True, "pairs": 0, "created_count": 0, "duplicates": 0, "missing_pairs": [], "created_pair_labels": [], "rerouted": False, "error": str(exc), } auto_terminal_access_fallback_bridges = { "enabled": bool(opts.get("auto_create_terminal_access_fallback_bridges", True)), "targets": 0, "created_count": 0, "duplicates": 0, "missing_targets": [], "created_pair_labels": [], "rerouted": False, } if bool(opts.get("auto_create_terminal_access_fallback_bridges", True)): try: auto_terminal_access_fallback_bridges = _create_terminal_access_fallback_bridges_from_report( doc, report, project_uuid=(project_uuid or _project_uuid(doc)), ) if int(auto_terminal_access_fallback_bridges.get("created_count", 0) or 0) > 0: retry_wire_uuids = _terminal_access_fallback_wire_uuids_from_report(report) refresh_terminal_access("auto_terminal_access_fallback_bridges") retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, retry_payload, options=opts, prepared_layout=prepared_network, ) report = _merge_retry_routes_into_report( doc, report, retry_report, retry_prefix="terminal_access_fallback", ) auto_terminal_access_fallback_bridges["retry_wires"] = len( retry_payload.get("wires", []) or [] ) auto_terminal_access_fallback_bridges["retry_replaced_routes"] = int( report.get("terminal_access_fallback_retry_replaced_routes", 0) or 0 ) auto_terminal_access_fallback_bridges["rerouted"] = True else: auto_terminal_access_fallback_bridges["retry_wires"] = 0 auto_terminal_access_fallback_bridges["retry_replaced_routes"] = 0 auto_terminal_access_fallback_bridges["rerouted"] = False except Exception as exc: auto_terminal_access_fallback_bridges = { "enabled": True, "targets": 0, "created_count": 0, "duplicates": 0, "missing_targets": [], "created_pair_labels": [], "rerouted": False, "error": str(exc), } report["routing_method"] = "eplan-route-v1" report["routing_path_network_updated"] = bool(update_network) report["routing_path_network_diagnostic"] = routing_path_network_diagnostic report["auto_diagnostic_bridges"] = auto_diagnostic_bridges report["auto_main_path_target_bridges"] = auto_main_path_target_bridges report["same_main_path_target_retry"] = same_main_path_target_retry report["auto_main_path_detour_bridges"] = auto_main_path_detour_bridges report["auto_terminal_access_fallback_bridges"] = auto_terminal_access_fallback_bridges report["terminal_access_refreshes"] = terminal_access_refreshes if isinstance(prepared_network, dict): report["routing_path_network"] = prepared_network if opts.get("hide_route_carriers_after_route", True): report["hidden_route_carriers"] = RoutingNetwork.set_route_carriers_visibility(doc, False) else: report["hidden_route_carriers"] = 0 report["visible_routed_wires"] = _ensure_routed_wires_visible_and_styled(doc) report["routed_wire_visibility"] = _routed_wire_visibility_summary(doc) report["wire_style_application"] = _wire_style_application_summary(doc) report["route_carrier_visibility"] = _route_carrier_visibility_summary( doc, expected_hidden=bool(opts.get("hide_route_carriers_after_route", True)), ) _refresh_routing_view(doc) _write_routing_connection_batch_diagnostic(doc, report) return report def wire_task_count(doc): return len(_iter_wire_tasks(doc)) def clear_routing_connections(doc): removed = 0 removed_wire_uuids = set() for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue wire_uuid = (getattr(obj, "QetWireUuid", "") or "").strip() try: _detach_object_from_groups(doc, obj) doc.removeObject(obj.Name) removed += 1 if wire_uuid: removed_wire_uuids.add(wire_uuid) except Exception: pass for wire_uuid in sorted(removed_wire_uuids): _set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), "Task") _clear_routing_connection_batch_diagnostics(doc) try: doc.recompute() except Exception: pass return removed def _console_message(message): try: App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) except Exception: pass def _console_error(message): try: App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) except Exception: pass class CommandRouteEplanConnections: def GetResources(self): return { "MenuText": "生成布线连接(全部导线)", "ToolTip": "按布线路径网络生成全部 3D 布线连接", } def IsActive(self): return getattr(App, "ActiveDocument", None) is not None def Activated(self): doc = getattr(App, "ActiveDocument", None) try: payload = getattr(App, "_qet_exchange_payload", None) report = route_eplan_connections( doc, payload=payload if isinstance(payload, dict) and payload.get("wires") else None, update_network=True, ) if report.get("total_wires", 0) <= 0: _console_error("没有导线任务。生成布线连接需要 QET wires[] 或 QETWiring_01_Tasks。") return _console_message(format_eplan_connection_route_report(report)) except Exception as exc: _console_error("批量生成布线连接失败:{0}".format(exc)) _COMMANDS_REGISTERED = False def register_commands(): global _COMMANDS_REGISTERED if _COMMANDS_REGISTERED: return if Gui is None or not hasattr(Gui, "addCommand"): return Gui.addCommand("QET_Exchange_RouteEplanConnections", CommandRouteEplanConnections()) _COMMANDS_REGISTERED = True register_commands()