diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 982d210..808b7e1 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -260,6 +260,8 @@ FreeCAD 的 `3D 布线连接` 面板提供“线槽桥接容差 mm”数值框 同一面板还提供“端子接入最大距离 mm”和“端子出线长度 mm”。前者用于控制端子距离最近路由网络超过多少毫米时不再生成 `TerminalAccess`,避免设备还没摆放好时生成超长悬空接入线;后者用于控制端子沿 LCS 出线方向先走出的短线长度,避免导线从设备壳体内部或端子原点直接折返。 +路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。 + 面板还提供“并行线间距 mm”和“并行线方向”,用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。 ### 2.1 路由优先级 @@ -268,7 +270,7 @@ FreeCAD 的 `3D 布线连接` 面板提供“线槽桥接容差 mm”数值框 1. `WireDuct`:线槽中心路径,最高优先级。 2. `RoutingPath`:历史兼容和内部调试用的明确路由线,不作为当前正式入口。 -3. `TerminalAccess`:端子到路由网络的自动接入路径,只用于把工程端子接入线槽/布线面。 +3. `TerminalAccess`:端子到路由网络的自动接入路径,只用于把工程端子接入 `WireDuct` / `UserPath` / `WiringCutOut` / `RoutingRange` 等主路径网络。 4. `AuxiliaryPath`:辅助路径,后续扩展使用。 5. `RoutingRange`:柜面/安装板等支撑面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。 6. `WireDuctOpenEnd`:线槽开口端横向路径,用于模拟 EPLAN 在线槽开口端生成的横向 routing path。 @@ -314,7 +316,7 @@ QetTerminalBindingMode = qet ```text QetRoutingRole = "RoutingCarrier" -QetRouteCarrierKind = "WireDuct" | "RoutingPath" | "AuxiliaryPath" | "RoutingRange" +QetRouteCarrierKind = "WireDuct" | "WireDuctOpenEnd" | "WiringCutOut" | "UserPath" | "RoutingPath" | "AuxiliaryPath" | "RoutingRange" | "TerminalAccess" CanRouteWire = true QetProjectUuid = Points = [Vector, Vector, ...] @@ -325,7 +327,7 @@ Points = [Vector, Vector, ...] ```text QetRouteSourceName = QetRouteSourceLabel = -QetRouteSourceKind = "WireDuct" | "RoutingRange" | "WiringCutOut" +QetRouteSourceKind = "WireDuct" | "RoutingRange" | "WiringCutOut" | "UserPath" | "TerminalAccess" ``` 这些属性只用于 FreeCAD 文档内部刷新和清理,不写入数据库,也不要求 QET 提供。 @@ -543,6 +545,16 @@ QetWiringCutOutBridgeExtensionMm = 20.0 批量布线报告还会汇总本批次路线中使用到的路径网络特征:如果路线依赖相邻线槽自动桥接,报告会显示自动桥接段数;如果主动避障时屏蔽了穿过障碍包围盒的网络边,报告会显示避障屏蔽段数。这里采用路线中的最大值展示,避免多条导线共用同一网络时重复累加。 +一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长等问题带出来。 + +当单条路线使用 `RoutingRange` 或 `AuxiliaryPath` 时,批量报告会提示“路径质量提示”,说明该导线可能没有完全优先进入线槽。这个提示不阻止布线,只用于暴露“当前路径依赖布线面兜底”的情况,方便后续补线槽、补 `UserPath` 或调整设备位置。批量诊断 JSON 也会记录这类提示:`route_quality_warning_count` 表示依赖布线面/辅助路径的导线数量,`route_quality_warning_samples` 保留少量导线样例及其使用的 carrier 类型。 + +路径网络检查还会识别“只有 `RoutingRange`、没有 `WireDuct` / `UserPath` / `WiringCutOut` 主路径”的情况,并记录 `routing_range_only_network`。这类网络可以作为无线槽或路径不完整时的临时兜底,但不是推荐的第一版主路径形态;手动测试看到该提示时,优先补线槽、补 `UserPath` 或补过线孔路径。 + +如果当前文档没有任何可用路径段,路径网络检查会记录 `empty_routing_path_network`,中文报告显示“布线路径网络为空”。这表示还没有生成可供自动布线搜索的线槽、`UserPath`、过线孔或布线面路径,不能把 0 carrier / 0 segment 当作检查通过。 + +如果 carrier 对象存在但 `Points` 为空、只有一个点,或多个点归一化后仍不足两个有效点,路径网络检查会记录 `invalid_route_carriers`,中文报告提示“路径对象几何无效”。这通常意味着用户路径、线槽路径或刷新后的 carrier 几何已经损坏,需要重新生成该路径对象。 + 如果多条导线共用同一路径并触发 lane 偏移,批量报告会显示最大 lane 编号和 lane 间距。这个值用于确认当前结果是否只是完全重叠的导线,还是已经按共路情况做了可视错位;它仍然是显示层偏移,不等于真实线槽截面排布或填充率计算。 当单条路线的最大并行线数超过该路线 route track 中记录的路径最小容量时,批量报告会给出容量提示。这个提示只基于 `QetRouteCarrierCapacity` 和当前 lane 情况,用于暴露“可能容量不足”的调试线索,不等同于按线径、截面积和线槽填充率计算的工程容量校核。 @@ -613,7 +625,7 @@ tests/python/freecad_exchange_auto_routing_test.py 16. 无线槽或线槽不完整时,可使用自动识别的支撑面辅助路径完成贴面布线。 17. 面板流程已简化为“准备布线布局空间 -> 生成布线路径网络 -> 生成布线连接”。 18. “准备布线布局空间”始终按整份文档识别线槽、支撑面和工程端子,并标记障碍处理方式。 -19. “生成布线路径网络”按 EPLAN 的 Generate routing path network 语义生成 WireDuct、RoutingRange 和 TerminalAccess carrier;有选择时,选中线槽只作为额外识别提示,仍会扫描整份文档。 +19. “生成布线路径网络”按 EPLAN 的 Generate routing path network 语义生成 `WireDuct` / `UserPath` / `WiringCutOut` / `RoutingRange` / `TerminalAccess` carrier;有选择时,选中线槽或草图路径只作为额外识别提示,仍会扫描整份文档。 20. “生成布线连接”会先更新同一套布线路径网络,再按全部 QET 导线任务批量求路。 21. 相邻线槽端点在容差内会被网络自动连通;端子接入会连接到最近的网络线段点,而不是只连接到已有端点。 22. 线槽端部会生成 `WireDuctOpenEnd` 横向路径,穿线孔/过线孔会生成 `WiringCutOut` carrier。 @@ -715,6 +727,7 @@ end_terminal_display 3. 系统把选中路径转换为 `UserPath` carrier,并参与后续自动布线最短路搜索。“选中路径作为用户路径”只创建用户路径;“生成布线路径网络”会同时更新线槽、布线面、端子接入等完整网络。 4. 再次选择同一个路径对象生成网络时,系统会刷新原 carrier,不会重复生成。 5. 如果删除了原草图/线段源对象,再点击“选中路径作为用户路径”或重新生成网络,系统会清理对应的失效 `UserPath` carrier。 +6. 如果源对象设置了 `QetRouteCarrierCapacity` 或 `QetWireCapacity`,生成/刷新出的 `UserPath` 会继承该容量,用于多根线共路和容量提示。 `UserPath` 与线槽的关系: @@ -739,6 +752,8 @@ QetTerminalLocalRoutePointsJson 自动生成 `TerminalAccess` 时,系统会先把这些局部点按端子和父设备的 `Placement` 转成全局点,再从局部路径末端连接到最近的柜内主路径、线槽、用户路径或布线面。没有该字段时,仍使用原来的端子 LCS `+Z` 方向短出线。 +路径网络检查也使用同一口径:如果端子有有效局部路径,端子到主路径网络的接入距离按局部路径末端计算,而不是按默认 LCS 出线点计算。这样可以避免局部路径已经接入线槽、但诊断仍误报“端子未接入”的情况。 + 当前模板链路已经支持把局部路径从设备模板带到工程端子。模板 sidecar 或 `QetTemplateSlotsJson` 可以在对应端子槽位上提供: ```json @@ -756,6 +771,8 @@ QetTerminalLocalRoutePointsJson 导入/更新工程端子时,FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。 +路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、不是数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。 + 如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数: ```python @@ -872,9 +889,9 @@ PE 线优先路径 当前版本验收只看“能否生成布线连接”: 1. 文档中有至少两个真实工程端子。 -2. 文档中有至少一条 `WireDuct` carrier,或有可作为低优先级路径的 `RoutingRange` 支撑面 carrier。 -3. 执行“生成布线网络路径”后,能生成 `WireDuct` carrier。 -4. 执行“生成布线布局空间”后,能生成或复用 `WireDuct` / `RoutingRange` carrier,并为工程端子生成 `TerminalAccess` 接入 carrier。 +2. 文档中有至少一条 `WireDuct` / `UserPath` / `WiringCutOut` carrier,或有可作为低优先级路径的 `RoutingRange` 支撑面 carrier。 +3. 执行“生成布线路径网络”后,能生成或复用 `WireDuct` / `UserPath` / `WiringCutOut` / `RoutingRange` carrier。 +4. 执行“生成布线布局空间”后,能识别线槽、用户路径、穿线孔或支撑面语义,并为工程端子生成 `TerminalAccess` 接入 carrier。 5. 存在导线任务时执行“生成布线连接”,会先准备布线路径网络,再批量生成 `AutoSuggested` 导线。 6. 生成导线在 `QETWiring_04_Routed` 下可见。 7. 没有路由网络时正式布线不生成长距离悬空线。 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index e697d2c..ae356d9 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -1912,21 +1912,14 @@ def _route_track_carrier_kinds(route_track): def _route_quality_warning_summary(report): warning_count = 0 sample = None - for route in report.get("routes", []) or []: - if not isinstance(route, dict): - continue - carrier_kinds = _route_track_carrier_kinds(route.get("route_track", {})) - warning_labels = [ - label - for kind, label in _ROUTE_QUALITY_WARNING_KIND_LABELS.items() - if carrier_kinds.get(kind, 0) - ] + 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": _wire_sample_text(route), + "wire": warning.get("wire_label") or warning.get("wire_uuid") or "未知导线", "labels": warning_labels, } if warning_count <= 0: @@ -1937,6 +1930,37 @@ def _route_quality_warning_summary(report): } +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: + samples.append( + { + "wire_uuid": route.get("wire_uuid", ""), + "wire_label": route.get("wire_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 + ], + } + ) + return samples + + def format_eplan_connection_route_report(report): message = "批量生成布线连接完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( report.get("routed", 0), @@ -1984,6 +2008,13 @@ def format_eplan_connection_route_report(report): prepared_layout.get("surface_carriers", 0), prepared_layout.get("terminal_access_carriers", 0), ) + 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 "存在问题") if report.get("skipped_missing_route_network", 0) > 0: message += "\n缺少布线路径网络:{0} 条导线已跳过。请先生成线槽、布线面或布线路径网络。".format( report.get("skipped_missing_route_network", 0) @@ -2202,6 +2233,9 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): payload["route_count"] = len(routes) payload["route_samples"] = [_compact_route_sample(route) for route in routes[:limit]] payload["route_sample_count"] = len(payload["route_samples"]) + 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 payload["diagnostic_payload"] = "compact-routing-connection-batch-v1" return payload @@ -2353,6 +2387,42 @@ def check_eplan_routing_path_network(doc, project_uuid="", options=None): } +def _compact_routing_path_network_diagnostic(diagnostic): + if not isinstance(diagnostic, dict): + return {} + issues = _dict_items(diagnostic.get("issues", []) or []) + return { + "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 {}, + } + + +_PATH_NETWORK_ISSUE_LABELS = { + "empty_routing_path_network": "布线路径网络为空", + "invalid_route_carriers": "路径对象几何无效", + "routing_range_only_network": "仅使用布线面兜底", + "invalid_terminal_local_routes": "端子局部路径无效", + "long_terminal_accesses": "端子接入过长", + "unconnected_terminals": "端子未接入", + "wire_duct_endpoint_breaks": "线槽端点疑似断开", + "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)) @@ -2409,6 +2479,10 @@ def format_routing_path_network_report(diagnostic): 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] @@ -2428,6 +2502,37 @@ def format_routing_path_network_report(diagnostic): _format_point_text(sample.get("point")), ) + 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 + ) + + long_accesses = _dict_items(diagnostic.get("long_terminal_accesses", []) or []) + if long_accesses: + sample = long_accesses[0] + message += "\n端子接入过长:{0},接入段 {1},建议补设备局部路径、移动设备或补一段 UserPath/线槽靠近端子。".format( + _diagnostic_terminal_text(sample), + _format_distance_mm(sample.get("terminal_access_length_mm")), + ) + + invalid_local_routes = _dict_items(diagnostic.get("invalid_terminal_local_routes", []) or []) + if invalid_local_routes: + sample = invalid_local_routes[0] + message += "\n端子局部路径无效:{0},字段 {1}。请检查模板端子局部路径或 QetTerminalLocalRoutePointsJson。".format( + _diagnostic_terminal_text(sample), + sample.get("property_name", "未知字段"), + ) + + 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] @@ -2435,7 +2540,7 @@ def format_routing_path_network_report(diagnostic): carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知 carrier" message += "\n存在孤立路径网络:{0}。请用线槽/辅助路径把孤立网络接入主网络。".format(carrier_text) - if not (unconnected or possible_breaks or isolated): + if not (empty_network or unconnected or possible_breaks or invalid_carriers or long_accesses or invalid_local_routes or routing_range_only or isolated): first_issue = issues[0] message += "\n首个问题:{0} ({1})。".format( first_issue.get("code", "unknown"), @@ -2475,6 +2580,31 @@ def route_eplan_connections( options=opts, selection_ex=selection_ex, ) + routing_path_network_diagnostic = {} + 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_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), + ) + ) + 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), + } target_payload = payload if target_payload is None: @@ -2496,6 +2626,7 @@ def route_eplan_connections( report["routing_method"] = "eplan-route-v1" report["routing_path_network_updated"] = bool(update_network) + report["routing_path_network_diagnostic"] = routing_path_network_diagnostic if isinstance(prepared_network, dict): report["routing_path_network"] = prepared_network if opts.get("hide_route_carriers_after_route", True): diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index fefcf2e..de8bb26 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -39,6 +39,7 @@ DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH = 20.0 DEFAULT_ROUTE_PATH_FACE_OFFSET = 2.0 DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT = 2.5 DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE = 1000.0 +DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE = 500.0 DEFAULT_ADJOINING_DUCT_TOLERANCE = 5.0 DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION = 20.0 WIRE_DUCT_OBSTACLE_MODE = "PassThrough" @@ -1211,6 +1212,10 @@ def _points_from_selection_item(selection_item): points.append(center) obj = getattr(selection_item, "Object", None) + if obj is not None and _is_route_path_source_object(obj): + for point in list(getattr(obj, "Points", []) or []): + points.append(_vector(point)) + shape = getattr(obj, "Shape", None) if shape is not None and _is_route_path_source_object(obj): for edge in list(getattr(shape, "Edges", []) or []): @@ -1469,6 +1474,8 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") if id(source) in seen_sources: continue seen_sources.add(id(source)) + if is_route_carrier(source): + continue if ( _is_wire_duct_candidate(source) or _is_support_surface_candidate(source) @@ -1482,10 +1489,12 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") points = _project_points_to_face(points, support_face) label = "QET User Route Path {0}".format(index) + capacity = 1 if source is not None: label = "QET User Route Path {0}".format( getattr(source, "Label", "") or getattr(source, "Name", "") or index ) + capacity = _route_carrier_capacity_value(source, default=1) live_carrier = _live_source_carrier(doc, source) if live_carrier is not None: if _update_route_carrier( @@ -1493,6 +1502,7 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, ): _mark_user_path_source(source, live_carrier) continue @@ -1503,6 +1513,7 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") label=label, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, ) if source is not None: _mark_user_path_source(source, carrier) @@ -2245,6 +2256,58 @@ def _terminal_local_route_points(terminal): return [] +def _terminal_local_route_issue(terminal): + invalid_samples = [] + saw_raw = False + for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): + raw = (getattr(terminal, property_name, "") or "").strip() + if not raw: + continue + saw_raw = True + try: + parsed = json.loads(raw) + except Exception as exc: + invalid_samples.append( + { + "property_name": property_name, + "reason": "invalid_json", + "message": str(exc), + "raw_sample": raw[:160], + } + ) + continue + if not isinstance(parsed, list): + invalid_samples.append( + { + "property_name": property_name, + "reason": "not_array", + "message": "Local route points JSON must be an array.", + "raw_sample": raw[:160], + } + ) + continue + points = [_json_route_point(item) for item in parsed if item is not None] + valid_points = [point for point in points if point is not None] + if len(_normalized_route_points(valid_points)) >= 2: + return None + invalid_samples.append( + { + "property_name": property_name, + "reason": "too_few_valid_points", + "message": "Local route points must contain at least two distinct valid points.", + "raw_sample": raw[:160], + "valid_point_count": len(valid_points), + } + ) + if not saw_raw or not invalid_samples: + return None + payload = _terminal_diagnostic_payload(terminal) + payload.update(invalid_samples[0]) + payload["invalid_samples"] = invalid_samples + payload["code"] = "terminal_local_route_invalid" + return payload + + def _terminal_parent_chain(terminal): chain = [] current = terminal @@ -3070,6 +3133,35 @@ def _network_summary_from_graph(network): } +def _routing_range_only_network_payload(summary): + if not isinstance(summary, dict): + return {} + kinds = summary.get("kinds", {}) + if not isinstance(kinds, dict): + return {} + primary_route_carriers = sum( + int(kinds.get(kind, 0) or 0) + for kind in ( + ROUTE_CARRIER_KIND_WIRE_DUCT, + ROUTE_CARRIER_KIND_USER_PATH, + ROUTE_CARRIER_KIND_WIRING_CUT_OUT, + ) + ) + routing_range_carriers = int(kinds.get(ROUTE_CARRIER_KIND_ROUTING_RANGE, 0) or 0) + if routing_range_carriers <= 0 or primary_route_carriers > 0: + return {} + return { + "primary_route_carriers": primary_route_carriers, + "routing_range_carriers": routing_range_carriers, + "primary_route_kinds": [ + ROUTE_CARRIER_KIND_WIRE_DUCT, + ROUTE_CARRIER_KIND_USER_PATH, + ROUTE_CARRIER_KIND_WIRING_CUT_OUT, + ], + "fallback_kind": ROUTE_CARRIER_KIND_ROUTING_RANGE, + } + + def _route_graph_components(network): nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} @@ -3144,6 +3236,35 @@ def _wire_duct_endpoint_breaks(network): return breaks +def _invalid_route_carriers(network): + invalid = [] + for carrier in network.get("carriers", []) or []: + points = _carrier_points(carrier) + normalized = _normalized_route_points(points) + if len(normalized) >= 2: + continue + invalid.append( + { + "carrier": _carrier_track_payload(carrier), + "point_count": len(points), + "distinct_point_count": len(normalized), + "code": "route_carrier_invalid_geometry", + } + ) + return invalid + + +def _polyline_length(points): + total = 0.0 + previous = None + for point in points or []: + current = _vector(point) + if previous is not None: + total += _distance(previous, current) + previous = current + return total + + def _terminal_diagnostic_payload(terminal): return { "name": getattr(terminal, "Name", ""), @@ -3168,32 +3289,66 @@ def diagnose_routing_path_network( summary = _network_summary_from_graph(network) isolated_components = components if len(components) > 1 else [] unconnected_terminals = [] + long_terminal_accesses = [] + invalid_terminal_local_routes = [] + routing_range_only_network = _routing_range_only_network_payload(summary) max_distance = max(float(terminal_access_max_distance or 0.0), 0.0) + warning_distance = min(max(max_distance * 0.5, DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE), max_distance) if max_distance > 0.0 else DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE for terminal in _collect_routable_terminals(doc): - exit_point = _terminal_exit_point(terminal, terminal_exit_length) + local_route_issue = _terminal_local_route_issue(terminal) + if local_route_issue is not None: + invalid_terminal_local_routes.append(local_route_issue) + terminal_access_points = terminal_access_path_points(terminal, terminal_exit_length) + exit_point = terminal_access_points[-1] if terminal_access_points else _terminal_exit_point(terminal, terminal_exit_length) nearest_point, distance = nearest_point_on_network(network, exit_point) access_carrier = _live_source_carrier(doc, terminal) access_live = access_carrier is not None and is_route_carrier(access_carrier) too_far = nearest_point is None or (max_distance > 0.0 and float(distance or 0.0) > max_distance) connected_directly = nearest_point is not None and float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE - if (access_live or connected_directly) and not too_far: + if not ((access_live or connected_directly) and not too_far): + payload = _terminal_diagnostic_payload(terminal) + payload.update( + { + "access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "", + "nearest_network_distance_mm": None if distance is None else float(distance), + "nearest_network_point": None if nearest_point is None else _point_payload(nearest_point), + "terminal_access_max_distance_mm": float(max_distance), + "terminal_exit_length_mm": float(max(float(terminal_exit_length or 0.0), 0.0)), + "code": "terminal_access_missing" if not access_live else "terminal_access_too_far", + } + ) + unconnected_terminals.append(payload) + continue + + access_points = _carrier_points(access_carrier) if access_live else [] + access_length = _polyline_length(access_points) + if access_length <= warning_distance: continue payload = _terminal_diagnostic_payload(terminal) payload.update( { "access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "", - "nearest_network_distance_mm": None if distance is None else float(distance), - "nearest_network_point": None if nearest_point is None else _point_payload(nearest_point), + "terminal_access_length_mm": float(access_length), + "terminal_access_warning_distance_mm": float(warning_distance), "terminal_access_max_distance_mm": float(max_distance), - "terminal_exit_length_mm": float(max(float(terminal_exit_length or 0.0), 0.0)), - "code": "terminal_access_missing" if not access_live else "terminal_access_too_far", + "code": "terminal_access_long", } ) - unconnected_terminals.append(payload) + long_terminal_accesses.append(payload) possible_breaks = _wire_duct_endpoint_breaks(network) + invalid_route_carriers = _invalid_route_carriers(network) issues = [] + if int(summary.get("segments", 0) or 0) <= 0: + issues.append( + { + "severity": "error", + "code": "empty_routing_path_network", + "message": "Routing path network has no usable segments.", + "count": 0, + } + ) if isolated_components: issues.append( { @@ -3221,6 +3376,42 @@ def diagnose_routing_path_network( "count": len(possible_breaks), } ) + if long_terminal_accesses: + issues.append( + { + "severity": "warning", + "code": "long_terminal_accesses", + "message": "Some terminal access carriers are unusually long.", + "count": len(long_terminal_accesses), + } + ) + if invalid_terminal_local_routes: + issues.append( + { + "severity": "warning", + "code": "invalid_terminal_local_routes", + "message": "Some terminals have invalid local route point metadata.", + "count": len(invalid_terminal_local_routes), + } + ) + if routing_range_only_network: + issues.append( + { + "severity": "warning", + "code": "routing_range_only_network", + "message": "Routing path network only contains fallback routing ranges.", + "count": int(routing_range_only_network.get("routing_range_carriers", 0) or 0), + } + ) + if invalid_route_carriers: + issues.append( + { + "severity": "error", + "code": "invalid_route_carriers", + "message": "Some route carriers have invalid or degenerate geometry.", + "count": len(invalid_route_carriers), + } + ) return { "summary": summary, @@ -3228,6 +3419,10 @@ def diagnose_routing_path_network( "components": components, "isolated_components": isolated_components, "unconnected_terminals": unconnected_terminals, + "long_terminal_accesses": long_terminal_accesses, + "invalid_terminal_local_routes": invalid_terminal_local_routes, + "routing_range_only_network": routing_range_only_network, + "invalid_route_carriers": invalid_route_carriers, "possible_breaks": possible_breaks, "issues": issues, "ok": not issues, @@ -3244,11 +3439,28 @@ def _highlight_routing_network_diagnostics(doc, diagnostic): for item in diagnostic.get("unconnected_terminals", []) or [] if item.get("name", "") ) + long_access_terminal_names = set( + item.get("name", "") + for item in diagnostic.get("long_terminal_accesses", []) or [] + if item.get("name", "") + ) + unconnected_terminal_names.update(long_access_terminal_names) + invalid_local_route_terminal_names = set( + item.get("name", "") + for item in diagnostic.get("invalid_terminal_local_routes", []) or [] + if item.get("name", "") + ) + unconnected_terminal_names.update(invalid_local_route_terminal_names) break_carriers = set( item.get("carrier", {}).get("name", "") for item in diagnostic.get("possible_breaks", []) or [] if item.get("carrier", {}).get("name", "") ) + break_carriers.update( + item.get("carrier", {}).get("name", "") + for item in diagnostic.get("invalid_route_carriers", []) or [] + if item.get("carrier", {}).get("name", "") + ) for obj in list(getattr(doc, "Objects", []) or []): name = getattr(obj, "Name", "") diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 5ab3b89..58e26b0 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -1252,6 +1252,57 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, len(carriers)) self.assertEqual("UserPath", carriers[0].QetRouteCarrierKind) + def test_selected_points_object_can_be_used_as_user_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "PointRoute") + route_path.Points = [ + app.Vector(0, 0, 20), + app.Vector(40, 0, 20), + app.Vector(40, 30, 20), + ] + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual( + [(0.0, 0.0, 20.0), (40.0, 0.0, 20.0), (40.0, 30.0, 20.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + + def test_selected_user_path_copies_source_capacity(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "PointRoute") + route_path.Points = [app.Vector(0, 0, 20), app.Vector(100, 0, 20)] + route_path.QetRouteCarrierCapacity = 5 + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carrier = routing_network.collect_route_carriers(doc)[0] + + self.assertEqual(5, carrier.QetRouteCarrierCapacity) + def test_controller_create_user_paths_reports_removed_stale_source_carriers(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -1693,6 +1744,120 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("端子接入最大距离 1000.0 mm", message) self.assertIn("补一段线槽/辅助路径", message) + def test_check_routing_path_network_warns_for_long_terminal_access(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalLongAccess", "terminal-long-access", app.Vector(0, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(900, 0, 20), app.Vector(1000, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=1000.0, + ) + + result = auto_routing.check_eplan_routing_path_network( + doc, + project_uuid="project-1", + options={"terminal_access_max_distance": 1000.0}, + ) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertEqual(1, len(payload["long_terminal_accesses"])) + self.assertEqual("terminal-long-access", payload["long_terminal_accesses"][0]["terminal_uuid"]) + self.assertEqual(900.0, payload["long_terminal_accesses"][0]["terminal_access_length_mm"]) + self.assertIn("端子接入过长", message) + self.assertIn("900.0 mm", message) + + def test_check_routing_path_network_warns_for_invalid_terminal_local_route_points(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + terminal = _terminal(doc, terminal_objects, "TerminalInvalidLocalPath", "terminal-invalid-local-path", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = "{not-valid-json" + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=1000.0, + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertEqual(1, len(payload["invalid_terminal_local_routes"])) + self.assertEqual( + "terminal-invalid-local-path", + payload["invalid_terminal_local_routes"][0]["terminal_uuid"], + ) + self.assertEqual( + "QetTerminalLocalRoutePointsJson", + payload["invalid_terminal_local_routes"][0]["property_name"], + ) + self.assertIn("端子局部路径无效", message) + self.assertIn("terminal-invalid-local-path", message) + + def test_check_routing_path_network_uses_terminal_local_route_end_for_connectivity(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + terminal = _terminal(doc, terminal_objects, "TerminalLocalEndOnDuct", "terminal-local-end-on-duct", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [1000, 0, 0]]) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=100.0, + ) + + result = auto_routing.check_eplan_routing_path_network( + doc, + project_uuid="project-1", + options={"terminal_access_max_distance": 100.0}, + ) + + self.assertEqual([], created) + self.assertEqual([], result["diagnostic"]["unconnected_terminals"]) + self.assertNotIn( + "unconnected_terminals", + [issue.get("code") for issue in result["diagnostic"]["issues"]], + ) + def test_format_routing_path_network_report_tolerates_malformed_samples(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -1730,6 +1895,76 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("(0.0, 0.0, 20.0)", message) self.assertIn("补齐相邻线槽", message) + def test_check_routing_path_network_warns_when_network_is_empty(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertEqual("empty_routing_path_network", payload["issues"][0]["code"]) + self.assertEqual(0, payload["summary"]["segments"]) + self.assertIn("布线路径网络为空", message) + + def test_check_routing_path_network_warns_for_invalid_route_carrier_geometry(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="坏用户路径", + project_uuid="project-1", + kind="UserPath", + ) + carrier.Points = [app.Vector(0, 0, 20)] + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertEqual(1, len(payload["invalid_route_carriers"])) + self.assertEqual("UserPath", payload["invalid_route_carriers"][0]["carrier"]["kind"]) + self.assertEqual(1, payload["invalid_route_carriers"][0]["point_count"]) + self.assertIn("路径对象几何无效", message) + self.assertIn("坏用户路径", message) + + def test_check_routing_path_network_warns_when_only_routing_range_is_available(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertEqual(1, payload["routing_range_only_network"]["routing_range_carriers"]) + self.assertEqual( + 0, + payload["routing_range_only_network"]["primary_route_carriers"], + ) + self.assertIn("routing_range_only_network", [issue.get("code") for issue in payload["issues"]]) + self.assertIn("仅使用布线面兜底", message) + def test_format_routing_path_network_report_includes_bridged_segment_count(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -2692,6 +2927,47 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"]) self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"]) + def test_route_eplan_connections_batch_diagnostic_includes_quality_warnings(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-surface", + "wire_label": "N-SURFACE", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + diagnostic_payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, diagnostic_payload["route_quality_warning_count"]) + self.assertEqual( + "wire-surface", + diagnostic_payload["route_quality_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + ["RoutingRange"], + diagnostic_payload["route_quality_warning_samples"][0]["carrier_kinds"], + ) + def test_route_eplan_connections_reports_total_connection_route_length(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -3108,6 +3384,49 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, route["network"]["carriers"]) self.assertEqual("WireDuct", route["route_track"]["segments"][0]["carrier"]["kind"]) + def test_route_eplan_connections_report_includes_routing_path_network_diagnostic(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-range-only", + "wire_label": "N-RANGE", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + options={"hide_route_carriers_after_route": False}, + project_uuid="project-1", + ) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(1, report["routed"]) + self.assertFalse(report["routing_path_network_diagnostic"]["ok"]) + self.assertIn( + "routing_range_only_network", + report["routing_path_network_diagnostic"]["issue_codes"], + ) + self.assertIn("路径网络检查提示", message) + self.assertIn("仅使用布线面兜底", message) + def test_route_eplan_connections_preserves_endpoint_metadata_on_routed_wire(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()