diff --git a/docs/2D-3D交换协议.md b/docs/2D-3D交换协议.md index 9418b4b..53cfdba 100644 --- a/docs/2D-3D交换协议.md +++ b/docs/2D-3D交换协议.md @@ -333,6 +333,11 @@ - 不再混入几何 `Conductor` UUID 作为导线主标识 - `wire_style_id` 只取 `start_terminal` 所连接导线的样式 +- 如果 FreeCAD 需要直接渲染导线颜色/线宽,可在顶层额外提供 `wire_style_database_path`,指向包含 `wire_properties` 表的项目 SQLite 数据库;FreeCAD 会按 `wires[].wire_style_id -> wire_properties.id` 查询样式。该字段是可选字段,也可以通过环境变量 `QET_WIRE_PROPERTIES_DB` 提供。 +- 如果顶层没有提供数据库路径,FreeCAD 导入 `2d_to_3d.json` 时会尝试扫描 JSON 同目录下的 `.sqlite / .sqlite3 / .db` 文件;只有确认其中存在 `wire_properties` 表时,才会自动使用该库作为 `wire_style_database_path`,并在 `_qet_exchange_summary.wire_style_database_path`、批量布线 report 与 compact 诊断中记录最终路径。这是 FreeCAD 侧便利推断,不是 QET 必填输出字段。 +- 当前 FreeCAD 会读取 `wire_properties.line_color / line_width / diameter_mm / line_type / area_or_spec` 做第一版显示渲染;颜色支持 `#RRGGBB`、`RRGGBB`、`0xRRGGBB`、`#AARRGGBB`、`0xAARRGGBB`、十进制颜色整数、`rgb(...)`、逗号 RGB 和常见英文色名,ARGB 的 alpha 暂不参与线颜色。显示线宽优先使用 `line_width`,缺失时用 `diameter_mm`,两者都缺失时会尝试从 `area_or_spec` 的 `2.5mm2 / 2.5mm^2 / 2.5mm²` 等截面积文本估算。 +- 查到样式后,FreeCAD 会把常用样式字段展开成 3D 导线对象属性:`QetWireStyleName`、`QetWireSpecText`、`QetWireColorText`、`QetWireLineType`、`QetWireType`、`QetWireFormat`、`QetWireDiameterMm`、`QetWireLineWidth`;这些是 3D 侧查看/调试属性,不是 QET 必填输出字段。 +- FreeCAD 会在生成导线对象上写入 `QetWireStyleStatus=Resolved/Missing`,并同步写入 `QetRouteDiagnosticsJson.wire_style_status`,用于判断 `wire_style_id` 是否成功回查到 `wire_properties`;批量报告会汇总 `wire_style_status_counts`,已解析样式会进入批量 `routes[].wire_style` 和 compact `route_samples[].wire_style`,compact 诊断的 `missing_wire_style_samples[]` 会列出缺失样式样例,`route_samples[]` 会保留 `wire_style_id` 和 `wire_style_status`。这是 3D 侧诊断结果,不是 QET 必填输出字段。 - 不按整条几何路径聚合多个样式 - `wires` 是交换 JSON 的扩展层 diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 1307203..2882b4e 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -126,6 +126,45 @@ terminal_uuid 导线规格、颜色、线耳等导线数据由 QET 提供,FreeCAD 第一版只消费和保留,不在 3D 侧重新发明导线主数据。 +#### 1.4.4 空机柜和未装配状态的处理 + +自动布线必须基于 FreeCAD 文档中的真实 3D 位姿。左侧树目录中存在设备、线槽或导轨,只表示对象已经导入,并不表示它们已经装配到机柜内,也不表示已经形成可走线的路径网络。 + +因此,下面状态不能用来判断自动布线最终效果: + +1. 设备、端子排、小型断路器仍停留在导入位置,尚未摆到导轨或安装板上。 +2. 线槽、导轨尚未贴到机柜背板或安装板。 +3. 安装板、柜内空间或柜体没有作为柜内边界/布线区域参与识别。 +4. 没有生成 `WireDuct`、`RoutingRange`、`UserPath` 或 `TerminalAccess` carrier。 + +当前 FreeCAD 自动布线预检会把这些前置状态显式报告出来: + +```text +路径网络:0 段 +布线源:未识别到线槽/布线面/用户路径 +柜内边界:未标记 +``` + +点击自动布线面板中的 `检查布线准备度` 后,FreeCAD 还会在树目录 `QETWiring_05_Diagnostics` 下写入一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示本次诊断是否通过;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要;`QetDiagnosticJson` 保存压缩后的最新预检结果,包括导线任务数量、工程端子数量、路径网络段数、布线源摘要、柜内边界摘要、导线样式库状态、问题码 `issue_codes`、缺失端点样例等。重复检查时旧的 `RoutingPreflight` 会被替换,只保留最新一次结果。 + +`RoutingPreflight` 还会附带 compact 路径网络诊断。若已标记 `CabinetInterior`,但主路径 carrier 或工程端子越出柜内边界,预检报告会直接追加 `route_carriers_outside_boundary` 或 `terminals_outside_boundary`,并在中文摘要中给出“越界路径”或“越界端子”样例。这样用户在生成导线前就能发现装配态问题。 + +预检的端点缺失示例会同时显示导线标签和端子对,例如 `导线 N4111,terminal-start -> terminal-missing`。这用于第一时间判断问题来自哪条 QET 导线任务、哪个端子 UUID 没有绑定到 FreeCAD 工程端子。 + +看到这类提示时,应先完成最小装配闭环,再测试布线结果: + +```text +安装板/背板 + -> 导轨 + -> 线槽或用户主路径 + -> 真实 QET 设备实例 + -> 工程端子 + -> 布线路径网络 + -> 自动布线连接 +``` + +如果现场本来没有线槽,也需要用草图、Draft 线或线段定义柜内主路径,并点击 `选中路径作为用户路径`,让它生成 `UserPath` carrier。否则算法没有可走的主网络,只能报告缺少路径网络。 + ### 1.5 设备脚号与 3D 脚点绑定方案 设备脚号与 3D 脚点绑定分成两层:模板槽位绑定和工程端子绑定。 @@ -199,6 +238,37 @@ QET 侧还需要保证导线任务中继续提供: 其中导线规格、颜色、线耳等导线主数据以后由 `wire_style_id` 或等价字段回查 QET。 +当前自动布线已经支持第一版导线样式渲染:`wires[].wire_style_id` 对应 QET 项目数据库 `wire_properties.id`。FreeCAD 可通过自动布线 options 中的 `wire_style_database_path`、`2d_to_3d.json` 顶层 `wire_style_database_path`,或环境变量 `QET_WIRE_PROPERTIES_DB`,打开项目 SQLite 数据库并查询 `wire_properties`。查询时优先匹配当前 `project_uuid`,读取到的样式会保存到已生成导线对象的 `QetWireStyleJson` 和 `QetRouteDiagnosticsJson.wire_style`。 + +如果 `2d_to_3d.json` 没有显式提供数据库路径,FreeCAD 导入交换文件时会尝试扫描 JSON 同目录下的 `.sqlite / .sqlite3 / .db` 文件;只有确认数据库中存在 `wire_properties` 表时,才会自动写入 `wire_style_database_path`。导入完成后,`_qet_exchange_summary.wire_style_database_path` 会记录最终使用的路径;自动布线面板摘要、批量布线 report、compact 诊断和中文报告都会显示“导线样式库:<路径>”,便于检查 FreeCAD 是否识别到样式库。这是 FreeCAD 侧便利推断,不要求 QET 修改输出协议。 + +第一版使用字段如下: + +```text +wire_properties.id -> wires[].wire_style_id +wire_properties.name -> 样式名称,写入诊断 +wire_properties.line_color -> FreeCAD 导线显示颜色,支持 #RRGGBB / RRGGBB / 0xRRGGBB / #AARRGGBB / 0xAARRGGBB / 十进制颜色整数 / rgb(...) / 逗号 RGB / 常见英文色名;ARGB 的 alpha 暂不参与线颜色 +wire_properties.line_type -> FreeCAD 导线线型,支持 Solid / DashLine / DotLine / DashDotLine 等常见写法 +wire_properties.line_width -> FreeCAD 视图线宽,优先使用 +wire_properties.diameter_mm -> line_width 缺失时作为显示线宽回退 +wire_properties.area_or_spec -> 导线规格文本,写入诊断;当 line_width 和 diameter_mm 都缺失时,支持从 2.5mm2 / 2.5mm^2 / 2.5mm² 等文本估算显示线宽 +``` + +查到样式后,FreeCAD 除了保存完整 `QetWireStyleJson`,还会把常用字段展开到导线对象属性,方便在 FreeCAD 属性面板中直接查看: + +```text +QetWireStyleName -> 样式名称 +QetWireSpecText -> 导线规格文本 +QetWireColorText -> 原始颜色文本 +QetWireLineType -> 原始线型文本 +QetWireType -> 导线类型 +QetWireFormat -> 导线格式 +QetWireDiameterMm -> 直径 +QetWireLineWidth -> 显示线宽 +``` + +如果查不到样式,导线仍按默认蓝色显示,并在导线对象 `QetWireStyleStatus` 与 `QetRouteDiagnosticsJson.wire_style_status` 中写入 `Missing`;查到样式时写入 `Resolved`。批量布线报告会汇总 `wire_style_status_counts`,中文报告会提示“导线样式:缺失 N 条”,并带上第一条缺失样例,例如“示例导线 N2 样式 404”。已解析样式会进入批量 `routes[].wire_style` 和 compact `route_samples[].wire_style`,compact 诊断也会输出 `missing_wire_style_samples[]`,`route_samples[]` 会保留 `wire_style_id` 和 `wire_style_status`,方便定位是哪条线缺少样式。这个状态由 FreeCAD 根据查询结果生成,不要求 QET 新增字段。如果导线存在碰撞告警,颜色仍会被红色告警覆盖,但线宽可以继续使用样式值。当前这是“视图线宽/颜色”的渲染,不是带真实半径的 3D 圆管导线。 + ### 1.6 区域与批量排布方案 #### 1.6.1 设备区域/柜内区域 @@ -303,14 +373,14 @@ QetTerminalUuid = local:: QetTerminalBindingMode = local ``` -这类端子只有空间槽位,没有 2D 电气语义,不能直接用于批量导线任务。正式布线前需要执行“检查/绑定工程端子”。系统会根据导线任务中的 `start/end_terminal_uuid`、`start/end_instance_id`、`start/end_element_uuid` 和端子显示号,在对应 3D 设备下查找 local 端子或模板槽位;匹配成功后写入: +这类端子只有空间槽位,没有 2D 电气语义,不能直接用于批量导线任务。正式布线前需要执行“检查/绑定工程端子”。系统会优先根据导线任务中的 `start/end_terminal_uuid`、`start/end_element_uuid` 和端子显示号,在对应 3D 设备下查找 local 端子或模板槽位;如果任务或 `devices[]` 中已有 `instance_id`,只作为 FreeCAD 侧辅助定位信息使用,不作为第一版 QET `wires[]` 的必填字段。匹配成功后写入: ```text QetTerminalUuid = QetTerminalBindingMode = qet ``` -如果导线任务缺少实例定位信息,或 QET 端子显示号与模板槽位名称不一致,绑定会跳过并给出诊断。 +如果导线任务缺少 `element_uuid`、找不到对应 3D 设备,或 QET 端子显示号与模板槽位名称不一致,绑定会跳过并给出诊断。 ### 3.2 路由路径 Carrier @@ -330,9 +400,11 @@ Points = [Vector, Vector, ...] QetRouteSourceName = QetRouteSourceLabel = QetRouteSourceKind = "WireDuct" | "RoutingRange" | "WiringCutOut" | "UserPath" | "TerminalAccess" +QetRouteSourcePathIndex = "1" | "2" | ... ``` 这些属性只用于 FreeCAD 文档内部刷新和清理,不写入数据库,也不要求 QET 提供。 +其中 `QetRouteSourcePathIndex` 主要用于同一个草图拆成多条 `UserPath` 时区分第几条源路径,便于诊断和路径示例回溯。只有同一源对象生成多条 `UserPath` 时才保留该序号;草图刷新后只剩单条路径时会清空旧序号。最终导线的 `QetRouteTrackJson` segment carrier payload 和公开 `carrier_payload()` 都会同步输出 `source_path_index`,便于导出诊断 JSON 后直接定位源草图路径。批量布线中文报告的“路径示例”在存在序号时会显示为 `源路径标签(路径1)`、`源路径标签(路径2)` 这类格式;普通单路径不写该字段,因此不会显示 `(路径1)`。 carrier 统一放在: @@ -543,19 +615,125 @@ QetWiringCutOutBridgeExtensionMm = 20.0 生成导线的 `QetRouteTrackJson` 会记录实际经过的 carrier。carrier 如果来自线槽、过线孔、支撑面或端子接入源对象,route track 中还会保留 `source_name`、`source_label`、`source_kind`,用于手动测试时追踪“这段线实际走过哪个 3D 源对象”。route track 同时记录 carrier 的 `capacity`,用于后续核对多根线共路、容量偏好和绕行行为。 +生成导线对象的树目录 Label 会尽量包含导线标识、起终点端子和状态,例如 `N4111: terminal-start -> terminal-end (Routed)` 或 `N4111: terminal-start -> terminal-end (CollisionWarning)`。这里的端点优先使用 FreeCAD 端子对象 Label;在 QET 导入端子中通常就是 `terminal_uuid`。这样手动测试截图时,可以直接从树目录定位是哪条 QET 导线、哪两个端子以及当前状态。 + +单根导线对象会写入 `QetRouteIssueCodes` 和 `QetRouteIssueLabels`,用于汇总这根线自身的问题。当前会把长接入、碰撞/安全间隙、路径兜底、容量压力、柜内越界和候选入口碰撞风险映射成与批量诊断一致的问题码,例如 `long_terminal_access`、`collision_warnings`、`route_quality_warnings`、`route_capacity_pressure`、`route_candidate_boundary_violations`。`QetRouteDiagnosticsJson.issue_codes` 和 `issue_labels` 会保存同一份数组,便于导出 JSON 后按单线筛选。 + +为了让手动测试不用每次展开 `QetRouteTrackJson`,单根导线对象会同步写入 `QetRouteSourceLabels` 和 `QetRouteCarrierNames`。`QetRouteSourceLabels` 优先显示源对象标签,例如线槽、黄色草图路径或用户路径标签;同一个源草图拆成多条 `UserPath` 时会显示为 `源路径标签(路径2)` 这类格式。`QetRouteCarrierNames` 保留实际经过的 carrier 对象名,便于在树目录中进一步定位。完整数组也会写入 `QetRouteDiagnosticsJson.route_source_labels` 和 `QetRouteDiagnosticsJson.route_carrier_names`。 + 如果导线实际走过自动桥接边,`QetRouteTrackJson` 中对应段会记录 `is_bridge=true`,并汇总 `bridged_segments`。批量布线报告和诊断对象中的 route sample 会优先使用这个本路线实际桥接数量;旧诊断缺少该字段时,再回退到整张路径网络的桥接数量。自动桥接段是虚拟连通边,不代表真实线槽截面,因此不参与容量最小值计算,也不参与共路 lane 计数、路径复用惩罚、真实 carrier 类型汇总、诊断样例 carrier 列表和路径质量提示。 -批量生成布线连接后,面板/控制台报告会从第一条可追踪路径中提取一条“路径示例”,显示导线经过的源对象标签,便于快速确认线路是否进入了预期线槽、过线孔和支撑面。路径示例会跳过 `is_bridge=true` 的虚拟桥接段,避免把自动补出来的连通边误显示成真实线槽或用户路径。 +批量生成布线连接后,面板/控制台报告会从第一条可追踪路径中提取一条“路径示例”,显示导线经过的源对象标签,便于快速确认线路是否进入了预期线槽、过线孔和支撑面。如果某个 carrier 没有 `source_label` / `source_name`,路径示例会回退显示 carrier 自身的 `label` / `name`,避免手动创建或旧版本 carrier 完全无法定位。路径示例会跳过 `is_bridge=true` 的虚拟桥接段,避免把自动补出来的连通边误显示成真实线槽或用户路径。 批量布线报告还会汇总本批次路线中使用到的路径网络特征:如果路线依赖相邻/投影主路径自动桥接,报告会显示自动桥接段数;如果主动避障时屏蔽了穿过障碍包围盒的网络边,报告会显示避障屏蔽段数。这里采用路线中的最大值展示,避免多条导线共用同一网络时重复累加。 -一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长等问题带出来。 +中文报告会区分 `布线布局空间` 和 `当前路径网络`。前者表示本次操作中新生成或刷新的 carrier 数量,因此已有线槽路径复用时可能显示线槽路径 0 条;后者来自实际构建的路径网络 `route_network_carrier_kind_counts`,会显示当前参与求路的 `WireDuct / WireDuctOpenEnd / UserPath / TerminalAccess / RoutingRange` 等总数。判断是否识别到线槽或用户路径时,以 `当前路径网络` 和 `路径采用` 为准。 + +`RoutingConnectionBatch.QetDiagnosticJson.route_samples[]` 会保留少量导线样例。每个 route sample 除了基础导线、端子、路径来源和 network 数据外,还会同步写入 `access`、`collision_summary`、`quality`、`capacity`、`boundary` 等状态分组,字段口径与单根导线对象属性一致。这样手动测试后即使不逐根选中导线,也可以直接从诊断对象 JSON 看出样例线是否存在长接入、穿模/安全间隙、路径兜底、容量压力或柜内越界。 + +每个 route sample 还会保留 `wire_object_label`,其值与 FreeCAD 左侧树目录中生成导线对象的 Label 一致,例如 `N4111: terminal-start -> terminal-end (CollisionWarning)`。当用户把诊断 JSON 发回开发侧时,可以用这个字段直接对应到树目录里的导线对象,减少反复按 UUID 查找。 + +各类 warning sample 也会尽量保留 `wire_object_label`,包括接入距离、路径质量、候选入口碰撞风险、柜内边界、路径约束、容量压力和碰撞样例。这样不论问题来自哪一类诊断,都可以用同一个字段回到 `QETWiring_04_Routed` 下定位导线。 + +中文报告会区分“定位类示例”和“统计类提示”:碰撞示例、缺失端点示例优先显示 `wire_object_label`,方便在 FreeCAD 树目录中直接查找;导线样式缺失、路径示例、总量统计等仍使用短导线号,避免一行报告被完整对象 Label 拉得过长。 + +`route_samples[]` 不是简单截取前几条导线,而是优先保留带 `issue_codes` 的问题路线;问题数量相同或没有问题时,再按原生成顺序保留。这样当一次布线有很多正常线、少量异常线时,压缩诊断对象仍会优先给出异常样例,避免手动测试复制 JSON 后看不到真正需要处理的导线。 + +一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。 + +真实工程中路径 carrier 数量可能达到数百个,入口候选组合会直接影响批量布线耗时。第一版保留单根布线的 `network_entry_candidate_limit`,同时在批量布线中增加 `batch_network_entry_candidate_limit`,默认按更保守的候选数求路,避免 `入口候选 x 出口候选 x 导线数量` 过度放大。批量入口候选还增加了总量保护 `batch_network_entry_total_candidate_limit`,当前默认值为 6;它会限制单根导线最终参与组合评分的入口/出口候选总量,避免“距离候选 + 柜内候选 + 避障候选”叠加后把一次布线放大成几十次 Dijkstra 求路。缺路径重试仍可以按 `missing_route_retry_candidate_limit` 临时放宽候选数量,但正常批量路径优先受总上限保护。批量布线还会复用本次已构建的基础路径图,避免每根导线重复构建同一套网络;碰撞障碍物也会先收集成候选缓存,再按每根导线的端点设备和端点附近规则过滤,避免重复扫描数千个模型对象。当前批量默认采用性能优先的 `batch_avoid_obstacles=false`:不额外构建障碍过滤图,但仍会在生成后做碰撞诊断并输出 `collision_warnings`;需要更激进避障时再开启批量障碍过滤。相关参数会写入 `RoutingConnectionBatch.QetDiagnosticJson.batch_network_entry_candidate_limit`、`batch_network_entry_total_candidate_limit`、`batch_avoid_obstacles` 和 `batch_obstacle_candidates`,便于手测时确认当前性能保护是否生效。 + +线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径;诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。 + +孤立路径网络诊断只针对可行动的路径组件。线槽、UserPath、过线孔、辅助路径和端子接入如果分成多个组件,会继续输出 `isolated_network_components`;但纯 `RoutingRange` 布线面孤岛只作为兜底网格保留在 `components` 明细中,不再单独触发“存在孤立路径网络”问题码。这样可以避免真实工程中安装板/布线面网格被误当作主路径断网问题,手测时优先处理线槽、用户路径和端子局部接入。 + +端子接入过长属于质量告警,不等同于路径断开。`terminal_access_max_distance` 控制是否允许生成端子接入;`terminal_access_warning_distance` 只控制超过多长时提示 `long_terminal_accesses`。当该值为 0 时继续沿用默认自动阈值;在较大机柜或端子到线槽本来就有较长局部出线的场景,可以把警告距离设置为 700mm 等工程可接受值,以减少误报,同时仍保留最大接入距离作为硬限制。 + +为了减少手动测试时反复展开属性查 JSON,`3D 布线连接` 面板提供 `汇总布线诊断`。它读取 FreeCAD 文档中最新的 `RoutingPreflight`、`RoutingPathNetwork`、`RoutingConnectionBatch` 三类诊断对象,按诊断类型合并状态、中文消息和 `issue_codes`,输出一个总的“通过/未通过”摘要,并刷新 `RoutingDiagnosticSummary` 对象。所有这类诊断对象都会把问题码同步写到 `QetDiagnosticIssueCodes`,把中文问题标签同步写到 `QetDiagnosticIssueLabels`,方便在属性面板中直接查看;完整明细仍保留在 `QetDiagnosticJson`。汇总诊断会从诊断 payload 中提取 `runtime_version`,优先采用 `RoutingConnectionBatch` 的版本号,没有批量布线结果时再回退到预检或路径网络版本,便于确认当前工程是否加载了最新运行模块。该汇总不重新生成路径、不重新布线,也不访问 QET 或修改数据库;它只是把 FreeCAD 已保存的诊断状态集中显示并固化到诊断树中,便于把一次装配/布线测试的问题快速分成“准备不足、路径网络问题、批量布线问题”三类。 + +当最新 `RoutingConnectionBatch` 存在时,汇总诊断会把它视为最终诊断入口。若批量报告中已经内嵌 `routing_path_network_diagnostic`,则不再要求额外存在独立 `RoutingPathNetwork` 诊断对象;同样,也不会因为用户没有单独执行 `检查布线准备度` 而把一次完整批量布线误判为失败。这样面板既支持完整流程,也支持现场更常用的简化流程:直接 `生成布线连接` 后点击 `汇总布线诊断`。 + +如果汇总时发现旧版诊断对象存在但 `QetDiagnosticJson` 为空,会追加 `diagnostic_json_empty / 诊断 JSON 为空`。这类对象不能证明当前布线状态有效,应重新运行对应诊断或重新生成布线连接,让新版本写入完整 `QetDiagnosticJson`。 + +汇总诊断还会扫描 `QETWiring_04_Routed` 下的已生成导线。如果发现旧版 `RoutedConnection` 缺少单线 `QetRouteDiagnosticsJson`,会追加 `routed_wire_diagnostics_missing / 导线诊断缺失`,并给出一条导线 Label 示例。这能区分“模型里看得到线”和“这条线具备当前版本碰撞、柜内边界、路径质量等诊断数据”。 + +旧版批量诊断对象可能没有 `issue_codes`,但会保留 `route_status_counts`、`skipped_missing_terminal` 和 `missing_endpoint_samples`。汇总诊断会从这些旧字段反推出 `routing_errors`、`missing_terminals` 和 `missing_endpoints`,并在中文摘要里显示“结果状态:错误 N 条,缺失端子 N 条”。这样用户重新打开旧工程时,不会因为旧诊断缺少新版字段而误以为没有布线错误。 + +如果单线 `QetRouteDiagnosticsJson` 存在但无法解析为合法 JSON,会追加 `routed_wire_diagnostics_invalid / 导线诊断 JSON 无效`。这类对象同样不能作为当前版本诊断依据,需要重新生成布线连接,让 FreeCAD 重新写入完整单线诊断。 当导线因为缺少布线路径网络被跳过时,批量报告会显示一条“缺路径网络示例”,包含导线号、起终点端子标签和已记录的失败原因。这里既包括整份文档没有有效路径段,也包括路径网络存在但该导线两端无法连通、端子接入距离阈值过小等情况。手动测试时可先按该示例定位设备两端附近是否缺线槽、`UserPath`、过线孔或布线面路径,或判断是否需要调整端子接入距离。 -当最终导线虽然布通、但起点或终点到主路径入口距离超过警戒阈值时,批量报告会显示“接入距离提示”,列出触发导线数量、一条导线样例及起点/终点接入距离。这个提示不阻止生成导线,用于暴露设备附近缺少局部路径、主路径离端子过远或端子接入距离设置过大的情况。批量诊断 JSON 也会记录 `route_entry_distance_warning_count` 和 `route_entry_distance_warning_samples`,便于导出后定位全部样例。 +批量 `issue_codes` 会把缺端子原因从样例提升到顶层:`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不匹配`。这样真实工程中只有少量缺端子时,也可以不展开 JSON 就判断下一步是补 QET 端点元数据、补 3D 设备装配/绑定,还是核对同设备端子 UUID。 + +批量布线原始 report 和 compact 批量诊断都会同步写入 `missing_terminal_summary`,复用汇总诊断的缺端子分组口径。`reason_code_counts` 统计每类原因,`device_groups[]` 按缺失侧设备聚合 `element_uuid`、`instance_id`、缺失端子、端子 UUID 和相关导线,便于把“缺 3D 设备”或“端子 UUID 不匹配”的问题直接交给装配/绑定流程处理。脚本或面板二次处理时应优先读取这个结构化字段,而不是解析中文报告里的 `需补端子设备` 文本。 + +当用户从已打开的 FCStd 任务对象直接执行布线,而任务对象自身没有携带完整 `devices[]` 时,FreeCAD 会尝试从当前 QET 交换上下文的 `2d_to_3d.json` 只读回补设备列表。该回补只合并 `devices[]`,不会用磁盘 JSON 覆盖当前 FreeCAD 文档中的导线任务;项目 UUID 不一致时会拒绝回补。批量 report 会写入 `context_devices_loaded`、`context_device_count` 和 `context_devices_json_path`,用于确认本次是否加载了上下文设备列表。这样真实工程中 `UD:8 / UD:10 / UD:5` 这类缺设备分组可以继续带出 `instance_id`,方便装配/绑定侧定位。 + +当批量布线已经生成导线,但 `route_path_usage.main_path_routes = 0` 且 `fallback_routes > 0` 时,诊断会追加 `main_path_not_used / 未使用线槽或用户主路径`。这表示导线虽然能连通,但全部依赖 `RoutingRange` 或 `AuxiliaryPath` 兜底路径,没有真正进入线槽、过线孔或用户主路径网络。手动测试看到该提示时,应优先补线槽中心路径、柜内黄色 `UserPath`、设备局部出线路径或主路径桥接,再重新生成布线连接。 + +批量诊断还会记录 `route_network_carrier_kind_counts` 和 `route_network_main_path_carriers`。如果 `route_network_main_path_carriers > 0` 但仍触发 `main_path_not_used`,说明 FreeCAD 已经识别到线槽/UserPath/过线孔主路径,但这些主路径没有接入端子局部网络,或距离/连通关系导致最终选路仍退回布线面。此时优先检查线槽端点、线槽到端子附近的桥接、`TerminalAccess` 最大距离、`UserPath` 是否贴近线槽中段,以及是否需要点击“按诊断建议生成桥接”。 + +当最终导线虽然布通、但起点或终点到主路径入口距离超过警戒阈值时,批量报告会显示“接入距离提示”,列出触发导线数量、一条导线样例、起点/终点接入距离和该样例实际经过的路径标签。这个提示不阻止生成导线,用于暴露设备附近缺少局部路径、主路径离端子过远或端子接入距离设置过大的情况。批量诊断 JSON 也会记录 `route_entry_distance_warning_count` 和 `route_entry_distance_warning_samples`,其中 warning sample 会保留 `route_source_labels`,便于导出后定位全部样例。 -当单条路线使用 `RoutingRange` 或 `AuxiliaryPath` 时,批量报告会提示“路径质量提示”,说明该导线可能没有完全优先进入线槽。这个提示不阻止布线,只用于暴露“当前路径依赖布线面兜底”的情况,方便后续补线槽、补 `UserPath` 或调整设备位置。批量诊断 JSON 也会记录这类提示:`route_quality_warning_count` 表示依赖布线面/辅助路径的导线数量,`route_quality_warning_samples` 保留少量导线样例及其使用的 carrier 类型。 +单根导线对象也会展开端子接入距离,避免手动测试时只能打开 JSON。`QetRouteEntryDistanceMm` / `QetRouteExitDistanceMm` 分别表示起点、终点端子出线点到主路径网络入口的距离;`QetRouteEntryPointMode` / `QetRouteExitPointMode` 表示接入点来自路径端点还是中段投影;`QetRouteEntryCandidateRank` / `QetRouteExitCandidateRank` 表示最终采用的入口候选排名;`QetRouteAccessWarningDistanceMm` 保存本次告警阈值。若 `QetRouteAccessStatus=LongAccessWarning`,说明该线虽然已经布通,但起点或终点接入主路径过长,`QetRouteAccessWarningSides` 会标出 `entry`、`exit` 或二者都触发。完整明细同步写入 `QetRouteDiagnosticsJson.access`。手动测试看到该告警时,应优先补设备局部路径、把用户路径/线槽靠近设备端子,或重新检查设备是否已经装配到正确位置。 + +当候选路线评分发现最终候选仍有接入段或候选折线穿过障碍包围盒时,批量报告会显示“接入避障提示”,列出触发导线数量、一条样例和该样例实际经过的路径标签。这个提示通常表示当前附近缺少可绕开的线槽、`UserPath` 或设备局部路径;它不替代最终碰撞状态,但能帮助区分“已经布通但入口路径仍不理想”的情况。批量诊断 JSON 也会记录 `route_candidate_obstacle_warning_count` 和 `route_candidate_obstacle_warning_samples`,其中 warning sample 会保留 `route_source_labels`;`route_samples[].network` 中仍保留样例路线自身的候选入口 rank、候选评分和候选障碍命中数。 + +入口候选会按“投影点 + carrier”去重,避免同一个近路径因为桥接边或重复边占满候选名额。有障碍物参与候选评分时,系统除保留距离排序靠前的候选外,还会额外保留一批接入折线不穿过障碍包围盒的候选,再统一评分。这样可以减少“近入口需要穿设备、远一点入口更干净,但远入口被候选上限截掉”的穿模问题。 + +最终导线碰撞诊断会区分两类碰撞:`HardIntersection` 表示导线段穿过原始障碍包围盒,中文报告显示为“硬碰撞”;`ClearanceWarning` 表示导线没有穿过原始障碍,但进入了按安全间隙膨胀后的包围盒,中文报告显示为“安全间隙”。批量报告会增加“碰撞分类”,compact 诊断 JSON 会记录 `collision_kind_counts`,用于快速判断当前问题是明显穿模还是安全距离不足。单根导线对象的 `QetRouteDiagnosticsJson.collisions[]` 和批量碰撞样例都会尽量保留该导线实际经过的路径标签,例如 `路径 主线槽A`,方便判断是线槽中心线、`UserPath` 还是兜底路径导致穿模。 + +批量 `issue_codes` 会在 `collision_warnings` 之外进一步追加碰撞处理分类:`structural_collision_candidates / 结构件碰撞候选` 表示存在可确认的柜体、门板、支架等结构件碰撞候选;`device_or_layout_collisions / 设备/布局碰撞` 表示存在真实设备或布局碰撞。这样汇总诊断和手动测试可以直接区分“可确认忽略的结构件”与“需要补路径或调整装配的设备碰撞”。 + +如果端子或设备模板明确提供了 `QetTerminalLocalRoutePointsJson` 局部出线路径,最终导线会保留这些局部路径点,并在 `QetRouteDiagnosticsJson.endpoint_access` 中写入起点/终点接入路径。碰撞诊断会把这些明确的端子局部接入段视为设备内部或设备附近的受控出线段,不把它们当成主路径穿模问题;但局部路径接入后的柜内主路径、中段线槽和 `UserPath` 仍会继续做碰撞诊断。这样可以减少设备壳体附近的误报,同时不会隐藏真正穿过柜体、设备或主路径障碍的导线。 + +批量 `collision_samples[]` 也会保留 `wire_object_label`,其值与树目录导线对象 Label 一致。这样当报告显示某条线有硬碰撞或安全间隙告警时,可以直接用 `wire_object_label` 在 `QETWiring_04_Routed` 下找到对应导线,再检查 `QetRouteCollisionStatus`、路径来源和碰撞包围盒。 + +单根导线对象会同步展开碰撞状态:`QetRouteCollisionCount` 表示总碰撞/间隙告警数量;`QetRouteHardIntersectionCount` 表示硬碰撞数量;`QetRouteClearanceWarningCount` 表示安全间隙告警数量;`QetRouteCollisionStatus` 会在 `NoCollision`、`ClearanceWarning`、`HardIntersectionWarning` 之间切换。手动测试时,如果状态是 `HardIntersectionWarning`,应优先检查导线是否穿过设备、线槽壁或柜体;如果只是 `ClearanceWarning`,通常表示路线贴近障碍,需要调大线槽/用户路径距离、调整设备位置或降低安全间隙阈值后复测。 + +`3D 布线连接` 面板提供“障碍安全间隙 mm”设置,对应 `obstacle_clearance`。该值用于膨胀障碍包围盒:值越大,越容易把贴近设备或柜体的导线标记为 `ClearanceWarning`,也会让候选入口评分更倾向避开贴近障碍的接入折线;值为 0 时只按原始障碍包围盒判断明显穿模。 + +柜内区域边界可以由 FreeCAD 文档中的对象提供,给对象设置 `QetRoutingBoundaryKind = "CabinetInterior"` 即可把该对象包围盒视为柜内可布线范围。边界对象不写数据库,也不会被当作障碍物;它先参与路径图过滤,再参与最终导线候选评分。当存在 `CabinetInterior` 时,系统会优先构建“柜内路径图”,只保留完全落在柜内边界内的路径段并先在这张图上求路;只有柜内路径图不可达时,才回退到原始路径图并保留柜内越界告警。这样即使柜外路径几何距离更近、柜内路径稍远,也会优先选择仍在柜内的线槽、`UserPath` 或支撑面路径。批量诊断 JSON 的 `route_samples[].network` 会记录 `boundary_aware`、`boundary_filtered`、`boundary_filtered_segments` 和 `route_candidate_boundary_violations`,用于确认本次布线是否启用了柜内边界约束、是否先使用了柜内过滤图,以及最终候选是否仍存在越界点。 + +如果柜内过滤图无法连通两端,系统仍会回退到原始路径图并生成综合评分最高的路径,但批量报告会显示“柜内边界提示”,指出有多少条导线最终路径仍越出柜内区域,并给出一条导线样例、越界点数量和该样例实际经过的路径标签。compact 诊断 JSON 同步记录 `route_candidate_boundary_warning_count` 和 `route_candidate_boundary_warning_samples`,其中 warning sample 会保留 `route_source_labels`。单根导线对象也会写入 `QetRouteBoundaryAware=true`、`QetRouteBoundaryStatus=BoundaryWarning` 和 `QetRouteBoundaryViolationCount`,便于选中某条导线后直接判断它是否跑出柜内区域。手动测试看到该提示时,应优先补柜内 `UserPath`、线槽或设备局部路径;如果边界对象本身建模过窄,也可以调整 `CabinetInterior` 对象包围盒。 + +有柜内边界时,入口候选不会只按几何距离截断。系统会先保留距离排序靠前的候选,再额外保留一批投影点位于 `CabinetInterior` 内部的候选,最后统一按路径成本、接入距离、障碍命中和柜内越界点数评分。这样可以避免柜外近路径数量较多时,把稍远但正确的柜内 `UserPath` / 线槽在评分前就挤掉。 + +路径网络检查也会提前检查主路径 carrier 自身是否越出柜内边界。当文档中存在 `CabinetInterior` 时,`WireDuct`、`UserPath`、`WiringCutOut` 等非 `TerminalAccess` 路径点如果落在边界外,会记录 `route_carriers_outside_boundary / 路径越出柜内边界`,并在中文报告中给出路径标签和越界点数量。这用于在生成导线前发现“用户路径或线槽中心线本身画到柜外”的问题,避免后续所有导线都沿错误主路径求路。 + +路径网络检查还会检查工程端子是否落在柜内边界内。当端子原点或端子出线末端位于 `CabinetInterior` 外时,会记录 `terminals_outside_boundary / 端子越出柜内边界`,并高亮对应端子对象。这主要用于发现设备还停留在导入位置、端子 LCS 没有跟随装配实例移动、或柜内边界对象标得过窄等装配态问题。 + +`3D 布线连接` 面板摘要会显示当前文档内已识别的柜内边界数量,例如 `柜内边界:1`。执行“选中对象作为柜内边界”后,可先看摘要确认边界对象已经生效,再生成布线路径网络和布线连接。 + +被标记为 `CabinetInterior` 的对象只作为边界使用,不会再被自动识别成线槽、安装面、过线孔或 `UserPath` 源对象。这样可以避免辅助边界盒、柜体内腔对象或用户误选的柜体对象在生成布线路径网络时又变成导线可走 carrier。 + +路径约束已支持 SW/EPLAN 风格“禁止经过”和“必须经过”的第一版能力。调用自动布线时可以通过 options 传入 `forbidden_route_carrier_names`、`forbidden_route_carrier_labels`、`forbidden_route_carrier_source_names`、`forbidden_route_carrier_source_labels` 或 `forbidden_route_carrier_kinds`,Dijkstra 搜索会直接跳过匹配 carrier 的边;也可以传入 `required_route_carrier_names`、`required_route_carrier_labels`、`required_route_carrier_source_names`、`required_route_carrier_source_labels` 或 `required_route_carrier_kinds`,Dijkstra 状态会记录已经经过的必经 carrier 或源对象,只有所有必经条件满足时才允许结束。批量 `wires[]` 中的单条导线任务也可以携带这些同名字段,用于表达“某一根导线必须/禁止经过某些路径”。该能力当前先作为算法层和导线任务可选字段,后续可接入面板、FreeCAD carrier 属性或 QET 导线规则;如果禁止/必经规则导致两端不再连通,导线会按“缺少布线路径网络/不连通”进入失败诊断。 + +当必经/禁经规则导致无可用路径时,单条布线会提示“没有满足路径约束的布线路径网络”;批量布线会把它归入 `MissingRouteNetwork`,并在“缺路径网络示例”的 `error` 字段里保留路径约束原因。这样手动测试时可以区分“真的没有线槽/UserPath”和“路径存在但被规则禁止或必经规则无法满足”。 + +当路径约束成功生效并生成导线时,`QetRouteNetworkJson` 会保留 `route_constraints`,记录本路线使用到的 required / forbidden carrier 名称、标签、源标签或类型。批量 compact 诊断的 `route_samples[].network.route_constraints` 也会保留这些字段,方便回看某条线为什么没有走最近路径。 + +批量生成布线连接的中文报告会汇总路径约束使用情况:当有导线应用必经/禁经规则时,报告显示“路径约束提示”,列出触发导线数量和一条样例导线,并按“必须经过/禁止经过”展示标签、名称、源标签或类型。compact 诊断 JSON 也会记录 `route_constraint_warning_count` 和 `route_constraint_warning_samples`,便于导出后快速确认哪些导线受全局或单线规则影响。 + +为了便于 FreeCAD 手动测试,也可以直接在路径 carrier 对象上设置 `QetRouteConstraintMode`。值为 `Forbidden` 时,该 carrier 会被所有自动布线跳过;值为 `Required` 时,所有自动布线都必须经过该 carrier,否则进入路径约束失败诊断。这个对象属性适合验证工程规则或临时屏蔽某段线槽;如果未来要做到“只对某一根导线生效”,仍建议通过单条 `wires[]` 的 required/forbidden 字段表达。 + +`3D 布线连接` 面板提供“选中路径必须经过”“选中路径禁止经过”“清除选中路径约束”和“清除全部路径约束”四个入口。用户可以选中已经生成的 route carrier,或选中草图、Draft 线等源路径对象,然后点击对应按钮写入或清空 `QetRouteConstraintMode`。如果源路径对象还没有生成 carrier,面板会按“源路径”计数;后续生成 `UserPath` 时会继承该约束,避免用户必须严格按“先生成路径、再设置约束”的顺序操作。如果多次手动测试后不确定哪些路径仍保留 Required / Forbidden,可使用“清除全部路径约束”,它会同时清空当前文档内 route carrier 和源路径对象上的约束,避免重新生成路径网络后旧约束再次继承回来。该设置是 FreeCAD 文档内的全局路径规则,会影响后续所有自动布线。 + +手动编辑属性时,`QetRouteConstraintMode` 同时支持英文和中文别名:`Required`、`必须经过`、`必经` 都表示必经路径;`Forbidden`、`禁止经过`、`禁经`、`禁止` 都表示禁经路径。面板按钮仍写入标准英文值,便于程序稳定判断。 + +面板摘要会显示当前文档中 carrier 级路径约束数量,例如 `路径约束:必经 1,禁经 1`。如果约束写在尚未生成 carrier 的草图、Draft 线等源路径对象上,或写在线槽、过线孔、支撑面等已标记路由源对象上,摘要会单独显示 `源路径约束:必经 1,禁经 0`。前者代表已经参与当前路径网络求路的 carrier 规则,后者代表后续生成或刷新 `UserPath` / `WireDuct` / `WiringCutOut` 等 carrier 时会继承的源对象规则。标记或清除路径约束后,可先看摘要确认状态,再重新生成布线连接。 + +当用户选中源路径对象(例如草图、Draft 线或线槽源对象)设置路径约束时,系统会同时把 `QetRouteConstraintMode` 写到源对象和它已经生成的 carrier 上。后续即使清除走线路径并重新生成 carrier,新 carrier 也会继承源对象上的 Required / Forbidden 约束,避免工程规则在刷新路径网络后丢失。如果源路径对象的约束已经清空,刷新 `UserPath` 时也会同步清空旧 carrier 上残留的 Required / Forbidden,避免多轮手动测试后旧规则继续影响自动布线。 + +同一个草图或 Draft 源对象可能包含多条不连通 `Wire`,生成时会拆成多条 `UserPath`。当约束来自源对象时,系统会按 `QetRouteSourceName` 聚合判断:`Required` 表示路线至少经过该源对象生成网络中的一条相关路径即可满足,不再强制同时经过所有生成子路径;`Forbidden` 表示该源对象生成的全部路径都不可走。这样更符合甲方“预先画黄色路径作为布线输入”的操作习惯,也避免多分支草图被误判为必经规则无法满足。 + +当单条路线使用 `RoutingRange` 或 `AuxiliaryPath` 时,批量报告会提示“路径质量提示”,说明该导线可能没有完全优先进入线槽。这个提示不阻止布线,只用于暴露“当前路径依赖布线面兜底”的情况,方便后续补线槽、补 `UserPath` 或调整设备位置。报告会尽量显示具体的布线面/辅助路径 carrier 标签,例如 `示例 N4111 使用布线面:安装板辅助路径`;没有具体标签时仍回退显示 carrier 类型。批量诊断 JSON 也会记录这类提示:`route_quality_warning_count` 表示依赖布线面/辅助路径的导线数量,`route_quality_warning_samples` 保留少量导线样例、使用的 carrier 类型和 `route_carrier_labels`。 + +单根导线对象也会展开路径质量状态:`QetRouteQualityStatus=NormalPath` 表示该线没有使用布线面/辅助路径兜底;`QetRouteQualityStatus=FallbackPathWarning` 表示实际路线经过了 `RoutingRange` 或 `AuxiliaryPath`。`QetRouteFallbackCarrierKinds` 会列出兜底 carrier 类型,`QetRouteFallbackCarrierLabels` 会列出具体标签,例如安装板辅助路径。这个状态不代表布线失败,但说明第一版算法是在“能连通”的基础上用了低优先级路径;手动测试看到它时,应优先补线槽、黄色草图 `UserPath`、过线孔或设备局部路径,让导线进入更明确的工程主路径。 + +当并行 lane 数超过实际经过路径的最小容量时,批量报告会提示“容量提示”,显示最大并行线数、路径最小容量,并给出一条样例导线和真实经过的路径名称。这个提示不阻止布线;它用于暴露线槽外共线拥挤、线槽容量设置过小或需要增加并行路径的场景。compact 诊断 JSON 同步记录 `route_capacity_pressure_warning_count` 和 `route_capacity_pressure_warning_samples`,样例包含导线、并行线数、最小容量、lane index、carrier 名称和源路径标签。若容量压力来自同一个草图拆出的多条 `UserPath`,中文报告和 compact 诊断会优先显示 `源路径标签(路径1)` 这类可回溯到黄色草图线的标签,而不是只显示自动生成的 carrier 名称。 + +`3D 布线连接` 面板提供“共路复用惩罚”设置,对应 `segment_reuse_penalty`。当某段路径的已用导线数超过该 carrier 的 `QetRouteCarrierCapacity` 时,Dijkstra 会按该惩罚增加复用成本;调高后更倾向绕到备用线槽或 `UserPath`,调低后更倾向继续走几何距离更短的公共路径。该参数只影响搜索成本,不代表真实线槽填充率校核。 路径网络检查还会识别“只有 `RoutingRange`、没有 `WireDuct` / `UserPath` / `WiringCutOut` 主路径”的情况,并记录 `routing_range_only_network`。这类网络可以作为无线槽或路径不完整时的临时兜底,但不是推荐的第一版主路径形态;手动测试看到该提示时,优先补线槽、补 `UserPath` 或补过线孔路径。 @@ -569,6 +747,8 @@ QetWiringCutOutBridgeExtensionMm = 20.0 当单条路线的最大并行线数超过该路线 route track 中记录的路径最小容量时,批量报告会给出容量提示。这个提示只基于 `QetRouteCarrierCapacity` 和当前 lane 情况,用于暴露“可能容量不足”的调试线索,不等同于按线径、截面积和线槽填充率计算的工程容量校核。 +单根导线对象会展开共路和容量状态,便于选中导线后直接检查。`QetRouteLaneIndex` 表示该线在共享路径中的 lane 序号,`QetRouteLaneAxis` / `QetRouteLaneOffsetMm` 表示显示错位方向和偏移量,`QetRouteParallelWireCount` 表示到该 lane 为止的并行线数量,`QetRouteMinCarrierCapacity` 表示该线实际经过路径的最小 carrier 容量。若 `QetRouteCapacityStatus=CapacityWarning`,说明该线所在共享路径的并行线数已经超过当前路径容量,应补备用线槽、用户路径或调高真实 carrier 容量属性。 + ### 5.3 布线连接功能 已完成: @@ -586,6 +766,7 @@ QetWiringCutOutBridgeExtensionMm = 20.0 11. 自动导线可见显示并保存到 FreeCAD 文档。 12. 生成布线连接时保存 `QetRouteTrackJson`,记录实际经过的 `WireDuct` / `RoutingRange` / `TerminalAccess` / `WiringCutOut` carrier。 13. 支持检查布线路径网络,诊断孤立网络、未接入端子和疑似线槽端点断点,并写入 `QETWiring_05_Diagnostics`。 +14. 支持柜内边界约束:当文档中存在 `QetRoutingBoundaryKind = "CabinetInterior"` 对象时,自动布线会先在柜内过滤后的路径图上求路,再回退原始路径图;路径网络检查也会提前提示主路径 carrier 或工程端子越出柜内边界。 ### 5.4 FreeCAD 面板 @@ -599,6 +780,11 @@ QET模板 -> 3D布线连接 ```text 准备布线布局空间 +选中对象作为柜内边界 +选中路径必须经过 +选中路径禁止经过 +清除选中路径约束 +清除全部路径约束 生成布线路径网络 检查布线路径网络 生成布线连接 @@ -639,7 +825,7 @@ tests/python/freecad_exchange_auto_routing_test.py 20. “生成布线连接”会先更新同一套布线路径网络,再按全部 QET 导线任务批量求路。 21. 相邻主路径端点在容差内会被网络自动连通;支路端点靠近主路径中段时也会投影桥接;端子接入会连接到最近的网络线段点,而不是只连接到已有端点。 22. 线槽端部会生成 `WireDuctOpenEnd` 横向路径,穿线孔/过线孔会生成 `WiringCutOut` carrier。 -23. 导线会保存 routing track;网络检查会生成 `RoutingPathNetwork` 诊断对象。 +23. 导线会保存 routing track;网络检查会生成 `RoutingPathNetwork` 诊断对象,并在返回结果中同步给出 `issue_codes`。诊断对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示是否通过;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要,`QetDiagnosticJson` 保存路径网络诊断明细和 `issue_codes`。 24. 自动生成的线槽、过线孔和支撑面 carrier 会在源对象移动、缩放、删除或失效后刷新/清理。 25. `WiringCutOut` 会在穿孔方向外扩虚拟路径,用于桥接开孔两侧附近的线槽或支撑面网络,并支持通过 `QetWiringCutOutBridgeExtensionMm` 按对象调整外扩距离。 26. `QetRouteTrackJson` 会在 carrier 有源对象元数据时保存 `source_name`、`source_label`、`source_kind`,方便核对导线实际走过的线槽、过线孔或支撑面。 @@ -654,19 +840,99 @@ tests/python/freecad_exchange_auto_routing_test.py 35. `QetRouteTrackJson` 的 carrier payload 会记录 `capacity`,方便后续分析线槽容量偏好和共路绕行。 36. 批量布线报告会在最大并行线数超过路径最小容量时显示容量提示,但当前仍不做真实填充率计算。 37. `3D 布线连接` 面板提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”设置,用于调整多线共路时的可视 lane 偏移。 -38. 最终导线选路会在多个入口候选中避开接入段穿障碍的入口,并优先选择可避障的线槽 / `UserPath` 入口。 -39. 同一入口下的端子接入正交折线会尝试不同轴向顺序,优先选择不穿过障碍包围盒的折线。 +38. `3D 布线连接` 面板提供“障碍安全间隙 mm”设置,用于调整安全间隙告警和接入候选避障评分。 +39. `3D 布线连接` 面板提供“共路复用惩罚”设置,用于调整超过 carrier 容量后的复用成本和绕行倾向。 +40. 最终导线选路会在多个入口候选中避开接入段穿障碍的入口,并优先选择可避障的线槽 / `UserPath` 入口。 +41. 入口候选按投影点和 carrier 去重;存在障碍时会额外保留干净接入候选,避免重复近候选挤掉稍远的避障入口。 +41. 同一入口下的端子接入正交折线会尝试不同轴向顺序,优先选择不穿过障碍包围盒的折线。 40. 并行导线可视 lane 偏移默认限制在固定上限内,防止密集共路时导线被显示到柜外。 41. 完整自动布线流程会使用支路端点到主路径中段的投影桥接,避免这类支路网络被误判为孤立。 42. `QetRouteTrackJson` 会标记实际走过的自动桥接段,并记录本路线实际使用的桥接段数量。 43. 批量布线报告的路径示例会跳过虚拟桥接段,只列出真实经过的源对象标签。 44. 共路 lane 计数和路径复用惩罚会跳过虚拟桥接段,避免仅共享自动桥接边的导线被误判为真实共路。 +45. `3D 布线连接` 面板提供“选中对象作为柜内边界”,用于把选中对象的包围盒标记为 `CabinetInterior`,让自动布线优先留在柜内区域。 +46. 面板摘要会显示已识别的柜内边界数量,方便确认边界约束是否生效。 +47. `CabinetInterior` 边界对象不会被自动识别成 `WireDuct`、`RoutingRange`、`WiringCutOut` 或 `UserPath` 路径源。 +48. 柜内边界存在时,入口候选会额外保留柜内候选参与评分,并且系统会先尝试柜内过滤路径图,避免大量柜外近路径或柜外捷径挤掉稍远但正确的柜内路径。 45. 路径质量提示会按非桥接段重新判断 carrier 类型,避免把虚拟桥接到 `RoutingRange` 误报为真实使用布线面兜底。 46. 缺少布线路径网络或路径网络两端不连通时,批量布线报告会显示一条导线、端点样例和失败原因,便于直接定位需要补路径的设备区域。 47. “端子接入最大距离”同时约束自动 `TerminalAccess` 和最终导线入口候选,防止最终求路绕过面板设置生成超长接入线。 48. 批量诊断 JSON 的 route sample 会跳过虚拟桥接段统计 carrier 类型和 carrier 名称,保持与中文报告一致。 49. 最终导线路由不会把 `TerminalAccess` 当作公共 transit carrier,入口候选也会优先真实主路径,避免端子局部接入线被误用来桥接主路径缺口或作为其它导线的起步路径。 50. 批量布线报告会提示最终导线起点/终点接入距离过长的样例,用于排查设备附近缺局部路径或主路径离端子太远。 +51. 单根导线对象会展开 `QetRouteEntryDistanceMm`、`QetRouteExitDistanceMm` 和 `QetRouteAccessStatus`,用于选中某条线后直接判断是否存在长接入。 +52. 单根导线对象会展开 `QetRouteSourceLabels` 和 `QetRouteCarrierNames`,用于直接查看该线实际经过哪些线槽、黄色草图路径或用户路径。 +53. 单根导线对象会展开 `QetRouteCollisionStatus`、`QetRouteHardIntersectionCount` 和 `QetRouteClearanceWarningCount`,用于直接区分穿模和安全间隙告警。 +54. 单根导线对象会展开 `QetRouteQualityStatus`、`QetRouteFallbackCarrierKinds` 和 `QetRouteFallbackCarrierLabels`,用于判断该线是否依赖布线面/辅助路径兜底。 +55. 批量诊断的 `route_samples[]` 会同步输出 `access`、`collision_summary`、`quality`、`capacity`、`boundary` 状态分组,便于不逐根选中导线也能复盘样例路线。 +56. 生成导线对象的树目录 Label 会包含导线标识、起终点端子和状态,便于在 FreeCAD 左侧树中定位问题导线。 +57. 单根导线对象会展开 `QetRouteIssueCodes` 和 `QetRouteIssueLabels`,用与批量诊断一致的问题码汇总该线自身的问题。 +58. 批量诊断的 `route_samples[]` 会优先保留带问题码的路线样例,避免问题线排在后面时被 sample limit 截掉。 +59. 批量诊断的 `route_samples[]` 会保留 `wire_object_label`,用于从诊断 JSON 直接定位 FreeCAD 树目录中的导线对象。 +60. 批量碰撞样例 `collision_samples[]` 会保留 `wire_object_label`,便于从穿模/安全间隙告警直接定位问题导线。 +61. 接入距离、路径质量、候选入口、柜内边界、路径约束和容量压力 warning samples 会保留 `wire_object_label`,统一诊断定位方式。 +62. 路径网络长接入样例会保留父设备、端子全局点、接入折点、主要超长方向和各轴长度。真实工程中如果 `terminal_access_dominant_axis=z` 且竖向长度占大头,可以直接判断为端子/设备高度或局部出线路径问题,而不是主路径网络断开。 +63. 碰撞样例会保留障碍对象的父装配名称和标签,方便把 `NAUOxxx` 这类导入零件追溯到前门、柜体、安装板或具体设备,再决定是标记忽略碰撞还是补柜内路径。 +64. 面板提供“选择越界路径/端子”,从最新 `RoutingPathNetwork` 诊断的 `route_carriers_outside_boundary` 和 `terminals_outside_boundary` 反向选择越出柜内边界的路径 carrier 与工程端子。该功能只定位对象,不自动调整边界、不移动设备、不写数据库。 +64. 批量布线报告的 `top_collision_obstacles[]` 不再只记录对象标签和次数,还会记录对象名称、父装配、硬碰撞/安全间隙分类计数;中文摘要也会显示父装配,例如 `NAUO118(CABINET ASS'Y) 18 处`,便于区分柜体/门板 AABB 误报和真实设备穿模。 +65. 缺失端点样例会记录同 2D 设备、同 3D 实例下的 FreeCAD 工程端子数量,并输出原因码。真实工程里若原因是 `missing_device_binding_metadata`,说明 QET 导线任务端点缺少 `element_uuid`,FreeCAD 无法判断缺失端子属于哪个 2D 设备;第一版不要求 QET 在 `wires[]` 端点提供 `start/end_instance_id`。若原因是 `device_not_in_3d_scene`,说明该 2D 设备当前没有对应 3D 设备实例,应回到设备导入、装配和 2D/3D 绑定流程排查;若原因是 `no_3d_terminals_for_element`,说明设备实例在场景中但没有生成工程端子,应回到端子生成流程排查。这些都不是路径网络问题。 +66. 高发碰撞对象会输出处理建议。疑似柜体、门板、支架、盖板等结构件时,建议用户确认后通过现有“选中对象忽略碰撞”标记为 `PassThrough`;疑似设备或安装区域碰撞时,建议补主路径、局部出线路径或调整装配,而不是直接忽略。 +67. 面板提供“选择高发碰撞对象”,从最新批量诊断 `top_collision_obstacles[]` 反向选择 FreeCAD 对象,方便现场确认后再手动标记忽略或调整路径。该功能只定位对象,不自动修改碰撞规则。 +68. 面板提供“选择碰撞导线”,从最新批量诊断 `collision_samples[]` 和带 `collision_warnings` 的 `route_samples[]` 中反向选择 RoutedConnection 导线对象,便于和高发碰撞对象一起核对穿模位置。该功能只定位导线,不重新求路。 +68. 面板提供“选择缺主路径导线”,从最新 `route_samples[]` 和导线对象自身的 `QetRouteIssueCodes` 中选择带 `main_path_detour_missing` 的 RoutedConnection 导线。该功能用于定位“选择性避障重算本可减少碰撞,但会退回到辅助路径/布线面兜底,因此被当前主路径优先策略拒绝”的导线;下一步应补 `UserPath`、桥接主路径、调整线槽入口或完善设备局部出线路径,不自动接受 fallback 结果。 +68. 自动布线会对明确的 `main_path_detour_missing` 做一次收敛处理:当选择性避障已经得到碰撞更少的 fallback 折线,但该折线因包含 `RoutingRange` 被拒绝时,系统会把这条折线固化为 `MainPathDetourPath` 类型的 `UserPath`,再按 `兜底区域 -> 当前主路径` 生成 `MainPathDetourBridge`,随后只重试受影响导线。这样保持主路径优先,不直接接受宽泛布线面兜底,同时避免整批导线二次全量重跑。 +69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象,便于检查端子高度、设备装配和局部出线路径。该功能只定位端子,不修改端子或路径数据。 +70. 面板提供“选择缺端子设备”,从最新批量诊断 `missing_endpoint_samples[]` 的缺失侧读取 `*_instance_id` / `*_element_uuid` 并反向选择 3D 设备,便于补工程端子或检查 2D/3D 绑定。若缺失设备不在当前场景中,控制器仍会返回 `missing_terminal_device_instance_ids[]`、`missing_terminal_device_element_uuids[]` 和可读标签,状态栏也会显示 instance_id,便于把缺设备清单交给装配/绑定流程。该功能只定位设备,不自动创建端子、不修改 QET 数据。 +71. 面板提供“选择缺端子另一端”,从缺端子样例中选择已找到的另一端工程端子,便于确认失败导线本来要连接到哪里,再对照缺失侧设备和端子脚号。该功能只定位端子,不自动补端子、不写数据库。 +72. 面板提供“选择缺端子候选端子”,从 `*_instance_terminal_samples` / `*_element_terminal_samples` 中反向选择同设备或同实例已有工程端子,便于排查 `terminal_uuid_not_in_element` 这类“同设备已有端子但 UUID 不匹配”的问题。该功能只定位候选端子,不自动改绑定、不写数据库。 +73. 碰撞障碍语义按 FreeCAD 父装配链递归识别。用户把柜体、门板、支架、盖板等父装配标记为 `PassThrough` 后,深层导入子零件也会被排除在导线障碍之外;碰撞样例的 `parent_refs` 也会尽量输出完整父链,避免只看到中间 `Compound/NAUO`。 +73. 第一版会自动过滤一类未绑定导入结构件碰撞:障碍物没有 QET `element_uuid`,并且位于 `QET Exchange Devices / QETCabinet / LinkGroup / Compound / NAUO` 等导入装配上下文,同时名称或父链命中柜体、门板、支架、盖板等结构关键词时,不再计入导线碰撞。该规则不作用于带 `element_uuid` 的 3D 设备,因此不会吞掉真实设备/端子/断路器碰撞。 +74. 面板提供“选择碰撞父装配”,从最新 `top_collision_obstacles[]` 的 `parent_names / parent_labels` 反向选择父装配,适合先确认前门、柜体、支架等总成是否可穿越,再统一标记 `PassThrough`。该功能只定位父装配,不自动忽略碰撞。 +75. 面板提供“确认结构件忽略碰撞”,按最新诊断只把疑似柜体、门板、支架、盖板等结构件碰撞的最近结构父装配标记为 `PassThrough`。该动作不会沿父链继续标记 `QET Exchange Devices` 这类工程根组,不会标记 `review_device_or_layout_collision`,也不写数据库;重新生成布线连接后,下级导入结构子件会因对应父装配 `PassThrough` 被排除出障碍候选,真实设备碰撞仍会继续保留诊断。 +76. 面板提供“选择设备碰撞对象”,只从最新 `top_collision_obstacles[]` 中选择 `review_device_or_layout_collision` 候选。该功能用于处理结构件忽略后仍剩余的真实设备/布局碰撞,只定位对象,不自动忽略碰撞;后续应补设备局部出线路径、调整 UserPath/线槽入口或检查设备装配。 +77. 面板提供“选择长接入设备”,从 `long_terminal_accesses[]` 的 `parent_device_name / parent_device_label` 反向选择设备对象,便于从设备整体高度、端子 LCS 跟随和局部出线路径三个角度排查长接入。 +78. 面板提供“选择异常导线”,从最新 `route_samples[]` 中选择所有带 `issue_codes` 的 RoutedConnection 导线,并补充扫描导线对象自身的 `QetRouteIssueCodes`,避免 compact 样例数量有限时漏选问题线。该功能是长接入、越界、容量、路径质量和碰撞问题的统一入口,只定位导线,不重新求路。 +79. 面板提供“选择异常导线路径”,从异常 `route_samples[]` 的 `carrier_names` 和 `route_track.segments[].carrier` 反向选择导线实际经过的路径 carrier,并尽量选择 carrier 的源草图/线槽对象。该功能只定位路径,不自动改 Required/Forbidden、容量或几何。 +80. 面板提供“选择选中导线路径”,从当前选中的 RoutedConnection 导线对象读取 `QetRouteTrackJson`,反向选择该线实际经过的路径 carrier 和源草图/线槽。该功能用于 compact `route_samples[]` 为空或样例不足时的单线排查,只读取 FreeCAD 文档中的导线元数据,不写数据库、不要求 QET 提供 3D 路径。 +80. 面板提供“选择拒绝兜底路径”,从当前选中的 RoutedConnection 导线对象读取 `QetRouteDiagnosticsJson.selective_collision_reroute.rejected_fallback_labels`,反向选择被局部避障重算发现但因使用 `RoutingRange` / `AuxiliaryPath` 而被拒绝的路径来源。该功能只用于判断应在哪里补 `UserPath`、桥接主路径或设备局部出线路径,不会自动接受 fallback 路线,也不写数据库。 +81. `汇总布线诊断` 会统计实际 RoutedConnection 导线对象上的 `QetRouteIssueCodes`,输出异常导线总数和各问题码数量。该统计来自 FreeCAD 文档中的导线对象,不依赖 compact `route_samples[]` 的样例上限。 +82. `汇总布线诊断` 会统计批量缺端子数量和缺失端点原因,例如 `missing_device_binding_metadata / 导线端点缺少 2D/3D 设备绑定信息`、`device_not_in_3d_scene / 该 2D 设备未在 FreeCAD 场景中找到`、`no_3d_terminals_for_element / 该 2D 设备在 FreeCAD 中没有工程端子`,用于区分 QET 端点绑定元数据缺失、设备未导入/未绑定、端子未生成和路径网络问题。 +83. `汇总布线诊断` 会根据当前问题生成手测建议动作,例如选择缺端子设备、选择异常导线、选择长接入端子/设备、选择碰撞父装配等。建议只引导 FreeCAD 面板操作,不自动修改路径或数据库。 +84. 缺端子建议会按原因码分流:`missing_device_binding_metadata` 提示检查 QET 导线端点是否提供 `element_uuid` 和 `terminal_uuid`,并明确第一版不要求 `start/end_instance_id`;`device_not_in_3d_scene` 优先提示检查设备是否已导入、装配并完成 2D/3D 绑定;`no_3d_terminals_for_element` / `no_3d_terminals_for_instance` 才提示选择缺端子设备;`terminal_uuid_not_in_element` 提示选择缺端子候选端子核对 UUID 与脚号绑定。 +85. `选择缺端子设备` 本身也会按原因码分流状态提示:如果缺失设备不在当前 FreeCAD 场景中,提示先补设备导入、装配和绑定;如果缺少 QET 端点 `element_uuid`,提示先补齐 QET 端点绑定信息,避免用户在 3D 场景中反复寻找不存在的对象。 +86. `生成布线连接` 的中文报告会根据缺端子原因追加关键提示:若包含 `missing_device_binding_metadata`,直接提示 `QET 导线端点缺少 element_uuid`,并注明第一版不要求 `start/end_instance_id`;若包含 `device_not_in_3d_scene`,直接提示部分导线引用的设备未在当前 FreeCAD 场景中找到。这个提示不依赖 `routed=0`,即使多数导线已经成功、只有少量导线缺端子,也会在报告中显示。 +87. 对旧版批量诊断中缺端子样例没有原因码的情况,汇总诊断会尝试用当前 FreeCAD 文档回填原因码:样例里有 `terminal_uuid` 但没有 `element_uuid` 或可回查的设备标识时回填为 `missing_device_binding_metadata`;样例里有设备标识但当前场景找不到设备时回填为 `device_not_in_3d_scene`;设备存在但无工程端子时回填为 `no_3d_terminals_for_element`。只有基础字段不足以判断时才显示 `缺端点原因未记录` 并建议重新生成布线连接。 +62. 对于 `missing_route_network_samples[]`、`error_samples[]`、`missing_endpoint_samples[]` 这类失败样例,`wire_object_label` 保存的是任务侧最接近对象标题的显示名,不一定已经对应到真实 3D 导线对象。 +51. 自动布线 options 支持按 carrier 名称、标签、源对象名称、源标签或类型设置禁止经过路径,Dijkstra 会跳过这些路径边。 +52. 自动布线 options 支持按 carrier 名称、标签、源对象名称、源标签或类型设置必须经过路径,Dijkstra 状态会记录必经条件并只返回满足条件的路径。 +53. 批量 `wires[]` 的单条导线任务可选携带 `required_route_carrier_*` / `forbidden_route_carrier_*` 字段,实现不同导线使用不同路径约束。 +54. 成功布线的 `QetRouteNetworkJson` 和 compact route sample 会记录 `route_constraints`,用于追踪某条线实际应用的必经/禁经规则。 +55. 批量布线中文报告和 compact 诊断会汇总路径约束使用情况,显示受 Required/Forbidden 规则影响的导线数量和样例。 +55. 批量布线后会在返回 report 和 `QETWiring_05_Diagnostics` 下的 `RoutingConnectionBatch` 诊断对象中写入同一套 `issue_codes`;诊断对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示本次批量布线是否无问题码;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要,`QetDiagnosticJson` 保存 compact 批量诊断明细,并包含当前 `runtime_version`,方便手测时确认 FreeCAD 已加载最新自动布线代码。即使当前没有导线任务,也会保留该诊断对象并提示“没有导线任务”。问题码示例包括 `no_wire_tasks`、`no_routed_connections`、`missing_terminals`、`missing_route_network`、`collision_warnings`、`route_capacity_pressure` 等,便于脚本或人工快速筛选问题。 +56. 当批量布线 `total_wires > 0` 但最终 `routed = 0` 时,中文报告会明确提示“未生成有效导线:本次只有路径承载/诊断对象,未生成 RoutedConnection 导线”。这类场景常见于端子缺失、路径网络不连通、模块版本不一致或普通布线错误,不能把树目录中的 `WireDuct`、`RoutingRange`、`TerminalAccess` carrier 当成成功布线结果。 +57. `检查布线准备度` 会记录 `runtime_capabilities`。如果当前 FreeCAD 会话加载的 `RoutingNetwork` 模块缺少路径约束收集函数,会写入 `runtime_route_constraint_collector_missing` 并提示同步运行目录、重启 FreeCAD,避免模块版本不一致导致批量布线整批失败。 +58. 外层“生成布线连接”在补齐路径网络检查、`hidden_route_carriers` 和 `routing_path_network_updated` 等最终字段后,会重写一次 `RoutingConnectionBatch` 诊断对象,保证“汇总布线诊断”读取到的是最终 report,而不是内层批量求路刚结束时的半成品诊断。 +59. compact 批量诊断会记录容量压力样例,便于定位哪条导线在哪些路径上触发“并行线数超过路径容量”。 +60. 批量布线中文报告和 compact 诊断会按 `HardIntersection` / `ClearanceWarning` 汇总碰撞分类,便于区分穿模和安全间隙不足。 +57. FreeCAD 路径 carrier 支持 `QetRouteConstraintMode = Forbidden / Required`,用于手动测试全局禁经/必经路径约束。 +58. `3D 布线连接` 面板提供“选中路径必须经过”和“选中路径禁止经过”,可直接给选中 route carrier 写入 `QetRouteConstraintMode`。 +59. `3D 布线连接` 面板提供“清除选中路径约束”,可把选中 route carrier 的 `QetRouteConstraintMode` 清空。 +60. `3D 布线连接` 面板提供“清除全部路径约束”,可一次清空当前文档中 route carrier 和源路径对象上的 Required/Forbidden 规则。 +61. 面板摘要会显示 carrier 级路径约束数量,便于确认当前 Required/Forbidden 规则是否仍在生效。 +62. 源路径对象上的 `QetRouteConstraintMode` 会在清除/重生成 carrier 后继承到新 carrier,避免路径约束刷新后丢失。 +63. 选中的草图、Draft 线和纯线状对象如果包含弧线、样条边或整条 `Wire` 拓扑,会先离散为 polyline 再生成 `UserPath`,避免曲线路径在自动布线网络中被拉直。 +64. `UserPath` 从草图、Draft 线、边或 `Wire` 提取点时会按源对象的 `Placement` / `getGlobalPlacement()` 转成文档坐标,避免装配移动后的路径仍按本地坐标生成。 +65. 同一个草图或 Draft 对象中存在多条不连通 `Wire` 时,会分别生成多条 `UserPath`,不会把第一条路径末端和第二条路径起点硬连成一条假路径。 +66. 面板和报告中的 `user_path_carriers` 表示本次生成或刷新成功的 `UserPath` 数量;重复选择同一路径对象重生成时会刷新原 carrier,并仍计入数量,便于确认操作生效。 +67. 同一个源草图生成多条 `UserPath` 时,对源草图设置 Required/Forbidden 路径约束会同步标记全部生成 carrier,避免多路径草图只约束第一条路径。 +68. 多 `Wire` 源草图刷新时,如果草图中的路径数量减少,系统会删除多余旧 `UserPath` carrier;如果路径数量增加,系统会新增对应 carrier,避免布线网络和当前黄色路径不一致。 +69. 同一个源草图生成多条 `UserPath` 时,每条 carrier 会记录 `QetRouteSourcePathIndex`,用于区分同一源对象下第几条路径,方便诊断和路径示例回溯。 +70. 通用 route carrier 创建入口也使用同样的多 `Wire` 分段规则,脚本或旧入口创建 `RoutingPath` 时不会把不连通路径硬拼成一条。 +71. 多 `Wire` 源草图设置 `QetRouteCarrierCapacity` / `QetWireCapacity` 时,生成的每条 `UserPath` 都会继承该容量,用于共路容量提示和复用成本。 +72. 创建 `UserPath` 时如果同时选中支撑面 Face 和悬空草图/Draft 线,系统会把路径点投影到该支撑面并保留默认偏移,减少 Draft 工作平面不正确导致的悬空路径。 +73. 同一个源草图生成多条 `UserPath` 时,通过面板清除选中路径约束会同时清空源草图和全部生成 carrier 的 Required/Forbidden 约束。 +74. 多 `Wire` 源草图设置 Required 时,自动布线按源对象聚合判断,经过该源对象生成的任一相关 `UserPath` 即可满足必经条件;设置 Forbidden 时,该源对象生成的全部 `UserPath` 都会被跳过。 +63. `QetRouteConstraintMode` 支持中文别名:`必须经过` / `必经` / `禁止经过` / `禁经` / `禁止`。 已完成 FreeCAD smoke: @@ -687,20 +953,22 @@ tests/manual/freecad_auto_routing_smoke.py 4. 清除走线路径 5. 点击“准备布线布局空间” 6. 按当前机柜情况调整主路径桥接容差、端子接入最大距离、端子出线长度 -7. 可选:选中无法自动识别的线槽实体 -8. 点击“生成布线路径网络”;如果不选择,则使用整份文档自动识别 -9. 点击“生成布线连接” +7. 可选:选中柜内空间、柜体内腔或辅助实体,点击“选中对象作为柜内边界” +8. 可选:选中无法自动识别的线槽实体 +9. 点击“生成布线路径网络”;如果不选择,则使用整份文档自动识别 +10. 点击“生成布线连接” ``` 三个按钮的职责: ```text 准备布线布局空间:识别并标记 layout space 里的线槽、支撑面、工程端子和障碍处理方式 +选中对象作为柜内边界:把选中对象包围盒标记为 CabinetInterior,只参与 3D 自动布线边界评分 生成布线路径网络:按 EPLAN routing path network 逻辑生成 WireDuct、UserPath、RoutingRange 和 TerminalAccess carrier 生成布线连接:先更新布线路径网络,再检查/绑定工程端子,按 QET 导线任务批量求路并生成 AutoSuggested 导线 ``` -如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。若甲方现场没有线槽,或需要绕开线槽自由定义柜内主路径,可以选中草图、Draft 线、线段或纯线状对象,再执行“生成布线路径网络”,系统会生成 `UserPath`。 +如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。若甲方现场没有线槽,或需要绕开线槽自由定义柜内主路径,可以选中草图、Draft 线、线段或纯线状对象,再执行“生成布线路径网络”,系统会生成 `UserPath`。若手动测试发现导线容易跑到柜外,可先选中柜内空间或辅助包围盒执行“选中对象作为柜内边界”。 ### 6.2 批量生成布线连接前提 @@ -708,16 +976,20 @@ tests/manual/freecad_auto_routing_smoke.py 2. 每条导线包含: ```text -start_instance_id 或 start_element_uuid +start_element_uuid start_terminal_uuid start_terminal_display -end_instance_id 或 end_element_uuid +end_element_uuid end_terminal_uuid end_terminal_display ``` -3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子,或存在可按设备和端子显示号匹配的 `local:*` 模板端子。 -4. 布线连接只按导线任务生成,不会把场景里所有端子任意两两相连。 +`start_instance_id / end_instance_id` 不作为第一版 `wires[]` 的必填字段;FreeCAD 会通过端点 `element_uuid`、`terminal_uuid`、`devices[]` 和当前 3D 文档中的绑定属性回查 3D 实例。 + +3. 可选:单条导线可以携带 `required_route_carrier_*` / `forbidden_route_carrier_*` 路径约束字段,按 carrier 名称、标签、源标签或类型控制必须/禁止经过。 +4. 可选:QET 启动 FreeCAD 时提供 `QET_WIRE_PROPERTIES_DB=<项目数据库路径>`,或在 `2d_to_3d.json` 顶层提供 `wire_style_database_path`,或调用自动布线时传入同名 options 字段,用于按 `wire_style_id` 查询 `wire_properties` 并渲染导线颜色/线宽。 +5. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子,或存在可按设备和端子显示号匹配的 `local:*` 模板端子。 +6. 布线连接只按导线任务生成,不会把场景里所有端子任意两两相连。 注意:批量生成布线连接的依据是导线任务,不是“所有端子自动互连”。如果文档中只有端子而没有 `wires[]` 或 `QETWiring_01_Tasks`,系统不能判断哪些端子应该连接。 @@ -733,6 +1005,14 @@ end_terminal_display 6. 线槽是导线主路径。导线应优先从设备端子经 `TerminalAccess` 进入线槽,再沿 `WireDuct` 网络到达另一端。 7. 过线孔/穿线孔用于连接不同安装面、线槽或柜体开孔处的网络,应建模为 `WiringCutOut`,不是普通障碍。 +从甲方提供的 KYN28-12 3D 布线教程和新增截图看,成熟软件里的流程并不是“导入设备后完全自动推导所有路径”。实际操作会先完成 3D 装配和电气设备关联,再由工程人员预先绘制黄色草图路径,最后自动布线沿这些路径生成导线。也就是说,预置草图路径是正式布线输入,不是临时调试线;FreeCAD 第一版应优先保证“导入/选择已有草图路径后稳定生成 `UserPath` 并参与求路”。 + +视频里还显示门板、仪表、端子排和柜内主区域之间存在跨部件路径。例如门板上的局部梳状路径会接入一条跨门板到柜内的长主路径,端子排区域也会先形成局部短路径,再接入主路径。这说明自动布线需要支持“设备局部路径 -> 柜内主路径”的分层网络,而不是只按端子到最近线槽的一跳距离判断。 + +装配阶段和布线阶段应分开理解:端子排、仪表、按钮、导轨等对象先通过装配/配合确定最终 3D 位置;自动布线只读取最终几何位姿、工程端子、草图路径和导线任务。FreeCAD 不应让自动布线反向修改装配关系,也不应把 3D 草图路径写入第一版数据库绑定表。 + +视频中的 SW Electrical 还要求把 3D 设备和电气树中的设备/端子进行关联。对应到本项目,QET 的 `wires[]`、`terminal_uuid` 和 2D/3D 绑定仍是电气真相源;FreeCAD 侧需要在自动布线前校验工程端子是否已由这些 UUID 绑定到正确的 3D 端子对象。 + 因此,自动布线的推荐空间语义是: ```text @@ -748,9 +1028,17 @@ end_terminal_display 1. 选中草图、Draft 线、线段或纯线状对象。 2. 点击 3D 布线连接面板中的“选中路径作为用户路径”,或直接点击“生成布线路径网络”。 3. 系统把选中路径转换为 `UserPath` carrier,并参与后续自动布线最短路搜索。“选中路径作为用户路径”只创建用户路径;“生成布线路径网络”会同时更新线槽、布线面、端子接入等完整网络。 -4. 再次选择同一个路径对象生成网络时,系统会刷新原 carrier,不会重复生成。 +4. 再次选择同一个路径对象生成网络时,系统会刷新原 carrier,不会重复生成;面板报告中的 `user_path_carriers` 会把刷新成功的 carrier 也计入数量。如果多 `Wire` 草图里的路径数量减少,刷新时会删除多余旧 carrier。 5. 如果删除了原草图/线段源对象,再点击“选中路径作为用户路径”或重新生成网络,系统会清理对应的失效 `UserPath` carrier。 -6. 如果源对象设置了 `QetRouteCarrierCapacity` 或 `QetWireCapacity`,生成/刷新出的 `UserPath` 会继承该容量,用于多根线共路和容量提示。 +6. 如果源对象设置了 `QetRouteCarrierCapacity` 或 `QetWireCapacity`,生成/刷新出的 `UserPath` 会继承该容量;同一个源草图拆成多条 `UserPath` 时,每条生成路径都会继承,用于多根线共路和容量提示。 + +甲方视频里的黄色路径有两类:一类是柜内/门板区域的主路径或过渡路径,适合生成 `UserPath`;另一类是每个设备、端子排、按钮附近的短梳状局部出线路径,更适合沉淀为端子级 `TerminalAccess` 或 `QetTerminalLocalRoutePointsJson`。两者都可以来自草图,但语义不同:主路径可被多根线共享,局部路径只服务对应端子或设备附近接入。 + +SW 教程中路径可以由“创建草图”或“转换草图”得到,并且路径可能包含弧线、样条或多段折线。FreeCAD 侧处理草图路径时不能只依赖直线 `Points`;当前已支持把草图边、整条 `Wire`、Draft 线和曲线按离散精度转换为稳定 polyline,再按源对象的最终 `Placement` 转到文档坐标生成 route carrier,并保留 `QetRouteSourceName` / `QetRouteSourceLabel`,方便路径刷新和诊断回溯。这样甲方视频里的黄色曲线路径不会被简单拉直成首尾两点,也不容易因为导入对象只暴露 `Shape.Wires` 或装配移动后仍使用本地坐标而漏建、错位用户路径。若同一个草图里有多条不连通 `Wire`,系统会分别生成多条 `UserPath`,避免在两条路径之间产生并不存在的直连段。 + +已有草图路径随工程导入一起存在时,手动测试推荐流程是:先打开工程并确认黄色/草图路径在树中可见,再执行“生成布线路径网络”或“选中路径作为用户路径”,最后生成布线连接。这样比在 FreeCAD 里临时画 Draft 线更接近甲方实际流程,也能减少因工作平面选错导致的悬空路径问题。 + +如果临时画的 Draft 线或草图线明显悬空,可同时选中安装板/柜板上的支撑面 Face 和该路径对象,再执行“选中路径作为用户路径”或“生成布线路径网络”。系统会把路径点投影到支撑面附近,而不是直接使用 Draft 当前工作平面坐标。 `UserPath` 与线槽的关系: @@ -773,6 +1061,18 @@ QetTerminalLocalRoutePointsJson [[0, 0, 0], [10, 0, 0], [10, 30, 0]] ``` +也可以写成对象包装格式,便于后续模板工具附加其它元数据;当前会识别 `points`、`route_points` 或 `local_points` 三个数组键: + +```json +{ + "points": [ + {"x": 0, "y": 0, "z": 0}, + {"x": 10, "y": 0, "z": 0}, + {"x": 10, "y": 30, "z": 0} + ] +} +``` + 自动生成 `TerminalAccess` 时,系统会先把这些局部点按端子和父设备的 `Placement` 转成全局点,再从局部路径末端连接到最近的柜内主路径、线槽、用户路径或布线面。没有该字段时,仍使用原来的端子 LCS `+Z` 方向短出线。 路径网络检查也使用同一口径:如果端子有有效局部路径,端子到主路径网络的接入距离按局部路径末端计算,而不是按默认 LCS 出线点计算。这样可以避免局部路径已经接入线槽、但诊断仍误报“端子未接入”的情况。 @@ -794,7 +1094,7 @@ QetTerminalLocalRoutePointsJson 导入/更新工程端子时,FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。 -路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、不是数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。 +路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。 如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数: @@ -813,6 +1113,18 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) 4. 系统把所选路径的文档坐标转换为该端子的本地坐标,并写入 QetTerminalLocalRoutePointsJson。 ``` +如果已经在工程机柜里完成装配,也可以直接给工程端子补现场局部出线路径: + +```text +1. 在 3D 布线连接面板中,先选中一个可布线工程端子。 +2. 再选中一条表示该端子局部出线的草图、Draft 线、边或连续 Wire。 +3. 点击“选中端子设置局部出线”。 +4. 系统把所选路径从 FreeCAD 文档坐标转换为该端子的本地坐标,并写入工程端子的 QetTerminalLocalRoutePointsJson。 +5. 重新点击“生成布线路径网络”或“生成布线连接”,新的 TerminalAccess 会优先沿这段局部路径接入柜内主路径。 +``` + +这个工程端子现场设置入口只修改当前 FreeCAD 文档,不写数据库,也不要求 QET 输出 3D 路径。它适合手动测试中发现某个设备端子接入过长、从设备内部穿模、或默认 LCS 出线方向不符合实物时使用。若同一类设备会在多个项目复用,应优先把局部路径沉淀到 FCStd 设备模板里;若只是当前机柜现场微调,可以直接在工程端子上设置。 + 第一版不要求 QET 提供这个字段。它属于 FreeCAD 设备模板/工程端子的 3D 几何元数据,由 FreeCAD 模板作者维护;QET 仍只提供导线任务、设备实例、端子实例和 2D/3D 绑定所需 UUID。 ## 7. 当前限制 @@ -840,14 +1152,14 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) 2. 路径网络规模较大,但检查提示存在孤立路径网络和端子接入过长,说明部分设备局部路径没有可靠接入柜内主路径。 3. 部分导线穿过设备模型。当前碰撞检测只给出告警,不会强制阻止生成,因此在可用绕行路径不足时仍可能生成穿模导线。 4. 多根导线在公共路径上共线或高度拥挤。在线槽内共路可以接受,但在线槽外和设备端子附近需要更好的并行错位、束线显示和容量策略。 -5. 部分导线跑到机柜外侧。该问题需要后续增加柜内有效区域/柜体边界诊断,但当前优先级低于提升布通率。 +5. 部分导线跑到机柜外侧。当前已支持把柜内空间、柜体或辅助实体标记为 `CabinetInterior` 边界,并在批量报告中提示仍越出柜内区域的导线;但该能力依赖用户先标记边界,且仍需要补充柜内 `UserPath`、线槽或局部路径来提供可选的柜内路线。 6. Draft 线段可能悬空。原因通常是 Draft 当前工作平面没有锁定到安装板或线槽面;作为自由空间 `UserPath` 这是允许的,但作为贴面主路径时需要投影、吸附或明确提示。 当前开发优先级调整为: 1. 先保证更多导线能稳定布通,优先处理孤立路径网络和端子接入过长。 2. 其次降低明显穿模和线槽外共线拥挤。 -3. 柜内越界诊断放到后续阶段,不阻塞当前布通率改进。 +3. 柜内越界诊断已进入第一版收尾能力:有边界对象时会参与候选评分,并在最终路径仍越界时输出“柜内边界提示”;后续重点是让边界更容易自动识别和让用户路径更容易贴合到柜内结构。 已完成的对应改进: @@ -860,6 +1172,7 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) 7. 接入候选评分会检查端子出口到路径网络入口之间的小段是否穿过障碍包围盒;当近入口接入段穿模、稍远入口可避开障碍时,会优先选择不穿模的入口。最终碰撞诊断仍保留端点附近设备外壳的宽容规则,避免把端子自身外壳误报成碰撞。 8. 同一个路径入口已经确定后,端子出口到入口、主路径出口到端子入口的正交折线会尝试不同轴向顺序;当“先走 X”会穿设备、“先走 Y/Z”可绕开时,优先使用不穿模的折线顺序。 9. 并行导线 lane 偏移增加默认上限,避免大量导线共路时可视错位距离随 lane 序号无限增大,把导线推到线槽或柜体外。lane 序号仍保留,用于容量提示和并行数量报告。 +10. 柜内边界对象会先参与路径图过滤,再参与路径候选评分。若柜内过滤图不可达且最终路径仍存在柜内越界点,中文报告会显示“柜内边界提示”,compact 诊断会写入 `route_candidate_boundary_warning_count` 和 `route_candidate_boundary_warning_samples`。 ### 8.1 近期优先级 @@ -953,6 +1266,8 @@ PE 线优先路径 11. 两条相交或重叠的线槽中心路径能在交点/重叠端点处连通并自动拐弯。 12. 自动识别出的安装板/柜面能生成低优先级 `RoutingRange`,并可被布线连接使用。 13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。 +14. 如果 `wires[].wire_style_id` 能在 `wire_properties` 中解析,生成导线会使用对应的显示颜色、线宽和线型;解析失败时诊断显示 `Missing`,但仍按默认蓝色样式生成导线。 +15. “生成布线连接”后的 `RoutingConnectionBatch` 诊断对象保存最终 report,包括 `hidden_route_carriers`、`routing_path_network_updated`、路径网络检查结果和 `no_routed_connections` 等问题码。 ## 10. 开发验证命令 diff --git a/docs/FreeCAD 机柜装配操作文档.md b/docs/FreeCAD 机柜装配操作文档.md index 00c30d9..9ea9138 100644 --- a/docs/FreeCAD 机柜装配操作文档.md +++ b/docs/FreeCAD 机柜装配操作文档.md @@ -691,7 +691,10 @@ QETExchangeDevices 3. 点击 `生成布线连接`。 4. 查看状态中的 routed、collision_warnings、missing_terminals。 5. 若有 missing terminals,说明某些 2D 端子没有对应工程端子。 -6. 保存。 +6. 在树目录 `QETWiring_05_Diagnostics` 下查看 `RoutingConnectionBatch`。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示本次批量布线是否没有问题码;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 是本次批量布线中文摘要;`QetDiagnosticJson` 是 compact 诊断明细,包含 `runtime_version`、`issue_codes`、缺失端点、碰撞、路径质量、容量、柜内边界和路径约束样例。重启 FreeCAD 后手测时,可以先看 `3D 布线连接` 面板状态摘要中的“版本:...”或诊断 JSON 里的 `runtime_version` 是否为当前开发版本,避免旧模块未刷新导致误判。 + - 真实工程批量布线还会记录 `batch_network_entry_candidate_limit`、`batch_avoid_obstacles` 和 `batch_obstacle_candidates`。前者表示批量求路时每端最多采用多少个路径入口候选,第二个字段表示是否额外构建障碍过滤路径图,第三个字段表示本次复用的碰撞障碍物候选数量;当前默认性能优先,仍会在结果中输出碰撞诊断。如果批量按钮长时间无响应,优先把这三个字段和 `route_network_carriers / route_network_segments` 一起反馈给开发侧。 +7. 如果没有导线任务,也会生成 `RoutingConnectionBatch` 诊断对象,并在 `QetDiagnosticMessage` 中提示“没有导线任务”,便于确认问题来自 QET `wires[]` 或 `QETWiring_01_Tasks`。 +8. 保存。 --- @@ -752,6 +755,237 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 19. 点击 `检查最近导线` 或 `检查全部导线`。 20. 点击 `保存并回写`。 +### 14.1 自动布线前的最小可测装配 + +如果目标是测试 `3D布线连接` 的自动布线效果,空机柜本身还不够。至少需要先完成下面的最小装配: + +1. 安装板或背板已经放入机柜,并作为设备安装基准。 +2. 导轨已经贴到安装板或背板上。 +3. 线槽已经放到柜内,或已经用草图/Draft 线定义用户主路径。 +4. QET 导入的真实设备实例已经摆到导轨或安装板上。 +5. 已点击 `生成工程端子`,工程端子能在 `QETTerminals_*` 分组中看到。 +6. 如需限制导线不能跑出柜外,选择柜内空间、柜体或辅助包围盒,点击 `选中对象作为柜内边界`。 + +完成后按下面顺序检查: + +```text +3D布线连接 + -> 检查布线准备度 + -> 准备布线布局空间 + -> 生成布线路径网络 + -> 检查布线路径网络 + -> 生成布线连接 +``` + +如果 `检查布线准备度` 显示: + +```text +路径网络:0 段 +布线源:未识别到线槽/布线面/用户路径 +柜内边界:未标记 +``` + +说明当前问题仍是装配或路径源准备不足,不应把此时的布线效果当作自动布线算法结果。 + +每次点击 `检查布线准备度`,树目录 `QETWiring_05_Diagnostics` 下会刷新一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示预检是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 是中文摘要;展开属性中的 `QetDiagnosticJson`,可以查看缺失端点、路径源数量、柜内边界数量、路径网络诊断和导线样式库状态。这个对象只保存最新一次预检结果,避免多次测试后诊断对象堆积。 + +`检查布线准备度` 默认不再抽样求解导线可达性,避免真实机柜中大量设备、路径 carrier 和障碍对象导致预检长时间卡住。需要排查少量导线是否能连通时,再把面板里的 `可达性抽样` 数量从 `0` 调到 1、5 或更高;这个抽样只用于诊断,不影响正式点击 `生成布线连接` 时的全量布线。 + +预检阶段也会读取路径网络诊断摘要。如果已经标记 `CabinetInterior`,但工程端子或主路径 carrier 越出柜内边界,`检查布线准备度` 会显示“路径网络检查提示”,并带出“越界端子”或“越界路径”样例。这样可以在生成导线前发现装配位置、端子 LCS 或用户路径本身的问题。 + +如果通过 QET 的 `3D` 按钮启动 FreeCAD,随后关闭它新建的工程,再手动打开已经装配好的 `FCStd`,可以直接在装配工程里点击 `检查布线准备度`。中文报告会显示 `导线来源`:显示 `QET 会话交换数据` 表示 FreeCAD 仍在读取 QET 按钮传入的 `2d_to_3d.json / wires[]`;显示 `当前 FreeCAD 文档任务` 表示读取的是当前文档中已保存的 `QETWiring_01_Tasks`;如果导线任务为 0,则需要重新从 QET 导入或确认当前会话交换数据是否还在。 + +同一份中文报告还会显示 `运行版本`。如果截图中没有 `运行版本` 或 `导线来源`,说明当前 FreeCAD 窗口仍在使用旧的 `AutoRouting.py`,需要完全关闭 FreeCAD 后重新从 QET 3D 按钮启动,再打开已装配工程。 + +如果预检发现导线端点没有匹配到 3D 工程端子,`QetDiagnosticMessage` 的“端点缺失示例”会同时显示导线标签和起终点端子,例如 `导线 N4111,terminal-start -> terminal-missing`。这样可以先回到 QET 导线任务或 FreeCAD 工程端子绑定处排查,而不必只靠 terminal UUID 猜是哪根线。 + +端点缺失明细还会显示 `FreeCAD同设备端子=N`。如果该值为 `0`,说明这根导线端点所属的 2D 设备在当前 FreeCAD 工程里没有任何 3D 工程端子,优先检查该设备是否已装配、是否已生成端子 LCS、以及 `project_2d3d_terminal_binding` 是否包含对应 `terminal_uuid`。如果该值大于 `0` 但目标端子仍缺失,通常表示同设备已有端子,但这一个端子的 `terminal_uuid` 没有绑定或不一致。 + +每次点击 `检查布线路径网络`,同一目录下会刷新 `RoutingPathNetwork` 诊断对象。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示路径网络检查是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 会直接提示空路径网络、端子未接入、端子接入过长、端子越出柜内边界、路径对象几何无效、路径越出柜内边界、孤立路径网络等问题;`QetDiagnosticJson` 保存完整诊断明细和 `issue_codes`。 + +面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 mm`,也不会让超过最大距离的端子强行接入。 + +如果有线槽但导线仍大量走布线面,优先看 `RoutingPathNetwork.QetDiagnosticIssueCodes` 是否包含 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`。这个问题表示线槽已经识别成路径 carrier,但它所在的路径组件没有任何 `TerminalAccess`,导线很难自然进入线槽。中文报告会尽量显示“建议桥接到哪个主网络”和最近距离;`QetDiagnosticJson.wire_ducts_without_terminal_access[].bridge_suggestion` 会保存建议连接的两段 carrier、两个最近点和距离。处理方式是在 FreeCAD 中用 UserPath、线槽开口或桥接路径,把线槽组件接到端子接入所在的主网络,再重新生成布线路径网络和导线。 + +`生成布线路径网络` 不会把 FreeCAD 的 Origin 坐标轴、已有 `QETRouteCarrier*` 或异常巨大包围盒对象当成用户路径源。真正的 `UserPath` 需要来自你选中的草图线、Draft 线、带 `Points` 的路径对象,或通过 `按诊断建议生成桥接` / `选中两路径生成桥接` 生成。如果 `生成布线连接` 后诊断显示 `路径采用:线槽/主路径 0 条,布线面/辅助路径 N 条`,说明当前导线基本都在走安装板/门板等辅助 RoutingRange,优先补线槽到端子主网络的桥接路径,或手动画柜内主路径后点击 `选中路径作为用户路径`。 + +`生成布线连接` 会先按路径网络诊断里的 `wire_ducts_without_terminal_access.bridge_suggestion` 自动生成一次 `UserPath` 桥接,再重新生成路径网络并开始布线。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,避免绕过面板按钮后又退回布线面兜底。报告中如果出现 `自动诊断桥接:生成 UserPath N 条`,表示系统已经自动把孤立线槽组件接入端子主网络。这个动作只修改 FreeCAD 文档中的路径 carrier,不写 QET 数据库;重复点击时已存在的同点桥接不会重复生成。 + +端子局部接入会优先连接 `WireDuct / UserPath / WiringCutOut` 等主路径;即使附近有更近的 `RoutingRange` 布线面,只要主路径仍在 `端子接入最大距离 mm` 内,系统会优先接主路径。`RoutingRange` 只作为没有线槽、没有用户主路径、或主路径距离超限时的兜底区域。这一规则用于贴近 SW/EPLAN 的工程习惯:设备端子先接入线槽/主路径,柜内大路径再沿主网络走。 + +手动桥接建议流程: + +1. 在树目录或 3D 视图中找到诊断提示的线槽 carrier,例如 `QETRouteCarrier`、`QETRouteCarrier_1`。 +2. 优先点击 `按诊断建议生成桥接`。系统会先刷新路径网络诊断,再按 `bridge_suggestion` 自动生成对应的 `UserPath` 桥接路径;如果重复点击时网络已经接通,不会再重复生成同一条桥。 +3. 如果诊断没有建议,或建议对象已经失效,再找到端子接入所在的主网络,通常是靠近设备端子、安装板或布线面的 `TerminalAccess` / `RoutingRange` carrier;如果报告已经显示“建议桥接到 xxx”,优先选中这个目标 carrier。 +4. 同时选中线槽 carrier 和目标 carrier,点击 `选中两路径生成桥接`。系统会在两条路径最近点之间生成一段 `UserPath` 桥接路径。 +5. 如果选不到已生成的 carrier,也可以选中能找到 live carrier 的源路径对象;若仍无法生成,再用草图线、Draft 线或已有线段,从线槽开口附近画到主网络附近,然后点击 `选中路径作为用户路径`。 +6. 桥接线段不需要变成实体线槽,但应落在合理柜内空间,避免悬空穿过设备。 +7. 点击 `生成布线路径网络` 或直接重新点击 `生成布线连接`。 +8. 再点击 `检查布线路径网络`,确认 `wire_ducts_without_terminal_access` 消失或数量减少。 +9. 重新生成导线后,选中导线查看 `QetRouteQualityStatus`;如果从 `FallbackPathWarning` 变成更少兜底,说明线槽接入已经改善。 + +完成 `检查布线准备度`、`检查布线路径网络` 和 `生成布线连接` 后,可以点击 `汇总布线诊断`。该按钮不会重新布线,也不会修改 QET 数据;它只读取 `QETWiring_05_Diagnostics` 下最新的 `RoutingPreflight`、`RoutingPathNetwork`、`RoutingConnectionBatch`,合并 `issue_codes` 并在面板中显示“通过/未通过、缺了哪类诊断、主要问题是什么”。同时会刷新一个 `RoutingDiagnosticSummary` 诊断对象,保存 `QetDiagnosticIssueCodes`、`QetDiagnosticIssueLabels`、`QetDiagnosticMessage` 和 `QetDiagnosticJson`,便于手动测试后在树目录中复查或复制给开发侧分析。汇总消息会显示本次诊断采用的 `runtime_version`,优先取 `RoutingConnectionBatch`,用于确认当前工程确实由最新自动布线模块生成。手动测试截图或录屏时,建议最后点一次这个按钮,方便快速判断问题来自装配准备、路径网络,还是批量布线结果。 + +如果只点击了 `生成布线连接`,没有单独点击 `检查布线准备度` 或 `检查布线路径网络`,`汇总布线诊断` 会把最新 `RoutingConnectionBatch` 当作最终诊断入口;只要批量报告内已经包含路径网络诊断摘要,就不会再因为缺少独立的 `RoutingPreflight / RoutingPathNetwork` 对象而判定失败。也就是说,真实手测可以采用“生成布线连接 -> 汇总布线诊断”的简化流程;汇总结果仍会显示端子缺失、路径网络、碰撞、柜内越界等真正问题。 + +批量报告里的 `布线布局空间` 表示本次点击时新生成或刷新的路径 carrier 数量;如果线槽路径已经存在,可能会显示 `线槽路径 0 条`。这不等于当前工程没有线槽路径。继续看下一行 `当前路径网络`,它表示本次真实参与求路的全部路径 carrier,例如 `当前路径网络:线槽路径 4 条,线槽开口 8 条,用户路径 2 条,端子接入 132 条,布线面 391 条`。判断是否识别到线槽时,以 `当前路径网络` 和 `路径采用:线槽/主路径 N 条` 为准。 + +如果旧版工程中已经存在空白的诊断对象,`汇总布线诊断` 会把它标记为 `diagnostic_json_empty / 诊断 JSON 为空`。这通常表示该诊断对象不是当前版本完整生成的,应重新执行对应步骤,例如重新点击 `检查布线准备度`、`检查布线路径网络` 或 `生成布线连接`。 + +如果工程里已有旧版 `RoutedConnection` 导线对象,但单根导线缺少 `QetRouteDiagnosticsJson`,汇总诊断会提示 `routed_wire_diagnostics_missing / 导线诊断缺失`,并给出一条导线 Label 示例。这类旧线可以显示在模型里,但无法提供碰撞、越界、接入距离等新诊断,应重新执行 `生成布线连接`。 + +如果单根导线的 `QetRouteDiagnosticsJson` 存在但不是合法 JSON,汇总诊断会提示 `routed_wire_diagnostics_invalid / 导线诊断 JSON 无效`,并给出一条导线 Label 示例。这通常表示对象来自旧版本、手工修改过属性,或导线对象保存过程中诊断字段损坏,应重新生成布线连接。 + +`RoutingConnectionBatch` 的 `QetDiagnosticJson.route_samples[]` 会保留少量样例导线。样例里可以直接看 `access`、`collision_summary`、`quality`、`capacity`、`boundary` 分组,分别对应长接入、碰撞/安全间隙、路径兜底、容量压力和柜内越界状态。手动测试后如果只想快速反馈问题,可以复制这个诊断对象 JSON;开发侧不一定需要逐根在模型里点选导线。 + +样例中的 `wire_object_label` 与左侧树目录中导线对象 Label 一致。复制诊断 JSON 给开发侧时,可以同时说明这个 Label,开发侧就能更快在 `QETWiring_04_Routed` 下定位对应导线。 + +接入距离、路径质量、候选入口碰撞风险、柜内边界、路径约束、容量压力和碰撞等 warning sample 都会尽量带 `wire_object_label`。手动反馈时优先复制这个字段,比只说“第几条线”更稳定。对于 `missing_route_network_samples[]`、`error_samples[]`、`missing_endpoint_samples[]` 这类失败样例,`wire_object_label` 是任务侧最接近对象标题的显示名,不一定已经生成出真实 3D 导线对象。 + +中文报告中,碰撞示例和缺失端点示例会优先显示 `wire_object_label`,便于直接在左侧树目录定位对象;导线样式、路径示例和统计类提示仍优先显示短导线号,避免报告过长。 + +`route_samples[]` 会优先保留有问题码的导线样例。也就是说,如果本次布线大多数线正常、少数线穿模或接入过长,压缩诊断会优先把异常线放进样例里,便于后续排查。 + +如果批量报告出现 `MissingTerminal / 缺失端子`,先看 `missing_endpoint_samples[]`。每个缺失侧会记录 `*_element_uuid`、`*_instance_id`、`*_terminal_display`、`*_device_in_scene`、`*_device_name`、`*_element_terminal_count`、`*_instance_terminal_count`、`*_missing_endpoint_reason_code` 和中文 `*_missing_endpoint_reason_label`。其中 `missing_device_binding_metadata / 导线端点缺少 2D 设备绑定信息` 表示导线任务端点缺少 `element_uuid`,FreeCAD 无法判断缺失端子属于哪个 2D 设备;第一版不要求 QET 在 `wires[]` 端点提供 `start/end_instance_id`。`device_not_in_3d_scene / 该 2D 设备未在 FreeCAD 场景中找到` 表示这条导线引用的设备当前没有对应 3D 设备实例,应优先检查该设备是否已导入、装配并完成 2D/3D 绑定;`no_3d_terminals_for_element / 该 2D 设备在 FreeCAD 中没有工程端子` 表示设备实例在场景中,但没有生成工程端子,应重新生成端子或检查模板端子。这些都不是线槽、用户路径或 Dijkstra 路径网络问题。 + +`汇总布线诊断` 会把批量报告里的缺端子信息汇总成类似 `缺端子:4 条(该 2D 设备未在 FreeCAD 场景中找到 4 处)` 或 `缺端子:2 条(该 2D 设备在 FreeCAD 中没有工程端子 2 处)` 的文本。这个统计能帮助快速判断当前问题是不是设备未导入/未绑定、设备端子未生成,还是路径网络不连通。 + +当缺端点原因是 `device_not_in_3d_scene` 时,`汇总布线诊断` 的建议会优先提示“检查缺失 3D 设备是否已导入、装配并完成 2D/3D 绑定”。这种情况下场景里没有可选中的缺失设备,不能靠调整线槽或点击路径按钮解决;应先回到设备导入、装配和绑定流程。只有原因是 `no_3d_terminals_for_element` 或 `no_3d_terminals_for_instance` 时,才优先使用 `选择缺端子设备` 去定位已存在但缺工程端子的设备。 + +当缺端点原因是 `missing_device_binding_metadata` 时,`汇总布线诊断` 的建议会提示“检查 QET 导线端点是否提供 element_uuid 和 terminal_uuid(第一版不要求 start/end_instance_id)”。这表示 FreeCAD 无法判断缺失端子属于哪个 2D 设备,应由 QET 导线任务补齐端点设备标识;第一版只要求 `wires[]` 每个端点携带 `terminal_uuid` 和 `element_uuid`,`instance_id` 由 FreeCAD 通过 `devices[]`、端子绑定或当前 3D 文档回查。 + +直接点击 `生成布线连接` 时,如果本次没有生成任何有效导线且缺端子原因包含 `missing_device_binding_metadata`,面板中文报告也会直接提示 `QET 导线端点缺少 element_uuid`,并注明第一版不要求 `start/end_instance_id`。这时不要去调整线槽、柜内边界或 Dijkstra 参数,应先检查 QET 导出的 `wires[]` 端点数据是否完整。 + +如果本次大多数导线已经成功、只有少量导线缺端子,`生成布线连接` 的中文报告也会显示 `缺端子原因提示`。例如真实工程中 `total_wires=75, routed=71, missing_terminals=4` 时,如果缺失侧设备未在当前 FreeCAD 场景中找到,报告会直接提示先检查设备导入、装配和 2D/3D 绑定,而不是只显示一条缺失样例。报告还会按设备聚合输出类似 `需补端子设备:UD:8 缺 2 处(as、sa),UD:10 缺 1 处(sa),UD:5 缺 1 处(1)` 的文本;这表示自动布线本身已经完成可布的 71 条,剩余 4 条应先补这些 3D 设备/端子场景数据,再重新生成布线连接。 + +批量诊断的 `issue_codes` 会把缺端子原因提升到顶层,例如 `device_not_in_3d_scene / 3D场景缺少设备`、`missing_device_binding_metadata / 端点缺少绑定信息`、`terminal_uuid_not_in_element / 端子UUID不匹配`、`no_3d_terminals_for_element / 设备缺少工程端子`。手测时如果看到 `device_not_in_3d_scene`,先补设备导入、装配和 2D/3D 绑定;如果看到 `terminal_uuid_not_in_element`,再用 `选择缺端子候选端子` 核对同设备端子 UUID 和脚号。 + +批量诊断还会写入 `missing_terminal_summary`。其中 `reason_code_counts` 汇总每类缺端子原因,`device_groups[]` 按缺失侧设备归并,包含 `element_uuid`、`instance_id`、缺失端子显示名、端子 UUID 和相关导线号。把该分组发给装配或绑定相关同事时,比逐条复制 `missing_endpoint_samples[]` 更容易定位需要补哪个设备。 + +这个 `missing_terminal_summary` 不只存在于 `RoutingConnectionBatch.QetDiagnosticJson`,也会直接存在于本次批量布线返回的原始 report 中。后续脚本、面板按钮或调试探针如果要拿缺设备清单,应优先读取 `report.missing_terminal_summary.device_groups[]`,不要解析中文 `需补端子设备` 文本。 + +如果布线是直接从 FCStd 里的任务对象发起,而任务对象没有保存完整设备列表,FreeCAD 会尝试从当前 QET 交换上下文的 `2d_to_3d.json` 回补 `devices[]`,用于补齐 `device_groups[].instance_id` 和设备标签。这个回补只读 JSON,不写数据库、不覆盖当前导线任务;如果项目 UUID 不一致,则不会回补。批量 report 会写入 `context_devices_loaded`、`context_device_count` 和 `context_devices_json_path`,用于判断本次是否真的加载了上下文设备列表。若缺设备分组里 `instance_id` 仍为空,优先检查当前 FreeCAD 会话是否还保留正确的交换 JSON 上下文,或环境变量 `QET_2D_TO_3D_JSON` 是否指向当前项目。 + +如果工程里保存的是旧版批量诊断,缺少 `*_missing_endpoint_reason_code / label`,`汇总布线诊断` 会尝试根据当前 FreeCAD 文档现场回填原因。只要样例里还有 `terminal_uuid`、`element_uuid` 或 `instance_id`,通常可以直接回填成“QET 端点绑定信息缺失”“设备未在 3D 场景中找到”“设备存在但无工程端子”等原因。只有样例里连这些基础字段也缺失时,才会显示 `缺端点原因未记录`,此时需要重新点击 `生成布线连接` 刷新诊断。 + +如果要快速定位这些缺端子设备,点击 `选择缺端子设备`。系统会从最新批量布线诊断的 `missing_endpoint_samples[]` 中读取缺失侧的 `*_instance_id` 和 `*_element_uuid`,并在 FreeCAD 中选中对应 3D 设备。若缺失原因是 `device_not_in_3d_scene`,当前场景里没有可选中的 3D 对象,面板会优先显示可读设备标签,例如 `UD:8`、`UD:10`,并在状态栏补充对应 `instance_id`,而不是只显示很长的 UUID;这类提示表示要先补设备导入、装配和 2D/3D 绑定。控制器返回值也会包含 `missing_terminal_device_instance_ids[]` 和 `missing_terminal_device_element_uuids[]`,便于脚本直接交给装配/绑定流程处理。该按钮只做定位,不会自动创建端子;选中后应检查该设备是否已有工程端子、端子是否来自 QET 绑定、以及设备是否装配在当前工程中。 + +如果点击 `选择缺端子设备` 后没有选中任何对象,并且诊断原因是 `device_not_in_3d_scene`,面板会直接提示缺失侧 2D 设备未在当前 FreeCAD 场景中找到。这表示没有可选中的设备对象,应先补设备导入、装配和 2D/3D 绑定;如果原因是 `missing_device_binding_metadata`,面板会提示 QET 导线端点缺少 `element_uuid`,应先补齐 QET 端点绑定信息。 + +如果缺端子样例中一端已经找到、另一端缺失,可以点击 `选择缺端子另一端`。系统会选择这些失败导线里已找到的工程端子,例如真实工程中起点缺失但终点已找到时,会选中终点端子。这个按钮用于快速确认“这根失败导线本来要连到哪里”,再回到缺失侧设备检查是否没有生成工程端子、端子脚号是否不一致或 `terminal_uuid` 是否绑定错位;它只定位端子,不会自动补端子或改 QET 数据。 + +如果缺端点原因是 `terminal_uuid_not_in_element / 同设备存在端子,但没有匹配该 terminal_uuid`,可以点击 `选择缺端子候选端子`。系统会从 `*_instance_terminal_samples` 或 `*_element_terminal_samples` 中选择同设备/同实例已有的工程端子,便于直接查看这些端子的 `QetTerminalUuid`、`QetTemplateSlotName`、`QetTerminalLabel` 和位置。若候选端子存在但 UUID 不一致,优先检查 QET 端子绑定或重新生成工程端子;若没有候选端子,则回到 `选择缺端子设备` 检查该设备是否真的生成了端子。 + +如果已经标记柜内边界,自动布线会先在柜内过滤后的路径图上求路;如果这张图不能连通两端,才回退到原始路径图。批量报告里的样例 network 会记录 `boundary_filtered` 和 `boundary_filtered_segments`,用于判断本次路线是否使用了柜内过滤图。如果某条导线仍跑出柜内区域,批量报告会提示“柜内边界提示”。选中具体导线对象时,也可以在属性里查看 `QetRouteBoundaryAware`、`QetRouteBoundaryStatus` 和 `QetRouteBoundaryViolationCount`:其中 `BoundaryWarning` 表示该导线存在柜内越界点,应优先补柜内 `UserPath`、线槽或设备局部路径。 + +导线样式来自 QET 导线任务中的 `wire_style_id`,FreeCAD 会按项目数据库 `wire_properties` 查询颜色、线宽、线型和规格文本。手动测试时,选中生成的 `RoutedConnection` 导线,可以先看 `QetWireStyleStatus`:`Resolved` 表示已查到样式,`Missing` 表示样式 ID 没查到;再看 `QetWireColorText`、`QetWireLineType`、`QetWireLineWidth`、`QetWireDiameterMm` 等属性确认原始样式数据。GUI FreeCAD 中导线会按解析到的颜色、线宽和线型显示;如果用 `FreeCADCmd` 做命令行验证,因为没有 `ViewObject`,只能确认样式属性写入,不能证明界面颜色已经显示。 + +如果 `检查布线路径网络` 提示 `route_carriers_outside_boundary / 路径越出柜内边界`,说明某条线槽中心线、`UserPath` 或过线孔路径本身已经有点落在 `CabinetInterior` 外。此时应先调整该路径源或重新标记正确的柜内边界,再生成导线;否则后续自动布线即使能连通,也容易把线带到柜外。 + +如果提示 `terminals_outside_boundary / 端子越出柜内边界`,说明至少一个工程端子的原点或出线末端落在 `CabinetInterior` 外。优先检查该设备是否真的装配到机柜内、端子 LCS 是否跟随设备实例移动,以及柜内边界对象是否过窄或标错。 + +如果要快速定位这些对象,点击 `选择越界路径/端子`。系统会从最新 `RoutingPathNetwork` 诊断里选择越界的路径 carrier 和工程端子。选中后通常按下面顺序处理:若是 `UserPath` 或线槽越界,调整源草图/线槽或重新标记正确柜内边界;若是端子越界,检查设备是否真正装配到柜内、端子 LCS 是否跟随设备移动;修正后重新点击 `检查布线路径网络` 和 `生成布线连接`。 + +如果直接点击 `生成布线连接(全部导线)`,批量报告也会带出同类路径网络检查提示。出现“越界路径:<路径标签> N 个越界点”时,优先在树目录中定位该路径源或其生成的 carrier,修正后重新生成布线路径网络和导线。出现“越界端子:<端子对象/UUID> N 个越界点”时,优先定位该端子所属设备,确认设备已经装配到柜内且端子 LCS 跟随实例移动。 + +如果多根导线共用同一路径,选中具体导线对象可以查看 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm`、`QetRouteParallelWireCount`、`QetRouteMinCarrierCapacity` 和 `QetRouteCapacityStatus`。其中 `CapacityWarning` 表示这条线所在共享路径的并行线数已经超过当前路径容量,需要补备用路径、调整线槽容量或优化路径约束。 + +如果某条线已经生成但端子附近拉出很长一段斜线或折线,选中该导线对象查看 `QetRouteEntryDistanceMm`、`QetRouteExitDistanceMm`、`QetRouteAccessWarningDistanceMm` 和 `QetRouteAccessStatus`。其中 `LongAccessWarning` 表示起点或终点到主路径网络的接入距离超过当前告警阈值;`QetRouteAccessWarningSides` 会显示触发侧,`entry` 是起点侧,`exit` 是终点侧。出现该提示时,优先检查设备是否已经装配到正确位置、端子局部出线路径是否存在、用户路径或线槽是否离设备端子太远。 + +`检查布线路径网络` 和批量布线的 `routing_path_network_diagnostic.long_terminal_accesses[]` 会保留长接入样例。样例里包含 `parent_device_label / parent_device_name`、`terminal_origin`、`terminal_access_points`、`terminal_access_dominant_axis` 和 `terminal_access_axis_lengths_mm`。如果 `terminal_access_dominant_axis` 是 `z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。 + +如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。 + +如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。 + +如果确认是某个工程端子缺少设备局部出线路径,可以直接在当前装配工程里补: + +1. 选中一个可布线工程端子。 +2. 再选中一条表示该端子出线方向的草图、Draft 线、边或连续 Wire。 +3. 点击 `选中端子设置局部出线`。 +4. 系统会把所选路径写入该工程端子的 `QetTerminalLocalRoutePointsJson`,只修改当前 FreeCAD 文档,不写 QET 数据库。 +5. 重新点击 `生成布线路径网络` 或 `生成布线连接`。 + +这个动作适合处理端子附近拉出长斜线、从设备内部穿模、或默认 LCS 出线方向不符合实物的情况。若同类设备后续会反复使用,应把局部出线路径沉淀到 FCStd 设备模板里;当前装配工程里的按钮更适合现场手动测试和个别端子的快速修正。 + +如果要确认某根线到底走了哪条线槽或黄色草图路径,选中导线对象查看 `QetRouteSourceLabels`。该属性会优先显示源路径标签;同一个草图拆成多条路径时会显示 `源路径标签(路径2)` 这类后缀。`QetRouteCarrierNames` 则显示实际经过的 carrier 对象名,适合在左侧树目录中继续定位。 + +生成导线对象在左侧树目录中的 Label 会尽量显示为 `导线号: 起点端子 -> 终点端子 (状态)`,例如 `N4111: terminal-start -> terminal-end (CollisionWarning)`。如果批量报告提示某条线有问题,可以先按导线号或端子 UUID 在 `QETWiring_04_Routed` 下定位对象,再查看它的属性。 + +选中导线对象后,也可以先看 `QetRouteIssueCodes` 和 `QetRouteIssueLabels`。这两个属性会用与批量诊断一致的问题码汇总该线自身的问题,例如端子接入过长、碰撞告警、路径质量告警、路径容量压力或柜内越界。看到问题码后,再展开对应的详细属性。 + +如果要先把本次批量布线中的问题线全部找出来,点击 `选择异常导线`。系统会从最新批量诊断 `route_samples[]` 中选择所有带 `issue_codes` 的 RoutedConnection 导线,同时也会扫描已生成导线对象自身的 `QetRouteIssueCodes`,避免 compact 样例数量有限时漏掉问题线。这个按钮适合统一排查长接入、柜内越界、容量压力、路径质量或碰撞问题;不会选择没有问题码的正常导线,也不会修改路径。 + +如果只想定位“主路径网络不够导致无法按主路径绕开碰撞”的线,点击 `选择缺主路径导线`。系统会选择带 `main_path_detour_missing` 的 RoutedConnection 导线;这类线通常表示选择性避障重算发现了可行绕法,但该绕法会退回 `RoutingRange`、`AuxiliaryPath` 或其它兜底空间路径,所以当前版本按主路径优先策略拒绝采用。选中后应优先补黄色草图 `UserPath`、桥接线槽/主路径、调整线槽入口,或给设备端子补局部出线路径,而不是直接把 fallback 结果当作正式布线。 + +如果要看这些缺主路径导线当前实际走了哪条线槽、`UserPath` 或源草图,点击 `选择缺主路径线路径`。系统会先从带 `main_path_detour_missing` 的样例中选择当前路径 carrier 和源对象,并补充扫描已生成导线对象自身的 `QetRouteIssueCodes / QetRouteTrackJson`,避免 compact 样例数量有限时漏掉问题线;其它异常线的路径不会混进来。这个按钮适合与 `选择拒绝兜底路径` 对照:前者看“现在走哪里”,后者看“算法想绕到哪里但被拒绝”,两边之间通常就是需要补主路径或局部路径的位置。 + +如果要继续定位异常导线实际经过了哪条线槽、`UserPath` 或源草图,点击 `选择异常导线路径`。系统会从最新异常 `route_samples[]` 的 `carrier_names` 和 `route_track.segments[].carrier` 中选择路径 carrier,并尽量选择其 `source_name / source_label` 对应的源对象。选中后可以检查该路径是否越界、穿模、容量不足,或直接对源路径设置 Required/Forbidden、调整容量、移动草图路径。 + +如果你已经在树目录或 3D 视图中选中了某一根问题导线,可以直接点击 `选择选中导线路径`。系统会读取该导线对象上的 `QetRouteTrackJson`,反向选择这根线实际经过的路径 carrier 和源草图/线槽;这个功能不依赖 compact `route_samples[]`,适合诊断样例为空、样例数量不足,或你想单独排查某一根穿模/越界/共线导线的场景。该操作只定位对象,不修改 QET 数据库、不重新生成导线。 + +如果这根线带 `main_path_detour_missing`,可以在选中导线后继续点击 `选择拒绝兜底路径`。系统会读取导线 `QetRouteDiagnosticsJson` 里被拒绝的 `RoutingRange` / `AuxiliaryPath` 标签,并反选对应的路径源对象;状态栏会显示 `需补路径位置`,列出前几个被拒绝的兜底路径标签。这个按钮的用途不是把兜底路线改成正式结果,而是帮助判断算法“想从哪里绕过去”:若选中的是安装板布线面或辅助路径,通常应在该区域补一条明确的黄色草图 `UserPath`、桥接到线槽/主路径,或给设备端子补局部出线路径。 + +`汇总布线诊断` 也会扫描已生成导线对象的 `QetRouteIssueCodes`,输出类似 `异常导线:2/71 条(端子接入过长 1 条、碰撞告警 1 条)` 的统计。这个数量来自实际导线对象,不受 `route_samples[]` 样例数量限制,更适合作为手测总览。 + +执行 `生成布线连接(全部导线)` 后,批量报告本身也会带出 `缺主路径绕行:N 条` 和 `需补路径位置`,不用必须再点一次汇总诊断才能看到剩余补路区域;后续若需要更完整的异常线统计,再点击 `汇总布线诊断`。如果系统还能读取到这些导线当前实际经过的主路径,会继续显示 `补路配对:兜底区域 -> 当前主路径`,例如 `FRONT DOOR-R_P00 -> WireDuct_Body001 4 条`。这表示应优先在该门板/布线面区域和对应主线槽或 UserPath 之间补桥接路径,而不是把门板兜底路线直接当成正式布线。 + +如果存在 `main_path_detour_missing`,`汇总布线诊断` 会额外显示 `缺主路径绕行:N 条`,并把这些导线诊断中被拒绝的 `RoutingRange / AuxiliaryPath` 标签汇总成 `需补路径位置`。如果单线诊断中保存了 `QetRouteTrackJson`,汇总还会显示 `补路配对`,把被拒绝的兜底区域和当前实际主路径成对列出来。这一步适合在逐根选线前先判断问题集中在哪个柜内区域、应桥接到哪条主路径;随后再点击 `选择缺主路径导线` 和 `选择拒绝兜底路径` 做单线定位。 + +如果 `补路配对` 两端都能在当前 FreeCAD 文档里找到 live carrier,可以点击 `按诊断建议生成桥接`。该按钮除了处理“线槽未接入端子主网络”的桥接建议,也会按 `兜底区域 -> 当前主路径` 配对生成一段 `UserPath` 桥接。生成的桥接对象会写入 `QetRouteBridgeKind=MainPathDetourBridge`、`QetRouteBridgePairLabel`、左右源对象 `Name/Label`,便于后续在属性面板里确认这条桥到底连接了哪两个区域。生成后重新点击 `生成布线连接(全部导线)`,检查 `main_path_detour_missing` 是否减少。若状态栏提示“未找到配对”,说明对应兜底区域或主路径源对象无法定位,需要先用 `选择缺主路径补路位置` 查看两端是否存在,或手动画 UserPath。 + +`生成布线连接(全部导线)` 默认只按当前路径网络布线,不会悄悄把诊断建议固化成新的 `UserPath`。如果现场确认某个 `main_path_detour_missing` 确实需要补主路径,可先用面板里的桥接/补路径按钮手动生成,或在脚本选项中显式开启 `auto_create_main_path_detour_bridges`。开启后,如果选择性避障已经找到一条无碰撞或碰撞更少的兜底绕行,但该绕行包含 `RoutingRange` 而被主路径优先策略拒绝,系统会先把这条已验证折线固化成 `QetRouteBridgeKind=MainPathDetourPath` 的 `UserPath`,再按 `兜底区域 -> 当前主路径` 生成 `MainPathDetourBridge`,随后只重试受影响的导线,不会整批全量重跑。批量报告中的 `auto_main_path_detour_bridges` 会记录生成数量、重试导线数和替换结果。 + +碰撞告警会自动过滤一类导入结构件误报:如果障碍物没有 QET `element_uuid`,并且位于 `QET Exchange Devices / QETCabinet / LinkGroup / Compound / NAUO` 这类导入装配上下文,同时名称或父装配命中柜体、门板、支架、盖板等结构关键词,系统会把它视为未绑定结构件,不再让导线变成 `CollisionWarning`。带 `element_uuid` 的真实设备、断路器、端子等仍然保留为碰撞告警,需要通过补局部路径、调整装配或设置路径约束处理。 + +如果只想从汇总结果直接定位这些区域,点击 `选择缺主路径补路位置`。系统会读取汇总诊断里的 `需补路径位置` 标签,并按对象 `Name / Label` 以及路径源 `QetRouteSourceName / QetRouteSourceLabel` 反选对应的 `RoutingRange / AuxiliaryPath` 来源对象;如果诊断里已经有 `补路配对`,还会同时选择当前实际主路径对应的线槽或 `UserPath` 源对象。状态栏会显示兜底区域、当前主路径和配对统计,例如 `FRONT DOOR-R_P00 -> WireDuct_Body001 4 条`。这个按钮不要求你先选中导线,适合快速查看剩余问题集中在哪个安装板、布线面或辅助路径附近,以及应桥接到哪条主路径。 + +当汇总诊断已经能读取到缺主路径绕行问题时,建议动作会按顺序提示:先点击 `选择缺主路径导线` 选中问题线,再点击 `选择缺主路径线路径` 对照当前实际路径;如果诊断中还有被拒绝的兜底路径标签,可以先点击 `选择缺主路径补路位置` 快速定位汇总需补区域,也可以在这些导线保持选中的情况下点击 `选择拒绝兜底路径` 查看单线需补路径位置。这个顺序适合处理真实工程中少量剩余碰撞线。 + +`汇总布线诊断` 还会根据当前问题给出下一步建议,例如 `点击“选择缺端子设备”定位需要补工程端子的设备`、`点击“选择异常导线”定位带问题码的导线`、`点击“选择碰撞父装配”确认结构件后再标记忽略碰撞`。手测时可以先看这一行,再决定下一步点哪个定位按钮。 + +如果要判断某根线是明显穿模还是只是距离太近,选中导线对象查看 `QetRouteCollisionStatus`、`QetRouteHardIntersectionCount` 和 `QetRouteClearanceWarningCount`。`HardIntersectionWarning` 表示导线穿过障碍包围盒,应优先改路径或设备位置;`ClearanceWarning` 表示导线没有穿过障碍,但低于安全间隙,通常需要微调路径或安全间隙参数。 + +批量诊断中的 `collision_samples[]` 也会带 `wire_object_label`。如果报告出现“碰撞示例”,可以先复制这个 Label 到树目录中查找对应导线,再结合 `collision_kind` 判断是硬碰撞还是安全间隙。碰撞样例还会带 `obstacle_parent_labels / obstacle_parent_names`,用于判断类似 `NAUO141` 这样的零件属于前门、柜体、安装板还是具体设备;确认是装配辅助件或可穿过结构后,再手动标记为忽略碰撞对象。 + +批量报告和汇总诊断里的 `top_collision_obstacles[]` 会按碰撞对象聚合高发对象,并保留 `name`、`label`、`collision_kind_counts`、`parent_labels` 和 `parent_names`。中文摘要会显示类似 `NAUO141(FRONT DOOR-R ASS'Y) 6 处`。如果高发对象属于柜体、门板、盖板或安装辅助结构,先确认导线是否真的应该穿过该区域;确认可忽略后再选择对应对象并标记为忽略碰撞。不要只因为对象名像 `NAUOxxx` 就直接全局忽略。 + +高发碰撞对象还会给出 `resolution_hint_code / resolution_hint_label`。`review_pass_through_structural_obstacle` 表示疑似柜体、门板、支架、盖板等结构件;处理方式是先在模型中定位该对象,确认它不是实际需要避让的设备实体后,选中对象点击 `选中对象忽略碰撞`,再重新生成布线。`review_device_or_layout_collision` 表示更像设备或安装区域穿模,应优先补线槽、`UserPath`、设备局部出线路径,或调整装配位置。 + +为了避免在树目录里手工查找 `NAUOxxx`,可以先点击 `选择高发碰撞对象`。系统会从最新批量布线诊断的 `top_collision_obstacles[]` 中查找对象,并在 FreeCAD 里选中这些高发碰撞对象。这个按钮只做定位和选择,不会自动忽略碰撞;确认对象确实是柜体、门板、支架或盖板等可穿越结构后,再点击 `选中对象忽略碰撞`。 + +如果诊断里已经能看到 `parent_names / parent_labels`,也可以点击 `选择碰撞父装配`。系统会直接选择高发碰撞对象所属的父装配,例如前门总成或柜体总成;确认这些总成是可穿越结构后,再点击 `选中对象忽略碰撞`,可以一次影响其下层导入子件。 + +如果汇总诊断已经把高发碰撞对象标记为 `review_pass_through_structural_obstacle / 疑似结构件可确认忽略`,可以点击 `选择结构件碰撞父装配` 先定位检查。确认这些对象确实是柜体、门板、支架、盖板等结构件后,也可以直接点击 `确认结构件忽略碰撞`。该按钮只会把诊断判定为结构件候选的最近结构父装配标记为 `PassThrough`,不会沿父链继续标记 `QET Exchange Devices` 这类工程根组,也不会标记 `review_device_or_layout_collision` 这类疑似设备或布局碰撞。标记完成后需要重新点击 `生成布线连接`,再看碰撞数量是否减少;如果剩余高发对象变成真实设备,应优先补设备局部出线路径、调整 UserPath/线槽入口或检查装配位置。 + +结构件碰撞处理后,如果 `top_collision_obstacles[]` 剩余对象的 `resolution_hint_code` 是 `review_device_or_layout_collision`,可以点击 `选择设备碰撞对象`。该按钮只定位真实设备/布局碰撞对象,不会标记 `PassThrough`,也不会修改数据库。选中后优先检查这些设备附近是否缺少局部出线路径、线槽入口是否离端子过远、用户路径是否穿过设备包围盒,或设备本身是否装配到错误位置。 + +批量诊断的 `issue_codes` 会把碰撞进一步拆成 `structural_collision_candidates / 结构件碰撞候选` 和 `device_or_layout_collisions / 设备/布局碰撞`。前者适合先定位父装配并确认是否可标记 `PassThrough`;后者不应直接忽略,应回到设备、端子局部出线路径、UserPath 或装配位置处理。 + +如果碰撞对象是导入总成下的深层子零件,例如 `门板总成 -> Compound -> NAUOxxx`,也可以选择其父装配或中间 Compound 后点击 `选中对象忽略碰撞`。当前版本会沿父装配链递归识别 `PassThrough`,因此父装配被确认可穿越后,其下层导入子件不会继续作为导线障碍。这个操作只改变 FreeCAD 文档内的布线障碍语义,不写入 QET 数据库。 + +如果要反向查看“哪些导线正在碰撞”,点击 `选择碰撞导线`。系统会从最新批量布线诊断的 `collision_samples[]` 和带 `collision_warnings` 的 `route_samples[]` 中查找 RoutedConnection 导线对象并选中。现场排查时可以先点 `选择高发碰撞对象`,再点 `选择碰撞导线`,结合 3D 视图判断是结构件误报、路径离设备太近,还是需要补局部路径。 + +如果要判断某根线是否真正走了工程主路径,选中导线对象查看 `QetRouteQualityStatus`。`NormalPath` 表示没有使用布线面/辅助路径兜底;`FallbackPathWarning` 表示路线经过了 `RoutingRange` 或 `AuxiliaryPath`,可以继续查看 `QetRouteFallbackCarrierKinds` 和 `QetRouteFallbackCarrierLabels`。这个状态不是失败,但通常说明需要补线槽、黄色草图 `UserPath`、过线孔或设备局部路径。 + +### 14.2 第一版自动布线手测验收清单 + +第一版验收不要只看“模型里有没有线”,而要同时看诊断对象和单线属性。一次有效手测至少记录下面这些结果: + +1. 面板状态摘要显示 `版本:2026-06-08-runtime-routing-v4` 或更新版本。 +2. `检查布线准备度` 能识别到 QET 导线来源、工程端子、路径网络和柜内边界;若有问题,`RoutingPreflight.QetDiagnosticIssueCodes` 能说明原因。 +3. `检查布线路径网络` 不应出现空网络、路径对象几何无效、端子越出柜内边界或路径越出柜内边界;如果出现,应先修装配或路径源。 +4. `生成布线连接` 后,`RoutingConnectionBatch.QetDiagnosticJson.runtime_version` 与面板版本一致。 +5. 有导线任务时,`RoutingConnectionBatch.routed` 应大于 0;如果为 0,应优先看 `missing_endpoint_samples`、`missing_route_network_samples` 或 `error_samples`。 +6. 正常导线的单线 `QetRouteIssueCodes` 应为空;若存在问题码,应能归类到端子接入、碰撞、柜内越界、容量压力、路径质量或路径约束。 +7. 已标记 `CabinetInterior` 时,优先要求 `QetRouteBoundaryStatus=InsideBoundary`;出现 `BoundaryWarning` 时,应补柜内主路径或修正边界。 +8. 碰撞状态优先看 `QetRouteCollisionStatus`:`NoCollision` 为理想结果,`ClearanceWarning` 可作为间隙问题记录,`HardIntersectionWarning` 视为穿模问题。 +9. 多根线共路时,检查 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm` 和 `QetRouteCapacityStatus`,确认新增导线不会无诊断地贴到旧线上。 +10. 最后点击 `汇总布线诊断`,把 `RoutingDiagnosticSummary.QetDiagnosticMessage` 和主要 `issue_codes` 作为本次手测结论。 + +如果上面 1、4、5 成立,且问题导线都能通过诊断字段定位原因,说明当前版本已经具备第一版可测闭环。若仍存在导线穿模、柜外线或未布通,应优先把对应导线的 `wire_object_label`、`QetRouteIssueCodes`、`QetRouteDiagnosticsJson` 和录屏时间点一起反馈。 + --- ## 15. 常见问题 @@ -794,18 +1028,25 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 常见原因: +- 设备、线槽、导轨还没有装配到机柜内,左侧树中有对象但 3D 位姿还不是柜内真实位置。 - 没有导入线槽。 - 线槽没有标记为线槽。 - 没有从线槽实体生成中心路径。 +- 没有用草图/Draft 线创建用户主路径。 +- 没有生成 `WireDuct`、`RoutingRange`、`UserPath` 或 `TerminalAccess` carrier。 - 端子离线槽太远,缺少过渡路径。 处理: -1. 选择线槽,点击 `标记为线槽`。 -2. 打开 `3D布线连接`。 -3. 点击 `从线槽实体生成中心路径`。 -4. 点击 `扫描端子/网络`。 -5. 再尝试生成布线连接。 +1. 先把导轨、线槽和设备摆到机柜内真实位置。 +2. 选择线槽,点击 `标记为线槽`。 +3. 如果没有线槽,先画草图或 Draft 线,再点击 `选中路径作为用户路径`。 +4. 打开 `3D布线连接`。 +5. 点击 `检查布线准备度`,确认有布线源。 +6. 点击 `准备布线布局空间`。 +7. 点击 `生成布线路径网络`。 +8. 点击 `检查布线路径网络`。 +9. 再尝试生成布线连接。 ### 15.4 为什么保存后 QET 看不到 3D 位姿? diff --git a/docs/superpowers/specs/2026-05-28-电气自动布线设计.md b/docs/superpowers/specs/2026-05-28-电气自动布线设计.md index be3cf40..302c4b2 100644 --- a/docs/superpowers/specs/2026-05-28-电气自动布线设计.md +++ b/docs/superpowers/specs/2026-05-28-电气自动布线设计.md @@ -348,6 +348,6 @@ allow_floating_fallback = false 为避免第一版范围漂移,下面三项采用明确默认值,后续阶段再扩展: -1. 第一版读取 `wire_style_id` 仅用于诊断和后续扩展,不强制映射 FreeCAD 导线颜色和线宽。 +1. 第一版读取 `wire_style_id` 后会按 `wire_properties` 解析导线显示样式:能解析时映射 FreeCAD 导线颜色、线宽和线型,并把 `Resolved/Missing` 状态写入导线对象和批量诊断;缺少数据库或查不到样式时使用默认显示样式,不阻止布线。 2. 第一版只在 FreeCAD 中保存和显示自动布线长度,不导出正式长度报表。 3. 第一版不提供 `AutoSuggested` 转锁定确认导线的完整工作流;用户需要固定路径时,先使用已有手动布线能力重新创建正式手动导线。 diff --git a/docs/三期3D功能任务拆解与开发顺序.md b/docs/三期3D功能任务拆解与开发顺序.md new file mode 100644 index 0000000..99753df --- /dev/null +++ b/docs/三期3D功能任务拆解与开发顺序.md @@ -0,0 +1,848 @@ +# 三期 3D 功能任务拆解与开发顺序 + +更新时间:2026-06-08 + +## 1. 文档目标 + +本文档根据当前任务表拆解三期 3D 建模和三维布线相关功能,明确: + +- 每个任务包含哪些具体功能。 +- FreeCAD 原生是否已经具备对应能力。 +- 当前项目是否已经完成。 +- 第一版是否必须做。 +- 后续应该如何开发,以及开发顺序。 + +本文档只按当前正式路线评估: + +```text +QET / 明图CAD + -> 2d_to_3d.json + -> FreeCADExchange + -> scene.FCStd + -> 3d_to_2d.json +``` + +不再把旧 ThreeD 模块作为正式完成依据。 + +## 2. 状态定义 + +| 状态 | 含义 | +| --- | --- | +| 已完成第一版 | 当前代码已经能支撑最小可用流程,但仍可能需要优化体验和边界情况。 | +| 部分完成 | 已有基础代码或 FreeCAD 原生能力,但还没有达到任务表描述的完整交付标准。 | +| 未完成 | 当前正式 FreeCAD 路线中还没有形成可用能力。 | +| FreeCAD 原生可用 | FreeCAD 已经提供通用 CAD 能力,第一版可以直接使用,不需要重复开发。 | + +## 3. 三期 3D 建模功能交付 + +### 3.1 3D 数据模型与映射规范开发 + +任务描述: + +> 基于 QET 设备、符号、端子、项目数据库,建立设备-3D资产-场景实例-端子连接点-2D图元映射关系;定义 STEP/IGES/FCStd 资产、sidecar 元数据、设备参数、安装规则、连接点语义。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 设备实例与 3D 实例映射 | 已完成第一版 | 必须 | 使用 `project_uuid + element_uuid + instance_id`。 | QET 设备打开到 FreeCAD 后能稳定生成或复用同一个 3D 设备实例。 | +| 2D 端子与 3D 工程端子映射 | 已完成第一版 | 必须 | 第一版唯一核心依据是 `terminal_uuid`。 | 2D 端子在 FreeCAD 中能找到对应工程端子。 | +| 设备与 3D 资产映射 | 已完成第一版 | 必须 | QET 侧解析设备绑定资产,FreeCAD 侧读取 `resolved_model_path`。 | QET 绑定的 FCStd/STEP 资产能被 FreeCAD 正确导入。 | +| 场景实例保存 | 部分完成 | 必须 | 第一版不把 3D 位姿放数据库,以 `scene.FCStd` 为准。 | 设备移动后保存 `scene.FCStd`,重新打开位置不丢。 | +| STEP/IGES/FCStd 资产定义 | 部分完成 | 必须 | STEP/IGES 作为几何输入;FCStd 作为正式电气 3D 资产。 | FCStd 能保存模型几何、模板端子、工程端子和布线对象。 | +| sidecar 元数据 | 未作为主链路 | 非必须 | 当前正式路线优先 FCStd LCS,不建议第一版依赖 sidecar。 | 如后续启用,sidecar 只能作为 FCStd 之外的兼容补充。 | +| 设备参数 | 部分完成 | 非第一版必须 | 属于参数化设备库能力。 | 能用参数生成不同规格设备模型。 | +| 安装规则 | 部分完成 | 后续必须 | 如安装到导轨、安装板、柜体。 | 设备知道自己安装在哪个宿主上,移动宿主时能跟随或校验。 | +| 连接点语义 | 已改为端子语义 | 必须 | 正式叫“端子”,分模板端子和工程端子。 | 工程端子能被选中接线,带端子 UUID、位置、方向和可接线属性。 | + +### 3.2 FreeCAD 参数化设备建模能力开发 + +任务描述: + +> 基于 FreeCAD Part/PartDesign 建立电气元件参数化建模模板,支持断路器、继电器、端子排、导轨、线槽、柜体等常用结构生成;支持模型复用、尺寸参数配置、STEP/IGES 导出。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| FreeCAD 手工建模 | FreeCAD 原生可用 | 非必须二开 | 直接使用 FreeCAD Part/PartDesign。 | CAD 人员能手工建模。 | +| STEP/IGES 模型导入 | FreeCAD 原生可用,项目已接入第一版 | 必须 | 用于把厂家模型导入后保存为 FCStd。 | STEP/STP/IGES 能导入并继续添加模板端子。 | +| 给模型添加模板端子 | 已完成第一版 | 必须 | 当前端子和布线的基础。 | 用户能在模型上创建模板端子并保存为 FCStd。 | +| 断路器/继电器/端子排参数化生成 | 未完整完成 | 非当前必须 | 属于后续设备库效率能力。 | 输入极数、宽度、高度、端子数等参数后自动生成模型。 | +| 导轨/线槽/柜体参数化生成 | 部分完成 | 后续建议 | 当前已有部分基础资产和布线载体能力。 | 能按长度、宽度、高度生成导轨、线槽、柜体模型。 | +| 模型复用 | 已完成第一版 | 必须 | 复用 FCStd 模板。 | 同一个 FCStd 可在不同工程中作为设备资产使用。 | +| 尺寸参数配置 | 部分完成 | 后续建议 | 可以用 FreeCAD Spreadsheet/Expression 或 Python 生成器。 | 不改代码即可生成不同规格模型。 | +| STEP/IGES 导出 | FreeCAD 原生可用 | 非当前必须 | 主要用于对外交付几何。 | 能导出标准 STEP/IGES 文件。 | + +### 3.3 3D 资产绑定与导入管理开发 + +任务描述: + +> 在 QET 设备库中支持绑定 FreeCAD 生成模型或外部 STEP/IGES/STL 资产;提供资产路径解析、版本记录、缺失诊断、重新加载、模型元数据读取能力。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| QET 设备绑定 3D 资产 | 已完成第一版,主要在 QET 侧 | 必须 | FreeCAD 侧消费导出的模型路径。 | QET 设备能绑定 FCStd/STEP 资产。 | +| 资产路径解析 | 已完成第一版 | 必须 | `2d_to_3d.json` 提供 `resolved_model_path`。 | FreeCAD 能找到本地真实模型文件。 | +| FCStd 导入 | 已完成第一版 | 必须 | 当前正式电气 3D 资产格式。 | 导入后能读取模型、模板端子。 | +| STEP/IGES/STL 导入 | 部分完成 | 必须 STEP/IGES,STL 非重点 | STEP/IGES 只作为几何输入,STL 不适合作为电气语义资产。 | 文件能进入 FreeCAD,必要时转存 FCStd。 | +| 版本记录 | 部分完成 | 后续建议 | 至少记录文件 hash、更新时间、模板版本。 | 模型变更后能提示需要重新加载或重新生成端子。 | +| 缺失诊断 | 部分完成 | 必须 | 找不到模型文件时给出明确提示。 | 用户能知道哪个设备缺少 3D 模型文件。 | +| 重新加载 | 部分完成 | 后续建议 | 模型文件变更后刷新场景实例。 | 重新绑定或替换资产后,FreeCAD 能更新对应设备。 | +| 模型元数据读取 | 已完成第一版 | 必须 | 当前重点读取模板端子 LCS。 | 能读取端子槽位、端子类型、坐标、方向、局部出线路径。 | + +### 3.4 复杂设备结构装配开发 + +任务描述: + +> 构建机柜、安装板、DIN 导轨、线槽、设备实例的 3D 场景装配能力;支持设备拖放、吸附、对齐、旋转、偏移、安装宿主绑定和装配约束保存。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 机柜导入 | 部分完成 | 必须 | 机柜可作为场景背景和布线范围。 | QET 选择机柜后 FreeCAD 能打开对应场景。 | +| 安装板、导轨、线槽导入 | 部分完成 | 必须 | 导轨和线槽后续作为安装/布线参考。 | 能在场景中识别并显示这些载体。 | +| 设备实例装配 | 部分完成 | 必须 | 第一版允许手动摆放。 | 设备实例能放入机柜场景并保存位置。 | +| 拖放、旋转、偏移 | FreeCAD 原生可用,项目部分接入 | 必须 | 第一版可依赖 FreeCAD 原生变换。 | 用户能移动和旋转设备,保存后不丢。 | +| 吸附、对齐 | 部分完成 | 后续建议 | 需要自定义电气柜装配命令。 | 设备能自动贴合导轨、安装板或线槽边界。 | +| 安装宿主绑定 | 未完整完成 | 后续必须 | 如设备绑定到某根 DIN 导轨。 | 宿主移动时设备关系可追踪,校验时知道设备安装位置是否合法。 | +| 装配约束保存 | 部分完成 | 后续必须 | 当前主要保存 FreeCAD 位姿,不是完整约束系统。 | 重新打开后不只是位置在,还能知道设备为什么在这里。 | + +### 3.5 3D 视图导航功能开发 + +任务描述: + +> 实现 3D 视图缩放、平移、旋转、前/顶/左/右等轴测视角切换、选择高亮、实例聚焦、相机状态保存。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 缩放、平移、旋转 | FreeCAD 原生可用 | 必须但不需要二开 | 使用 FreeCAD 自带 3D 视图。 | 用户能正常查看场景。 | +| 轴测视角切换 | FreeCAD 原生可用 | 必须但不需要二开 | 使用 FreeCAD 视图命令。 | 用户能切换前/顶/左/右/等轴测视角。 | +| 选择高亮 | FreeCAD 原生可用 | 必须但不需要二开 | 端子和导线对象需要可选择。 | 选择对象时能明显看到目标。 | +| 实例聚焦 | 部分完成 | 后续建议 | 可做“定位到设备/端子/导线”命令。 | 从任务或树节点能快速定位目标。 | +| 相机状态保存 | 未完整完成 | 非第一版必须 | 属于使用体验增强。 | 重新打开工程后恢复上次视角。 | + +### 3.6 2D 到 3D 单向联动开发 + +任务描述: + +> 根据 QET 原理图/布置图中的设备、端子、柜体、导轨、线槽信息,单向生成或更新 3D 场景实例。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| QET 导出设备 | 已完成第一版 | 必须 | 导出 `devices[]`。 | FreeCAD 能得到设备实例列表。 | +| QET 导出端子 | 已完成第一版但需稳定 | 必须 | 导出 `devices[].terminals[]`。 | FreeCAD 能知道端子属于哪个设备实例。 | +| QET 导出资产路径 | 已完成第一版 | 必须 | 导出 `device_models[]`。 | FreeCAD 能找到设备 3D 模型。 | +| FreeCAD 生成/更新设备 | 已完成第一版 | 必须 | 按 `instance_id` 复用或创建设备组。 | 多次打开不会重复生成混乱设备。 | +| FreeCAD 生成/更新工程端子 | 已完成第一版 | 必须 | 依赖 FCStd 模板端子。 | 工程端子落在设备正确端子位置。 | +| 柜体、导轨、线槽联动 | 部分完成 | 后续必须 | 当前不应阻塞端子和手动布线。 | QET 中柜体/载体信息能稳定进入 FreeCAD 场景。 | + +### 3.7 3D 布线基础能力开发 + +任务描述: + +> 基于 3D 连接点和端子映射,支持线路路径采集、手动布线路径编辑、路径叠加显示、线槽/导轨空间参考,为后续自动布线预留接口。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 工程端子可被选中接线 | 已完成第一版 | 必须 | 手动布线只连接工程端子。 | 不能误选模板端子或普通几何作为接线端。 | +| 线路路径采集 | 已完成第一版 | 必须 | 起点、终点、手动路径点。 | 能记录一条导线的完整路径点。 | +| 手动布线路径编辑 | 已完成第一版 | 必须 | 添加路径点、更新导线。 | 用户能调整导线走向。 | +| 路径叠加显示 | 已完成第一版 | 必须 | 在 FreeCAD 场景中生成可见导线。 | 导线几何可见,能区分起终点。 | +| 线槽/导轨空间参考 | 部分完成 | 后续必须 | 自动布线需要,手动布线先可弱化。 | 线槽/导轨能作为布线候选路径或参考对象。 | +| 自动布线接口 | 已完成第一版 | 后续必须 | 为 qdj 自动布线提供端子、导线任务、路径网络基础。 | 自动布线可以复用工程端子和路径载体。 | + +## 4. 三期三维布线功能交付 + +### 4.1 布线数据模型设计 + +任务描述: + +> 定义端子、设备、线缆的数据结构与接口规范。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 设备数据结构 | 已完成第一版 | 必须 | `QETDevice_xxx` 设备组保存实例信息。 | 可按 `instance_id` 找到设备。 | +| 工程端子数据结构 | 已完成第一版 | 必须 | LCS 对象保存端子语义。 | 可按 `terminal_uuid` 找到工程端子。 | +| 导线任务数据结构 | 已完成第一版 | 必须 | 从 QET `wires[]` 导入。 | 任务能描述起点端子、终点端子、线号等。 | +| 已布导线数据结构 | 已完成第一版 | 必须 | FreeCAD 对象保存起终点、路径点、线长、状态。 | 保存后重开不丢,能参与回写。 | +| 线缆/多芯线数据结构 | 未完整完成 | 后续建议 | 比单根导线更复杂。 | 支持线缆、芯线、屏蔽层等关系。 | + +### 4.2 基础数据解析开发 + +任务描述: + +> 开发 3D 场景中设备、端子数据的读取与解析模块。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 读取设备实例 | 已完成第一版 | 必须 | 从 FreeCAD 文档中扫描 QET 设备组。 | 能列出当前场景设备。 | +| 读取工程端子 | 已完成第一版 | 必须 | 扫描设备下的工程端子组。 | 能按设备和端子 UUID 建索引。 | +| 读取模板端子 | 已完成第一版 | 必须 | 从 FCStd 模板 LCS 读取槽位。 | 工程端子能参考模板端子位置生成。 | +| 读取导线任务 | 已完成第一版 | 必须 | 从 `2d_to_3d.json` 的 `wires[]` 导入。 | 能得到待布线起点和终点。 | +| 端子数据稳定匹配 | 部分完成 | 必须 | 依赖 QET 提供稳定 `terminal_uuid`,可增加 `slot_name_hint`。 | 同一设备多个端子不会错位或串线。 | + +### 4.3 智能连接识别算法开发 + +任务描述: + +> 实现端子与线缆、设备接口的自动匹配识别逻辑。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 按 `terminal_uuid` 找端子 | 已完成第一版 | 必须 | 最稳定的识别方式。 | 给定端子 UUID 能找到唯一工程端子。 | +| 按槽位提示匹配模板端子 | 已完成第一版 | 必须 | 使用 `slot_name_hint` 或 `terminal_label` 辅助。 | P1/P2/A1/A2 等端子能落到正确模板槽位。 | +| 手动选择两个端子识别连接 | 已完成第一版 | 必须 | 手动布线场景使用。 | 用户选两个工程端子即可生成导线。 | +| 自动识别线缆与设备接口 | 部分完成 | 后续建议 | 属于自动布线和智能匹配。 | 系统能从导线任务自动找到起终点端子。 | +| 复杂接线关系纠错 | 未完成 | 后续建议 | 需要电气规则和 QET 数据完整支持。 | 能提示端子不匹配、线缆类型不匹配等。 | + +### 4.4 更新 BOM 并生成取线表 + +任务描述: + +> 自动更新线长、线表和 BOM 数据,生成生产用取线表。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 线长计算 | 部分完成 | 建议第一版保留字段 | 可根据导线路径点计算长度。 | 每根导线有可回写的长度。 | +| 线表生成 | 未完整完成 | 后续必须 | 需要 QET 侧线号、线型、颜色、截面积等数据。 | 能导出起点、终点、线号、长度、规格。 | +| BOM 更新 | 未完成 | 后续必须 | 需要接入 QET BOM 或物料系统。 | 导线、端子附件、线槽等物料可进入 BOM。 | +| 取线表 | 未完成 | 后续必须 | 面向生产下线。 | 可按柜体、线号、长度批量输出。 | + +### 4.5 电气布线规则梳理 + +任务描述: + +> 明确布线的电气规范,如线距、转角、分层规则等。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 基础线距/避障规则 | 部分完成 | 必须基础版 | 自动布线已有部分间距参数。 | 导线不会明显穿过设备或柜体。 | +| 转角规则 | 部分完成 | 建议 | 当前可用正交路径表达。 | 路径转角符合甲方要求。 | +| 分层规则 | 未完整完成 | 后续必须 | 如强弱电、不同电压等级分层。 | 不同线缆类别按规则走不同区域。 | +| 线槽容量规则 | 部分完成 | 后续必须 | 当前已有容量/复用思路,但未完整产品化。 | 超容量时提示或改道。 | +| 甲方规范配置 | 未完成 | 必须依赖甲方 | 规则需要甲方确认。 | 规则可配置、可验收。 | + +### 4.6 路径规划算法开发 + +任务描述: + +> 基于规则实现自动生成无碰撞、合规的布线路径。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 端子到端子路径生成 | 已完成第一版 | 后续自动布线必须 | 自动布线使用工程端子作为起终点。 | 能从起点端子生成到终点端子的 3D 路径。 | +| 正交路径生成 | 已完成第一版 | 必须基础版 | 符合柜内布线常见表达。 | 导线由水平/垂直/深度方向线段组成。 | +| 线槽/路径网络布线 | 已完成第一版 | 后续必须 | 依赖 RoutingNetwork。 | 导线能优先走线槽或用户定义路径。 | +| 避障 | 部分完成 | 后续必须 | 已有包围盒避障和碰撞检查。 | 不穿设备、不穿柜体、不超出布线区域。 | +| 合规评分与择优 | 部分完成 | 后续必须 | 当前仍需优化。 | 多条候选路径中选择更符合规则的一条。 | + +### 4.7 算法性能优化与测试 + +任务描述: + +> 优化路径生成效率,处理复杂场景下的布线稳定性问题。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 单根导线性能 | 部分完成 | 必须 | 当前先保证单根或少量导线可用。 | 单根导线生成不卡顿。 | +| 批量导线性能 | 未完整完成 | 后续必须 | 大量导线需要缓存和分批处理。 | 大工程批量布线耗时可接受。 | +| 复杂场景稳定性 | 未完整完成 | 后续必须 | 需要机柜、设备、线槽组合测试。 | 多设备、多线槽、多导线时不崩溃、不乱连。 | +| 自动测试用例 | 未完整完成 | 后续必须 | 需要固定样例工程。 | 每次修改后能跑回归测试。 | + +### 4.8 手动调整功能开发 + +任务描述: + +> 实现布线路径的拖拽、修改、撤销/重做等交互功能。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 设置起点端子 | 已完成第一版 | 必须 | 从选择对象中识别工程端子。 | 起点不是工程端子时给出提示。 | +| 设置终点并生成 | 已完成第一版 | 必须 | 连接两个工程端子。 | 生成导线对象并保存起终点 UUID。 | +| 添加手动路径点 | 已完成第一版 | 必须 | 控制导线走向。 | 导线按用户路径点生成。 | +| 修改已布导线 | 部分完成 | 必须 | 对已有导线重新生成。 | 调整后不重复生成错误导线。 | +| 拖拽路径点 | 部分完成 | 后续建议 | 更偏 UI 体验。 | 通过鼠标拖拽调整导线路径。 | +| 撤销/重做 | 部分依赖 FreeCAD 原生 | 后续建议 | 可先依赖 FreeCAD 文档操作栈。 | 用户误操作可以恢复。 | + +### 4.9 实时错误检查逻辑开发 + +任务描述: + +> 开发布线冲突、连接错误、电气规范违规的实时检测。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| 非端子选择检查 | 已完成第一版 | 必须 | 手动布线时必须限制对象类型。 | 选错对象时不给生成导线。 | +| 起终点相同检查 | 已完成第一版 | 必须 | 避免自连接。 | 同一端子不能生成导线。 | +| 缺失端子检查 | 部分完成 | 必须 | 自动布线和任务导入都需要。 | 找不到端子时给出明确原因。 | +| 碰撞检查 | 部分完成 | 后续必须 | 当前已有碰撞/间隙诊断雏形。 | 导线与设备冲突时能提示。 | +| 电气规则违规检查 | 未完整完成 | 后续必须 | 依赖完整规则库。 | 强弱电混走、线径不匹配等可检测。 | +| 真正实时检查 | 部分完成 | 后续建议 | 当前更接近命令执行后检查。 | 用户调整路径时实时刷新错误提示。 | + +### 4.10 整体联调与验收 + +任务描述: + +> 全流程功能联调,修复 bug 并完成验收测试。 + +| 功能 | 当前完成情况 | 是否第一版必须 | 开发说明 | 完成标准 | +| --- | --- | --- | --- | --- | +| QET 到 FreeCAD 打开链路 | 已完成第一版 | 必须 | QET 3D 视图打开 FreeCAD。 | 能打开对应工程和场景文件。 | +| 设备导入链路 | 已完成第一版 | 必须 | FreeCAD 根据 QET 数据导入设备。 | 设备模型能正常显示。 | +| 端子生成链路 | 已完成第一版但需继续稳定 | 必须 | 模板端子生成工程端子。 | 工程端子位置准确,不错位。 | +| 手动布线链路 | 已完成第一版 | 必须 | 两个工程端子生成导线。 | 保存重开后导线不丢。 | +| 自动布线链路 | 部分完成 | 后续必须 | qdj 负责继续完善。 | 批量线缆能按规则生成路径。 | +| BOM/取线表链路 | 未完成 | 后续必须 | 需要 QET 侧数据和导线长度回写。 | 可生成生产可用线表。 | +| 验收测试 | 未完成 | 必须 | 需要固定工程和操作用例。 | 每项任务有可演示、可复现的验收步骤。 | + +## 5. 当前 zwl 应优先负责的范围 + +当前 zwl 的重点不是整张任务表,而是下面这条 FreeCAD 端子和手动布线链路: + +```text +FCStd 模板端子 + -> 工程端子生成 + -> 工程端子显示/选择 + -> 手动连接两个工程端子 + -> 生成 3D 导线 + -> 保存到 scene.FCStd + -> 生成 3d_to_2d.json 回写 +``` + +### 5.1 zwl 当前必须做 + +| 功能 | 说明 | 完成标准 | +| --- | --- | --- | +| 模板端子规范 | 定义设备 FCStd 内哪里可以接线。 | STEP/FCStd 模型能保存模板端子、槽位名和出线方向。 | +| 模型元数据读取 | FreeCAD 读取 FCStd 内模板端子。 | 导入设备后能读取模板端子位置和方向。 | +| 工程端子生成 | 从模板端子生成项目内可接线端子。 | 工程端子落在设备正确位置。 | +| 端子全局坐标计算 | 设备移动/旋转后仍能得到正确端子位置。 | 导线起终点跟随设备变化。 | +| 手动布线 | 选两个工程端子生成导线。 | 导线几何正确,起终点 UUID 正确。 | +| 手动路径点 | 支持用户控制导线路径。 | 路径点保存后重开不丢。 | +| 导线保存 | 导线对象保存到 `scene.FCStd`。 | 关闭重开工程,导线仍存在。 | +| 3D 回写 | 生成 `3d_to_2d.json`。 | 回写设备实例、端子绑定、导线基础数据。 | +| 基础错误检查 | 检查选错对象、缺失端子、起终点相同。 | 错误操作有明确提示,不生成错误导线。 | + +### 5.2 zwl 当前不应作为主线做 + +| 功能 | 原因 | +| --- | --- | +| 完整参数化设备库 | 当前可先用已有 STEP/FCStd 资产加端子。 | +| 断路器/继电器/端子排通用参数模板 | 属于提高资产制作效率的后续能力。 | +| QET 设备库资产绑定界面 | 主要在 QET 侧。 | +| 完整复杂装配约束 | 当前只要求端子和导线稳定。 | +| 完整自定义 3D 视图导航 | FreeCAD 原生能力够第一版使用。 | +| 自动布线核心算法 | qdj 负责,依赖 zwl 的工程端子和手动布线基础。 | +| BOM/取线表 | 后续基于导线长度和端子回写再做。 | +| 完整电气规则库 | 需要甲方规则和整体设计。 | + +## 6. 推荐开发顺序 + +### 阶段 1:端子资产基础 + +目标:让一个普通 STEP 模型变成可接线的 FCStd 电气资产。 + +开发顺序: + +1. 完善模板端子创建和校验。 +2. 明确模板端子属性:槽位名、端子类型、是否可接线、出线方向。 +3. 支持把模板端子的局部出线路径保存进 FCStd。 +4. 保存为可复用 FCStd 设备模板。 + +验收: + +- 打开一个 STEP 电流互感器模型。 +- 添加 P1/P2 等模板端子。 +- 保存为 FCStd。 +- 重新打开后模板端子仍然存在,属性不丢。 + +### 阶段 2:工程端子生成 + +目标:QET 打开 FreeCAD 后,设备实例上有可用于接线的工程端子。 + +开发顺序: + +1. 导入 QET 设备实例。 +2. 读取设备 FCStd 内模板端子。 +3. 根据 QET `terminal_uuid` 生成工程端子。 +4. 如果 QET 没有端子 UUID,支持生成 `local:*` 本地工程端子用于 3D 验证。 +5. 设备移动/旋转后,工程端子全局坐标正确。 + +验收: + +- 一个设备实例能生成工程端子。 +- 两个同型号设备实例的工程端子分别落在各自设备上。 +- 移动设备后,端子位置跟随变化。 + +### 阶段 3:手动布线 + +目标:用户能连接两个工程端子。 + +开发顺序: + +1. 选择工程端子作为起点。 +2. 选择工程端子作为终点。 +3. 根据端子全局坐标生成导线。 +4. 支持添加手动路径点。 +5. 保存导线属性:起点端子 UUID、终点端子 UUID、路径点、线长、状态。 +6. 提供基础错误提示。 + +验收: + +- 选两个端子能生成一根导线。 +- 不能连接模板端子或普通几何。 +- 起终点不能相同。 +- 导线显示正确,不乱连到已有导线。 + +### 阶段 4:保存和回写 + +目标:3D 布线结果可以保存和交还 QET。 + +开发顺序: + +1. 导线、端子、设备保存在 `scene.FCStd`。 +2. 重新打开工程时恢复端子和导线。 +3. 生成 `3d_to_2d.json`。 +4. 回写设备实例、端子绑定、导线基础信息。 +5. 跳过本地端子或无法可靠回写的端子,并给出说明。 + +验收: + +- 保存关闭再打开,导线不丢。 +- `3d_to_2d.json` 中能看到实例、端子和导线信息。 +- QET 能消费回写结果或至少不报错。 + +### 阶段 5:基础诊断和联调 + +目标:让 CAD 人员使用时不会因为错误操作把工程弄乱。 + +开发顺序: + +1. 检查缺失模型。 +2. 检查设备没有模板端子。 +3. 检查端子 UUID 缺失。 +4. 检查工程端子错位。 +5. 检查导线起终点无效。 +6. 输出清晰提示。 + +验收: + +- 出错时能告诉用户是哪台设备、哪个端子、什么原因。 +- 不产生半成品错误对象。 + +### 阶段 6:装配和布线增强 + +目标:提升机柜内真实装配和走线质量。 + +开发顺序: + +1. 设备吸附到导轨/安装板。 +2. 保存安装宿主关系。 +3. 线槽/导轨作为布线载体。 +4. 导线优先沿线槽或用户路径走线。 +5. 增加碰撞和越界检查。 + +验收: + +- 设备能稳定放在导轨或安装板上。 +- 导线能沿线槽走,不明显穿设备。 + +### 阶段 7:自动布线、BOM 和取线表 + +目标:在手动布线稳定后,扩展自动化生产能力。 + +开发顺序: + +1. qdj 基于工程端子和布线载体开发自动布线。 +2. 引入甲方电气布线规则。 +3. 批量生成导线。 +4. 计算线长。 +5. 回写线表、BOM、取线表。 +6. 做大工程性能优化和验收测试。 + +验收: + +- 一批导线能自动生成。 +- 有冲突能提示。 +- 能输出生产可用取线表。 + +## 7. 第一版最小交付清单 + +第一版不要求完成整张任务表,只要先完成下面这些: + +1. FCStd 模板端子制作。 +2. FCStd 模板端子读取。 +3. 工程端子生成。 +4. 工程端子选择。 +5. 两个工程端子生成手动导线。 +6. 手动路径点保存。 +7. 导线保存到 `scene.FCStd`。 +8. 重新打开不丢端子和导线。 +9. 生成 `3d_to_2d.json`。 +10. 基础错误提示。 + +完成这 10 项,就可以对外说: + +> FreeCAD 端子显示、端子手动连线、保存回写第一版完成。 + +## 8. 甲方资料补充分析 + +资料来源: + +- `D:\video\甲方视频\3D布线功能需求开发文档.docx` +- `D:\video\甲方视频\3D布线KYN28-12操作教程(终版).mp4` +- `D:\video\甲方视频\甲方布线操作.mp4` +- `D:\video\甲方视频\保护装置模型视频.mp4` +- `D:\video\甲方视频\面板装配、路径绘制、导轨配合038F3440.MP4` +- `D:\downloadWX\xwechat_files\wxid_pv577xuccot722_5d4a\msg\file\2026-06\20260601_10365403921E50.MP4` + +甲方当前参考软件是 SOLIDWORKS Electrical 3D。设计时也应参考 EPLAN Pro Panel 的电气柜 3D 安装布局和布线思想。 + +### 8.1 甲方需求归纳 + +Word 需求文档中核心需求可以归纳为 6 类: + +| 序号 | 甲方需求 | 对应我们系统的含义 | 当前优先级 | +| --- | --- | --- | --- | +| 1 | 三维模型读取与关系配合 | 导入柜体、安装板、面板、设备,并能做装配配合。 | 高 | +| 2 | 配合面定义与快速装配 | 设备能快速安装到导轨、安装板、面板等基准上,支持端子排、小型断路器批量插入。 | 中高 | +| 3 | 三维零件建模与电气脚点定义 | STEP/FCStd 设备需要补电气端子,端子带脚号、方向、可接线属性。 | 高 | +| 4 | 草图路径自定义与布线规则 | 用户可在 3D 空间画线段/曲线作为布线路径,导线可沿这些路径或区域走线。 | 高 | +| 5 | 取线表数据生成与导出 | 从布线结果生成线长、线缆规格、颜色、线耳、源/目标设备脚号等生产数据。 | 后续高 | +| 6 | 错误自检与线束高亮提示 | 检查缺失、未连接、路径冲突,选中线束时高亮并提示源/目标信息。 | 中高 | + +### 8.2 从视频看到的实际操作意图 + +| 视频 | 观察到的重点 | 对我们开发的启发 | +| --- | --- | --- | +| `面板装配、路径绘制、导轨配合038F3440.MP4` | 安装板/面板是柜体结构的一部分,通过面、边、孔或基准与柜体配合;导轨、端子排、设备再装到面板上。 | FreeCAD 侧需要把安装板/面板作为“结构载体对象”,支持配合面、安装面、孔位参考和路径对象。 | +| `甲方布线操作.mp4` | 先插入设备、打开柜体或装配体,再做设备端子、路径绘制、布线效果展示。 | 我们应先确保设备、工程端子、路径对象、导线对象这四类对象的语义稳定。 | +| `3D布线KYN28-12操作教程(终版).mp4` | 有保存在线缆工程目录、装配完成状态、草图路径、端子排线槽配合、最终布线效果。 | 路径草图和线槽/导轨配合不是装饰几何,应成为布线网络或布线参考。 | +| `保护装置模型视频.mp4` | 保护装置模型带前面板、后部结构和端子区域。 | 设备模板端子不能只按包围盒猜点,必须由模板端子或连接点模式明确指定。 | +| `20260601_10365403921E50.MP4` | 显示机柜内端子排、导轨、线槽、局部装配和设备位置参考。 | 蓝色 CAD 参考线或草图线应区别于最终布线导线,作为安装/路径参考。 | + +### 8.3 SW/EPLAN 对标结论 + +SOLIDWORKS Electrical 3D 的核心思想: + +- 2D 电气原理图和 3D 柜体布局联动。 +- 3D 中管理设备布局,并布置 wires、cables、harnesses。 +- 通过 routing paths / ducts 引导线缆路径。 +- routing 完成后更新线长,并可生成包含长度的报表。 +- routing cables 时需要 origin / destination;routing wires 时起终点来自电气图。 + +EPLAN Pro Panel 的核心思想: + +- 在布局空间中放置柜体、安装板、安装导轨、电缆槽和设备。 +- 机械元件可以有安装面,设备放在安装面或导轨上。 +- 3D 部件放置上的连接点可以图形化显示,并显示连接点方向。 +- 布线需要布线路径网络、布线范围、连接点方向和待布线连接。 +- 已布线连接会计算长度,可用于生产和报表。 + +对我们项目的结论: + +```text +安装板/面板/导轨/线槽 + 不只是普通模型 + -> 应该成为 3D 装配和布线的结构载体 + +设备端子 + 不只是几何点 + -> 应该成为带 terminal_uuid、槽位名、方向、脚号的工程端子 + +蓝色/彩色草图路径 + 不应该当作最终导线 + -> 应该作为布线路径网络、布线范围或安装参考 + +最终导线 + 必须连接两个工程端子 + -> 保存起点、终点、路径、线长、规格、状态 +``` + +### 8.4 对当前 FreeCAD 开发的影响 + +| 功能 | 是否 FreeCAD 原生已有 | 我们要做什么 | +| --- | --- | --- | +| 导入柜体、安装板、导轨、线槽 | FreeCAD 原生能导入几何 | 给这些对象补业务类型,如 Cabinet、MountingPlate、DinRail、WireDuct、RoutingPath。 | +| 装配配合 | FreeCAD 有基础移动/约束能力,但不是电气柜业务规则 | 做轻量配合语义:安装面、宿主、局部坐标、吸附结果保存。 | +| 设备放置 | FreeCAD 能移动设备 | 增加“设备属于哪个安装面/导轨”的语义。 | +| 设备端子 | FreeCAD 没有电气端子语义 | 用 FCStd LCS 模板端子和工程端子实现。 | +| 连接点方向 | FreeCAD 有坐标系方向 | 规定 LCS 本地 +Z 为出线方向,并在布线时使用。 | +| 草图路径 | FreeCAD 能画草图/线段 | 把草图/边/线转换为 RoutingPath,供布线算法使用。 | +| 布线范围 | FreeCAD 没有电气布线范围语义 | 需要新增 RoutingArea 或 CabinetRoutingZone 概念。 | +| 线长/取线表 | FreeCAD 没有 QET 生产数据 | FreeCAD 计算路径长度,QET 或后处理生成生产取线表。 | +| 错误自检 | FreeCAD 没有电气规则检查 | 我们需要检查缺失端子、路径断开、碰撞、越界、线槽容量等。 | + +### 8.5 需要 QET 侧配合的内容 + +以下内容如果涉及改 QET 代码,需要找 QET 对应开发者配合: + +| QET 侧能力 | 为什么需要 | FreeCAD 侧依赖 | +| --- | --- | --- | +| 稳定导出设备 `element_uuid` 和 `instance_id` | FreeCAD 用它找到设备实例。 | 设备导入和回写。 | +| 稳定导出端子 `terminal_uuid` | FreeCAD 工程端子绑定 2D 端子的唯一依据。 | 工程端子生成、导线起终点绑定。 | +| 导出端子显示名或槽位提示 | 防止同一设备多个端子顺序错位。 | `slot_name_hint` / `terminal_label` 匹配模板端子。 | +| 导出导线任务 `wires[]` | 自动布线和待布线列表需要。 | 起点端子、终点端子、线号、线型。 | +| 导出线缆规格、颜色、截面积 | 取线表和导线显示需要。 | 线长、线色、线径、线耳。 | +| QET 设备库绑定 FCStd 资产 | FreeCAD 需要知道每个设备用哪个 3D 模型。 | 资产导入。 | +| QET 消费 `3d_to_2d.json` | FreeCAD 回写结果要进入 QET。 | 实例绑定、端子绑定、线长/布线结果。 | +| 取线表格式定义 | 最终要给终端取线机读取。 | FreeCAD 只提供线长和路径基础数据。 | + +### 8.6 新增建议开发顺序 + +结合甲方资料,推荐把后续顺序调整为: + +1. 端子模板和工程端子稳定。 +2. 手动布线和保存回写稳定。 +3. 草图/边/线转换为 `RoutingPath`。 +4. 安装板/面板/导轨/线槽标记为结构载体。 +5. 设备和结构载体建立轻量配合关系。 +6. 自动布线使用 `RoutingPath`、线槽、布线范围。 +7. 错误自检和高亮。 +8. 线长、线缆属性、取线表。 + +其中第 1、2 项是 zwl 当前主线;第 6 项已经在另一个会话中推进;第 8 项需要 QET 和生产数据格式共同确定。 + +### 8.7 第一版建议不要扩大到的范围 + +为了避免 FreeCAD 侧失控,第一版暂不建议做: + +- 完整替代 SW 的机械装配 Mate 系统。 +- 完整参数化设备库。 +- 完整自动端子排生成和编号规则。 +- 完整取线机格式导出。 +- 完整 EPLAN 级规则库。 + +第一版只要把下面链路跑通: + +```text +FCStd 电气资产 + -> 模板端子 + -> 工程端子 + -> 结构载体/布线路径参考 + -> 手动或自动布线 + -> 线长和错误诊断 + -> scene.FCStd + 3d_to_2d.json +``` + +### 8.8 甲方取线表样例分析 + +取线表样例: + +- `D:\downloadWX\xwechat_files\wxid_pv577xuccot722_5d4a\msg\file\2026-04\PT2柜取线表.xlsx` + +工作簿结构: + +| 工作表 | 行列情况 | 说明 | +| --- | --- | --- | +| `线束组件编码` | 1 行 1 列 | 当前样例中只有标题。 | +| `取线表` | 528 行 52 列 | 真实取线表数据。 | + +主要字段可以分成 5 类: + +| 字段类别 | 代表字段 | 来源判断 | 说明 | +| --- | --- | --- | --- | +| 连接两端信息 | `号码管字符1`、`号码管方向1`、`线鼻子型号1`、`剥皮长度1`、`号码管字符2`、`号码管方向2`、`线鼻子型号2`、`剥皮长度2` | QET 为主,FreeCAD 可补位置/区域 | 这些是电气连接和生产加工字段,不能只靠 3D 几何推断。 | +| 导线规格信息 | `导线型号`、`导线颜色`、`导线截面积mm2` | QET 为主 | 来自 2D 图纸、导线样式、线缆规则或物料数据。 | +| 线长信息 | `导线长度(mm)`、`加工总数` | FreeCAD 计算,QET 汇总 | FreeCAD 按 3D 路径计算实际长度,QET 或后处理计算生产数量和汇总。 | +| 生产数量信息 | `生产总数`、`单批数量`、`套数`、`加工总数` | QET/生产系统为主 | 依赖项目数量、批次、套数,不应由 FreeCAD 单独决定。 | +| 区域和线束编号 | `区域`、`始端区域`、`末端区域`、`分线束编号`、`总线束编号`、`小区域` | QET + FreeCAD | QET 知道柜体/图纸区域,FreeCAD 可根据 3D 设备所在柜内区域辅助计算。 | + +从样例看,取线表最终需要的不只是“线长”,而是一条完整生产记录: + +```text +起点设备/端子/号码管/线鼻子 + + 终点设备/端子/号码管/线鼻子 + + 导线型号/颜色/截面积 + + 3D 路径计算线长 + + 所属区域/线束编号/生产数量 +``` + +因此推荐职责边界: + +| 职责 | FreeCAD 侧 | QET 侧 | +| --- | --- | --- | +| 端子空间位置 | 负责 | 消费结果或仅保存绑定 | +| 实际 3D 走线路径 | 负责 | 可读取回写 | +| 线长计算 | 负责 | 汇总、修正、导出 | +| 线号/号码管字符 | 不负责生成,最多回传关联端子 | 负责 | +| 导线型号/颜色/截面积 | 不负责主数据 | 负责 | +| 线鼻子/剥皮长度 | 不负责主数据 | 负责 | +| 区域/柜体/小区域 | 可根据 3D 空间辅助判断 | 负责主数据和最终分类 | +| 取线表格式导出 | 可提供基础 JSON | 负责最终 Excel/取线机格式 | + +### 8.9 当前 JSON 交换样例分析 + +当前工程交换目录示例: + +```text +D:\test\MT\电气工程4.0\电气工程414\电气工程414\.qet_freecad + 2d_to_3d.json + 3d_to_2d.json +``` + +当前 `2d_to_3d.json` 统计: + +| 内容 | 数量 | 说明 | +| --- | --- | --- | +| `devices[]` | 86 | QET 导出的 2D 设备实例和端子上下文。 | +| `device_models[]` | 85 | QET 解析出的 3D 资产路径。 | +| `wires[]` | 75 | QET 导出的导线任务。 | +| `stale_devices[]` | 27 | 已失效或不在当前快照中的设备。 | +| `cabinet` | 1 | 当前图纸绑定的机柜上下文。 | + +当前 `wires[]` 已有字段示例: + +```json +{ + "start_element_uuid": "...", + "start_terminal_uuid": "...", + "start_terminal_display": "12", + "end_element_uuid": "...", + "end_terminal_uuid": "...", + "end_terminal_display": "21", + "wire_id": "direction:...", + "wire_mark": "N4131", + "wire_mark_is_manual": false, + "wire_style_id": 3 +} +``` + +这些字段已经足够支持“从 QET 导线任务找到 FreeCAD 工程端子并生成导线”。 + +但对取线表还不够,缺少: + +| 取线表需要 | 当前 JSON 是否已有 | 建议责任 | +| --- | --- | --- | +| 导线型号 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 | +| 导线颜色 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 | +| 导线截面积 | 未直接看到,可能需通过 `wire_style_id` 回查 | QET 补充或回查 | +| 线鼻子型号 | 未看到 | QET 提供 | +| 剥皮长度 | 未看到 | QET 提供 | +| 号码管字符 | 部分可由 `wire_mark`、端子显示、设备信息组合 | QET 负责最终生成 | +| 3D 实际线长 | 当前 `2d_to_3d.json` 不应有,应该 FreeCAD 回写 | FreeCAD 计算 | +| 分线束编号/总线束编号 | 未看到 | QET/生产规则提供 | +| 起终点区域 | 当前有柜体上下文,但不完整 | QET 主导,FreeCAD 可辅助空间判断 | + +当前 `3d_to_2d.json` 统计: + +| 内容 | 数量 | 说明 | +| --- | --- | --- | +| `instances[]` | 85 | FreeCAD 回写设备实例绑定。 | +| `terminals[]` | 138 | FreeCAD 回写端子绑定。 | + +当前 `3d_to_2d.json` 尚未包含: + +- 已布导线列表。 +- 每根导线的 3D 路径点。 +- 每根导线的实际长度。 +- 碰撞/错误状态。 +- 线缆/导线规格回传字段。 + +因此如果目标是最终生成甲方取线表,需要新增 FreeCAD 回写结构,建议命名为: + +```json +{ + "routed_wires": [ + { + "wire_id": "string", + "wire_mark": "string", + "start_terminal_uuid": "string", + "end_terminal_uuid": "string", + "start_instance_id": "string", + "end_instance_id": "string", + "length_mm": 1234.5, + "route_status": "Routed", + "route_mode": "Manual", + "route_points": [], + "collision_count": 0, + "diagnostics": [] + } + ] +} +``` + +注意:`routed_wires[]` 只负责把 3D 布线结果回写给 QET。最终取线表中的导线型号、颜色、截面积、线鼻子、剥皮长度、号码管字符、区域、生产数量,仍建议由 QET 根据主数据和生产规则生成。 + +### 8.10 后续需要 QET 开发者确认的问题 + +如果要从当前 3D 布线走到甲方取线表,需要 QET 侧确认: + +1. `wire_style_id` 能否稳定回查导线型号、颜色、截面积。 +2. 线鼻子型号、剥皮长度、导线半脱长度来自哪里。 +3. 号码管字符当前生成规则是否已经在 QET 中存在。 +4. 始端区域、末端区域、小区域、分线束编号、总线束编号的规则由谁生成。 +5. QET 是否准备消费 FreeCAD 回写的 `routed_wires[]`。 +6. QET 最终是否负责导出甲方 Excel 取线表,还是 FreeCAD 直接导出。 + +推荐边界: + +```text +FreeCAD: + 负责 3D 设备、端子、路径、线长、碰撞状态。 + +QET: + 负责 2D 电气连接、线号、线型、颜色、截面积、线鼻子、剥皮长度、区域、取线表格式。 +``` + +## 9. 任务描述合理性修订标注 + +本章不覆盖原始任务表,而是标注其中不够合理或容易误导开发范围的描述,方便后续和项目负责人、QET 开发者、自动布线开发者对齐。 + +标注规则: + +| 标记 | 含义 | +| --- | --- | +| 【建议修改】 | 原描述容易扩大范围或职责不清,建议修改。 | +| 【建议拆分】 | 一个任务里混入多类能力,建议拆成多个阶段。 | +| 【建议降级】 | 第一版不应承诺完整能力,只做基础版或依赖 FreeCAD 原生能力。 | +| 【职责需拆分】 | 该能力不能只由 FreeCAD 完成,需要 QET 或生产数据配合。 | + +### 9.1 三期 3D 建模功能任务描述 + +| 原任务 | 合理性判断 | 建议改法 | +| --- | --- | --- | +| `3D 数据模型与映射规范开发`:基于 QET 设备、符号、端子、项目数据库,建立设备-3D资产-场景实例-端子连接点-2D图元映射关系;定义 STEP/IGES/FCStd 资产、sidecar 元数据、设备参数、安装规则、连接点语义。 | 【建议拆分】这句话把第一版最小映射、资产格式、sidecar、参数、安装规则、连接点语义都混在一起,范围过大。并且当前正式路线不建议用 `connectionPoint` 作为核心术语,也不建议第一版依赖 sidecar。 | 改为:`建立 QET 设备/端子与 FreeCAD 设备实例/工程端子的最小映射;定义 FCStd 电气资产、模板端子、工程端子和 3D 回写协议。STEP/IGES 仅作为几何输入,sidecar、设备参数、安装规则作为后续扩展。` | +| `FreeCAD 参数化设备建模能力开发`:支持断路器、继电器、端子排、导轨、线槽、柜体等常用结构生成。 | 【建议降级】这相当于做一个完整参数化设备库,工作量很大,不应和端子/布线第一版绑死。 | 改为:`第一版支持 STEP/FCStd 资产加模板端子和少量基础载体模板;断路器、继电器、端子排、导轨、线槽、柜体的完整参数化生成作为设备库后续任务。` | +| `3D 资产绑定与导入管理开发`:支持绑定 FreeCAD 生成模型或外部 STEP/IGES/STL 资产。 | 【建议修改】STL 只有网格几何,不适合作为电气语义资产;正式电气资产应优先 FCStd。 | 改为:`优先支持 FCStd 电气资产;STEP/IGES 作为几何输入;STL/OBJ 只作为显示类或临时参考资产,不作为正式可接线设备资产。` | +| `3D 资产绑定与导入管理开发`:提供版本记录、缺失诊断、重新加载、模型元数据读取能力。 | 【职责需拆分】版本记录和资产绑定主要在 QET/设备库侧;FreeCAD 侧负责读取模型语义和诊断导入状态。 | 改为:`QET 负责资产绑定、版本记录、路径解析;FreeCAD 负责导入诊断、FCStd 模板端子读取、模型变更后的场景更新提示。` | +| `复杂设备结构装配开发`:支持设备拖放、吸附、对齐、旋转、偏移、安装宿主绑定和装配约束保存。 | 【建议修改】“装配约束”容易被理解为 SW Mate 级机械约束,第一版不现实。 | 改为:`第一版依赖 FreeCAD 原生移动/旋转,补充轻量电气装配语义:安装面、安装宿主、吸附结果、局部坐标。完整机械 Mate 系统不作为第一版目标。` | +| `3D 视图导航功能开发`:实现缩放、平移、旋转、视角切换、选择高亮。 | 【建议降级】这些是 FreeCAD 原生能力,不应作为大量二开任务。 | 改为:`复用 FreeCAD 原生视图能力;二开只做对象定位、端子/导线高亮、设备预览、错误对象聚焦。` | +| `2D 到 3D 单向联动开发`:根据原理图/布置图中的设备、端子、柜体、导轨、线槽信息,单向生成或更新 3D 场景实例。 | 【职责需拆分】设备、端子、导线任务可以由 QET 当前数据提供;导轨、线槽、安装板等结构载体未必来自 2D 原理图,需要明确数据源。 | 改为:`QET 第一版导出设备、端子、导线任务、3D 资产路径和柜体上下文;FreeCAD 生成或更新设备、工程端子和导线任务。导轨、线槽、安装板可先由 FreeCAD 场景或机柜 FCStd 提供。` | +| `3D 布线基础能力开发`:基于 3D 连接点和端子映射。 | 【建议修改】当前正式术语应统一为“端子”,不要继续把 `连接点 connectionPoint` 作为核心。 | 改为:`基于 3D 工程端子和 2D terminal_uuid 映射,支持手动布线、路径点编辑、路径显示和自动布线接口。` | + +### 9.2 三维布线功能任务描述 + +| 原任务 | 合理性判断 | 建议改法 | +| --- | --- | --- | +| `三期-3 维布线功能交付`:完成 3D 自动布线全链路功能开发,实现智能连接、规则化布线、手动调整与实时错误校验。 | 【建议拆分】自动布线全链路依赖端子、装配、布线路径、QET 导线任务、电气规则和取线表,不能作为一个单点任务承诺。 | 改为:`先完成工程端子、手动布线、路径保存和线长回写;再基于稳定端子和布线路径网络开发自动布线、规则检查和生产数据输出。` | +| `布线数据模型设计`:定义端子、设备、线缆的数据结构与接口规范。 | 基本合理,但应明确 FreeCAD/QET 边界。 | 改为:`QET 定义电气连接、线号、线型、规格;FreeCAD 定义工程端子、布线路径、线长、碰撞状态和回写结构。` | +| `基础数据解析开发`:开发 3D 场景中设备、端子数据的读取与解析模块。 | 合理,但“端子数据没取对”这类问题通常不只在 FreeCAD,可能是 QET 导出的端子和 FCStd 模板槽位不匹配。 | 增加说明:`解析模块需要同时校验 QET terminal_uuid、terminal_label/slot_name_hint 与 FCStd 模板端子槽位。` | +| `智能连接识别算法开发`:实现端子与线缆、设备接口的自动匹配识别逻辑。 | 【建议降级】第一版不应做复杂智能推断,必须先以 `terminal_uuid` 精确绑定为主。 | 改为:`第一版按 terminal_uuid 精确匹配;slot_name_hint/terminal_label 只作模板槽位匹配提示;复杂智能识别作为后续。` | +| `更新BOM并生成取线表`:自动更新线长、线表和 BOM 数据,生成生产用取线表。 | 【职责需拆分】FreeCAD 只能可靠生成 3D 路径和线长;取线表中的线鼻子、剥皮长度、导线型号、颜色、区域、生产数量主要来自 QET/生产规则。 | 改为:`FreeCAD 回写 routed_wires[],包含线长、路径、起终点端子和状态;QET 根据主数据生成 BOM、线表和取线表。` | +| `电气布线规则梳理`:明确布线的电气规范,如线距、转角、分层规则等,甲方提供。 | 合理,但它是算法开发的输入,不应等到后期才补。 | 调整为前置任务:`在自动布线前由甲方确认最小规则集:线距、转角、线槽优先级、强弱电分层、线槽容量、禁止区域。` | +| `路径规划算法开发`:基于规则实现自动生成无碰撞、合规的布线路径。 | 【建议修改】“无碰撞、合规”是理想目标,不应绝对承诺。实际应生成候选路径并给出诊断。 | 改为:`基于布线路径网络生成优选路径;尽量避障并标记碰撞/间隙/越界诊断,不能保证所有复杂场景自动无碰撞。` | +| `手动调整功能开发`:实现布线路径的拖拽、修改、撤销/重做等交互功能。 | 【建议降级】完整拖拽路径点和撤销重做 UI 工作量大,第一版可以先做路径点添加/删除/重新生成,并复用 FreeCAD 原生撤销。 | 改为:`第一版支持设置起点、终点、添加路径点、删除路径点、重新生成导线;拖拽路径点作为后续体验增强。` | +| `实时错误检查逻辑开发`:开发布线冲突、连接错误、电气规范违规的实时检测。 | 【建议降级】真正实时检测成本高,第一版可以做命令执行前后检查和批量诊断。 | 改为:`第一版做布线前置检查、生成后诊断和错误高亮;实时跟随鼠标/拖拽刷新作为后续。` | +| `整体联调与验收`:全流程功能联调,修复 bug 并完成验收测试。 | 合理,但需要明确验收样例。 | 增加说明:`验收必须基于甲方 KYN28/PT2 柜样例、真实 2d_to_3d.json、真实 FCStd 设备资产和取线表样例。` | + +### 9.3 建议调整后的第一版任务口径 + +如果要让任务表更符合当前项目实际,建议第一版总目标改成: + +```text +完成 QET 与 FreeCAD 的 3D 电气设计最小闭环: + +1. QET 提供设备、端子、导线任务和 3D 资产路径。 +2. FreeCAD 导入 FCStd 电气资产和柜体场景。 +3. FreeCAD 从模板端子生成工程端子。 +4. 用户可手动连接两个工程端子并编辑路径。 +5. FreeCAD 保存 scene.FCStd,并回写设备实例、端子绑定、已布导线、路径长度和诊断状态。 +6. QET 根据回写结果和电气主数据生成取线表。 +``` + +第一版不应承诺: + +- 完整 SW Mate 级装配系统。 +- 完整参数化设备库。 +- 完整自动端子排生成。 +- 完整 EPLAN 级自动布线规则库。 +- FreeCAD 单独生成最终生产取线表。 + +这些可以作为第二阶段或第三阶段能力继续扩展。 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index f203606..204c93d 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -7,6 +7,9 @@ import json import itertools import math +import os +import re +import sqlite3 import FreeCAD as App @@ -21,6 +24,10 @@ 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, @@ -28,12 +35,37 @@ DEFAULT_OPTIONS = { "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, @@ -51,8 +83,14 @@ DEFAULT_OPTIONS = { # 主干必须走 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, @@ -61,6 +99,14 @@ DEFAULT_OPTIONS = { "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": False, + # 第一次布线若发现“兜底区域 -> 当前主路径”的缺主路径绕行配对, + # 自动补一段 UserPath 桥并重跑一次,让少量剩余碰撞线回到主路径网络。 + "auto_create_main_path_detour_bridges": False, } @@ -75,6 +121,117 @@ def _merged_options(options): return merged +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) @@ -257,10 +414,202 @@ def _orthogonal_hit_count(points, obstacle_bboxes): 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 or len(base_order) <= 1: + if not obstacle_bboxes: return base_points active_axes = [ @@ -269,7 +618,17 @@ def _orthogonal_points_avoiding_obstacles(start_point, end_point, obstacle_bboxe if abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)) > 0.000001 ] if len(active_axes) <= 1: - return base_points + 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 @@ -294,6 +653,15 @@ def _orthogonal_points_avoiding_obstacles(start_point, end_point, obstacle_bboxe 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 @@ -445,6 +813,113 @@ def index_terminals(doc): return indexed +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": []} + samples = [] + count = 0 + for terminal_uuid, terminal in (terminals or {}).items(): + terminal_value = str(getattr(terminal, property_name, "") or "").strip() + if terminal_value != expected: + continue + count += 1 + if len(samples) >= limit: + continue + 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", +} + + +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" + 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(" ", "") @@ -735,6 +1210,10 @@ 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 {} @@ -772,28 +1251,320 @@ def _set_endpoint_metadata(wire, endpoint_metadata): return metadata -def _route_payload(route_data, collisions, wire_style_id="", endpoint_metadata=None): +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, + ) + 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) + 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": len(collisions), + "collision_count": collision_payload["collision_count"], "collisions": collisions, + "collision_summary": collision_payload, "network": route_data.get("network", {}), - "route_track": route_data.get("route_track", {}), + "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): +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", @@ -814,42 +1585,258 @@ def _set_routing_connection_metadata(wire, route_data, collisions, wire_style_id ) _set_string( wire, - "QetRouteDiagnosticsJson", - json.dumps( - _route_payload( - route_data, - collisions, - wire_style_id=wire_style_id, - endpoint_metadata=cleaned_endpoint_metadata, - ), - ensure_ascii=False, - ), - "Routing connection diagnostics", + "QetRouteIssueCodes", + _diagnostic_issue_codes_text(issue_codes), + "Routing issue codes for this wire", ) - 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 + _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), + ) exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) start_access_points = RoutingNetwork.terminal_access_path_points(start_terminal, exit_length) @@ -893,6 +1880,16 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non 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: @@ -907,25 +1904,39 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non points = [] for point in start_access_points or [start_origin, start_exit]: _append_unique(points, point) - _append_orthogonal(points, carrier_points[0], obstacle_bboxes=candidate_blocked_bboxes) + _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=candidate_blocked_bboxes) - for point in reversed(end_access_points or [end_origin, end_exit]): - _append_unique(points, point) - points = _simplify_collinear_points( + _append_orthogonal( points, - preserved_point_keys=_important_route_node_keys(network, path_keys, path_result), + 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 []], + }, "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), @@ -933,7 +1944,12 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non "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 + ), "obstacle_aware": bool(obstacle_aware), + "boundary_aware": bool(candidate_boundaries), + "route_constraints": _route_constraint_payload(constraint_options), }, "route_track": path_result, "lane": lane, @@ -941,17 +1957,105 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non def route_obstacle_hit_count(points): hits = 0 - if not candidate_blocked_bboxes: + 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 candidate_blocked_bboxes: + 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 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 @@ -962,7 +2066,7 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non 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_candidates = RoutingNetwork.rank_connection_point_candidates( + start_candidates = select_ranked_entry_candidates( network, RoutingNetwork.connection_point_candidates( network, @@ -970,8 +2074,9 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non limit=0, max_distance=max_distance, ), + candidate_limit, + access_point=start_exit, ) - start_candidates = start_candidates[:candidate_limit] if not start_candidates: return None @@ -988,7 +2093,7 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non ) if start_key is None: continue - end_candidates = RoutingNetwork.rank_connection_point_candidates( + end_candidates = select_ranked_entry_candidates( start_network, RoutingNetwork.connection_point_candidates( start_network, @@ -996,8 +2101,9 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non limit=0, max_distance=max_distance, ), + candidate_limit, + access_point=end_exit, ) - end_candidates = end_candidates[:candidate_limit] 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( @@ -1030,7 +2136,14 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non 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 @@ -1038,10 +2151,18 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non return best_route 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( @@ -1053,6 +2174,48 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non 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( @@ -1064,10 +2227,12 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non if route_data is not None: return route_data - network = RoutingNetwork.build_route_graph( - doc, - adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), - ) + 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) @@ -1113,6 +2278,118 @@ def _bbox_payload(obj, clearance=0.0): } +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 _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] @@ -1148,6 +2425,31 @@ def _expanded_obstacle_exclusion_ids(doc, exclude): 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 ( @@ -1165,21 +2467,136 @@ def _distance_point_to_bbox(point, bbox): return math.sqrt(squared) -def collect_obstacles(doc, exclude=None, options=None): +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) - 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 = [] + candidates = [] + if doc is None: + return {"clearance": clearance, "candidates": candidates} for obj in list(getattr(doc, "Objects", []) or []): - if id(obj) in excluded: + if _has_pass_through_obstacle_semantics(obj): continue - obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip() - if obstacle_mode in {"PassThrough", "WireDuctPassThrough", "SupportSurface"}: + 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_routing_boundary(obj): continue if _is_group(obj) or _is_origin_helper(obj): continue @@ -1201,6 +2618,10 @@ def collect_obstacles(doc, exclude=None, options=None): "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, } @@ -1260,6 +2681,14 @@ def detect_collisions(points, obstacles, ignored_segment_indices=None): "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 {}), } @@ -1267,13 +2696,86 @@ def detect_collisions(points, obstacles, ignored_segment_indices=None): return collisions -def _endpoint_collision_segment_indices(points): +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 _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 @@ -1365,22 +2867,255 @@ def _set_task_status(task, status): ) -def _style_wire(wire, collision_count=0): +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 _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): try: wire.ViewObject.Visibility = True - wire.ViewObject.LineWidth = 5.0 + wire.ViewObject.LineWidth = _wire_style_line_width(wire_style) or 5.0 if hasattr(wire.ViewObject, "DrawStyle"): - wire.ViewObject.DrawStyle = "Solid" + wire.ViewObject.DrawStyle = _wire_style_draw_style(wire_style) if hasattr(wire.ViewObject, "DisplayMode"): wire.ViewObject.DisplayMode = "Wireframe" if collision_count: wire.ViewObject.LineColor = (1.0, 0.1, 0.0) else: - wire.ViewObject.LineColor = (0.0, 0.35, 1.0) + wire.ViewObject.LineColor = _wire_style_color(wire_style) or (0.0, 0.35, 1.0) 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, @@ -1413,6 +3148,7 @@ def route_eplan_connection_between_terminals( 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 = build_network_route( start_terminal, @@ -1422,6 +3158,12 @@ def route_eplan_connection_between_terminals( 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( "没有可用的布线路径网络;请先生成布线布局空间和布线路径网络。" ) @@ -1433,8 +3175,23 @@ def route_eplan_connection_between_terminals( 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) + 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 = [] @@ -1451,7 +3208,14 @@ def route_eplan_connection_between_terminals( removed_existing = 0 try: wire = _create_wire_geometry(doc, wire_name, points) - wire.Label = wire_label or wire_mark or wire_uuid or "QET Routed Connection" + 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, @@ -1475,6 +3239,7 @@ def route_eplan_connection_between_terminals( 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) @@ -1484,7 +3249,7 @@ def route_eplan_connection_between_terminals( routed_group.ViewObject.Visibility = True except Exception: pass - _style_wire(wire, collision_count=len(collisions)) + _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) @@ -1508,7 +3273,10 @@ def route_eplan_connection_between_terminals( 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", {}), @@ -1517,6 +3285,7 @@ def route_eplan_connection_between_terminals( "length_mm": _route_length(points), "collision_count": len(collisions), "collisions": collisions, + "auto_ignored_collisions": auto_ignored_collisions, "replaced_routed_connections": removed_existing, } @@ -1531,6 +3300,167 @@ def _wire_item_value(item, *names): 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() + 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 + 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 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 @@ -1647,949 +3577,5131 @@ def format_terminal_binding_report(report): 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.") - - opts = _merged_options(options) - terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) - terminals = index_terminals(doc) - local_terminal_count = sum( - 1 - for terminal_uuid in terminals - if TerminalObjects.is_local_terminal_uuid(terminal_uuid) - ) - wires = payload.get("wires", []) or [] - report = { - "total_wires": len(wires), - "available_terminals": len(terminals), - "local_terminals": local_terminal_count, - "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 - ), - "missing_endpoint_uuids": [], - "missing_endpoint_samples": [], - "missing_route_network_samples": [], - "collision_samples": [], - "errors": [], - "error_samples": [], - "route_status_counts": {}, - "routes": [], +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 isinstance(prepared_layout, dict): - report["prepared_layout"] = prepared_layout + if not path: + return status + if not os.path.exists(path): + status["status"] = "Missing" + return status try: - route_network = RoutingNetwork.build_route_graph( - doc, - adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), - ) + connection = sqlite3.connect(path) except Exception as exc: - route_network = {} - report["route_network_error"] = str(exc) - 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 {}) - has_route_network = report["route_network_segments"] > 0 - missing_endpoint_uuids = set() - lane_indexes_by_pair = {} - lane_indexes_by_segment = {} - segment_usage_costs = _existing_routed_segment_usage( - doc, - excluded_wire_uuids=_incoming_wire_uuids(wires), - ) + 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 add_status(status): - key = str(status or "").strip() or "Unknown" - report["route_status_counts"][key] = report["route_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 _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 missing_route_network_sample(item, start_uuid, end_uuid, error_text=""): - sample = { - "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), - "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), - "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"), - } - 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 _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 is_missing_route_network_error(error_text): - text = str(error_text or "") - return "没有可用的布线路径网络" in text or "No route path" in text - def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata): - route_options = dict(options or {}) - if isinstance(item, dict) and "__segment_usage_costs" in item: - route_options["segment_usage_costs"] = item.get("__segment_usage_costs", {}) - 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, +def _apply_wire_style_database_option(opts, payload): + 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 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 + 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 _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 _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 _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) + if isinstance(payload, dict) and isinstance(payload.get("wires"), list): + return payload, list(payload.get("wires") or []), "payload" + task_payload = _wire_tasks_payload(doc) + 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): - 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_terminal = terminals.get(start_uuid) - end_terminal = terminals.get(end_uuid) - 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 in (start_uuid, end_uuid): - if terminal_uuid and terminal_uuid not in terminals: - missing_endpoint_uuids.add(terminal_uuid) - # 这里只保留少量样例,避免面板状态被大量导线任务刷屏。 - if len(report["missing_endpoint_samples"]) < 8: - report["missing_endpoint_samples"].append( + 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"), - "start_terminal_uuid": start_uuid, - "start_found": start_terminal is not None, - "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_found": end_terminal is not None, - "end_element_uuid": _wire_item_value(item, "end_element_uuid"), - "end_terminal_display": _wire_item_value(item, "end_terminal_display"), + "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 - 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) + style_id = _wire_item_value(item, "wire_style_id") + if not style_id: continue - lane_key = _route_lane_key(start_uuid, end_uuid) - route_lane_index = lane_indexes_by_pair.get(lane_key, 0) - try: - 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"), - "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"), - } - 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 - try: - result = create_route( - final_lane_index, - dict(item, __segment_usage_costs=segment_usage_costs), - 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) - except Exception as exc: - error_text = str(exc) - if is_missing_route_network_error(error_text): - # 路径网络存在但两端无法连通时,按缺路径网络处理,避免被普通 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 - report["errors"].append(error_text) - add_status("Error") - set_item_task_status(item, "Error") - if len(report["error_samples"]) < 8: - report["error_samples"].append( + 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"), - "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, + "wire_style_id": style_id, } ) - 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, + 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, ) - 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, + 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 + 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") + 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, ) - 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"]) - route_collision_samples = [] - for collision in list(result.get("collisions", []) or [])[:3]: - sample = dict(collision) - sample.update( + 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 "无法在当前路径网络中连通两端。", } ) - 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 - report["routes"].append( - { + 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) + source_payload, wires, source = _preflight_wire_payload(doc, payload) + _apply_wire_style_database_option(opts, source_payload) + opts.setdefault("__wire_style_cache", {}) + project_uuid = str(source_payload.get("project_uuid", "") or _project_uuid(doc)).strip() + terminals = index_terminals(doc) + local_terminal_count = sum( + 1 + for terminal_uuid in terminals + if TerminalObjects.is_local_terminal_uuid(terminal_uuid) + ) + report = { + "ok": True, + "source": source, + "runtime_version": AUTO_ROUTING_RUNTIME_VERSION, + "project_uuid": project_uuid, + "total_wires": len(wires), + "available_terminals": len(terminals), + "local_terminals": local_terminal_count, + "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_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", + ) + + 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_found = bool(start_uuid and start_uuid in terminals) + end_found = bool(end_uuid and end_uuid in terminals) + 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"), - "wire_style_id": _wire_item_value(item, "wire_style_id"), + # 预检阶段同样没有 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_device_label": _wire_item_value(item, "start_device_label"), "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_device_label": _wire_item_value(item, "end_device_label"), - "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"], - "collision_samples": route_collision_samples, } - ) - if report["routed"] > 0: - try: - doc.recompute() - except Exception: - pass + if not start_found: + _add_missing_endpoint_terminal_context(sample, "start", terminals, doc=doc) + if not end_found: + _add_missing_endpoint_terminal_context(sample, "end", terminals, doc=doc) + report["missing_endpoint_samples"].append(sample) report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) - _write_routing_connection_batch_diagnostic(doc, report) - return report + if report["route_network_segments"] > 0: + routeability = _preflight_routeability_summary(doc, wires, terminals, options=opts) + 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, + ) -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() - terminal_display = str(sample.get("{0}_terminal_display".format(side), "") or "").strip() - if 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 + 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 _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_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 _wire_sample_text(sample): - return ( - str(sample.get("wire_label", "") or "").strip() - or str(sample.get("wire_uuid", "") 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), ) - - -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"), + 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), ) - - -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() + 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", "")) ) - 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), + 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)) + 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]) ) - return "" - + 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_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_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.") -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: + payload = _load_context_payload_with_wire_styles(payload) + payload = _load_context_payload_with_devices(payload) + opts = _merged_options(options) + _apply_wire_style_database_option(opts, payload) + opts.setdefault("__wire_style_cache", {}) + terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) + terminals = index_terminals(doc) + local_terminal_count = sum( + 1 + for terminal_uuid in terminals + if TerminalObjects.is_local_terminal_uuid(terminal_uuid) + ) + 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(terminals), + "local_terminals": local_terminal_count, + "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 + try: + route_network = RoutingNetwork.build_route_graph( + doc, + adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), + ) + except Exception as exc: + route_network = {} + report["route_network_error"] = str(exc) + 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(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_terminal = terminals.get(start_uuid) + end_terminal = terminals.get(end_uuid) + 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 in (start_uuid, end_uuid): + if terminal_uuid and terminal_uuid not in terminals: + 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"), + "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"), + } + if start_terminal is None: + _add_missing_endpoint_terminal_context(sample, "start", terminals, doc=doc) + if end_terminal is None: + _add_missing_endpoint_terminal_context(sample, "end", terminals, 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 + try: + result = create_route( + final_lane_index, + dict(item, __segment_usage_costs=segment_usage_costs), + 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 + 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_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", ""), + "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": endpoint_metadata.get("end_device_label", ""), + "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 _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_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", {}) + 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), + } + ) + return samples + + +_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 _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 _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_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) + 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) + 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) + 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 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 []) + 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 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}。请补柜内 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, + ) + main_path_text = _main_path_not_used_text(report) + if main_path_text: + message += "\n{0}".format(main_path_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) + 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["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": "未使用线槽或用户主路径", + "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": "碰撞告警", + "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": "路径容量压力", + "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不匹配", +} + + +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 { - "max_lane_index": max_lane_index, - "spacing_mm": lane_spacing, - "max_offset_mm": lane_max_offset, + "count": missing_count, + "samples": missing_samples, + "invalid_count": invalid_count, + "invalid_samples": invalid_samples, } -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)): +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 - carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} - if not isinstance(carrier, dict): + total_count += 1 + issue_codes = _issue_codes_from_text(getattr(obj, "QetRouteIssueCodes", "")) + if not issue_codes: 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) + 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("检查柜内边界和 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 [])) + 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("选中缺主路径导线后点击“选择拒绝兜底路径”查看需补路径位置") + 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 + ): + 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 + ) + if has_parent_refs: + add("点击“选择碰撞父装配”确认结构件后再标记忽略碰撞") + else: + 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 _route_capacity_pressure_summary(report): - pressure = {} - for route in report.get("routes", []) or []: - if not isinstance(route, dict): - continue - lane = route.get("lane", {}) - if not isinstance(lane, dict): +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: - max_parallel_wires = int(lane.get("index", 0) or 0) + 1 + group.removeObject(obj) 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 not pressure or max_parallel_wires > pressure.get("max_parallel_wires", 0): - pressure = { - "max_parallel_wires": max_parallel_wires, - "min_capacity": route_capacity, - } - return pressure + 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 -_ROUTE_QUALITY_WARNING_KIND_LABELS = { - "RoutingRange": "布线面", - "AuxiliaryPath": "辅助路径", -} +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 _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): +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 - 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 {} + 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 _route_track_carrier_names(route_track, limit=8): +def _compact_route_sample(route): + route_track = route.get("route_track", {}) if isinstance(route, dict) else {} 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)] - - -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, - } - if warning_count <= 0: - return {} - return { - "count": warning_count, - "sample": sample or {}, + 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", {}), + } + return sample -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 _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 _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: +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, + ), + ( + "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)), + ( + "long_terminal_access", + bool(_long_network_entry_warning_samples(report, limit=1)), + ), + ( + "route_candidate_obstacle_hits", + bool(_route_candidate_obstacle_warning_samples(report, limit=1)), + ), + ( + "route_candidate_boundary_violations", + bool(_route_candidate_boundary_warning_samples(report, limit=1)), + ), + ( + "route_capacity_pressure", + bool(_route_capacity_pressure_samples(report, limit=1)), + ), + ) + return [code for code, enabled in checks if enabled] + +def _routed_route_issue_summary_from_report(report): + issue_counts = {} + issue_wire_count = 0 + total_wire_count = 0 samples = [] - max_samples = int(limit or 0) - for route in report.get("routes", []) or []: + 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 - 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: + 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 - if max_samples <= 0 or len(samples) < max_samples: + 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", ""), - "wire_label": route.get("wire_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), + "label": route.get("wire_object_label", "") or route.get("wire_label", ""), + "issue_codes": codes, } ) - return samples + return { + "issue_wire_count": issue_wire_count, + "total_wire_count": total_wire_count, + "issue_code_counts": dict(sorted(issue_counts.items())), + "samples": samples, + } -def _long_network_entry_summary(report): - samples = _long_network_entry_warning_samples(report, limit=1) - if not samples: - return {} +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 { - "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), + "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 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), - ) - 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", +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)) ): - 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)) - 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), - ) - 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) - ) - 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) - 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) - 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 capacity_pressure: - message += "\n容量提示:最大并行线数 {0},路径最小容量 {1}。".format( - capacity_pressure.get("max_parallel_wires", 0), - capacity_pressure.get("min_capacity", 0), - ) - candidate_ranks = [] - for route in report.get("routes", []) or []: - if not isinstance(route, dict): + refs.append(candidate) + seen_names.add(candidate_name) + return refs + + +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 - network = route.get("network", {}) - if not isinstance(network, dict): + left_label, right_label = [part.strip() for part in pair_text.split(" -> ", 1)] + if not left_label or not right_label: 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", {}) - # 最终导线接入距离过长时,通常意味着设备附近缺少局部路径或主路径离端子太远。 - message += "\n接入距离提示:{0} 条导线起点/终点接入过长,示例导线 {1} {2},可能存在悬空或跨距过长。".format( - long_entry_warning.get("count", 0), - sample.get("wire", "未知导线"), - ",".join(sample.get("warning_parts", []) or []), - ) - route_source_sample = _route_source_sample_text(report) - if route_source_sample: - message += "\n{0}".format(route_source_sample) - 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 []), - ) - 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 = ( - collision_sample.get("wire_label") - or collision_sample.get("wire_uuid") - or "未知导线" - ) - if collision_sample.get("collision_kind") == "ClearanceWarning": - message += "\n碰撞示例:导线 {0} 进入 {1} 的安全间隙。".format( - wire_text, - obstacle_text, - ) - else: - message += "\n碰撞示例:导线 {0} 碰到 {1}。".format( - wire_text, - obstacle_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, - ) - 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:...。" - sample = (report.get("missing_endpoint_samples") or [None])[0] - if sample: - message += "\n缺失示例:{0} -> {1}{2}".format( - _missing_endpoint_label(sample, "start"), - _missing_endpoint_label(sample, "end"), - _missing_endpoint_side_summary(sample), + 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 = RoutingNetwork.create_user_path_bridge_between_objects( + doc, + left_matches[0], + right_matches[0], + project_uuid=project_uuid, ) - return message + 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 _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: + +def _result_route_points(result): + points = [] + if isinstance(result, dict): + for point in list(result.get("points", []) or []): try: - group.Group = [ - candidate - for candidate in list(getattr(group, "Group", []) or []) - if candidate is not obj - ] + points.append(_vector(point)) except Exception: - pass - try: - if doc.getObject(getattr(obj, "Name", "")) is not None: - doc.removeObject(obj.Name) - removed += 1 - except Exception: - pass - return removed + 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 _compact_route_sample(route): - route_track = route.get("route_track", {}) if isinstance(route, dict) else {} - if not isinstance(route_track, dict): - route_track = {} - sample = { - "wire_uuid": route.get("wire_uuid", ""), - "wire_label": route.get("wire_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", ""), - "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), +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 _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 _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): + 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() } - 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), - } - return sample + 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 + report["main_path_detour_retry_wires"] = len(retry_routes) + report["main_path_detour_retry_replaced_routes"] = 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): @@ -2614,18 +8726,65 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): "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", + "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_detour_bridges"), dict): + payload["auto_main_path_detour_bridges"] = dict(report.get("auto_main_path_detour_bridges") 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", "missing_endpoint_samples", @@ -2637,10 +8796,33 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): 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) - payload["route_samples"] = [_compact_route_sample(route) for route in routes[:limit]] + 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) 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 @@ -2649,6 +8831,24 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): _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 + 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 @@ -2659,21 +8859,39 @@ def _write_routing_connection_batch_diagnostic(doc, report): project_uuid = _project_uuid(doc) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) _clear_routing_connection_batch_diagnostics(doc) - if ( - report.get("total_wires", 0) <= 0 - and not report.get("routes") - and not report.get("errors") - and not report.get("missing_endpoint_uuids") - and report.get("collision_warnings", 0) <= 0 - ): - return None + 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_routing_connection_batch_report(report), ensure_ascii=False), + json.dumps(compact_payload, ensure_ascii=False), "QET routing connection batch diagnostic payload", ) group.addObject(diagnostic) @@ -2726,9 +8944,99 @@ 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_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 _direct_task_auto_diagnostic_bridge_report(doc, opts): + diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) + bridge_report = { + "enabled": bool(opts.get("auto_create_diagnostic_bridges", True)), + "suggestions": 0, + "created_count": 0, + "duplicates": 0, + "stale_suggestions": 0, + } + 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 = { + "enabled": True, + "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), + } + if created: + # 任务直连入口没有“更新路径网络”前置步骤;桥接创建后补一次诊断,让报告反映桥接后的网络状态。 + diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) + except Exception as exc: + bridge_report = { + "enabled": True, + "suggestions": 0, + "created_count": 0, + "duplicates": 0, + "stale_suggestions": 0, + "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) - return route_eplan_connections_from_payload(doc, payload, options=options, prepared_layout=prepared_layout) + 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=""): @@ -2789,6 +9097,7 @@ def check_eplan_routing_path_network(doc, project_uuid="", options=None): project_uuid=target_project_uuid, 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), + 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 {} @@ -2797,6 +9106,7 @@ def check_eplan_routing_path_network(doc, project_uuid="", options=None): "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 [], } @@ -2804,7 +9114,7 @@ def _compact_routing_path_network_diagnostic(diagnostic): if not isinstance(diagnostic, dict): return {} issues = _dict_items(diagnostic.get("issues", []) or []) - return { + 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", "")], @@ -2818,6 +9128,68 @@ def _compact_routing_path_network_diagnostic(diagnostic): ], "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] + ] + 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), + "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] + ] + 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] + ] + return payload _PATH_NETWORK_ISSUE_LABELS = { @@ -2825,9 +9197,12 @@ _PATH_NETWORK_ISSUE_LABELS = { "invalid_route_carriers": "路径对象几何无效", "routing_range_only_network": "仅使用布线面兜底", "invalid_terminal_local_routes": "端子局部路径无效", + "route_carriers_outside_boundary": "路径越出柜内边界", + "terminals_outside_boundary": "端子越出柜内边界", "long_terminal_accesses": "端子接入过长", "unconnected_terminals": "端子未接入", "wire_duct_endpoint_breaks": "线槽端点疑似断开", + "wire_ducts_without_terminal_access": "线槽未接入端子主网络", "isolated_network_components": "存在孤立路径网络", } @@ -2875,6 +9250,13 @@ def _dict_items(value): 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): @@ -2917,6 +9299,25 @@ def format_routing_path_network_report(diagnostic): _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] @@ -2926,6 +9327,24 @@ def format_routing_path_network_report(diagnostic): 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] @@ -2955,7 +9374,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 (empty_network or unconnected or possible_breaks or invalid_carriers or long_accesses or invalid_local_routes or routing_range_only or isolated): + 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_local_routes or routing_range_only or isolated): first_issue = issues[0] message += "\n首个问题:{0} ({1})。".format( first_issue.get("code", "unknown"), @@ -2996,12 +9415,20 @@ def route_eplan_connections( selection_ex=selection_ex, ) 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, + } 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), + 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), ) ) @@ -3021,9 +9448,53 @@ def route_eplan_connections( "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 = { + "enabled": True, + "suggestions": int(bridge_report.get("suggestions", 0) or 0), + "created_count": len(created), + "duplicates": int(bridge_report.get("duplicates", 0) or 0), + "stale_suggestions": int(bridge_report.get("stale_suggestions", 0) or 0), + } + if created: + if update_network: + prepared_network = update_eplan_routing_path_network( + doc, + project_uuid=project_uuid, + options=opts, + selection_ex=selection_ex, + ) + 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), + 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: + auto_diagnostic_bridges = { + "enabled": True, + "suggestions": 0, + "created_count": 0, + "duplicates": 0, + "stale_suggestions": 0, + "error": str(exc), + } + target_payload = payload if target_payload is None: - target_payload = getattr(App, "_qet_exchange_payload", None) + candidate_payload = getattr(App, "_qet_exchange_payload", None) + if _payload_matches_document_project(doc, candidate_payload): + target_payload = candidate_payload if isinstance(target_payload, dict) and target_payload.get("wires"): report = route_eplan_connections_from_payload( @@ -3033,21 +9504,81 @@ def route_eplan_connections( prepared_layout=prepared_network, ) else: + task_route_options = dict(opts) + task_route_options["__skip_task_auto_diagnostic_bridges"] = True report = route_eplan_connection_tasks( doc, - options=opts, + options=task_route_options, prepared_layout=prepared_network, ) + 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) + if update_network: + prepared_network = update_eplan_routing_path_network( + doc, + project_uuid=project_uuid, + options=opts, + selection_ex=selection_ex, + ) + retry_payload = _payload_subset_for_wire_uuids(target_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), + } + 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_detour_bridges"] = auto_main_path_detour_bridges 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 + _write_routing_connection_batch_diagnostic(doc, report) return report def wire_task_count(doc): diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index 3ce310b..566f062 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -5,6 +5,7 @@ # 2. "生成布线路径网络" - generate the full routing path network # 3. "生成布线连接" - update the network and route all QET wire tasks +import json import FreeCAD as App try: @@ -118,6 +119,13 @@ class AutoRoutingController: max_distance = RoutingNetwork.DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE self.options["terminal_access_max_distance"] = max(max_distance, 0.0) + def set_terminal_access_warning_distance(self, value): + try: + warning_distance = float(value) + except Exception: + warning_distance = AutoRouting.DEFAULT_OPTIONS["terminal_access_warning_distance"] + self.options["terminal_access_warning_distance"] = max(warning_distance, 0.0) + def set_terminal_exit_length(self, value): try: exit_length = float(value) @@ -125,6 +133,20 @@ class AutoRoutingController: exit_length = AutoRouting.DEFAULT_OPTIONS["terminal_exit_length"] self.options["terminal_exit_length"] = max(exit_length, 0.0) + def set_obstacle_clearance(self, value): + try: + clearance = float(value) + except Exception: + clearance = AutoRouting.DEFAULT_OPTIONS["obstacle_clearance"] + self.options["obstacle_clearance"] = max(clearance, 0.0) + + def set_segment_reuse_penalty(self, value): + try: + penalty = float(value) + except Exception: + penalty = AutoRouting.DEFAULT_OPTIONS["segment_reuse_penalty"] + self.options["segment_reuse_penalty"] = max(penalty, 0.0) + def set_lane_spacing(self, value): try: lane_spacing = float(value) @@ -145,6 +167,20 @@ class AutoRoutingController: lane_axis = AutoRouting.DEFAULT_OPTIONS["lane_axis"] self.options["lane_axis"] = lane_axis + def set_selected_route_capacity(self, value): + try: + capacity = int(float(value)) + except Exception: + capacity = 1 + self.options["selected_route_capacity"] = max(capacity, 1) + + def set_preflight_routeability_sample_limit(self, value): + try: + sample_limit = int(value) + except Exception: + sample_limit = int(AutoRouting.DEFAULT_OPTIONS["preflight_routeability_sample_limit"]) + self.options["preflight_routeability_sample_limit"] = max(sample_limit, 0) + def summary(self): doc = _active_document() terminal_count = len(AutoRouting.index_terminals(doc)) @@ -153,6 +189,13 @@ class AutoRoutingController: payload_wire_count = 0 if isinstance(payload, dict) and isinstance(payload.get("wires"), list): payload_wire_count = len(payload.get("wires") or []) + boundary_count = len(AutoRouting.collect_routing_boundaries(doc)) + route_constraints = RoutingNetwork.collect_route_constraint_options(doc) + required_constraint_count = len(route_constraints.get("required_route_carrier_names", []) or []) + forbidden_constraint_count = len(route_constraints.get("forbidden_route_carrier_names", []) or []) + source_constraint_counts = RoutingNetwork.collect_route_constraint_source_counts(doc) + required_source_constraint_count = int(source_constraint_counts.get("required", 0) or 0) + forbidden_source_constraint_count = int(source_constraint_counts.get("forbidden", 0) or 0) network = RoutingNetwork.network_summary( doc, adjoining_duct_tolerance=float( @@ -173,7 +216,58 @@ class AutoRoutingController: bridge_text = "" if int(network.get("bridged_segments", 0) or 0) > 0: bridge_text = ";桥接:{0}".format(network.get("bridged_segments", 0)) - return "端子:{0};导线任务:{1};QET导线:{2};路由网络:{3} 条 carrier / {4} 段 / {5} 节点{6}{7}".format( + boundary_text = "" + if boundary_count > 0: + boundary_text = ";柜内边界:{0}".format(boundary_count) + obstacle_mode_text = "" + try: + obstacle_modes = AutoRouting.routing_obstacle_mode_summary(doc) + except Exception: + obstacle_modes = {} + if isinstance(obstacle_modes, dict): + pass_through = 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: + obstacle_mode_text = ";忽略碰撞:{0}".format(pass_through_count) + constraint_text = "" + if required_constraint_count > 0 or forbidden_constraint_count > 0: + constraint_text = ";路径约束:必经 {0},禁经 {1}".format( + required_constraint_count, + forbidden_constraint_count, + ) + source_constraint_text = "" + if required_source_constraint_count > 0 or forbidden_source_constraint_count > 0: + source_constraint_text = ";源路径约束:必经 {0},禁经 {1}".format( + required_source_constraint_count, + forbidden_source_constraint_count, + ) + style_database_text = "" + exchange_summary = getattr(App, "_qet_exchange_summary", None) + style_database_path = "" + doc_project_uuid = str( + getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "" + ).strip() + if isinstance(payload, dict): + payload_project_uuid = str(payload.get("project_uuid", "") or "").strip() + payload_matches_doc = not ( + doc_project_uuid and payload_project_uuid and doc_project_uuid != payload_project_uuid + ) + else: + payload_matches_doc = False + if payload_matches_doc: + style_database_path = str(payload.get("wire_style_database_path", "") or "").strip() + if not style_database_path and isinstance(exchange_summary, dict): + summary_project_uuid = str(exchange_summary.get("project_uuid", "") or "").strip() + summary_matches_doc = not ( + doc_project_uuid and summary_project_uuid and doc_project_uuid != summary_project_uuid + ) + if summary_matches_doc: + style_database_path = str(exchange_summary.get("wire_style_database_path", "") or "").strip() + if style_database_path: + style_database_text = ";导线样式库:{0}".format(style_database_path) + version_text = ";版本:{0}".format(AutoRouting.AUTO_ROUTING_RUNTIME_VERSION) + return "端子:{0};导线任务:{1};QET导线:{2};路由网络:{3} 条 carrier / {4} 段 / {5} 节点{6}{7}{8}{9}{10}{11}{12}{13}".format( terminal_count, task_count, payload_wire_count, @@ -182,6 +276,12 @@ class AutoRoutingController: network.get("nodes", 0), kind_text, bridge_text, + boundary_text, + obstacle_mode_text, + constraint_text, + source_constraint_text, + style_database_text, + version_text, ) def generate_routing_paths(self): @@ -209,6 +309,1507 @@ class AutoRoutingController: ) return self.last_report + def check_routing_readiness(self): + doc = _active_document() + payload = getattr(App, "_qet_exchange_payload", None) + self.last_report = AutoRouting.preflight_eplan_connections( + doc, + payload=payload if isinstance(payload, dict) else None, + options=self.routing_options(), + ) + AutoRouting.write_routing_preflight_diagnostic(doc, self.last_report) + return self.last_report + + def collect_routing_diagnostic_summary(self): + doc = _active_document() + self.last_report = AutoRouting.collect_routing_diagnostic_summary(doc) + AutoRouting.write_routing_diagnostic_summary(doc, self.last_report) + return self.last_report + + def select_top_collision_obstacles(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + obstacles = list(summary.get("batch_top_collision_obstacles", []) or []) + selected = [] + missing_names = [] + + try: + Gui.Selection.clearSelection() + except Exception: + pass + for item in obstacles: + if not isinstance(item, dict): + continue + obj = self._find_object_by_name_or_label( + doc, + item.get("name", ""), + item.get("label", ""), + ) + if obj is None: + missing = str(item.get("name", "") or item.get("label", "") or "").strip() + if missing: + missing_names.append(missing) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected.append(obj) + self.last_report = { + "selected_collision_obstacles": len(selected), + "selected_collision_obstacle_names": [getattr(obj, "Name", "") for obj in selected], + "missing_collision_obstacle_names": missing_names, + } + return self.last_report + + @staticmethod + def _is_device_or_layout_collision_candidate(item): + code = str(item.get("resolution_hint_code", "") or "").strip() + if code == "review_device_or_layout_collision": + return True + if code: + return False + hint = getattr(AutoRouting, "_collision_obstacle_resolution_hint", lambda _item: {})(item) + return ( + isinstance(hint, dict) + and str(hint.get("code", "") or "").strip() == "review_device_or_layout_collision" + ) + + def select_device_or_layout_collision_obstacles(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + obstacles = list(summary.get("batch_top_collision_obstacles", []) or []) + selected = [] + missing_names = [] + + try: + Gui.Selection.clearSelection() + except Exception: + pass + for item in obstacles: + if not isinstance(item, dict) or not self._is_device_or_layout_collision_candidate(item): + continue + obj = self._find_object_by_name_or_label( + doc, + item.get("name", ""), + item.get("label", ""), + ) + if obj is None: + missing = str(item.get("name", "") or item.get("label", "") or "").strip() + if missing: + missing_names.append(missing) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected.append(obj) + self.last_report = { + "selected_device_or_layout_collision_obstacles": len(selected), + "selected_device_or_layout_collision_obstacle_names": [getattr(obj, "Name", "") for obj in selected], + "missing_device_or_layout_collision_obstacle_names": missing_names, + } + return self.last_report + + def select_top_collision_parent_assemblies(self): + result = self._select_collision_parent_assemblies() + self.last_report = { + "selected_collision_parent_assemblies": result["selected_parent_assemblies"], + "selected_collision_parent_assembly_names": result["selected_parent_assembly_names"], + "missing_collision_parent_assembly_refs": result["missing_parent_assembly_refs"], + } + return self.last_report + + @staticmethod + def _is_structural_collision_candidate(item): + code = str(item.get("resolution_hint_code", "") or "").strip() + if code == "review_pass_through_structural_obstacle": + return True + if code: + return False + hint = getattr(AutoRouting, "_collision_obstacle_resolution_hint", lambda _item: {})(item) + return ( + isinstance(hint, dict) + and str(hint.get("code", "") or "").strip() == "review_pass_through_structural_obstacle" + ) + + def select_structural_collision_parent_assemblies(self): + result = self._select_collision_parent_assemblies( + filter_fn=self._is_structural_collision_candidate, + nearest_structural_parent=True, + ) + self.last_report = { + "selected_structural_collision_parent_assemblies": result["selected_parent_assemblies"], + "selected_structural_collision_parent_assembly_names": result["selected_parent_assembly_names"], + "missing_structural_collision_parent_assembly_refs": result["missing_parent_assembly_refs"], + } + return self.last_report + + def mark_structural_collision_parent_assemblies_pass_through(self): + result = self._select_collision_parent_assemblies( + filter_fn=self._is_structural_collision_candidate, + nearest_structural_parent=True, + select=False, + ) + marked = [] + marked_names = [] + for obj in list(result.get("parent_assembly_objects", []) or []): + if obj is None: + continue + RoutingNetwork.set_routing_obstacle_mode(obj, "PassThrough") + marked.append(obj) + marked_names.append(getattr(obj, "Name", "")) + try: + _active_document().recompute() + except Exception: + pass + self.last_report = { + "marked_structural_collision_parent_assemblies": len(marked), + "marked_structural_collision_parent_assembly_names": marked_names, + "missing_structural_collision_parent_assembly_refs": result["missing_parent_assembly_refs"], + "obstacle_mode": "PassThrough", + } + return self.last_report + + @staticmethod + def _is_broad_collision_parent_ref(name, label): + text = " ".join([str(name or ""), str(label or "")]).strip().lower() + if not text: + return False + broad_refs = ( + "qet exchange devices", + "qetexchangedevices", + ) + return any(ref in text for ref in broad_refs) + + @staticmethod + def _is_structural_collision_parent_ref(name, label): + if AutoRoutingController._is_broad_collision_parent_ref(name, label): + return False + text = " ".join([str(name or ""), str(label or "")]).strip().lower() + if not text: + return False + keywords = getattr( + AutoRouting, + "_STRUCTURAL_COLLISION_KEYWORDS", + ("cabinet", "door", "cover", "panel", "bracket", "support", "shell", "柜", "门", "盖", "板", "支架", "壳"), + ) + return any(str(keyword or "").lower() in text for keyword in keywords) + + @classmethod + def _nearest_structural_parent_refs(cls, parent_names, parent_labels): + max_count = max(len(parent_names), len(parent_labels)) + fallback = None + for index in range(max_count): + name = parent_names[index] if index < len(parent_names) else "" + label = parent_labels[index] if index < len(parent_labels) else "" + if cls._is_broad_collision_parent_ref(name, label): + continue + if fallback is None: + fallback = (name, label) + if cls._is_structural_collision_parent_ref(name, label): + return [(name, label)] + return [fallback] if fallback is not None else [] + + def _select_collision_parent_assemblies(self, filter_fn=None, select=True, nearest_structural_parent=False): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + obstacles = list(summary.get("batch_top_collision_obstacles", []) or []) + selected = [] + selected_names = set() + missing_refs = [] + seen_refs = set() + + if select: + try: + Gui.Selection.clearSelection() + except Exception: + pass + for item in obstacles: + if not isinstance(item, dict): + continue + if callable(filter_fn) and not filter_fn(item): + continue + parent_names = list(item.get("parent_names", []) or []) + parent_labels = list(item.get("parent_labels", []) or []) + parent_refs = ( + self._nearest_structural_parent_refs(parent_names, parent_labels) + if nearest_structural_parent + else [ + ( + parent_names[index] if index < len(parent_names) else "", + parent_labels[index] if index < len(parent_labels) else "", + ) + for index in range(max(len(parent_names), len(parent_labels))) + ] + ) + for name, label in parent_refs: + ref = str(name or label or "").strip() + if not ref or ref in seen_refs: + continue + seen_refs.add(ref) + obj = self._find_object_by_name_or_label(doc, name, label) + if obj is None: + missing_refs.append(ref) + continue + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + if select: + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + return { + "selected_parent_assemblies": len(selected), + "selected_parent_assembly_names": [getattr(obj, "Name", "") for obj in selected], + "missing_collision_parent_assembly_refs": missing_refs, + "missing_parent_assembly_refs": missing_refs, + "parent_assembly_objects": selected, + } + + @staticmethod + def _find_object_by_name_or_label(doc, name, label): + name = str(name or "").strip() + label = str(label or "").strip() + if name: + obj = doc.getObject(name) + if obj is not None: + return obj + if label: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == label: + return candidate + return None + + @staticmethod + def _route_track_refs(route_track): + carrier_refs = [] + source_refs = [] + if not isinstance(route_track, dict): + return carrier_refs, source_refs + for name in list(route_track.get("carrier_names", []) or []): + carrier_refs.append({"name": name, "label": ""}) + for segment in list(route_track.get("segments", []) or []): + if not isinstance(segment, dict): + continue + carrier_payload = segment.get("carrier", {}) if isinstance(segment.get("carrier", {}), dict) else {} + if not carrier_payload: + continue + carrier_refs.append( + { + "name": carrier_payload.get("name", ""), + "label": carrier_payload.get("label", ""), + } + ) + source_name = str(carrier_payload.get("source_name", "") or "").strip() + source_label = str(carrier_payload.get("source_label", "") or "").strip() + if source_name or source_label: + source_refs.append({"name": source_name, "label": source_label}) + return carrier_refs, source_refs + + def _select_route_refs(self, doc, route_tracks, result_prefix): + selected = [] + selected_names = set() + selected_carriers = [] + selected_sources = [] + missing_refs = [] + + try: + Gui.Selection.clearSelection() + except Exception: + pass + + def add_object(obj, bucket): + if obj is None: + return False + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + return False + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + return False + selected_names.add(obj_name) + selected.append(obj) + bucket.append(obj) + return True + + def remember_missing(ref): + ref = str(ref or "").strip() + if ref and ref not in missing_refs: + missing_refs.append(ref) + + for route_track in list(route_tracks or []): + carrier_refs, source_refs = self._route_track_refs(route_track) + for ref in carrier_refs: + name = str(ref.get("name", "") or "").strip() + label = str(ref.get("label", "") or "").strip() + if not name and not label: + continue + carrier = self._find_object_by_name_or_label(doc, name, label) + if carrier is None: + remember_missing(name or label) + continue + add_object(carrier, selected_carriers) + for ref in source_refs: + name = str(ref.get("name", "") or "").strip() + label = str(ref.get("label", "") or "").strip() + source = self._find_object_by_name_or_label(doc, name, label) + if source is None: + remember_missing(name or label) + continue + add_object(source, selected_sources) + + self.last_report = { + "{0}_objects".format(result_prefix): len(selected), + "{0}_carriers".format(result_prefix): len(selected_carriers), + "{0}_carrier_names".format(result_prefix): [getattr(obj, "Name", "") for obj in selected_carriers], + "{0}_sources".format(result_prefix): len(selected_sources), + "{0}_source_names".format(result_prefix): [getattr(obj, "Name", "") for obj in selected_sources], + "missing_{0}_refs".format(result_prefix): missing_refs, + } + return self.last_report + + def select_collision_wires(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = [] + seen_refs = set() + + def add_sample(item): + if not isinstance(item, dict): + return + wire_uuid = str(item.get("wire_uuid", "") or "").strip() + wire_label = str(item.get("wire_object_label", "") or item.get("wire_label", "") or "").strip() + key = wire_uuid or wire_label + if not key or key in seen_refs: + return + seen_refs.add(key) + samples.append({"wire_uuid": wire_uuid, "wire_label": wire_label, "ref": key}) + + for sample in list(batch_payload.get("collision_samples", []) or []): + add_sample(sample) + for sample in list(batch_payload.get("route_samples", []) or []): + issue_codes = [str(code or "") for code in list(sample.get("issue_codes", []) or [])] + if "collision_warnings" in issue_codes: + add_sample(sample) + + def find_wire(sample): + wire_uuid = sample.get("wire_uuid", "") + wire_label = sample.get("wire_label", "") + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if wire_uuid and str(getattr(candidate, "QetWireUuid", "") or "").strip() == wire_uuid: + return candidate + if wire_label: + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if str(getattr(candidate, "Label", "") or "").strip() == wire_label: + return candidate + return None + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + obj = find_wire(sample) + if obj is None: + missing_refs.append(sample.get("wire_uuid") or sample.get("wire_label") or sample.get("ref", "")) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected_names.add(getattr(obj, "Name", "")) + selected.append(obj) + collision_issue_codes = { + "collision_warnings", + "third_party_device_collisions", + "endpoint_device_collisions", + } + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if getattr(candidate, "Name", "") in selected_names: + continue + issue_codes = { + code.strip() + for code in str(getattr(candidate, "QetRouteIssueCodes", "") or "").split(",") + if code.strip() + } + if not issue_codes.intersection(collision_issue_codes): + continue + try: + Gui.Selection.addSelection(candidate) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(candidate, "Name", "")) + except Exception: + continue + selected_names.add(getattr(candidate, "Name", "")) + selected.append(candidate) + self.last_report = { + "selected_collision_wires": len(selected), + "selected_collision_wire_names": [getattr(obj, "Name", "") for obj in selected], + "missing_collision_wire_refs": missing_refs, + } + return self.last_report + + def select_issue_wires(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = [] + seen_refs = set() + + for item in list(batch_payload.get("route_samples", []) or []): + if not isinstance(item, dict): + continue + issue_codes = [ + str(code or "").strip() + for code in list(item.get("issue_codes", []) or []) + if str(code or "").strip() + ] + if not issue_codes: + continue + wire_uuid = str(item.get("wire_uuid", "") or "").strip() + wire_label = str(item.get("wire_object_label", "") or item.get("wire_label", "") or "").strip() + key = wire_uuid or wire_label + if not key or key in seen_refs: + continue + seen_refs.add(key) + samples.append({"wire_uuid": wire_uuid, "wire_label": wire_label, "ref": key}) + + def find_wire(sample): + wire_uuid = sample.get("wire_uuid", "") + wire_label = sample.get("wire_label", "") + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if wire_uuid and str(getattr(candidate, "QetWireUuid", "") or "").strip() == wire_uuid: + return candidate + if wire_label: + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if str(getattr(candidate, "Label", "") or "").strip() == wire_label: + return candidate + return None + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + obj = find_wire(sample) + if obj is None: + missing_refs.append(sample.get("wire_uuid") or sample.get("wire_label") or sample.get("ref", "")) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected_names.add(getattr(obj, "Name", "")) + selected.append(obj) + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if getattr(candidate, "Name", "") in selected_names: + continue + issue_codes_text = str(getattr(candidate, "QetRouteIssueCodes", "") or "").strip() + if not issue_codes_text: + continue + try: + Gui.Selection.addSelection(candidate) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(candidate, "Name", "")) + except Exception: + continue + selected_names.add(getattr(candidate, "Name", "")) + selected.append(candidate) + self.last_report = { + "selected_issue_wires": len(selected), + "selected_issue_wire_names": [getattr(obj, "Name", "") for obj in selected], + "missing_issue_wire_refs": missing_refs, + } + return self.last_report + + def select_main_path_detour_missing_wires(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = [] + seen_refs = set() + + for item in list(batch_payload.get("route_samples", []) or []): + if not isinstance(item, dict): + continue + issue_codes = [ + str(code or "").strip() + for code in list(item.get("issue_codes", []) or []) + if str(code or "").strip() + ] + if "main_path_detour_missing" not in issue_codes: + continue + wire_uuid = str(item.get("wire_uuid", "") or "").strip() + wire_label = str(item.get("wire_object_label", "") or item.get("wire_label", "") or "").strip() + key = wire_uuid or wire_label + if not key or key in seen_refs: + continue + seen_refs.add(key) + samples.append({"wire_uuid": wire_uuid, "wire_label": wire_label, "ref": key}) + + def find_wire(sample): + wire_uuid = sample.get("wire_uuid", "") + wire_label = sample.get("wire_label", "") + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if wire_uuid and str(getattr(candidate, "QetWireUuid", "") or "").strip() == wire_uuid: + return candidate + if wire_label: + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if str(getattr(candidate, "Label", "") or "").strip() == wire_label: + return candidate + return None + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + obj = find_wire(sample) + if obj is None: + missing_refs.append(sample.get("wire_uuid") or sample.get("wire_label") or sample.get("ref", "")) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected_names.add(getattr(obj, "Name", "")) + selected.append(obj) + + # 真实工程里有时 route_samples 只保留样例,已生成导线属性才是完整问题清单。 + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + if getattr(candidate, "Name", "") in selected_names: + continue + issue_codes = { + code.strip() + for code in str(getattr(candidate, "QetRouteIssueCodes", "") or "").split(",") + if code.strip() + } + if "main_path_detour_missing" not in issue_codes: + continue + try: + Gui.Selection.addSelection(candidate) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(candidate, "Name", "")) + except Exception: + continue + selected_names.add(getattr(candidate, "Name", "")) + selected.append(candidate) + self.last_report = { + "selected_main_path_detour_missing_wires": len(selected), + "selected_main_path_detour_missing_wire_names": [getattr(obj, "Name", "") for obj in selected], + "missing_main_path_detour_missing_wire_refs": missing_refs, + } + return self.last_report + + def select_issue_route_sources(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = list(batch_payload.get("route_samples", []) or []) + route_tracks = [] + + for sample in samples: + if not isinstance(sample, dict): + continue + issue_codes = [ + str(code or "").strip() + for code in list(sample.get("issue_codes", []) or []) + if str(code or "").strip() + ] + if not issue_codes: + continue + route_track = sample.get("route_track", {}) if isinstance(sample.get("route_track", {}), dict) else {} + if list(sample.get("carrier_names", []) or []): + route_track = dict(route_track) + route_track["carrier_names"] = list(sample.get("carrier_names", []) or []) + list( + route_track.get("carrier_names", []) or [] + ) + route_tracks.append(route_track) + + report = self._select_route_refs(doc, route_tracks, "selected_issue_route") + report["missing_issue_route_refs"] = report.pop("missing_selected_issue_route_refs", []) + return report + + def select_main_path_detour_missing_route_sources(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + route_tracks = [] + for sample in list(batch_payload.get("route_samples", []) or []): + if not isinstance(sample, dict): + continue + issue_codes = [ + str(code or "").strip() + for code in list(sample.get("issue_codes", []) or []) + if str(code or "").strip() + ] + if "main_path_detour_missing" not in issue_codes: + continue + route_track = sample.get("route_track", {}) if isinstance(sample.get("route_track", {}), dict) else {} + if list(sample.get("carrier_names", []) or []): + route_track = dict(route_track) + route_track["carrier_names"] = list(sample.get("carrier_names", []) or []) + list( + route_track.get("carrier_names", []) or [] + ) + route_tracks.append(route_track) + seen_wire_tracks = set() + for candidate in list(getattr(doc, "Objects", []) or []): + if (getattr(candidate, "RouteType", "") or "").strip() != "RoutedConnection": + continue + issue_codes = { + code.strip() + for code in str(getattr(candidate, "QetRouteIssueCodes", "") or "").split(",") + if code.strip() + } + if "main_path_detour_missing" not in issue_codes: + continue + route_track_text = str(getattr(candidate, "QetRouteTrackJson", "") or "").strip() + if not route_track_text: + continue + wire_name = str(getattr(candidate, "Name", "") or "").strip() + if wire_name and wire_name in seen_wire_tracks: + continue + try: + route_track = json.loads(route_track_text) + except Exception: + continue + if isinstance(route_track, dict): + route_tracks.append(route_track) + if wire_name: + seen_wire_tracks.add(wire_name) + report = self._select_route_refs(doc, route_tracks, "selected_main_path_detour_route") + report["missing_main_path_detour_route_refs"] = report.pop( + "missing_selected_main_path_detour_route_refs", + [], + ) + return report + + def select_selected_wire_route_sources(self): + doc = _active_document() + selected_wires = [] + seen_names = set() + for obj in list(Gui.Selection.getSelection() or []): + name = getattr(obj, "Name", "") + if name and name not in seen_names: + selected_wires.append(obj) + seen_names.add(name) + for selection in _selection_ex(): + obj = getattr(selection, "Object", None) + name = getattr(obj, "Name", "") + if obj is not None and name and name not in seen_names: + selected_wires.append(obj) + seen_names.add(name) + + route_tracks = [] + missing_refs = [] + for wire in selected_wires: + # 现场排查用:只读取 FreeCAD 导线对象里的路径轨迹,不写数据库、不要求 QET 提供 3D 路径。 + route_track_text = str(getattr(wire, "QetRouteTrackJson", "") or "").strip() + if not route_track_text: + continue + try: + route_track = json.loads(route_track_text) + except Exception: + missing_refs.append(getattr(wire, "Name", "")) + continue + if isinstance(route_track, dict): + route_tracks.append(route_track) + + report = self._select_route_refs(doc, route_tracks, "selected_wire_route") + existing_missing = list(report.get("missing_selected_wire_route_refs", []) or []) + report["missing_selected_wire_route_refs"] = existing_missing + [ + ref for ref in missing_refs if ref and ref not in existing_missing + ] + return report + + def select_selected_wire_rejected_fallback_sources(self): + doc = _active_document() + selected_wires = [] + seen_wire_names = set() + for obj in list(Gui.Selection.getSelection() or []): + name = getattr(obj, "Name", "") + if name and name not in seen_wire_names: + selected_wires.append(obj) + seen_wire_names.add(name) + for selection in _selection_ex(): + obj = getattr(selection, "Object", None) + name = getattr(obj, "Name", "") + if obj is not None and name and name not in seen_wire_names: + selected_wires.append(obj) + seen_wire_names.add(name) + + labels = [] + seen_labels = set() + kinds = [] + seen_kinds = set() + selected_wire_names = [] + invalid_wire_refs = [] + for wire in selected_wires: + # 这里只读导线诊断中被拒绝的兜底路径,不把 fallback 路线自动作为正式布线结果。 + diagnostic_text = str(getattr(wire, "QetRouteDiagnosticsJson", "") or "").strip() + if not diagnostic_text: + continue + try: + diagnostic = json.loads(diagnostic_text) + except Exception: + invalid_wire_refs.append(getattr(wire, "Name", "")) + continue + if not isinstance(diagnostic, dict): + continue + reroute = diagnostic.get("selective_collision_reroute", {}) + if not isinstance(reroute, dict): + continue + wire_name = getattr(wire, "Name", "") + if wire_name and wire_name not in selected_wire_names: + selected_wire_names.append(wire_name) + for kind in list(reroute.get("rejected_fallback_kinds", []) or []): + kind = str(kind or "").strip() + if kind and kind not in seen_kinds: + seen_kinds.add(kind) + kinds.append(kind) + for label in list(reroute.get("rejected_fallback_labels", []) or []): + label = str(label or "").strip() + if label and label not in seen_labels: + seen_labels.add(label) + labels.append(label) + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for label in labels: + matches = self._find_objects_by_name_or_label(doc, name=label, label=label) + if not matches: + missing_refs.append(label) + continue + for obj in matches: + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + for ref in invalid_wire_refs: + if ref and ref not in missing_refs: + missing_refs.append(ref) + self.last_report = { + "selected_rejected_fallback_sources": len(selected), + "selected_rejected_fallback_source_names": [getattr(obj, "Name", "") for obj in selected], + "selected_rejected_fallback_wire_names": selected_wire_names, + "rejected_fallback_source_labels": labels, + "rejected_fallback_source_kinds": kinds, + "missing_rejected_fallback_source_refs": missing_refs, + } + return self.last_report + + def select_main_path_detour_rejected_fallback_sources(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + detour_summary = summary.get("main_path_detour_missing_summary", {}) + labels = [] + seen_labels = set() + current_route_labels = [] + seen_current_route_labels = set() + kind_counts = {} + label_counts = {} + current_route_label_counts = {} + bridge_pair_counts = {} + if isinstance(detour_summary, dict): + for label in list(detour_summary.get("rejected_fallback_labels", []) or []): + label = str(label or "").strip() + if label and label not in seen_labels: + seen_labels.add(label) + labels.append(label) + raw_current_route_label_counts = detour_summary.get("current_route_source_label_counts", {}) + if isinstance(raw_current_route_label_counts, dict): + current_route_label_counts = { + str(label or "").strip(): int(count or 0) + for label, count in raw_current_route_label_counts.items() + if str(label or "").strip() + } + for label in current_route_label_counts: + if label and label not in seen_current_route_labels: + seen_current_route_labels.add(label) + current_route_labels.append(label) + raw_bridge_pair_counts = detour_summary.get("bridge_pair_counts", {}) + if isinstance(raw_bridge_pair_counts, dict): + bridge_pair_counts = { + str(pair or "").strip(): int(count or 0) + for pair, count in raw_bridge_pair_counts.items() + if str(pair or "").strip() + } + raw_label_counts = detour_summary.get("rejected_fallback_label_counts", {}) + if isinstance(raw_label_counts, dict): + label_counts = { + str(label or "").strip(): int(count or 0) + for label, count in raw_label_counts.items() + if str(label or "").strip() + } + raw_kind_counts = detour_summary.get("rejected_fallback_kind_counts", {}) + if isinstance(raw_kind_counts, dict): + kind_counts = { + str(kind or "").strip(): int(count or 0) + for kind, count in raw_kind_counts.items() + if str(kind or "").strip() + } + + selected = [] + selected_fallback = [] + selected_current = [] + selected_names = set() + missing_refs = [] + missing_current_route_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + def select_refs_for_label(label, missing_bucket, selected_bucket): + matches = self._find_objects_by_name_or_label(doc, name=label, label=label) + if not matches: + missing_bucket.append(label) + return + for obj in matches: + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + selected_bucket.append(obj) + + for label in labels: + select_refs_for_label(label, missing_refs, selected_fallback) + for label in current_route_labels: + select_refs_for_label(label, missing_current_route_refs, selected_current) + self.last_report = { + "selected_main_path_detour_bridge_endpoint_objects": len(selected), + "selected_main_path_detour_rejected_fallback_sources": len(selected_fallback), + "selected_main_path_detour_current_route_sources": len(selected_current), + "selected_main_path_detour_rejected_fallback_source_names": [ + getattr(obj, "Name", "") for obj in selected_fallback + ], + "selected_main_path_detour_current_route_source_names": [ + getattr(obj, "Name", "") for obj in selected_current + ], + "main_path_detour_rejected_fallback_labels": labels, + "main_path_detour_rejected_fallback_label_counts": label_counts, + "main_path_detour_rejected_fallback_kind_counts": kind_counts, + "main_path_detour_current_route_source_labels": current_route_labels, + "main_path_detour_current_route_source_label_counts": current_route_label_counts, + "main_path_detour_bridge_pair_counts": bridge_pair_counts, + "missing_main_path_detour_rejected_fallback_refs": missing_refs, + "missing_main_path_detour_current_route_refs": missing_current_route_refs, + } + return self.last_report + + @staticmethod + def _find_objects_by_name_or_label(doc, name="", label=""): + refs = [] + seen_names = set() + name = str(name or "").strip() + label = str(label or "").strip() + if name: + obj = doc.getObject(name) + if obj is not None: + refs.append(obj) + seen_names.add(getattr(obj, "Name", "")) + if label: + for candidate in list(getattr(doc, "Objects", []) or []): + candidate_name = getattr(candidate, "Name", "") + if candidate_name in seen_names: + continue + if ( + str(getattr(candidate, "Label", "") or "").strip() == label + or str(getattr(candidate, "QetRouteSourceLabel", "") or "").strip() == label + ): + refs.append(candidate) + seen_names.add(candidate_name) + if name: + for candidate in list(getattr(doc, "Objects", []) or []): + candidate_name = getattr(candidate, "Name", "") + if candidate_name in seen_names: + continue + if ( + candidate_name == name + or str(getattr(candidate, "QetRouteSourceName", "") or "").strip() == name + ): + refs.append(candidate) + seen_names.add(candidate_name) + return refs + + def select_long_terminal_accesses(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + path_diagnostic = batch_payload.get("routing_path_network_diagnostic", {}) + if not isinstance(path_diagnostic, dict): + path_diagnostic = {} + samples = list(path_diagnostic.get("long_terminal_accesses", []) or []) + + def find_terminal(item): + terminal_uuid = str(item.get("terminal_uuid", "") or "").strip() + name = str(item.get("name", "") or "").strip() + label = str(item.get("label", "") or "").strip() + if name: + obj = doc.getObject(name) + if obj is not None: + return obj + for candidate in list(getattr(doc, "Objects", []) or []): + if terminal_uuid and str(getattr(candidate, "QetTerminalUuid", "") or "").strip() == terminal_uuid: + return candidate + if label: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == label: + return candidate + return None + + selected = [] + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + if not isinstance(sample, dict): + continue + obj = find_terminal(sample) + if obj is None: + missing_refs.append( + str(sample.get("terminal_uuid", "") or sample.get("name", "") or sample.get("label", "") or "").strip() + ) + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) + except Exception: + continue + selected.append(obj) + self.last_report = { + "selected_long_terminal_accesses": len(selected), + "selected_long_terminal_names": [getattr(obj, "Name", "") for obj in selected], + "missing_long_terminal_refs": missing_refs, + } + return self.last_report + + def select_long_terminal_access_devices(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + path_diagnostic = batch_payload.get("routing_path_network_diagnostic", {}) + if not isinstance(path_diagnostic, dict): + path_diagnostic = {} + samples = list(path_diagnostic.get("long_terminal_accesses", []) or []) + selected = [] + selected_names = set() + missing_refs = [] + seen_refs = set() + + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + if not isinstance(sample, dict): + continue + name = str(sample.get("parent_device_name", "") or "").strip() + label = str(sample.get("parent_device_label", "") or "").strip() + ref = name or label + if not ref or ref in seen_refs: + continue + seen_refs.add(ref) + obj = self._find_object_by_name_or_label(doc, name, label) + if obj is None: + missing_refs.append(ref) + continue + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + self.last_report = { + "selected_long_terminal_access_devices": len(selected), + "selected_long_terminal_access_device_names": [getattr(obj, "Name", "") for obj in selected], + "missing_long_terminal_access_device_refs": missing_refs, + } + return self.last_report + + def select_missing_terminal_devices(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = list(batch_payload.get("missing_endpoint_samples", []) or []) + + def find_device(instance_id, element_uuid, device_name="", device_label=""): + obj = TerminalObjects.find_device_group_by_instance_id(doc, instance_id) + if obj is not None: + return obj + obj = TerminalObjects.find_device_group(doc, element_uuid) + if obj is not None: + return obj + instance_id = str(instance_id or "").strip() + element_uuid = str(element_uuid or "").strip() + device_name = str(device_name or "").strip() + device_label = str(device_label or "").strip() + if device_name: + obj = doc.getObject(device_name) + if obj is not None: + return obj + for candidate in list(getattr(doc, "Objects", []) or []): + if instance_id and str(getattr(candidate, "QetInstanceId", "") or "").strip() == instance_id: + return candidate + if element_uuid: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "QetElementUuid", "") or "").strip() == element_uuid: + return candidate + if device_label: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == device_label: + return candidate + return None + + selected = [] + selected_names = set() + missing_refs = [] + missing_labels = [] + missing_instance_ids = [] + missing_element_uuids = [] + reason_counts = {} + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + if not isinstance(sample, dict): + continue + for side in ("start", "end"): + if bool(sample.get("{0}_found".format(side), False)): + continue + terminal_uuid = str(sample.get("{0}_terminal_uuid".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() + device_name = str(sample.get("{0}_device_name".format(side), "") or "").strip() + device_label = str(sample.get("{0}_device_label".format(side), "") or "").strip() + if instance_id and instance_id not in missing_instance_ids: + missing_instance_ids.append(instance_id) + if element_uuid and element_uuid not in missing_element_uuids: + missing_element_uuids.append(element_uuid) + obj = find_device(instance_id, element_uuid, device_name=device_name, device_label=device_label) + if obj is None: + reason_code = str(sample.get("{0}_missing_endpoint_reason_code".format(side), "") or "").strip() + if reason_code: + reason_counts[reason_code] = reason_counts.get(reason_code, 0) + 1 + missing_refs.append(terminal_uuid or instance_id or element_uuid or device_name or device_label) + label = device_label or device_name or instance_id or element_uuid or terminal_uuid + if label and label not in missing_labels: + missing_labels.append(label) + continue + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + self.last_report = { + "selected_missing_terminal_devices": len(selected), + "selected_missing_terminal_device_names": [getattr(obj, "Name", "") for obj in selected], + "missing_terminal_device_refs": missing_refs, + "missing_terminal_device_labels": missing_labels, + "missing_terminal_device_instance_ids": missing_instance_ids, + "missing_terminal_device_element_uuids": missing_element_uuids, + "missing_terminal_device_reason_counts": reason_counts, + } + return self.last_report + + def select_missing_terminal_counterpart_terminals(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = list(batch_payload.get("missing_endpoint_samples", []) or []) + + def find_terminal(terminal_uuid="", name="", label=""): + terminal_uuid = str(terminal_uuid or "").strip() + name = str(name or "").strip() + label = str(label or "").strip() + if name: + obj = doc.getObject(name) + if obj is not None: + return obj + for candidate in list(getattr(doc, "Objects", []) or []): + if terminal_uuid and str(getattr(candidate, "QetTerminalUuid", "") or "").strip() == terminal_uuid: + return candidate + if label: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == label: + return candidate + return None + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + if not isinstance(sample, dict): + continue + sample_selected = False + for side in ("start", "end"): + if not bool(sample.get("{0}_found".format(side), False)): + continue + terminal_uuid = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() + terminal_name = str(sample.get("{0}_terminal_name".format(side), "") or "").strip() + terminal_label = str( + sample.get("{0}_terminal_label".format(side), "") + or sample.get("{0}_terminal_display".format(side), "") + or "" + ).strip() + obj = find_terminal(terminal_uuid, terminal_name, terminal_label) + if obj is None: + ref = terminal_uuid or terminal_name or terminal_label + if ref and ref not in missing_refs: + missing_refs.append(ref) + continue + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + sample_selected = True + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + sample_selected = True + if not sample_selected: + # 两端都缺失时,把缺失 terminal_uuid 留在报告里,方便和 QET 导线任务对照。 + for side in ("start", "end"): + ref = str(sample.get("{0}_terminal_uuid".format(side), "") or "").strip() + if ref and ref not in missing_refs: + missing_refs.append(ref) + + self.last_report = { + "selected_missing_terminal_counterpart_terminals": len(selected), + "selected_missing_terminal_counterpart_terminal_names": [getattr(obj, "Name", "") for obj in selected], + "missing_terminal_counterpart_refs": missing_refs, + } + return self.last_report + + def select_missing_terminal_candidate_terminals(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + batch_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingConnectionBatch", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + samples = list(batch_payload.get("missing_endpoint_samples", []) or []) + + def find_terminal_by_sample(item): + if not isinstance(item, dict): + return None + name = str(item.get("name", "") or "").strip() + label = str(item.get("label", "") or item.get("terminal_label", "") or "").strip() + terminal_uuid = str(item.get("terminal_uuid", "") or "").strip() + if name: + obj = doc.getObject(name) + if obj is not None: + return obj + for candidate in list(getattr(doc, "Objects", []) or []): + if terminal_uuid and str(getattr(candidate, "QetTerminalUuid", "") or "").strip() == terminal_uuid: + return candidate + if label: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == label: + return candidate + return None + + def fallback_terminals(instance_id, element_uuid): + result = [] + instance_id = str(instance_id or "").strip() + element_uuid = str(element_uuid or "").strip() + for candidate in list(getattr(doc, "Objects", []) or []): + try: + is_terminal = TerminalObjects.is_terminal_object(candidate) + except Exception: + is_terminal = False + if not is_terminal: + continue + if instance_id and str(getattr(candidate, "QetInstanceId", "") or "").strip() == instance_id: + result.append(candidate) + continue + if element_uuid and str(getattr(candidate, "QetElementUuid", "") or "").strip() == element_uuid: + result.append(candidate) + return result + + selected = [] + selected_names = set() + missing_refs = [] + try: + Gui.Selection.clearSelection() + except Exception: + pass + for sample in samples: + if not isinstance(sample, dict): + continue + for side in ("start", "end"): + if bool(sample.get("{0}_found".format(side), False)): + continue + candidate_samples = list(sample.get("{0}_instance_terminal_samples".format(side), []) or []) + if not candidate_samples: + candidate_samples = list(sample.get("{0}_element_terminal_samples".format(side), []) or []) + candidates = [] + for item in candidate_samples: + obj = find_terminal_by_sample(item) + if obj is not None: + candidates.append(obj) + elif isinstance(item, dict): + ref = str( + item.get("name", "") + or item.get("terminal_uuid", "") + or item.get("label", "") + or item.get("terminal_label", "") + or "" + ).strip() + if ref: + missing_refs.append(ref) + if not candidates: + candidates = fallback_terminals( + sample.get("{0}_instance_id".format(side), ""), + sample.get("{0}_element_uuid".format(side), ""), + ) + for obj in candidates: + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + continue + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + continue + selected_names.add(obj_name) + selected.append(obj) + self.last_report = { + "selected_missing_terminal_candidate_terminals": len(selected), + "selected_missing_terminal_candidate_terminal_names": [getattr(obj, "Name", "") for obj in selected], + "missing_terminal_candidate_terminal_refs": missing_refs, + } + return self.last_report + + def select_boundary_issue_route_carriers_and_terminals(self): + doc = _active_document() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + path_payload = ( + ((summary.get("diagnostics", {}) or {}).get("RoutingPathNetwork", {}) or {}).get("payload", {}) + if isinstance(summary.get("diagnostics", {}), dict) + else {} + ) + if not isinstance(path_payload, dict): + path_payload = {} + + selected = [] + selected_names = set() + selected_carriers = [] + selected_terminals = [] + missing_carriers = [] + missing_terminals = [] + + try: + Gui.Selection.clearSelection() + except Exception: + pass + + def add_object(obj): + if obj is None: + return False + obj_name = getattr(obj, "Name", "") + if obj_name in selected_names: + return False + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + return False + selected_names.add(obj_name) + selected.append(obj) + return True + + for item in list(path_payload.get("route_carriers_outside_boundary", []) or []): + if not isinstance(item, dict): + continue + carrier_payload = item.get("carrier", {}) if isinstance(item.get("carrier", {}), dict) else {} + name = str(carrier_payload.get("name", "") or "").strip() + label = str(carrier_payload.get("label", "") or "").strip() + obj = self._find_object_by_name_or_label(doc, name, label) + if obj is None: + ref = name or label + if ref: + missing_carriers.append(ref) + continue + if add_object(obj): + selected_carriers.append(obj) + + for item in list(path_payload.get("terminals_outside_boundary", []) or []): + if not isinstance(item, dict): + continue + name = str(item.get("name", "") or "").strip() + label = str(item.get("label", "") or "").strip() + terminal_uuid = str(item.get("terminal_uuid", "") or "").strip() + obj = self._find_object_by_name_or_label(doc, name, label) + if obj is None and terminal_uuid: + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "QetTerminalUuid", "") or "").strip() == terminal_uuid: + obj = candidate + break + if obj is None: + ref = name or terminal_uuid or label + if ref: + missing_terminals.append(ref) + continue + if add_object(obj): + selected_terminals.append(obj) + + self.last_report = { + "selected_boundary_objects": len(selected), + "selected_boundary_route_carriers": len(selected_carriers), + "selected_boundary_route_carrier_names": [getattr(obj, "Name", "") for obj in selected_carriers], + "selected_boundary_terminals": len(selected_terminals), + "selected_boundary_terminal_names": [getattr(obj, "Name", "") for obj in selected_terminals], + "missing_boundary_route_carrier_refs": missing_carriers, + "missing_boundary_terminal_refs": missing_terminals, + } + return self.last_report + def generate_layout_space(self): """Prepare the whole document as an EPLAN-style layout space.""" doc = _active_document() @@ -245,47 +1846,316 @@ class AutoRoutingController: } return self.last_report - def route_eplan_connections(self): + def set_selected_terminal_local_route_points(self): + result = RoutingNetwork.set_terminal_local_route_points_from_selection(_selection_ex()) + terminal = result.get("terminal") + self.last_report = { + "terminal_local_routes": 1, + "terminal_local_route_names": [getattr(terminal, "Name", "")] if terminal is not None else [], + "terminal_local_route_labels": [getattr(terminal, "Label", "")] if terminal is not None else [], + "terminal_local_route_point_count": int(result.get("point_count", 0) or 0), + "property_name": result.get("property_name", ""), + } + return self.last_report + + def create_user_path_bridge_from_selection(self): doc = _active_document() project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() - payload = getattr(App, "_qet_exchange_payload", None) - report = AutoRouting.route_eplan_connections( + created = RoutingNetwork.create_user_path_bridge_from_selection( doc, - payload=payload if isinstance(payload, dict) and payload.get("wires") else None, - options=self.routing_options(), + _selection_ex(), project_uuid=project_uuid, - update_network=True, ) - if report.get("total_wires", 0) <= 0: - raise AutoRoutingPanelError( - "没有导线任务。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。" - ) - self.last_report = report - return report + self.last_report = { + "user_path_bridges": len(created), + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report - def clear_routing_connections(self): + def create_user_path_bridges_from_main_path_detour_pairs(self): doc = _active_document() - removed = AutoRouting.clear_routing_connections(doc) - self.last_report = {"removed": removed} - return removed + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + summary = AutoRouting.collect_routing_diagnostic_summary(doc) + detour_summary = summary.get("main_path_detour_missing_summary", {}) + 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: (-int(item[1] or 0), 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 = self._find_objects_by_name_or_label(doc, name=left_label, label=left_label) + right_matches = self._find_objects_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 = RoutingNetwork.create_user_path_bridge_between_objects( + doc, + left_matches[0], + right_matches[0], + project_uuid=project_uuid, + ) + if new_bridges: + created.extend(new_bridges) + else: + duplicates += 1 - def clear_route_carriers(self): - doc = _active_document() - removed = RoutingNetwork.clear_route_carriers(doc) - self.last_report = {"removed_carriers": removed} - return removed + self.last_report = { + "main_path_detour_bridge_pairs": len(pair_counts), + "main_path_detour_user_path_bridges": len(created), + "main_path_detour_bridge_duplicates": duplicates, + "missing_main_path_detour_bridge_pairs": missing_pairs, + } + return self.last_report - def save(self): + def create_user_path_bridges_from_diagnostic_suggestions(self): doc = _active_document() - file_name = getattr(doc, "FileName", "") - if file_name and hasattr(doc, "save"): - doc.save() - if ExchangeWriteBack is not None: - return ExchangeWriteBack.write_back_document(doc) - return {"saved": bool(file_name)} - - -class AutoRoutingTaskPanel: + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + total_created = 0 + total_suggestions = 0 + total_duplicates = 0 + total_stale = 0 + diagnostic_passes = 0 + max_passes = 5 + for _index in range(max_passes): + diagnostic_report = AutoRouting.check_eplan_routing_path_network( + doc, + project_uuid=project_uuid, + options=self.routing_options(), + ) + diagnostic_passes += 1 + bridge_report = RoutingNetwork.create_user_path_bridges_from_diagnostic_suggestions( + doc, + diagnostic_report.get("diagnostic", {}), + project_uuid=project_uuid, + ) + created_count = len(bridge_report.get("created", []) or []) + total_created += created_count + total_suggestions += int(bridge_report.get("suggestions", 0) or 0) + total_duplicates += int(bridge_report.get("duplicates", 0) or 0) + total_stale += int(bridge_report.get("stale_suggestions", 0) or 0) + # 新桥接会改变路径组件关系;继续诊断一轮,处理链式接入建议。 + if created_count <= 0: + break + detour_bridge_report = self.create_user_path_bridges_from_main_path_detour_pairs() + detour_created = int(detour_bridge_report.get("main_path_detour_user_path_bridges", 0) or 0) + self.last_report = { + "user_path_bridges": total_created + detour_created, + "diagnostic_suggestions": total_suggestions, + "duplicate_bridges": total_duplicates, + "stale_suggestions": total_stale, + "diagnostic_passes": diagnostic_passes, + "main_path_detour_bridge_pairs": int(detour_bridge_report.get("main_path_detour_bridge_pairs", 0) or 0), + "main_path_detour_user_path_bridges": detour_created, + "main_path_detour_bridge_duplicates": int(detour_bridge_report.get("main_path_detour_bridge_duplicates", 0) or 0), + "missing_main_path_detour_bridge_pairs": list( + detour_bridge_report.get("missing_main_path_detour_bridge_pairs", []) or [] + ), + "routing_path_network_checked": True, + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + + def mark_cabinet_boundary_from_selection(self): + doc = _active_document() + marked = RoutingNetwork.mark_cabinet_interior_boundaries_from_selection(_selection_ex()) + self.last_report = { + "cabinet_boundary_objects": len(marked), + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + + def _mark_selected_objects_obstacle_mode(self, mode): + doc = _active_document() + marked = RoutingNetwork.mark_obstacle_mode_from_selection(_selection_ex(), mode) + self.last_report = { + "obstacle_mode": str(mode or "").strip(), + "obstacle_mode_objects": len(marked), + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + + def mark_selected_objects_pass_through_obstacle(self): + return self._mark_selected_objects_obstacle_mode("PassThrough") + + def restore_selected_objects_as_obstacles(self): + return self._mark_selected_objects_obstacle_mode("") + + def mark_selected_route_carriers_forbidden(self): + return self._mark_selected_route_carriers_constraint("Forbidden") + + def mark_selected_route_carriers_required(self): + return self._mark_selected_route_carriers_constraint("Required") + + def clear_selected_route_carrier_constraints(self): + return self._mark_selected_route_carriers_constraint("") + + def set_selected_route_carriers_capacity(self): + doc = _active_document() + selection = _selection_ex() + capacity = int(self.routing_options().get("selected_route_capacity", 1) or 1) + report = RoutingNetwork.set_route_carrier_capacity_from_selection( + doc, + selection, + capacity, + ) + report["network"] = RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ) + self.last_report = report + return report + + def clear_all_route_carrier_constraints(self): + doc = _active_document() + report = RoutingNetwork.clear_all_route_constraint_modes(doc) + report["network"] = RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ) + self.last_report = report + return report + + def _mark_selected_route_carriers_constraint(self, mode): + doc = _active_document() + selection = _selection_ex() + selected_sources = [] + source_modes_before = {} + seen_sources = set() + for item in selection or []: + source = getattr(item, "Object", None) + if source is None or RoutingNetwork.is_route_carrier(source) or id(source) in seen_sources: + continue + seen_sources.add(id(source)) + selected_sources.append(source) + source_modes_before[id(source)] = (getattr(source, "QetRouteConstraintMode", "") or "").strip() + marked = RoutingNetwork.mark_route_constraint_mode_from_selection( + doc, + selection, + mode, + ) + source_count = 0 + for source in selected_sources: + current_mode = (getattr(source, "QetRouteConstraintMode", "") or "").strip() + if mode: + if current_mode == mode: + source_count += 1 + continue + if source_modes_before.get(id(source), ""): + source_count += 1 + self.last_report = { + "route_constraint_mode": mode, + "route_constraint_carriers": len(marked), + "route_constraint_sources": source_count, + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + + def route_eplan_connections(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + payload = getattr(App, "_qet_exchange_payload", None) + report = AutoRouting.route_eplan_connections( + doc, + payload=payload if isinstance(payload, dict) and payload.get("wires") else None, + options=self.routing_options(), + project_uuid=project_uuid, + update_network=True, + ) + if report.get("total_wires", 0) <= 0: + raise AutoRoutingPanelError( + "没有导线任务。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。" + ) + self.last_report = report + return report + + def clear_routing_connections(self): + doc = _active_document() + removed = AutoRouting.clear_routing_connections(doc) + self.last_report = {"removed": removed} + return removed + + def clear_route_carriers(self): + doc = _active_document() + removed = RoutingNetwork.clear_route_carriers(doc) + self.last_report = {"removed_carriers": removed} + return removed + + def save(self): + doc = _active_document() + file_name = getattr(doc, "FileName", "") + if file_name and hasattr(doc, "save"): + doc.save() + if ExchangeWriteBack is not None: + return ExchangeWriteBack.write_back_document(doc) + return {"saved": bool(file_name)} + + +class AutoRoutingTaskPanel: def __init__(self, controller=None): if QtWidgets is None: raise AutoRoutingPanelError("Qt widgets are not available.") @@ -327,6 +2197,39 @@ class AutoRoutingTaskPanel: ) ) options_layout.addWidget(self.terminal_access_max_distance_spin) + options_layout.addWidget(QtWidgets.QLabel("端子接入警告距离 mm")) + self.terminal_access_warning_distance_spin = QtWidgets.QDoubleSpinBox() + self.terminal_access_warning_distance_spin.setRange(0.0, 100000.0) + self.terminal_access_warning_distance_spin.setDecimals(1) + self.terminal_access_warning_distance_spin.setSingleStep(50.0) + self.terminal_access_warning_distance_spin.setToolTip( + "超过该长度的端子接入会标记为过长;0 表示按最大距离和默认值自动计算。" + ) + self.terminal_access_warning_distance_spin.setValue( + float( + self.controller.routing_options().get( + "terminal_access_warning_distance", + AutoRouting.DEFAULT_OPTIONS["terminal_access_warning_distance"], + ) + ) + ) + options_layout.addWidget(self.terminal_access_warning_distance_spin) + options_layout.addWidget(QtWidgets.QLabel("预检抽样数")) + self.preflight_routeability_sample_limit_spin = QtWidgets.QSpinBox() + self.preflight_routeability_sample_limit_spin.setRange(0, 100000) + self.preflight_routeability_sample_limit_spin.setSingleStep(5) + self.preflight_routeability_sample_limit_spin.setToolTip( + "检查布线准备度时最多抽样求解多少条导线可达性;0 表示关闭可达性抽样,默认关闭。" + ) + self.preflight_routeability_sample_limit_spin.setValue( + int( + self.controller.routing_options().get( + "preflight_routeability_sample_limit", + AutoRouting.DEFAULT_OPTIONS["preflight_routeability_sample_limit"], + ) + ) + ) + options_layout.addWidget(self.preflight_routeability_sample_limit_spin) options_layout.addWidget(QtWidgets.QLabel("自动端子出线长度 mm")) self.terminal_exit_length_spin = QtWidgets.QDoubleSpinBox() self.terminal_exit_length_spin.setRange(0.0, 1000.0) @@ -341,6 +2244,40 @@ class AutoRoutingTaskPanel: ) ) options_layout.addWidget(self.terminal_exit_length_spin) + options_layout.addWidget(QtWidgets.QLabel("障碍安全间隙 mm")) + self.obstacle_clearance_spin = QtWidgets.QDoubleSpinBox() + self.obstacle_clearance_spin.setRange(0.0, 1000.0) + self.obstacle_clearance_spin.setDecimals(1) + self.obstacle_clearance_spin.setSingleStep(1.0) + self.obstacle_clearance_spin.setToolTip( + "用于膨胀障碍包围盒;导线进入膨胀范围但未穿过原始障碍时会标记为安全间隙告警。" + ) + self.obstacle_clearance_spin.setValue( + float( + self.controller.routing_options().get( + "obstacle_clearance", + AutoRouting.DEFAULT_OPTIONS["obstacle_clearance"], + ) + ) + ) + options_layout.addWidget(self.obstacle_clearance_spin) + options_layout.addWidget(QtWidgets.QLabel("共路复用惩罚")) + self.segment_reuse_penalty_spin = QtWidgets.QDoubleSpinBox() + self.segment_reuse_penalty_spin.setRange(0.0, 100000.0) + self.segment_reuse_penalty_spin.setDecimals(1) + self.segment_reuse_penalty_spin.setSingleStep(50.0) + self.segment_reuse_penalty_spin.setToolTip( + "多根导线复用超过 carrier 容量的路径段时增加的搜索成本;调高会更倾向绕到备用路径。" + ) + self.segment_reuse_penalty_spin.setValue( + float( + self.controller.routing_options().get( + "segment_reuse_penalty", + AutoRouting.DEFAULT_OPTIONS["segment_reuse_penalty"], + ) + ) + ) + options_layout.addWidget(self.segment_reuse_penalty_spin) options_layout.addWidget(QtWidgets.QLabel("并行线间距 mm")) self.lane_spacing_spin = QtWidgets.QDoubleSpinBox() self.lane_spacing_spin.setRange(0.0, 1000.0) @@ -382,6 +2319,13 @@ class AutoRoutingTaskPanel: axis_index = self.lane_axis_combo.findText(lane_axis) self.lane_axis_combo.setCurrentIndex(axis_index if axis_index >= 0 else 0) options_layout.addWidget(self.lane_axis_combo) + options_layout.addWidget(QtWidgets.QLabel("选中路径容量")) + self.selected_route_capacity_spin = QtWidgets.QSpinBox() + self.selected_route_capacity_spin.setRange(1, 999) + self.selected_route_capacity_spin.setValue( + int(self.controller.routing_options().get("selected_route_capacity", 4) or 4) + ) + options_layout.addWidget(self.selected_route_capacity_spin) self.generate_layout_button = _style_command_button( QtWidgets.QPushButton(), @@ -398,96 +2342,371 @@ class AutoRoutingTaskPanel: self.create_user_paths_button = _style_command_button( QtWidgets.QPushButton(), "选中路径作为用户路径", - "把选中的草图、线段或纯线状对象转换为可参与自动布线的 UserPath。", + "把选中的草图、Draft 线、曲线、Wire 或纯线状对象转换为可参与自动布线的 UserPath;同时选中支撑面 Face 时会投影到该面附近。", ) - self.check_paths_button = _style_command_button( + self.set_terminal_local_route_button = _style_command_button( QtWidgets.QPushButton(), - "检查布线路径网络", - "检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。", + "选中端子设置局部出线", + "选中一个可布线端子和一条草图/Draft 局部路径,把路径写入端子的 QetTerminalLocalRoutePointsJson;不写数据库。", ) - self.route_connections_button = _style_command_button( + self.create_user_path_bridge_button = _style_command_button( QtWidgets.QPushButton(), - "生成布线连接", - "自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。", + "选中两路径生成桥接", + "选中两个已生成的布线路径 carrier,或它们的源路径对象,自动在最近点之间生成一段 UserPath 桥接。", ) - self.clear_routes_button = _style_command_button(QtWidgets.QPushButton(), "清除布线连接") - self.clear_carriers_button = _style_command_button(QtWidgets.QPushButton(), "清除走线路径") - self.save_button = _style_command_button(QtWidgets.QPushButton(), "保存") + self.create_diagnostic_bridges_button = _style_command_button( + QtWidgets.QPushButton(), + "按诊断建议生成桥接", + "先刷新布线路径网络诊断,再按 wire_ducts_without_terminal_access 的 bridge_suggestion 生成 UserPath 桥接。", + ) - self.status_label = QtWidgets.QLabel("") - self.status_label.setWordWrap(True) + self.mark_cabinet_boundary_button = _style_command_button( + QtWidgets.QPushButton(), + "选中对象作为柜内边界", + "把选中的柜内空间或柜体对象包围盒作为 CabinetInterior,用于限制自动布线路径不要跑出柜内区域。", + ) - for widget in ( - self.generate_layout_button, - self.create_user_paths_button, - self.generate_paths_button, - self.check_paths_button, - self.route_connections_button, - self.clear_routes_button, - self.clear_carriers_button, - self.save_button, - ): - layout.addWidget(widget) + self.select_boundary_issue_objects_button = _style_command_button( + QtWidgets.QPushButton(), + "选择越界路径/端子", + "从最新路径网络诊断中选择越出 CabinetInterior 的路径 carrier 和工程端子,便于修正 UserPath、边界或设备位置。", + ) - layout.addLayout(options_layout) - layout.addWidget(self.status_label) + self.mark_pass_through_obstacle_button = _style_command_button( + QtWidgets.QPushButton(), + "选中对象忽略碰撞", + "把确认可穿越或不参与布线碰撞的支架、柜体辅助件等标记为 PassThrough;不会改数据库。", + ) - self.generate_paths_button.clicked.connect(self.generate_routing_paths) - self.create_user_paths_button.clicked.connect(self.create_user_paths_from_selection) - self.check_paths_button.clicked.connect(self.check_routing_path_network) - self.generate_layout_button.clicked.connect(self.generate_layout_space) - self.route_connections_button.clicked.connect(self.route_eplan_connections) - self.clear_routes_button.clicked.connect(self.clear_routing_connections) - self.clear_carriers_button.clicked.connect(self.clear_route_carriers) - self.save_button.clicked.connect(self.save) + self.select_top_collision_obstacles_button = _style_command_button( + QtWidgets.QPushButton(), + "选择高发碰撞对象", + "从最新批量布线诊断中选择 top_collision_obstacles,便于确认后再标记忽略碰撞或调整路径。", + ) - self._refresh_status() + self.select_device_collision_obstacles_button = _style_command_button( + QtWidgets.QPushButton(), + "选择设备碰撞对象", + "只选择诊断为设备/布局碰撞的高发对象,便于补设备局部路径、调整线槽入口或检查装配。", + ) - def _refresh_status(self): - try: - self._set_status(self.controller.summary()) - except Exception as exc: - self._set_error(str(exc)) + self.select_collision_parent_assemblies_button = _style_command_button( + QtWidgets.QPushButton(), + "选择碰撞父装配", + "从最新批量布线诊断中选择高发碰撞对象的父装配,适合门板/柜体总成确认后统一忽略碰撞。", + ) - def _set_status(self, message): - self.status_label.setText(message) - _console_message(message) + self.select_structural_collision_parent_assemblies_button = _style_command_button( + QtWidgets.QPushButton(), + "选择结构件碰撞父装配", + "只选择疑似柜体/门板/支架结构件的碰撞父装配,确认后再标记 PassThrough,避免误选真实设备。", + ) - def _set_error(self, message): - self.status_label.setText(message) - _console_error(message) + self.mark_structural_collision_parent_assemblies_button = _style_command_button( + QtWidgets.QPushButton(), + "确认结构件忽略碰撞", + "按最新诊断只把疑似柜体/门板/支架等结构件碰撞父装配标记为 PassThrough;不会标记疑似设备碰撞。", + ) - def _sync_options_from_widgets(self): - self.controller.set_adjoining_duct_tolerance(self.adjoining_duct_tolerance_spin.value()) - self.controller.set_terminal_access_max_distance(self.terminal_access_max_distance_spin.value()) - self.controller.set_terminal_exit_length(self.terminal_exit_length_spin.value()) - self.controller.set_lane_spacing(self.lane_spacing_spin.value()) - self.controller.set_lane_max_offset(self.lane_max_offset_spin.value()) - self.controller.set_lane_axis(self.lane_axis_combo.currentText()) + self.select_collision_wires_button = _style_command_button( + QtWidgets.QPushButton(), + "选择碰撞导线", + "从最新批量布线诊断中选择带 collision_warnings 的导线对象,便于和高发碰撞对象一起核对。", + ) - def generate_routing_paths(self): - try: - self._sync_options_from_widgets() - result = self.controller.generate_routing_paths() - wire_ducts = result.get("wire_duct_carriers", 0) - user_paths = result.get("user_path_carriers", 0) - surfaces = result.get("surface_carriers", 0) - terminal_access = result.get("terminal_access_carriers", 0) - network = result.get("network", {}) if isinstance(result.get("network", {}), dict) else {} - if network.get("segments", 0) == 0: - self._set_status( - "未生成可用布线路径网络。可选中线槽实体、草图/线段作为用户路径,或确认安装板/背板能作为布线区域。" - + self.controller.summary() - ) - return - self._set_status( - "已生成布线路径网络:线槽路径 {0} 条,用户路径 {1} 条,布线区域 {2} 条,端子接入 {3} 条,网络段 {4} 条。{5}".format( - wire_ducts, - user_paths, - surfaces, + self.select_main_path_detour_missing_wires_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺主路径导线", + "选择诊断为 main_path_detour_missing 的导线,用于补 UserPath、桥接主路径或调整装配。", + ) + + self.select_main_path_detour_missing_route_sources_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺主路径线路径", + "从缺主路径导线样例中选择当前实际经过的路径 carrier 和源草图/线槽,用于对照需补路径位置。", + ) + + self.select_main_path_detour_rejected_fallback_sources_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺主路径补路位置", + "从汇总诊断的需补路径位置中反选被拒绝的 RoutingRange/AuxiliaryPath 来源对象。", + ) + + self.select_issue_wires_button = _style_command_button( + QtWidgets.QPushButton(), + "选择异常导线", + "从最新批量布线诊断中选择 route_samples 里带 issue_codes 的导线对象,用于统一排查长接入、越界、容量和碰撞等问题。", + ) + + self.select_issue_route_sources_button = _style_command_button( + QtWidgets.QPushButton(), + "选择异常导线路径", + "从最新异常导线样例中选择实际经过的路径 carrier 和源草图/线槽,便于调整 UserPath、容量或路径约束。", + ) + + self.select_selected_wire_route_sources_button = _style_command_button( + QtWidgets.QPushButton(), + "选择选中导线路径", + "先选中一根或多根已生成导线,再从导线 QetRouteTrackJson 反选其经过的路径 carrier 和源草图/线槽。", + ) + + self.select_selected_wire_rejected_fallback_sources_button = _style_command_button( + QtWidgets.QPushButton(), + "选择拒绝兜底路径", + "先选中缺主路径导线,再定位其诊断中被拒绝的 RoutingRange/AuxiliaryPath 来源,用于补 UserPath 或桥接。", + ) + + self.select_long_terminal_accesses_button = _style_command_button( + QtWidgets.QPushButton(), + "选择长接入端子", + "从最新批量布线诊断中选择 long_terminal_accesses 端子,便于检查设备装配高度或局部出线路径。", + ) + + self.select_long_terminal_access_devices_button = _style_command_button( + QtWidgets.QPushButton(), + "选择长接入设备", + "从最新批量布线诊断中选择长接入端子所属设备,便于检查设备高度和局部出线路径。", + ) + + self.select_missing_terminal_devices_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺端子设备", + "从最新批量布线诊断中选择缺失端子所在的 3D 设备,便于补工程端子或检查 2D/3D 绑定。", + ) + + self.select_missing_terminal_counterpart_terminals_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺端子另一端", + "从缺端子导线样例中选择已找到的另一端工程端子,便于对照缺失侧设备和端子脚号。", + ) + + self.select_missing_terminal_candidate_terminals_button = _style_command_button( + QtWidgets.QPushButton(), + "选择缺端子候选端子", + "从最新缺端子诊断中选择同设备/同实例已有的工程端子,用于核对 terminal_uuid 或脚号绑定是否错位。", + ) + + self.restore_obstacle_button = _style_command_button( + QtWidgets.QPushButton(), + "选中对象恢复障碍", + "清除选中对象的碰撞忽略标记,使其重新参与导线碰撞检测。", + ) + + self.mark_route_required_button = _style_command_button( + QtWidgets.QPushButton(), + "选中路径必须经过", + "把选中的布线路径 carrier 标记为 Required;如果选中源路径对象,会同步标记它生成的全部路径。", + ) + + self.mark_route_forbidden_button = _style_command_button( + QtWidgets.QPushButton(), + "选中路径禁止经过", + "把选中的布线路径 carrier 标记为 Forbidden;如果选中源路径对象,会同步标记它生成的全部路径。", + ) + + self.clear_route_constraint_button = _style_command_button( + QtWidgets.QPushButton(), + "清除选中路径约束", + "清除选中布线路径 carrier 的 Required/Forbidden 约束;如果选中源路径对象,会同步清除它生成的全部路径。", + ) + + self.set_route_capacity_button = _style_command_button( + QtWidgets.QPushButton(), + "选中路径设置容量", + "把选中布线路径 carrier 或源路径对象的容量设置为上面的数值,用于容量压力诊断和共路复用惩罚。", + ) + + self.clear_all_route_constraints_button = _style_command_button( + QtWidgets.QPushButton(), + "清除全部路径约束", + "清除当前文档里所有布线路径 carrier 和源路径对象的 Required/Forbidden 约束。", + ) + + self.check_paths_button = _style_command_button( + QtWidgets.QPushButton(), + "检查布线路径网络", + "检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。", + ) + + self.check_readiness_button = _style_command_button( + QtWidgets.QPushButton(), + "检查布线准备度", + "检查导线任务、工程端子、路径网络和导线样式库是否已满足自动布线输入条件。", + ) + + self.route_connections_button = _style_command_button( + QtWidgets.QPushButton(), + "生成布线连接", + "自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。", + ) + + self.diagnostic_summary_button = _style_command_button( + QtWidgets.QPushButton(), + "汇总布线诊断", + "汇总预检、路径网络和批量布线的最新诊断对象,便于手动测试后统一复盘。", + ) + + self.clear_routes_button = _style_command_button(QtWidgets.QPushButton(), "清除布线连接") + self.clear_carriers_button = _style_command_button(QtWidgets.QPushButton(), "清除走线路径") + self.save_button = _style_command_button(QtWidgets.QPushButton(), "保存") + + self.status_label = QtWidgets.QLabel("") + self.status_label.setWordWrap(True) + + for widget in ( + self.generate_layout_button, + self.create_user_paths_button, + self.set_terminal_local_route_button, + self.create_user_path_bridge_button, + self.create_diagnostic_bridges_button, + self.mark_cabinet_boundary_button, + self.select_boundary_issue_objects_button, + self.select_top_collision_obstacles_button, + self.select_device_collision_obstacles_button, + self.select_collision_parent_assemblies_button, + self.select_structural_collision_parent_assemblies_button, + self.mark_structural_collision_parent_assemblies_button, + self.select_collision_wires_button, + self.select_main_path_detour_missing_wires_button, + self.select_main_path_detour_missing_route_sources_button, + self.select_main_path_detour_rejected_fallback_sources_button, + self.select_issue_wires_button, + self.select_issue_route_sources_button, + self.select_selected_wire_route_sources_button, + self.select_selected_wire_rejected_fallback_sources_button, + self.select_long_terminal_accesses_button, + self.select_long_terminal_access_devices_button, + self.select_missing_terminal_devices_button, + self.select_missing_terminal_counterpart_terminals_button, + self.select_missing_terminal_candidate_terminals_button, + self.mark_pass_through_obstacle_button, + self.restore_obstacle_button, + self.mark_route_required_button, + self.mark_route_forbidden_button, + self.clear_route_constraint_button, + self.set_route_capacity_button, + self.clear_all_route_constraints_button, + self.generate_paths_button, + self.check_paths_button, + self.check_readiness_button, + self.route_connections_button, + self.diagnostic_summary_button, + self.clear_routes_button, + self.clear_carriers_button, + self.save_button, + ): + layout.addWidget(widget) + + layout.addLayout(options_layout) + layout.addWidget(self.status_label) + + self.generate_paths_button.clicked.connect(self.generate_routing_paths) + self.create_user_paths_button.clicked.connect(self.create_user_paths_from_selection) + self.set_terminal_local_route_button.clicked.connect(self.set_selected_terminal_local_route_points) + self.create_user_path_bridge_button.clicked.connect(self.create_user_path_bridge_from_selection) + self.create_diagnostic_bridges_button.clicked.connect(self.create_user_path_bridges_from_diagnostic_suggestions) + self.mark_cabinet_boundary_button.clicked.connect(self.mark_cabinet_boundary_from_selection) + self.select_boundary_issue_objects_button.clicked.connect(self.select_boundary_issue_route_carriers_and_terminals) + self.select_top_collision_obstacles_button.clicked.connect(self.select_top_collision_obstacles) + self.select_device_collision_obstacles_button.clicked.connect(self.select_device_or_layout_collision_obstacles) + self.select_collision_parent_assemblies_button.clicked.connect(self.select_top_collision_parent_assemblies) + self.select_structural_collision_parent_assemblies_button.clicked.connect( + self.select_structural_collision_parent_assemblies + ) + self.mark_structural_collision_parent_assemblies_button.clicked.connect( + self.mark_structural_collision_parent_assemblies_pass_through + ) + self.select_collision_wires_button.clicked.connect(self.select_collision_wires) + self.select_main_path_detour_missing_wires_button.clicked.connect(self.select_main_path_detour_missing_wires) + self.select_main_path_detour_missing_route_sources_button.clicked.connect( + self.select_main_path_detour_missing_route_sources + ) + self.select_main_path_detour_rejected_fallback_sources_button.clicked.connect( + self.select_main_path_detour_rejected_fallback_sources + ) + self.select_issue_wires_button.clicked.connect(self.select_issue_wires) + self.select_issue_route_sources_button.clicked.connect(self.select_issue_route_sources) + self.select_selected_wire_route_sources_button.clicked.connect(self.select_selected_wire_route_sources) + self.select_selected_wire_rejected_fallback_sources_button.clicked.connect( + self.select_selected_wire_rejected_fallback_sources + ) + self.select_long_terminal_accesses_button.clicked.connect(self.select_long_terminal_accesses) + self.select_long_terminal_access_devices_button.clicked.connect(self.select_long_terminal_access_devices) + self.select_missing_terminal_devices_button.clicked.connect(self.select_missing_terminal_devices) + self.select_missing_terminal_counterpart_terminals_button.clicked.connect( + self.select_missing_terminal_counterpart_terminals + ) + self.select_missing_terminal_candidate_terminals_button.clicked.connect( + self.select_missing_terminal_candidate_terminals + ) + self.mark_pass_through_obstacle_button.clicked.connect(self.mark_selected_objects_pass_through_obstacle) + self.restore_obstacle_button.clicked.connect(self.restore_selected_objects_as_obstacles) + self.mark_route_required_button.clicked.connect(self.mark_selected_route_carriers_required) + self.mark_route_forbidden_button.clicked.connect(self.mark_selected_route_carriers_forbidden) + self.clear_route_constraint_button.clicked.connect(self.clear_selected_route_carrier_constraints) + self.set_route_capacity_button.clicked.connect(self.set_selected_route_carriers_capacity) + self.clear_all_route_constraints_button.clicked.connect(self.clear_all_route_carrier_constraints) + self.check_paths_button.clicked.connect(self.check_routing_path_network) + self.check_readiness_button.clicked.connect(self.check_routing_readiness) + self.generate_layout_button.clicked.connect(self.generate_layout_space) + self.route_connections_button.clicked.connect(self.route_eplan_connections) + self.diagnostic_summary_button.clicked.connect(self.collect_routing_diagnostic_summary) + self.clear_routes_button.clicked.connect(self.clear_routing_connections) + self.clear_carriers_button.clicked.connect(self.clear_route_carriers) + self.save_button.clicked.connect(self.save) + + self._refresh_status() + + def _refresh_status(self): + try: + self._set_status(self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def _set_status(self, message): + self.status_label.setText(message) + _console_message(message) + + def _set_error(self, message): + self.status_label.setText(message) + _console_error(message) + + def _sync_options_from_widgets(self): + self.controller.set_adjoining_duct_tolerance(self.adjoining_duct_tolerance_spin.value()) + self.controller.set_terminal_access_max_distance(self.terminal_access_max_distance_spin.value()) + self.controller.set_terminal_access_warning_distance(self.terminal_access_warning_distance_spin.value()) + self.controller.set_preflight_routeability_sample_limit(self.preflight_routeability_sample_limit_spin.value()) + self.controller.set_terminal_exit_length(self.terminal_exit_length_spin.value()) + self.controller.set_obstacle_clearance(self.obstacle_clearance_spin.value()) + self.controller.set_segment_reuse_penalty(self.segment_reuse_penalty_spin.value()) + self.controller.set_lane_spacing(self.lane_spacing_spin.value()) + self.controller.set_lane_max_offset(self.lane_max_offset_spin.value()) + self.controller.set_lane_axis(self.lane_axis_combo.currentText()) + self.controller.set_selected_route_capacity(self.selected_route_capacity_spin.value()) + + def generate_routing_paths(self): + try: + self._sync_options_from_widgets() + result = self.controller.generate_routing_paths() + wire_ducts = result.get("wire_duct_carriers", 0) + user_paths = result.get("user_path_carriers", 0) + surfaces = result.get("surface_carriers", 0) + terminal_access = result.get("terminal_access_carriers", 0) + network = result.get("network", {}) if isinstance(result.get("network", {}), dict) else {} + if network.get("segments", 0) == 0: + self._set_status( + "未生成可用布线路径网络。可选中线槽实体、草图/线段作为用户路径,或确认安装板/背板能作为布线区域。" + + self.controller.summary() + ) + return + self._set_status( + "已生成布线路径网络:线槽路径 {0} 条,用户路径 {1} 条,布线区域 {2} 条,端子接入 {3} 条,网络段 {4} 条。{5}".format( + wire_ducts, + user_paths, + surfaces, terminal_access, network.get("segments", 0), self.controller.summary(), @@ -508,7 +2727,7 @@ class AutoRoutingTaskPanel: stale_text = "已清理失效用户路径:{0} 条。".format(removed) self._set_status( stale_text - + "未创建用户路径。请先选择草图、Draft 线、线段或纯线状对象。" + + "未生成/刷新用户路径。请先选择草图、Draft 线、曲线、Wire 或纯线状对象;如果路径悬空,可同时选中安装板/柜板 Face。" + self.controller.summary() ) return @@ -516,7 +2735,7 @@ class AutoRoutingTaskPanel: if removed > 0: stale_text = "已清理失效用户路径:{0} 条。".format(removed) self._set_status( - "{0}已创建用户路径:{1} 条。{2}".format( + "{0}已生成/刷新用户路径:{1} 条。{2}".format( stale_text, created, self.controller.summary(), @@ -525,6 +2744,687 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def set_selected_terminal_local_route_points(self): + try: + result = self.controller.set_selected_terminal_local_route_points() + count = result.get("terminal_local_routes", 0) + point_count = result.get("terminal_local_route_point_count", 0) + labels = list(result.get("terminal_local_route_labels", []) or []) + display = labels[0] if labels else "" + if count <= 0: + self._set_status( + "未设置端子局部出线。请同时选中一个可布线端子和一条草图/Draft 局部路径。" + + self.controller.summary() + ) + return + self._set_status( + "已设置端子局部出线:{0},路径点 {1} 个。重新生成布线路径网络后会参与自动布线。{2}".format( + display or "选中端子", + point_count, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def create_user_path_bridge_from_selection(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_user_path_bridge_from_selection() + created = result.get("user_path_bridges", 0) + if created <= 0: + self._set_status( + "未生成桥接 UserPath。请先选择两个已生成的布线路径 carrier,或选择能找到 live carrier 的源路径对象。" + + self.controller.summary() + ) + return + self._set_status( + "已生成桥接 UserPath:{0} 条。{1}".format(created, self.controller.summary()) + ) + except Exception as exc: + self._set_error(str(exc)) + + def create_user_path_bridges_from_diagnostic_suggestions(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_user_path_bridges_from_diagnostic_suggestions() + created = result.get("user_path_bridges", 0) + suggestions = result.get("diagnostic_suggestions", 0) + duplicates = result.get("duplicate_bridges", 0) + stale = result.get("stale_suggestions", 0) + detour_pairs = result.get("main_path_detour_bridge_pairs", 0) + detour_created = result.get("main_path_detour_user_path_bridges", 0) + detour_duplicates = result.get("main_path_detour_bridge_duplicates", 0) + missing_detour_pairs = list(result.get("missing_main_path_detour_bridge_pairs", []) or []) + if created <= 0: + detour_text = "" + if detour_pairs: + detour_text = " 缺主路径配对 {0} 条,已存在 {1} 条。".format( + int(detour_pairs or 0), + int(detour_duplicates or 0), + ) + missing_text = "" + if missing_detour_pairs: + missing_text = " 未找到配对:{0}。".format("、".join(missing_detour_pairs[:3])) + self._set_status( + "未按诊断建议生成桥接。建议 {0} 条,已存在 {1} 条,失效 {2} 条。{3}{4}请先点击“检查布线路径网络”或“汇总布线诊断”确认是否存在可桥接建议。{5}".format( + suggestions, + duplicates, + stale, + detour_text, + missing_text, + self.controller.summary(), + ) + ) + return + detour_text = "" + if detour_pairs or detour_created: + detour_text = " 缺主路径配对 {0} 条,生成 {1} 条。".format( + int(detour_pairs or 0), + int(detour_created or 0), + ) + self._set_status( + "已按诊断建议生成桥接 UserPath:{0} 条。建议 {1} 条,已存在 {2} 条,失效 {3} 条。{4}{5}".format( + created, + suggestions, + duplicates, + stale, + detour_text, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def mark_cabinet_boundary_from_selection(self): + try: + result = self.controller.mark_cabinet_boundary_from_selection() + marked = result.get("cabinet_boundary_objects", 0) + if marked <= 0: + self._set_status( + "未标记柜内边界。请先选择带几何包围盒的柜内空间、柜体或辅助实体。" + + self.controller.summary() + ) + return + self._set_status( + "已标记柜内边界:{0} 个。{1}".format(marked, self.controller.summary()) + ) + except Exception as exc: + self._set_error(str(exc)) + + def select_boundary_issue_route_carriers_and_terminals(self): + try: + result = self.controller.select_boundary_issue_route_carriers_and_terminals() + carriers = result.get("selected_boundary_route_carriers", 0) + terminals = result.get("selected_boundary_terminals", 0) + missing_carriers = list(result.get("missing_boundary_route_carrier_refs", []) or []) + missing_terminals = list(result.get("missing_boundary_terminal_refs", []) or []) + if carriers <= 0 and terminals <= 0: + self._set_status( + "未选择越界路径/端子。请先点击“检查布线路径网络”,并确认诊断中存在路径越出柜内边界或端子越出柜内边界。" + + self.controller.summary() + ) + return + message = "已选择越界路径 {0} 条、越界端子 {1} 个。".format(carriers, terminals) + missing = missing_carriers + missing_terminals + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请调整这些 UserPath/线槽、端子所属设备位置,或重新标记正确的柜内边界。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def mark_selected_objects_pass_through_obstacle(self): + self._mark_selected_objects_obstacle_mode("PassThrough", "忽略碰撞") + + def select_top_collision_obstacles(self): + try: + result = self.controller.select_top_collision_obstacles() + selected = result.get("selected_collision_obstacles", 0) + missing = list(result.get("missing_collision_obstacle_names", []) or []) + if selected <= 0: + self._set_status( + "未选择高发碰撞对象。请先生成布线连接并查看批量诊断里的 top_collision_obstacles。" + + self.controller.summary() + ) + return + message = "已选择高发碰撞对象:{0} 个。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请确认这些对象是否可穿越,再点击“选中对象忽略碰撞”或调整路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_top_collision_parent_assemblies(self): + try: + result = self.controller.select_top_collision_parent_assemblies() + selected = result.get("selected_collision_parent_assemblies", 0) + missing = list(result.get("missing_collision_parent_assembly_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择碰撞父装配。请先生成布线连接,并确认 top_collision_obstacles 中存在 parent_names/parent_labels。" + + self.controller.summary() + ) + return + message = "已选择碰撞父装配:{0} 个。".format(selected) + if missing: + message += " 未找到父装配:{0}。".format("、".join(missing[:5])) + message += "请确认这些总成可穿越,再点击“选中对象忽略碰撞”;该标记会递归作用到下层导入子件。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_device_or_layout_collision_obstacles(self): + try: + result = self.controller.select_device_or_layout_collision_obstacles() + selected = result.get("selected_device_or_layout_collision_obstacles", 0) + missing = list(result.get("missing_device_or_layout_collision_obstacle_names", []) or []) + if selected <= 0: + self._set_status( + "未选择设备/布局碰撞对象。请先生成布线连接,并确认 top_collision_obstacles 中存在疑似设备或装配碰撞。" + + self.controller.summary() + ) + return + message = "已选择设备/布局碰撞对象:{0} 个。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请补设备局部出线路径、调整 UserPath/线槽入口,或检查这些设备的装配位置。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_structural_collision_parent_assemblies(self): + try: + result = self.controller.select_structural_collision_parent_assemblies() + selected = result.get("selected_structural_collision_parent_assemblies", 0) + missing = list(result.get("missing_structural_collision_parent_assembly_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择结构件碰撞父装配。请先生成布线连接并汇总诊断,确认存在疑似柜体/门板/支架结构件碰撞。" + + self.controller.summary() + ) + return + message = "已选择结构件碰撞父装配:{0} 个。".format(selected) + if missing: + message += " 未找到父装配:{0}。".format("、".join(missing[:5])) + message += "请确认这些结构件可穿越,再点击“选中对象忽略碰撞”。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def mark_structural_collision_parent_assemblies_pass_through(self): + try: + result = self.controller.mark_structural_collision_parent_assemblies_pass_through() + marked = result.get("marked_structural_collision_parent_assemblies", 0) + missing = list(result.get("missing_structural_collision_parent_assembly_refs", []) or []) + if marked <= 0: + self._set_status( + "未标记结构件忽略碰撞。请先生成布线连接并汇总诊断,确认 top_collision_obstacles 中存在结构件处理建议。" + + self.controller.summary() + ) + return + message = "已把结构件碰撞父装配标记为忽略碰撞:{0} 个。".format(marked) + if missing: + message += " 未找到父装配:{0}。".format("、".join(missing[:5])) + message += "请重新生成布线连接,查看结构件碰撞是否减少。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_collision_wires(self): + try: + result = self.controller.select_collision_wires() + selected = result.get("selected_collision_wires", 0) + missing = list(result.get("missing_collision_wire_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择碰撞导线。请先生成布线连接,并确认批量诊断中存在 collision_warnings。" + + self.controller.summary() + ) + return + message = "已选择碰撞导线:{0} 条。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "可结合“选择高发碰撞对象”判断是结构件误报还是需要补路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_main_path_detour_missing_wires(self): + try: + result = self.controller.select_main_path_detour_missing_wires() + selected = result.get("selected_main_path_detour_missing_wires", 0) + missing = list(result.get("missing_main_path_detour_missing_wire_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择缺主路径导线。请先生成布线连接,并确认诊断中存在 main_path_detour_missing。" + + self.controller.summary() + ) + return + message = "已选择缺主路径导线:{0} 条。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请检查这些线附近是否缺少 UserPath、线槽桥接或设备局部出线路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_main_path_detour_missing_route_sources(self): + try: + result = self.controller.select_main_path_detour_missing_route_sources() + carriers = result.get("selected_main_path_detour_route_carriers", 0) + sources = result.get("selected_main_path_detour_route_sources", 0) + missing = list(result.get("missing_main_path_detour_route_refs", []) or []) + if carriers <= 0 and sources <= 0: + self._set_status( + "未选择缺主路径线路径。请先生成布线连接,并确认 route_samples 中存在 main_path_detour_missing 和 route_track。" + + self.controller.summary() + ) + return + message = "已选择缺主路径线当前路径:carrier {0} 条,源对象 {1} 个。".format(carriers, sources) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "可与“选择拒绝兜底路径”对照,判断应在哪里补 UserPath、桥接或局部出线路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_main_path_detour_rejected_fallback_sources(self): + try: + result = self.controller.select_main_path_detour_rejected_fallback_sources() + selected = result.get( + "selected_main_path_detour_bridge_endpoint_objects", + result.get("selected_main_path_detour_rejected_fallback_sources", 0), + ) + selected_fallback = result.get("selected_main_path_detour_rejected_fallback_sources", 0) + selected_current = result.get("selected_main_path_detour_current_route_sources", 0) + missing = list(result.get("missing_main_path_detour_rejected_fallback_refs", []) or []) + missing_current = list(result.get("missing_main_path_detour_current_route_refs", []) or []) + labels = list(result.get("main_path_detour_rejected_fallback_labels", []) or []) + label_counts = result.get("main_path_detour_rejected_fallback_label_counts", {}) + if not isinstance(label_counts, dict): + label_counts = {} + current_label_counts = result.get("main_path_detour_current_route_source_label_counts", {}) + if not isinstance(current_label_counts, dict): + current_label_counts = {} + bridge_pair_counts = result.get("main_path_detour_bridge_pair_counts", {}) + if not isinstance(bridge_pair_counts, dict): + bridge_pair_counts = {} + kind_counts = result.get("main_path_detour_rejected_fallback_kind_counts", {}) + if not isinstance(kind_counts, dict): + kind_counts = {} + if selected <= 0: + self._set_status( + "未选择缺主路径补路位置。请先生成布线连接并点击“汇总布线诊断”,确认存在 main_path_detour_missing 和需补路径位置。" + + self.controller.summary() + ) + return + message = "已选择缺主路径补路位置:{0} 个。".format(selected) + if selected_current > 0: + message += " 其中兜底区域 {0} 个,当前主路径 {1} 个。".format( + int(selected_fallback or 0), + int(selected_current or 0), + ) + location_items = [] + if label_counts: + location_items = [ + "{0} {1} 条".format(str(label), int(count or 0)) + for label, count in sorted(label_counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0])))[:5] + if int(count or 0) > 0 + ] + if not location_items and labels: + location_items = [str(label) for label in labels[:5]] + if location_items: + message += " 位置:{0}。".format("、".join(location_items)) + current_items = [] + if current_label_counts: + current_items = [ + "{0} {1} 条".format(str(label), int(count or 0)) + for label, count in sorted(current_label_counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0])))[:5] + if int(count or 0) > 0 + ] + if current_items: + message += " 当前主路径:{0}。".format("、".join(current_items)) + pair_items = [] + if bridge_pair_counts: + pair_items = [ + "{0} {1} 条".format(str(pair), int(count or 0)) + for pair, count in sorted(bridge_pair_counts.items(), key=lambda item: (-int(item[1] or 0), str(item[0])))[:5] + if int(count or 0) > 0 + ] + if pair_items: + message += " 补路配对:{0}。".format("、".join(pair_items)) + if kind_counts: + kind_text = "、".join( + "{0} {1} 处".format(str(kind), int(count or 0)) + for kind, count in sorted(kind_counts.items()) + if int(count or 0) > 0 + ) + if kind_text: + message += " 类型:{0}。".format(kind_text) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + if missing_current: + message += " 未找到当前主路径:{0}。".format("、".join(missing_current[:5])) + message += "请在这些区域补 UserPath、桥接主路径或完善设备局部出线路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_issue_wires(self): + try: + result = self.controller.select_issue_wires() + selected = result.get("selected_issue_wires", 0) + missing = list(result.get("missing_issue_wire_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择异常导线。请先生成布线连接,并确认批量诊断 route_samples 中存在 issue_codes。" + + self.controller.summary() + ) + return + message = "已选择异常导线:{0} 条。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请查看导线属性 QetRouteIssueCodes / QetRouteIssueLabels,再按长接入、越界、容量或碰撞分类处理。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_issue_route_sources(self): + try: + result = self.controller.select_issue_route_sources() + carriers = result.get("selected_issue_route_carriers", 0) + sources = result.get("selected_issue_route_sources", 0) + missing = list(result.get("missing_issue_route_refs", []) or []) + if carriers <= 0 and sources <= 0: + self._set_status( + "未选择异常导线路径。请先生成布线连接,并确认批量诊断 route_samples 中存在 issue_codes 和 route_track/carrier_names。" + + self.controller.summary() + ) + return + message = "已选择异常导线路径:carrier {0} 条,源对象 {1} 个。".format(carriers, sources) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "可检查这些 UserPath/线槽是否越界、穿模、容量不足,或按需要设置 Required/Forbidden。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_selected_wire_route_sources(self): + try: + result = self.controller.select_selected_wire_route_sources() + carriers = result.get("selected_wire_route_carriers", 0) + sources = result.get("selected_wire_route_sources", 0) + missing = list(result.get("missing_selected_wire_route_refs", []) or []) + if carriers <= 0 and sources <= 0: + self._set_status( + "未选择选中导线路径。请先选中已生成的 3D 导线,并确认导线属性 QetRouteTrackJson 中存在 carrier/source 信息。" + + self.controller.summary() + ) + return + message = "已选择选中导线路径:carrier {0} 条,源对象 {1} 个。".format(carriers, sources) + if missing: + message += " 未找到或轨迹无效:{0}。".format("、".join(missing[:5])) + message += "可检查这些路径是否越界、穿模、容量不足,或按需要设置 Required/Forbidden。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_selected_wire_rejected_fallback_sources(self): + try: + result = self.controller.select_selected_wire_rejected_fallback_sources() + selected = result.get("selected_rejected_fallback_sources", 0) + missing = list(result.get("missing_rejected_fallback_source_refs", []) or []) + labels = list(result.get("rejected_fallback_source_labels", []) or []) + if selected <= 0: + self._set_status( + "未选择拒绝兜底路径。请先选中带 main_path_detour_missing 的导线,并确认其 QetRouteDiagnosticsJson 中存在 rejected_fallback_labels。" + + self.controller.summary() + ) + return + message = "已选择拒绝兜底路径来源:{0} 个。".format(selected) + if labels: + message += " 需补路径位置:{0}。".format("、".join(str(label) for label in labels[:5])) + if missing: + message += " 未找到或诊断无效:{0}。".format("、".join(missing[:5])) + message += "这些对象只用于判断应在哪里补 UserPath、桥接或局部出线路径,不会自动采用兜底路线。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_long_terminal_accesses(self): + try: + result = self.controller.select_long_terminal_accesses() + selected = result.get("selected_long_terminal_accesses", 0) + missing = list(result.get("missing_long_terminal_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择长接入端子。请先生成布线连接或检查布线路径网络,并确认诊断中存在 long_terminal_accesses。" + + self.controller.summary() + ) + return + message = "已选择长接入端子:{0} 个。".format(selected) + if missing: + message += " 未找到:{0}。".format("、".join(missing[:5])) + message += "请检查端子位置、设备装配高度,或为设备补局部出线路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_long_terminal_access_devices(self): + try: + result = self.controller.select_long_terminal_access_devices() + selected = result.get("selected_long_terminal_access_devices", 0) + missing = list(result.get("missing_long_terminal_access_device_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择长接入设备。请先生成布线连接或检查布线路径网络,并确认 long_terminal_accesses 中存在 parent_device_name/label。" + + self.controller.summary() + ) + return + message = "已选择长接入设备:{0} 个。".format(selected) + if missing: + message += " 未找到设备:{0}。".format("、".join(missing[:5])) + message += "请检查设备装配高度、端子 LCS 位置/方向,或为设备补局部出线路径。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_missing_terminal_devices(self): + try: + result = self.controller.select_missing_terminal_devices() + selected = result.get("selected_missing_terminal_devices", 0) + missing = list(result.get("missing_terminal_device_refs", []) or []) + missing_labels = list(result.get("missing_terminal_device_labels", []) or []) + missing_instances = list(result.get("missing_terminal_device_instance_ids", []) or []) + reason_counts = result.get("missing_terminal_device_reason_counts", {}) + if not isinstance(reason_counts, dict): + reason_counts = {} + if selected <= 0: + if int(reason_counts.get("device_not_in_3d_scene", 0) or 0) > 0: + message = ( + "未选择缺端子设备:缺失侧 2D 设备未在当前 FreeCAD 场景中找到。" + "请先检查设备是否已导入、装配并完成 2D/3D 绑定。" + ) + display_missing = missing_labels or missing + if display_missing: + message += " 未找到:{0}。".format("、".join(display_missing[:5])) + if missing_instances: + message += " instance_id:{0}。".format("、".join(missing_instances[:5])) + self._set_status(message + self.controller.summary()) + return + if int(reason_counts.get("missing_device_binding_metadata", 0) or 0) > 0: + message = ( + "未选择缺端子设备:QET 导线端点缺少 element_uuid," + "FreeCAD 无法判断缺失端子属于哪个 2D 设备;第一版不要求 start/end_instance_id。" + ) + if missing: + message += " 缺失端点:{0}。".format("、".join(missing[:5])) + self._set_status(message + self.controller.summary()) + return + self._set_status( + "未选择缺端子设备。请先生成布线连接,并确认批量诊断中存在 missing_endpoint_samples。" + + self.controller.summary() + ) + return + message = "已选择缺端子设备:{0} 个。".format(selected) + display_missing = missing_labels or missing + if display_missing: + message += " 未找到设备:{0}。".format("、".join(display_missing[:5])) + if missing_instances: + message += " instance_id:{0}。".format("、".join(missing_instances[:5])) + message += "请在这些设备上补工程端子,或检查 QET 端子 UUID 与 3D 实例绑定。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_missing_terminal_counterpart_terminals(self): + try: + result = self.controller.select_missing_terminal_counterpart_terminals() + selected = result.get("selected_missing_terminal_counterpart_terminals", 0) + missing = list(result.get("missing_terminal_counterpart_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择缺端子另一端。请先生成布线连接,并确认缺端子样例中至少一端已经找到工程端子。" + + self.controller.summary() + ) + return + message = "已选择缺端子另一端端子:{0} 个。".format(selected) + if missing: + message += " 未找到或两端都缺失:{0}。".format("、".join(missing[:5])) + message += "请对照这些已找到端子,检查缺失侧设备是否生成工程端子、terminal_uuid 是否匹配。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def select_missing_terminal_candidate_terminals(self): + try: + result = self.controller.select_missing_terminal_candidate_terminals() + selected = result.get("selected_missing_terminal_candidate_terminals", 0) + missing = list(result.get("missing_terminal_candidate_terminal_refs", []) or []) + if selected <= 0: + self._set_status( + "未选择缺端子候选端子。请先重新生成布线连接,确认 missing_endpoint_samples 中存在同设备端子样例。" + + self.controller.summary() + ) + return + message = "已选择缺端子候选端子:{0} 个。".format(selected) + if missing: + message += " 未找到候选端子:{0}。".format("、".join(missing[:5])) + message += "请核对这些 3D 工程端子的 terminal_uuid、脚号和 QET 导线端点是否一致。" + self._set_status(message + self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def restore_selected_objects_as_obstacles(self): + self._mark_selected_objects_obstacle_mode("", "恢复障碍") + + def _mark_selected_objects_obstacle_mode(self, mode, label): + try: + result = self.controller._mark_selected_objects_obstacle_mode(mode) + marked = result.get("obstacle_mode_objects", 0) + if marked <= 0: + self._set_status( + "未{0}。请先选择需要处理的实体对象,不要选择已生成的布线路径 carrier。".format(label) + + self.controller.summary() + ) + return + self._set_status( + "已{0}:对象 {1} 个。{2}".format( + label, + marked, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def mark_selected_route_carriers_required(self): + self._mark_selected_route_carriers_constraint("Required", "必须经过") + + def mark_selected_route_carriers_forbidden(self): + self._mark_selected_route_carriers_constraint("Forbidden", "禁止经过") + + def clear_selected_route_carrier_constraints(self): + self._mark_selected_route_carriers_constraint("", "约束清除") + + def set_selected_route_carriers_capacity(self): + try: + self._sync_options_from_widgets() + result = self.controller.set_selected_route_carriers_capacity() + marked = result.get("route_capacity_carriers", 0) + sources = result.get("route_capacity_sources", 0) + capacity = result.get("route_capacity", 0) + if marked <= 0 and sources <= 0: + self._set_status( + "未设置路径容量。请先选择已生成的布线路径 carrier,或选择草图/Draft/线槽/支撑面等源路径对象。" + + self.controller.summary() + ) + return + self._set_status( + "已设置路径容量:容量 {0},carrier {1} 条,源路径 {2} 个。{3}".format( + capacity, + marked, + sources, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def clear_all_route_carrier_constraints(self): + try: + result = self.controller.clear_all_route_carrier_constraints() + carriers = result.get("route_constraint_carriers", 0) + sources = result.get("route_constraint_sources", 0) + if carriers <= 0 and sources <= 0: + self._set_status("当前没有路径约束需要清除。{0}".format(self.controller.summary())) + return + self._set_status( + "已清除全部路径约束:carrier {0} 条,源路径 {1} 个。{2}".format( + carriers, + sources, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def _mark_selected_route_carriers_constraint(self, mode, label): + try: + result = self.controller._mark_selected_route_carriers_constraint(mode) + marked = result.get("route_constraint_carriers", 0) + sources = result.get("route_constraint_sources", 0) + if marked <= 0 and sources <= 0: + action_text = "清除" if not mode else "标记" + self._set_status( + "未{0}路径约束。请先选择已生成的布线路径 carrier,或选择草图/Draft 等源路径对象。".format( + action_text + ) + + self.controller.summary() + ) + return + if not mode: + self._set_status( + "已清除选中路径约束:carrier {0} 条,源路径 {1} 个。{2}".format( + marked, + sources, + self.controller.summary(), + ) + ) + return + self._set_status( + "已标记路径{0}:carrier {1} 条,源路径 {2} 个。{3}".format( + label, + marked, + sources, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def check_routing_path_network(self): try: self._sync_options_from_widgets() @@ -539,6 +3439,14 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def check_routing_readiness(self): + try: + self._sync_options_from_widgets() + report = self.controller.check_routing_readiness() + self._set_status(AutoRouting.format_eplan_routing_preflight_report(report)) + except Exception as exc: + self._set_error(str(exc)) + def generate_layout_space(self): try: result = self.controller.generate_layout_space() @@ -570,6 +3478,13 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def collect_routing_diagnostic_summary(self): + try: + report = self.controller.collect_routing_diagnostic_summary() + self._set_status(AutoRouting.format_routing_diagnostic_summary(report)) + except Exception as exc: + self._set_error(str(exc)) + def clear_routing_connections(self): try: removed = self.controller.clear_routing_connections() diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index 45081e1..23010cb 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -1,6 +1,7 @@ import json import traceback import os +import sqlite3 from pathlib import Path import FreeCAD as App @@ -405,6 +406,15 @@ def _optional_string(item, field_name, entry_label): return value.strip() if isinstance(value, str) else "" +def _optional_text(item, field_name): + value = item.get(field_name, "") + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + def _normalize_conductor_uuids(item, entry_label): values = item.get("conductor_uuids", []) if values is None: @@ -455,8 +465,10 @@ def _normalize_wires(payload): "wire_id": wire_id, "net_uuid": _optional_string(item, "net_uuid", entry_label), "group_uuid": _optional_string(item, "group_uuid", entry_label), + "wire_label": _optional_string(item, "wire_label", entry_label), "wire_mark": _optional_string(item, "wire_mark", entry_label), "wire_mark_is_manual": wire_mark_is_manual, + "wire_style_id": _optional_text(item, "wire_style_id"), "start_element_uuid": _optional_string(item, "start_element_uuid", entry_label), "start_instance_id": _optional_string(item, "start_instance_id", entry_label), "start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label), @@ -558,6 +570,68 @@ def _normalize_cabinet(payload): return normalized +def _has_wire_properties_table(database_path): + try: + connection = sqlite3.connect(str(database_path)) + except Exception: + return False + try: + row = connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'wire_properties' + LIMIT 1 + """ + ).fetchone() + return row is not None + except Exception: + return False + finally: + try: + connection.close() + except Exception: + pass + + +def _wire_style_database_path(payload, json_path): + for key in ("wire_style_database_path", "project_database_path", "database_path"): + value = payload.get(key, "") + if isinstance(value, str) and value.strip(): + return value.strip() + try: + directory = Path(json_path).resolve().parent + except Exception: + return "" + candidates = [] + search_dirs = [] + for base in (directory, directory.parent, directory.parent.parent): + if base and base not in search_dirs: + search_dirs.append(base) + data_dir = base / "datafiles" + if data_dir not in search_dirs: + search_dirs.append(data_dir) + for base in (directory.parent, directory.parent.parent): + try: + for data_dir in base.glob("*/datafiles"): + if data_dir not in search_dirs: + search_dirs.append(data_dir) + except Exception: + pass + for search_dir in search_dirs: + for pattern in ("project-local.db", "project-local.sqlite", "*.sqlite", "*.sqlite3", "*.db"): + try: + candidates.extend(search_dir.glob(pattern)) + except Exception: + pass + for candidate in sorted(set(candidates), key=lambda item: item.name.lower()): + # 不要求 QET 改协议;FreeCAD 只在候选库确实含 wire_properties 时自动使用。 + if _has_wire_properties_table(candidate): + return str(candidate) + return "" + + def load_exchange_payload(json_path): try: raw_text = Path(json_path).read_text(encoding="utf-8") @@ -594,6 +668,9 @@ def load_exchange_payload(json_path): "device_models": _normalize_device_models(payload), "wires": _normalize_wires(payload), } + wire_style_database_path = _wire_style_database_path(payload, json_path) + if wire_style_database_path: + normalized["wire_style_database_path"] = wire_style_database_path return normalized @@ -623,6 +700,7 @@ def _build_summary(payload, json_path): "missing_terminal_instances": missing_terminal_instances, "cabinet": cabinet, "scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(), + "wire_style_database_path": payload.get("wire_style_database_path", ""), } diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index 1157dec..e087838 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -23,6 +23,10 @@ ROUTE_CARRIER_KIND_USER_PATH = "UserPath" ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess" +ROUTING_BOUNDARY_ROLE = "RoutingBoundary" +ROUTING_BOUNDARY_KIND_CABINET_INTERIOR = "CabinetInterior" +ROUTE_CONSTRAINT_MODE_REQUIRED = "Required" +ROUTE_CONSTRAINT_MODE_FORBIDDEN = "Forbidden" BRIDGEABLE_ENDPOINT_CARRIER_KINDS = { ROUTE_CARRIER_KIND, ROUTE_CARRIER_KIND_WIRE_DUCT, @@ -45,11 +49,13 @@ DEFAULT_SURFACE_MARGIN = 20.0 DEFAULT_WIRE_DUCT_MARGIN = 20.0 DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH = 20.0 DEFAULT_ROUTE_PATH_FACE_OFFSET = 2.0 +DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION = 5.0 DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT = 2.5 DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE = 1000.0 DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE = 500.0 DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY = 25.0 DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY = 1000.0 +DEFAULT_TERMINAL_ACCESS_FALLBACK_CARRIER_PENALTY = 5000.0 DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY = 2000.0 DEFAULT_ADJOINING_DUCT_TOLERANCE = 5.0 DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION = 20.0 @@ -106,12 +112,16 @@ SUPPORT_SURFACE_NAME_KEYWORDS = ( "door panel", "rear door", "front door", + "side cover", + "side panel", "cabinet face", "cabinet panel", "\u5b89\u88c5\u677f", "\u80cc\u677f", "\u5e95\u677f", "\u95e8\u677f", + "\u4fa7\u76d6", + "\u4fa7\u677f", "\u67dc\u9762", ) SUPPORT_SURFACE_CARRIER_KINDS = { @@ -226,6 +236,21 @@ def _distance(left, right): return (dx * dx + dy * dy + dz * dz) ** 0.5 +def _placement_mult_vec(placement, point): + if placement is None: + return point + try: + transformed = placement.multVec(point) + if transformed is not None: + return _vector(transformed) + except Exception: + pass + base = getattr(placement, "Base", None) + if base is not None: + return App.Vector(point.x + base.x, point.y + base.y, point.z + base.z) + return point + + def _add(left, right): return App.Vector( float(left.x) + float(right.x), @@ -403,6 +428,43 @@ def _point_payload(point): } +def _bbox_axis_value(bbox, attr_name, dict_name): + if isinstance(bbox, dict): + return float(bbox[dict_name]) + return float(getattr(bbox, attr_name)) + + +def _point_inside_bbox(point, bbox, tolerance=0.000001): + if point is None or bbox is None: + return False + try: + point = _vector(point) + return ( + _bbox_axis_value(bbox, "XMin", "xmin") - tolerance + <= float(point.x) + <= _bbox_axis_value(bbox, "XMax", "xmax") + tolerance + and _bbox_axis_value(bbox, "YMin", "ymin") - tolerance + <= float(point.y) + <= _bbox_axis_value(bbox, "YMax", "ymax") + tolerance + and _bbox_axis_value(bbox, "ZMin", "zmin") - tolerance + <= float(point.z) + <= _bbox_axis_value(bbox, "ZMax", "zmax") + tolerance + ) + except Exception: + return False + + +def _point_inside_any_bbox(point, bboxes): + return any(_point_inside_bbox(point, bbox) for bbox in bboxes or []) + + +def _segment_inside_any_bbox(start, end, bboxes): + if not bboxes: + return True + # 柜内区域采用包围盒语义;一段线必须整体落在同一个柜内盒里才进入优先路径图。 + return any(_point_inside_bbox(start, bbox) and _point_inside_bbox(end, bbox) for bbox in bboxes or []) + + def _is_finite_point(point): try: return all( @@ -520,6 +582,27 @@ def _route_carrier_capacity_value(obj, default=1): return int(default or 1) +def _normalized_route_capacity(capacity): + try: + normalized = int(float(capacity or 0)) + except Exception: + normalized = 1 + return max(normalized, 1) + + +def _set_route_carrier_capacity_value(obj, capacity): + if obj is None: + return _normalized_route_capacity(capacity) + normalized = _normalized_route_capacity(capacity) + _ensure_integer_property( + obj, + "QetRouteCarrierCapacity", + "How many routed wires can reuse this carrier segment before detouring is preferred", + normalized, + ) + return normalized + + def _wire_duct_end_margin_value(source, default=DEFAULT_WIRE_DUCT_MARGIN): try: value = float(getattr(source, "QetWireDuctEndMarginMm", default) or 0.0) @@ -617,6 +700,68 @@ def _set_user_path_source_semantics(source): ) +def _object_has_bbox(obj): + shape = getattr(obj, "Shape", None) + return getattr(shape, "BoundBox", None) is not None + + +def _set_cabinet_interior_boundary_semantics(source): + if source is None: + return + TerminalObjects.ensure_string_property( + source, + "QetRoutingRole", + PROPERTY_GROUP, + "Routing role marker", + ROUTING_BOUNDARY_ROLE, + ) + TerminalObjects.ensure_string_property( + source, + "QetRoutingBoundaryKind", + PROPERTY_GROUP, + "Routing boundary kind", + ROUTING_BOUNDARY_KIND_CABINET_INTERIOR, + ) + TerminalObjects.ensure_string_property( + source, + "QetRoutingObstacleMode", + PROPERTY_GROUP, + "How routing connection collision checks should treat this object", + WIRE_DUCT_OBSTACLE_MODE, + ) + + +def _set_routing_obstacle_mode(source, mode): + if source is None: + return + normalized = str(mode or "").strip() + TerminalObjects.ensure_string_property( + source, + "QetRoutingObstacleMode", + PROPERTY_GROUP, + "How routing connection collision checks should treat this object", + normalized, + ) + + +def set_routing_obstacle_mode(source, mode): + _set_routing_obstacle_mode(source, mode) + return source + + +def mark_obstacle_mode_from_selection(selection_ex, mode): + marked = [] + seen = set() + for item in selection_ex or []: + source = getattr(item, "Object", None) + if source is None or is_route_carrier(source) or id(source) in seen: + continue + set_routing_obstacle_mode(source, mode) + seen.add(id(source)) + marked.append(source) + return marked + + def _style_route_carrier(carrier, kind): style = ROUTE_CARRIER_VIEW_STYLES.get(kind) or ROUTE_CARRIER_VIEW_STYLES[ROUTE_CARRIER_KIND] try: @@ -744,6 +889,16 @@ def is_route_carrier(obj): return False +def is_routing_boundary(obj): + if obj is None: + return False + boundary_kind = (getattr(obj, "QetRoutingBoundaryKind", "") or "").strip() + if boundary_kind in {ROUTING_BOUNDARY_KIND_CABINET_INTERIOR, ROUTING_BOUNDARY_ROLE}: + return True + role = (getattr(obj, "QetRoutingRole", "") or "").strip() + return role == ROUTING_BOUNDARY_ROLE + + def _carrier_points(obj): points = list(getattr(obj, "Points", []) or []) if points: @@ -1010,6 +1165,16 @@ def _shape_center(shape): def _edge_points(edge): + discretize = getattr(edge, "discretize", None) + if callable(discretize): + try: + # 草图弧线/样条需要离散成折线,否则会被首尾点直接拉直。 + points = [_vector(point) for point in discretize(Deflection=DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION)] + if len(points) >= 2: + return points + except Exception: + pass + first = None last = None vertexes = list(getattr(edge, "Vertexes", []) or []) @@ -1027,9 +1192,64 @@ def _edge_points(edge): return [] +def _wire_points(wire): + discretize = getattr(wire, "discretize", None) + if callable(discretize): + try: + # 整条 Wire 的拓扑顺序通常比逐条 Edge 更稳定,优先用它保留草图路径走向。 + points = [_vector(point) for point in discretize(Deflection=DEFAULT_USER_PATH_EDGE_DISCRETIZE_DEFLECTION)] + if len(points) >= 2: + return points + except Exception: + pass + + points = [] + for edge in list(getattr(wire, "Edges", []) or []): + points.extend(_edge_points(edge)) + return points + + +def _object_global_placement(obj): + if obj is None: + return None + try: + if hasattr(obj, "getGlobalPlacement"): + placement = obj.getGlobalPlacement() + if placement is not None: + return placement + except Exception: + pass + return getattr(obj, "Placement", None) + + +def _points_with_placement(points, placement): + return [_placement_mult_vec(placement, _vector(point)) for point in points] + + +def _is_valid_route_source_bbox(bbox, max_extent=1.0e9): + if bbox is None: + return True + for axis in ("x", "y", "z"): + try: + extent = float(_bbox_extent(bbox, axis)) + except Exception: + return False + if not math.isfinite(extent) or abs(extent) > float(max_extent or 0.0): + return False + return True + + def _is_route_path_source_object(obj): if obj is None: return False + if is_route_carrier(obj): + return False + if is_routing_boundary(obj): + return False + name = str(getattr(obj, "Name", "") or "").lower() + label = str(getattr(obj, "Label", "") or "").lower() + if name.startswith(("x_axis", "y_axis", "z_axis")) or label.startswith(("x轴", "y轴", "z轴")): + return False type_id = (getattr(obj, "TypeId", "") or "").lower() if "sketch" in type_id: return True @@ -1038,6 +1258,8 @@ def _is_route_path_source_object(obj): shape = getattr(obj, "Shape", None) if shape is None: return False + if not _is_valid_route_source_bbox(getattr(shape, "BoundBox", None)): + return False # SOLIDWORKS/EPLAN 的 routing path 是草图/线槽路径,不是把实体零件的全部边都当路径。 # 所以只有纯线状对象才允许整对象转换;带 Face/Solid 的实体必须显式选中边。 faces = list(getattr(shape, "Faces", []) or []) @@ -1045,7 +1267,7 @@ def _is_route_path_source_object(obj): shells = list(getattr(shape, "Shells", []) or []) if faces or solids or shells: return False - return bool(list(getattr(shape, "Edges", []) or [])) + return bool(list(getattr(shape, "Wires", []) or []) or list(getattr(shape, "Edges", []) or [])) def _routing_source_text(obj): @@ -1076,7 +1298,7 @@ def _bbox_aspect_ratio(bbox): def _is_wire_duct_candidate(obj, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): if obj is None: return False - if is_route_carrier(obj) or TerminalObjects.is_terminal_object(obj): + if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False @@ -1121,7 +1343,7 @@ def _is_thin_surface_bbox(bbox, min_surface_extent=50.0, max_thickness=40.0, thi def _is_support_surface_candidate(obj): if obj is None: return False - if is_route_carrier(obj) or TerminalObjects.is_terminal_object(obj): + if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False @@ -1148,7 +1370,7 @@ def _is_support_surface_candidate(obj): def _is_wiring_cut_out_candidate(obj): if obj is None: return False - if is_route_carrier(obj) or TerminalObjects.is_terminal_object(obj): + if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False if (getattr(obj, "RouteType", "") or "").strip(): return False @@ -1202,45 +1424,80 @@ def _support_face_from_bbox(bbox): return _BBoxFace(points, normal) -def _points_from_selection_item(selection_item): +def _normalize_point_run(points): + normalized = [] + for point in points or []: + if not normalized or _distance(normalized[-1], point) > DEFAULT_NODE_TOLERANCE: + normalized.append(point) + return normalized + + +def _point_runs_from_selection_item(selection_item): + runs = [] points = [] + obj = getattr(selection_item, "Object", None) + placement = _object_global_placement(obj) for point in list(getattr(selection_item, "PickedPoints", []) or []): points.append(_vector(point)) for sub_object in list(getattr(selection_item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() + if shape_type == "wire": + if points: + runs.append(points) + points = [] + runs.append(_points_with_placement(_wire_points(sub_object), placement)) + continue if shape_type == "edge": - points.extend(_edge_points(sub_object)) + points.extend(_points_with_placement(_edge_points(sub_object), placement)) continue if shape_type == "vertex": point = getattr(sub_object, "Point", None) if point is not None: - points.append(_vector(point)) + points.append(_placement_mult_vec(placement, _vector(point))) continue center = _shape_center(sub_object) if center is not None: - points.append(center) + points.append(_placement_mult_vec(placement, 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)) + points.append(_placement_mult_vec(placement, _vector(point))) shape = getattr(obj, "Shape", None) if shape is not None and _is_route_path_source_object(obj): - for edge in list(getattr(shape, "Edges", []) or []): - points.extend(_edge_points(edge)) - if not points: + wires = list(getattr(shape, "Wires", []) or []) + if wires: + if points: + runs.append(points) + points = [] + for wire in wires: + runs.append(_points_with_placement(_wire_points(wire), placement)) + else: + for edge in list(getattr(shape, "Edges", []) or []): + points.extend(_points_with_placement(_edge_points(edge), placement)) + if not runs and not points: center = _shape_center(shape) if center is not None: - points.append(center) + points.append(_placement_mult_vec(placement, center)) - normalized = [] - for point in points: - if not normalized or _distance(normalized[-1], point) > DEFAULT_NODE_TOLERANCE: - normalized.append(point) - return normalized + if points: + runs.append(points) + + normalized_runs = [] + for run in runs: + normalized = _normalize_point_run(run) + if len(normalized) >= 2: + normalized_runs.append(normalized) + return normalized_runs + + +def _points_from_selection_item(selection_item): + points = [] + for run in _point_runs_from_selection_item(selection_item): + points.extend(run) + return _normalize_point_run(points) def _support_face_from_selection(selection_ex): @@ -1452,22 +1709,28 @@ def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUT for index, item in enumerate(selection_ex or [], start=1): if support_face is not None and _selection_item_is_only_support_face(item): continue - points = _points_from_selection_item(item) - if len(points) < 2: - continue + point_runs = _point_runs_from_selection_item(item) if support_face is not None: # 如果同时选中了支撑面和草图/线段,先把草图点投影到支撑面的平面上。 # Draft 自身只记录工作平面坐标,不会自动吸附到柜板面。 - points = _project_points_to_face(points, support_face) - created.append( - create_route_carrier( - doc, - points, - label="QET Route Carrier {0}".format(index), - project_uuid=project_uuid, - kind=kind, + point_runs = [_project_points_to_face(points, support_face) for points in point_runs] + point_runs = [_normalize_point_run(points) for points in point_runs] + point_runs = [points for points in point_runs if len(points) >= 2] + if not point_runs: + continue + for run_index, points in enumerate(point_runs, start=1): + label = "QET Route Carrier {0}".format(index) + if len(point_runs) > 1: + label = "{0} {1}".format(label, run_index) + created.append( + create_route_carrier( + doc, + points, + label=label, + project_uuid=project_uuid, + kind=kind, + ) ) - ) return created @@ -1485,7 +1748,7 @@ 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): + if is_route_carrier(source) or is_routing_boundary(source): continue if ( _is_wire_duct_candidate(source) @@ -1493,11 +1756,13 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") or _is_wiring_cut_out_candidate(source) ): continue - points = _points_from_selection_item(item) - if len(points) < 2: - continue + point_runs = _point_runs_from_selection_item(item) if support_face is not None: - points = _project_points_to_face(points, support_face) + point_runs = [_project_points_to_face(points, support_face) for points in point_runs] + point_runs = [_normalize_point_run(points) for points in point_runs] + point_runs = [points for points in point_runs if len(points) >= 2] + if not point_runs: + continue label = "QET User Route Path {0}".format(index) capacity = 1 @@ -1506,109 +1771,429 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") 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( - live_carrier, - points, - project_uuid=project_uuid, - kind=ROUTE_CARRIER_KIND_USER_PATH, - capacity=capacity, - ): - _mark_user_path_source(source, live_carrier) + live_carriers = _live_source_carriers(doc, source) + if live_carriers: + refreshed = [] + for run_index, points in enumerate(point_runs, start=1): + if run_index <= len(live_carriers): + carrier = live_carriers[run_index - 1] + if _update_route_carrier( + carrier, + points, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, + ): + refreshed.append(carrier) + continue + run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) + refreshed.append( + create_route_carrier( + doc, + points, + label=run_label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, + ) + ) + if len(live_carriers) > len(point_runs): + _remove_route_carriers(doc, live_carriers[len(point_runs) :]) + _mark_user_path_source_carriers(source, refreshed) + created.extend(refreshed) continue - carrier = create_route_carrier( - doc, - points, - label=label, - project_uuid=project_uuid, - kind=ROUTE_CARRIER_KIND_USER_PATH, - capacity=capacity, - ) + new_carriers = [] + for run_index, points in enumerate(point_runs, start=1): + run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) + carrier = create_route_carrier( + doc, + points, + label=run_label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, + ) + new_carriers.append(carrier) + created.append(carrier) if source is not None: - _mark_user_path_source(source, carrier) - created.append(carrier) + _mark_user_path_source_carriers(source, new_carriers) return created -def _wire_duct_centerline_spec_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): - extents = { - axis: _bbox_extent(bbox, axis) - for axis in ("x", "y", "z") - } - main_axis = max(extents, key=extents.get) - sorted_extents = sorted(extents.values(), reverse=True) - if sorted_extents[0] <= DEFAULT_NODE_TOLERANCE: - return {"centerline": [], "open_ends": []} - if len(sorted_extents) > 1 and sorted_extents[1] > DEFAULT_NODE_TOLERANCE: - if sorted_extents[0] / sorted_extents[1] < float(min_aspect or 1.0): - return {"centerline": [], "open_ends": []} +def _nearest_points_between_route_point_runs(left_points, right_points): + left_points = _normalized_route_points(left_points) + right_points = _normalized_route_points(right_points) + if len(left_points) < 2 or len(right_points) < 2: + return None - low, high = _bbox_axis_range(bbox, main_axis) - center = _bbox_center(bbox) - usable_margin = max(float(margin or 0.0), 0.0) - if abs(high - low) <= usable_margin * 2.0: - usable_margin = 0.0 + best = None - start = _set_axis(center, main_axis, low + usable_margin) - end = _set_axis(center, main_axis, high - usable_margin) - if _distance(start, end) <= DEFAULT_NODE_TOLERANCE: - return {"centerline": [], "open_ends": []} + def remember(left_point, right_point): + nonlocal best + distance = _distance(left_point, right_point) + if best is None or distance < best[0]: + best = (distance, left_point, right_point) - cross_axes = sorted( - [axis for axis in ("x", "y", "z") if axis != main_axis], - key=lambda axis: _bbox_extent(bbox, axis), - reverse=True, - ) - open_ends = [] - if cross_axes: - cross_axis = cross_axes[0] - cross_extent = _bbox_extent(bbox, cross_axis) - half_length = max( - min(cross_extent * 0.5, float(margin or DEFAULT_WIRE_DUCT_MARGIN)), - min(cross_extent * 0.5, DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH * 0.5), - ) - if half_length > DEFAULT_NODE_TOLERANCE: - for endpoint in (start, end): - open_ends.append( - [ - _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) - half_length), - _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) + half_length), - ] - ) + for point in left_points: + for index in range(len(right_points) - 1): + projected = _closest_point_on_segment(point, right_points[index], right_points[index + 1]) + remember(point, projected) - return { - "centerline": [start, end], - "open_ends": open_ends, - "main_axis": main_axis, - } + for point in right_points: + for index in range(len(left_points) - 1): + projected = _closest_point_on_segment(point, left_points[index], left_points[index + 1]) + remember(projected, point) + return best -def _wire_duct_centerline_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): - return _wire_duct_centerline_spec_from_bbox( - bbox, - margin=margin, - min_aspect=min_aspect, - ).get("centerline", []) +def create_user_path_bridge_from_selection(doc, selection_ex, project_uuid=""): + """Create a short user-controlled bridge between two selected route carriers. -def _sync_wire_duct_source_carriers( - doc, - source, - spec, - project_uuid="", - capacity=1, - end_margin=DEFAULT_WIRE_DUCT_MARGIN, -): - carriers = _live_source_carriers(doc, source) - if not carriers: - return False + 这里刻意只连接用户选中的路径,不做全局远距离自动桥接; + 否则真实机柜里相互无关的线槽、布线面可能会被误接成一个错误网络。 + """ + carriers = _selected_route_carriers_for_constraint(doc, selection_ex) + if len(carriers) < 2: + return [] - desired = [ - (spec.get("centerline", []), ROUTE_CARRIER_KIND_WIRE_DUCT), - ] - desired.extend( + left = carriers[0] + right = carriers[1] + left_points = _carrier_points(left) + right_points = _carrier_points(right) + nearest = _nearest_points_between_route_point_runs(left_points, right_points) + if nearest is None: + return [] + + _distance_mm, left_point, right_point = nearest + if _distance(left_point, right_point) <= DEFAULT_NODE_TOLERANCE: + return [] + + left_label = getattr(left, "Label", "") or getattr(left, "Name", "") or "Path A" + right_label = getattr(right, "Label", "") or getattr(right, "Name", "") or "Path B" + bridge = create_route_carrier( + doc, + [left_point, right_point], + label="QET User Bridge {0} -> {1}".format(left_label, right_label), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=min( + _route_carrier_capacity_value(left, default=1), + _route_carrier_capacity_value(right, default=1), + ), + ) + return [bridge] + + +def _route_carriers_for_bridge_object(doc, source): + if source is None: + return [] + if is_route_carrier(source): + return [source] + carriers = [ + carrier + for carrier in _live_source_carriers(doc, source) + if carrier is not None and is_route_carrier(carrier) + ] + if carriers: + return carriers + source_name = (getattr(source, "Name", "") or "").strip() + source_label = (getattr(source, "Label", "") or "").strip() + source_route_label = (getattr(source, "QetRouteSourceLabel", "") or "").strip() + seen = set() + for candidate in collect_route_carriers(doc): + if candidate is None or not is_route_carrier(candidate): + continue + if ( + source_name + and (getattr(candidate, "QetRouteSourceName", "") or "").strip() == source_name + ) or ( + source_label + and (getattr(candidate, "QetRouteSourceLabel", "") or "").strip() == source_label + ) or ( + source_route_label + and (getattr(candidate, "QetRouteSourceLabel", "") or "").strip() == source_route_label + ): + identity = id(candidate) + if identity in seen: + continue + seen.add(identity) + carriers.append(candidate) + return carriers + + +def create_user_path_bridge_between_objects(doc, left_source, right_source, project_uuid=""): + """Create a UserPath bridge between the nearest carriers of two selected source objects.""" + left_carriers = _route_carriers_for_bridge_object(doc, left_source) + right_carriers = _route_carriers_for_bridge_object(doc, right_source) + best = None + for left in left_carriers: + left_points = _carrier_points(left) + for right in right_carriers: + if left is right: + continue + right_points = _carrier_points(right) + nearest = _nearest_points_between_route_point_runs(left_points, right_points) + if nearest is None: + continue + distance_mm, left_point, right_point = nearest + if best is None or float(distance_mm) < float(best[0]): + best = (distance_mm, left, right, left_point, right_point) + if best is None: + return [] + + distance_mm, left, right, left_point, right_point = best + if _distance(left_point, right_point) <= DEFAULT_NODE_TOLERANCE: + return [] + if _route_bridge_already_exists(doc, left_point, right_point): + return [] + + left_label = ( + getattr(left_source, "QetRouteSourceLabel", "") + or getattr(left_source, "Label", "") + or getattr(left, "QetRouteSourceLabel", "") + or getattr(left, "Label", "") + or getattr(left, "Name", "") + or "Path A" + ) + right_label = ( + getattr(right_source, "QetRouteSourceLabel", "") + or getattr(right_source, "Label", "") + or getattr(right, "QetRouteSourceLabel", "") + or getattr(right, "Label", "") + or getattr(right, "Name", "") + or "Path B" + ) + bridge = create_route_carrier( + doc, + [left_point, right_point], + label="QET User Bridge {0} -> {1}".format(left_label, right_label), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=min( + _route_carrier_capacity_value(left, default=1), + _route_carrier_capacity_value(right, default=1), + ), + ) + # 缺主路径绕行桥接需要保留来源,便于用户后续在 FreeCAD 树中复核是哪两个区域被自动补路。 + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgeKind", + PROPERTY_GROUP, + "QET route bridge kind", + "MainPathDetourBridge", + ) + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgePairLabel", + PROPERTY_GROUP, + "Human readable source pair for this generated bridge", + "{0} -> {1}".format(left_label, right_label), + ) + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgeLeftSourceName", + PROPERTY_GROUP, + "Left/source object name for this generated bridge", + getattr(left_source, "Name", "") or getattr(left, "QetRouteSourceName", "") or getattr(left, "Name", ""), + ) + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgeRightSourceName", + PROPERTY_GROUP, + "Right/source object name for this generated bridge", + getattr(right_source, "Name", "") or getattr(right, "QetRouteSourceName", "") or getattr(right, "Name", ""), + ) + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgeLeftSourceLabel", + PROPERTY_GROUP, + "Left/source object label for this generated bridge", + left_label, + ) + TerminalObjects.ensure_string_property( + bridge, + "QetRouteBridgeRightSourceLabel", + PROPERTY_GROUP, + "Right/source object label for this generated bridge", + right_label, + ) + return [bridge] + + +def _route_bridge_already_exists(doc, left_point, right_point): + for carrier in collect_route_carriers(doc): + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() + if kind != ROUTE_CARRIER_KIND_USER_PATH: + continue + points = _normalized_route_points(_carrier_points(carrier)) + if len(points) != 2: + continue + if ( + _distance(points[0], left_point) <= DEFAULT_NODE_TOLERANCE + and _distance(points[1], right_point) <= DEFAULT_NODE_TOLERANCE + ) or ( + _distance(points[0], right_point) <= DEFAULT_NODE_TOLERANCE + and _distance(points[1], left_point) <= DEFAULT_NODE_TOLERANCE + ): + return True + return False + + +def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, project_uuid=""): + """Create UserPath bridges from explicit path-network diagnostic suggestions.""" + report = { + "suggestions": 0, + "created": [], + "duplicates": 0, + "stale_suggestions": 0, + } + if doc is None or not isinstance(diagnostic, dict): + return report + + for item in diagnostic.get("wire_ducts_without_terminal_access", []) or []: + if not isinstance(item, dict): + continue + suggestion = item.get("bridge_suggestion", {}) + if not isinstance(suggestion, dict) or not suggestion: + continue + report["suggestions"] += 1 + from_carrier_payload = suggestion.get("from_carrier", {}) + to_carrier_payload = suggestion.get("to_carrier", {}) + if not isinstance(from_carrier_payload, dict) or not isinstance(to_carrier_payload, dict): + report["stale_suggestions"] += 1 + continue + from_carrier = _document_object_by_name(doc, from_carrier_payload.get("name", "")) + to_carrier = _document_object_by_name(doc, to_carrier_payload.get("name", "")) + if not is_route_carrier(from_carrier) or not is_route_carrier(to_carrier): + report["stale_suggestions"] += 1 + continue + try: + from_point = _vector(suggestion.get("from_point", {})) + to_point = _vector(suggestion.get("to_point", {})) + except Exception: + report["stale_suggestions"] += 1 + continue + if _distance(from_point, to_point) <= DEFAULT_NODE_TOLERANCE: + report["duplicates"] += 1 + continue + if _route_bridge_already_exists(doc, from_point, to_point): + report["duplicates"] += 1 + continue + label = "QET User Bridge {0} -> {1}".format( + from_carrier_payload.get("label") or from_carrier_payload.get("name") or "Path A", + to_carrier_payload.get("label") or to_carrier_payload.get("name") or "Path B", + ) + bridge = create_route_carrier( + doc, + [from_point, to_point], + label=label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=min( + _route_carrier_capacity_value(from_carrier, default=1), + _route_carrier_capacity_value(to_carrier, default=1), + ), + ) + report["created"].append(bridge) + return report + + +def mark_cabinet_interior_boundaries_from_selection(selection_ex): + """Mark selected FreeCAD objects as cabinet interior routing boundaries.""" + marked = [] + seen_sources = set() + for item in selection_ex or []: + source = getattr(item, "Object", None) + if source is None or id(source) in seen_sources: + continue + seen_sources.add(id(source)) + if is_route_carrier(source) or not _object_has_bbox(source): + continue + # 这里只写 FreeCAD 文档对象语义,后续布线按包围盒判断是否跑出柜内区域。 + _set_cabinet_interior_boundary_semantics(source) + marked.append(source) + return marked + + +def _wire_duct_centerline_spec_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): + extents = { + axis: _bbox_extent(bbox, axis) + for axis in ("x", "y", "z") + } + main_axis = max(extents, key=extents.get) + sorted_extents = sorted(extents.values(), reverse=True) + if sorted_extents[0] <= DEFAULT_NODE_TOLERANCE: + return {"centerline": [], "open_ends": []} + if len(sorted_extents) > 1 and sorted_extents[1] > DEFAULT_NODE_TOLERANCE: + if sorted_extents[0] / sorted_extents[1] < float(min_aspect or 1.0): + return {"centerline": [], "open_ends": []} + + low, high = _bbox_axis_range(bbox, main_axis) + center = _bbox_center(bbox) + usable_margin = max(float(margin or 0.0), 0.0) + if abs(high - low) <= usable_margin * 2.0: + usable_margin = 0.0 + + start = _set_axis(center, main_axis, low + usable_margin) + end = _set_axis(center, main_axis, high - usable_margin) + if _distance(start, end) <= DEFAULT_NODE_TOLERANCE: + return {"centerline": [], "open_ends": []} + + cross_axes = sorted( + [axis for axis in ("x", "y", "z") if axis != main_axis], + key=lambda axis: _bbox_extent(bbox, axis), + reverse=True, + ) + open_ends = [] + if cross_axes: + cross_axis = cross_axes[0] + cross_extent = _bbox_extent(bbox, cross_axis) + half_length = max( + min(cross_extent * 0.5, float(margin or DEFAULT_WIRE_DUCT_MARGIN)), + min(cross_extent * 0.5, DEFAULT_WIRE_DUCT_OPEN_END_MIN_LENGTH * 0.5), + ) + if half_length > DEFAULT_NODE_TOLERANCE: + for endpoint in (start, end): + open_ends.append( + [ + _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) - half_length), + _set_axis(endpoint, cross_axis, _axis_value(center, cross_axis) + half_length), + ] + ) + + return { + "centerline": [start, end], + "open_ends": open_ends, + "main_axis": main_axis, + } + + +def _wire_duct_centerline_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): + return _wire_duct_centerline_spec_from_bbox( + bbox, + margin=margin, + min_aspect=min_aspect, + ).get("centerline", []) + + +def _sync_wire_duct_source_carriers( + doc, + source, + spec, + project_uuid="", + capacity=1, + end_margin=DEFAULT_WIRE_DUCT_MARGIN, +): + carriers = _live_source_carriers(doc, source) + if not carriers: + return False + + desired = [ + (spec.get("centerline", []), ROUTE_CARRIER_KIND_WIRE_DUCT), + ] + desired.extend( (points, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END) for points in (spec.get("open_ends", []) or []) ) @@ -1715,7 +2300,7 @@ def _source_kind_value(source): return (getattr(source, "QetRoutingSourceKind", "") or "").strip() -def _set_route_carrier_source_metadata(carrier, source, source_kind=""): +def _set_route_carrier_source_metadata(carrier, source, source_kind="", source_path_index=None): if carrier is None or source is None: return source_name = (getattr(source, "Name", "") or "").strip() @@ -1743,6 +2328,32 @@ def _set_route_carrier_source_metadata(carrier, source, source_kind=""): "Routing source kind that generated this route carrier", kind, ) + if source_path_index is not None: + TerminalObjects.ensure_string_property( + carrier, + "QetRouteSourcePathIndex", + PROPERTY_GROUP, + "1-based path index generated from the same routing source", + str(source_path_index), + ) + elif "QetRouteSourcePathIndex" in getattr(carrier, "PropertiesList", []) or getattr( + carrier, "QetRouteSourcePathIndex", "" + ): + TerminalObjects.ensure_string_property( + carrier, + "QetRouteSourcePathIndex", + PROPERTY_GROUP, + "1-based path index generated from the same routing source", + "", + ) + constraint_mode = (getattr(source, "QetRouteConstraintMode", "") or "").strip() + TerminalObjects.ensure_string_property( + carrier, + "QetRouteConstraintMode", + PROPERTY_GROUP, + "Route constraint mode for automatic routing", + constraint_mode, + ) def _remember_source_carriers(source, carriers): @@ -1753,8 +2364,19 @@ def _remember_source_carriers(source, carriers): ] if live_names: source_kind = _source_kind_value(source) - for carrier in carriers or []: - _set_route_carrier_source_metadata(carrier, source, source_kind=source_kind) + for index, carrier in enumerate(carriers or [], start=1): + # 多 Wire 草图会生成多条 UserPath,序号用于诊断和路径样例回溯。 + source_path_index = ( + index + if source_kind == ROUTE_CARRIER_KIND_USER_PATH and len(live_names) > 1 + else None + ) + _set_route_carrier_source_metadata( + carrier, + source, + source_kind=source_kind, + source_path_index=source_path_index, + ) TerminalObjects.ensure_string_property( source, "QetRouteCarrierNamesJson", @@ -1833,6 +2455,24 @@ def _mark_user_path_source(source, carrier): pass +def _mark_user_path_source_carriers(source, carriers): + carriers = [carrier for carrier in (carriers or []) if carrier is not None] + if source is None or not carriers: + return + try: + _set_user_path_source_semantics(source) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierName", + PROPERTY_GROUP, + "Generated route carrier for this source", + getattr(carriers[0], "Name", ""), + ) + _remember_source_carriers(source, carriers) + except Exception: + pass + + def _mark_terminal_access_source(source, carrier): if source is None or carrier is None: return @@ -1973,6 +2613,67 @@ def detect_wiring_cut_out_sources(doc): return sources +def detect_user_path_sources(doc): + """Return sketch/Draft-like route path source objects that can become UserPath carriers.""" + sources = [] + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if id(obj) in seen: + continue + seen.add(id(obj)) + if _is_route_path_source_object(obj): + sources.append(obj) + return sources + + +def _source_sample(obj): + return { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "type_id": getattr(obj, "TypeId", ""), + "source_kind": (getattr(obj, "QetRoutingSourceKind", "") or "").strip(), + } + + +def routing_source_summary(doc): + """Summarize routing sources without creating or modifying carriers.""" + wire_duct_sources = detect_wire_duct_sources(doc) + support_surface_sources = detect_support_surface_sources(doc) + wiring_cut_out_sources = detect_wiring_cut_out_sources(doc) + user_path_sources = detect_user_path_sources(doc) + carriers = collect_route_carriers(doc) + candidate_objects = [] + seen = set() + for obj in ( + list(wire_duct_sources) + + list(support_surface_sources) + + list(wiring_cut_out_sources) + + list(user_path_sources) + ): + if id(obj) in seen: + continue + seen.add(id(obj)) + candidate_objects.append(obj) + marked_source_counts = {} + for obj in list(getattr(doc, "Objects", []) or []): + if obj is None or is_route_carrier(obj): + continue + source_kind = _source_kind_value(obj) + if not source_kind: + continue + marked_source_counts[source_kind] = marked_source_counts.get(source_kind, 0) + 1 + return { + "wire_duct_sources": len(wire_duct_sources), + "support_surface_sources": len(support_surface_sources), + "wiring_cut_out_sources": len(wiring_cut_out_sources), + "user_path_sources": len(user_path_sources), + "candidate_sources": len(candidate_objects), + "route_carriers": len(carriers), + "marked_source_counts": marked_source_counts, + "candidate_samples": [_source_sample(obj) for obj in candidate_objects[:8]], + } + + def prepare_layout_space_sources_from_document(doc, project_uuid=""): """Normalize the current FreeCAD document as an EPLAN-style layout space. @@ -2143,6 +2844,7 @@ def create_surface_carriers_from_document( offset=offset, margin=margin, ) + capacity = _route_carrier_capacity_value(source, default=1) live_carriers = _live_source_carriers(doc, source) if live_carriers: updated = [] @@ -2152,6 +2854,7 @@ def create_surface_carriers_from_document( points, project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + capacity=capacity, ): updated.append(carrier) source_created = [] @@ -2165,6 +2868,7 @@ def create_surface_carriers_from_document( label="QET Auto Support Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + capacity=capacity, ) source_created.append(carrier) created.append(carrier) @@ -2194,6 +2898,7 @@ def create_surface_carriers_from_document( label="QET Auto Support Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + capacity=capacity, ) source_created.append(carrier) created.append(carrier) @@ -2251,6 +2956,17 @@ def _json_route_point(item): return None +def _local_route_point_items(parsed): + if isinstance(parsed, list): + return parsed + if isinstance(parsed, dict): + for key in ("points", "route_points", "local_points"): + value = parsed.get(key) + if isinstance(value, list): + return value + return None + + def _terminal_local_route_points(terminal): for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): raw = (getattr(terminal, property_name, "") or "").strip() @@ -2260,7 +2976,10 @@ def _terminal_local_route_points(terminal): parsed = json.loads(raw) except Exception: continue - points = [_json_route_point(item) for item in parsed if item is not None] + point_items = _local_route_point_items(parsed) + if point_items is None: + continue + points = [_json_route_point(item) for item in point_items if item is not None] points = [point for point in points if point is not None] if points: return points @@ -2287,17 +3006,18 @@ def _terminal_local_route_issue(terminal): } ) continue - if not isinstance(parsed, list): + point_items = _local_route_point_items(parsed) + if point_items is None: invalid_samples.append( { "property_name": property_name, "reason": "not_array", - "message": "Local route points JSON must be an array.", + "message": "Local route points JSON must be an array or an object with a points array.", "raw_sample": raw[:160], } ) continue - points = [_json_route_point(item) for item in parsed if item is not None] + points = [_json_route_point(item) for item in point_items if item is not None] valid_points = [point for point in points if point is not None] if len(_normalized_route_points(valid_points)) >= 2: return None @@ -2340,21 +3060,6 @@ def _terminal_parent_chain(terminal): return chain -def _placement_mult_vec(placement, point): - if placement is None: - return point - try: - transformed = placement.multVec(point) - if transformed is not None: - return _vector(transformed) - except Exception: - pass - base = getattr(placement, "Base", None) - if base is not None: - return App.Vector(point.x + base.x, point.y + base.y, point.z + base.z) - return point - - def _terminal_local_point_to_global(terminal, local_point): try: if hasattr(terminal, "getGlobalPlacement"): @@ -2369,7 +3074,98 @@ def _terminal_local_point_to_global(terminal, local_point): return point -def terminal_access_path_points(terminal, exit_length=20.0): +def _document_point_to_terminal_local(terminal, point): + point = _vector(point) + try: + if hasattr(terminal, "getGlobalPlacement"): + placement = terminal.getGlobalPlacement() + inverse = placement.inverse() + transformed = inverse.multVec(point) + if transformed is not None: + return _vector(transformed) + except Exception: + pass + + origin = _vector(TerminalObjects.terminal_origin(terminal)) + return App.Vector( + float(point.x) - float(origin.x), + float(point.y) - float(origin.y), + float(point.z) - float(origin.z), + ) + + +def _local_route_point_payload(point): + point = _vector(point) + return [float(point.x), float(point.y), float(point.z)] + + +def set_terminal_local_route_points(terminal, document_points): + """Store a field-authored local exit path on one engineering terminal.""" + if not TerminalObjects.is_terminal_object(terminal): + raise RoutingNetworkError("请选择一个可布线端子,再设置端子局部出线路径。") + points = _normalized_route_points([_vector(point) for point in list(document_points or [])]) + if len(points) < 2: + raise RoutingNetworkError("端子局部出线路径至少需要两个有效路径点。") + + local_points = [_document_point_to_terminal_local(terminal, point) for point in points] + local_points = _normalized_route_points(local_points) + if len(local_points) < 2: + raise RoutingNetworkError("端子局部出线路径转换后少于两个有效路径点。") + payload = [_local_route_point_payload(point) for point in local_points] + TerminalObjects.ensure_string_property( + terminal, + "QetTerminalLocalRoutePointsJson", + PROPERTY_GROUP, + "端子到柜内主路径网络前的局部出线路径点", + json.dumps(payload, ensure_ascii=False), + ) + try: + terminal.Document.recompute() + except Exception: + pass + return { + "terminal": terminal, + "point_count": len(payload), + "property_name": "QetTerminalLocalRoutePointsJson", + "points": payload, + } + + +def set_terminal_local_route_points_from_selection(selection_ex): + """Use one selected terminal and one selected sketch/edge path as its local exit path.""" + terminal = None + support_face = _support_face_from_selection(selection_ex) + route_runs = [] + + for item in list(selection_ex or []): + source = getattr(item, "Object", None) + if TerminalObjects.is_terminal_object(source): + if terminal is not None and source is not terminal: + raise RoutingNetworkError("一次只能为一个端子设置局部出线路径。") + terminal = source + continue + if support_face is not None and _selection_item_is_only_support_face(item): + continue + + point_runs = _point_runs_from_selection_item(item) + if support_face is not None: + # 允许用户先选安装板/面,再选草图线,把局部出线贴到该面附近。 + point_runs = [_project_points_to_face(points, support_face) for points in point_runs] + for points in point_runs: + normalized = _normalize_point_run(points) + if len(normalized) >= 2: + route_runs.append(normalized) + + if terminal is None: + raise RoutingNetworkError("请同时选中一个可布线端子和一条草图/Draft 局部出线路径。") + if not route_runs: + raise RoutingNetworkError("请选择至少包含两个点的草图、Draft 线、边或路径对象。") + if len(route_runs) > 1: + raise RoutingNetworkError("端子局部出线路径一次只支持一条连续路径,请只选择一条草图线或一个连续 Wire。") + return set_terminal_local_route_points(terminal, route_runs[0]) + + +def terminal_access_path_points(terminal, exit_length=20.0): """Return terminal-to-network access points, honoring optional local route metadata.""" origin = _vector(TerminalObjects.terminal_origin(terminal)) local_points = _terminal_local_route_points(terminal) @@ -2479,6 +3275,9 @@ def rank_connection_point_candidates(network, candidates): if max_primary_segments > 0 and component_primary_segments <= 0: score += DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY carrier_kind = (getattr(candidate.get("carrier"), "QetRouteCarrierKind", "") or "").strip() + if max_primary_segments > 0 and not _is_primary_route_carrier(candidate.get("carrier")): + # 同一网络组件里也优先接线槽/UserPath/过线孔;RoutingRange 只是兜底布线面。 + score += DEFAULT_TERMINAL_ACCESS_FALLBACK_CARRIER_PENALTY if max_primary_segments > 0 and carrier_kind == ROUTE_CARRIER_KIND_TERMINAL_ACCESS: # 入口候选也要优先真实主路径,避免导线贴到其它端子的局部接入线上起步。 score += DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY @@ -2492,6 +3291,88 @@ def rank_connection_point_candidates(network, candidates): return ranked +def _component_index_by_node(network): + nodes = network.get("nodes", {}) if isinstance(network, dict) else {} + edges = network.get("edges", {}) if isinstance(network, dict) else {} + component_by_node = {} + seen = set() + component_index = 0 + for start_key in nodes.keys(): + if start_key in seen: + continue + stack = [start_key] + seen.add(start_key) + while stack: + key = stack.pop() + component_by_node[key] = component_index + for next_key, _weight, _carrier in edges.get(key, []) or []: + if next_key not in seen: + seen.add(next_key) + stack.append(next_key) + component_index += 1 + return component_by_node + + +def _candidate_component_index(candidate, component_by_node): + for key_name in ("projected_key", "key", "next_key"): + key = candidate.get(key_name) + if key in component_by_node: + return component_by_node[key] + return None + + +def _candidate_identity(candidate): + carrier = candidate.get("carrier") + return ( + candidate.get("projected_key"), + candidate.get("key"), + candidate.get("next_key"), + id(carrier) if carrier is not None else None, + ) + + +def select_diverse_connection_point_candidates(network, candidates, limit=8): + """Select ranked entry candidates while keeping alternate components visible.""" + max_items = max(int(limit or 0), 0) + ranked = [] + seen_identities = set() + for candidate in rank_connection_point_candidates(network, candidates): + identity = _candidate_identity(candidate) + if identity in seen_identities: + continue + seen_identities.add(identity) + ranked.append(candidate) + if max_items <= 0 or len(ranked) <= max_items: + return ranked + + component_by_node = _component_index_by_node(network) + selected = [] + selected_identities = set() + selected_components = set() + deferred = [] + for candidate in ranked: + identity = _candidate_identity(candidate) + component_index = _candidate_component_index(candidate, component_by_node) + if component_index is None or component_index in selected_components: + deferred.append(candidate) + continue + selected.append(candidate) + selected_identities.add(identity) + selected_components.add(component_index) + if len(selected) >= max_items: + return selected + + for candidate in deferred: + identity = _candidate_identity(candidate) + if identity in selected_identities: + continue + selected.append(candidate) + selected_identities.add(identity) + if len(selected) >= max_items: + break + return selected + + def _terminal_access_target_candidate(network, exit_point, max_distance): candidates = connection_point_candidates( network, @@ -2736,6 +3617,8 @@ def create_surface_carriers_from_selection( created = [] for item in selection_ex or []: item_created = [] + selection_source = getattr(item, "Object", None) + capacity = _route_carrier_capacity_value(selection_source, default=1) for sub_object in list(getattr(item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() if shape_type != "face": @@ -2755,6 +3638,7 @@ def create_surface_carriers_from_selection( label="QET Surface Route {0}".format(index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + capacity=capacity, ) item_created.append(carrier) created.append(carrier) @@ -2776,6 +3660,7 @@ def create_surface_carriers_from_selection( offset=offset, margin=margin, ) + capacity = _route_carrier_capacity_value(obj, default=1) source_created = [] label = getattr(obj, "Label", "") or getattr(obj, "Name", "") or "Support Surface" for index, points in enumerate(grids, start=1): @@ -2787,6 +3672,7 @@ def create_surface_carriers_from_selection( label="QET Surface Route {0} {1}".format(label, index), project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + capacity=capacity, ) source_created.append(carrier) created.append(carrier) @@ -2809,6 +3695,7 @@ def build_route_graph( doc, tolerance=DEFAULT_NODE_TOLERANCE, blocked_bboxes=None, + allowed_bboxes=None, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): """Build an undirected graph from every enabled route carrier.""" @@ -2817,8 +3704,10 @@ def build_route_graph( carriers = collect_route_carriers(doc) segment_count = 0 blocked_segment_count = 0 + boundary_filtered_segment_count = 0 bridged_segment_count = 0 blocked_bboxes = list(blocked_bboxes or []) + allowed_bboxes = list(allowed_bboxes or []) segments = [] bridgeable_endpoint_nodes = [] projection_bridge_candidates = [] @@ -2885,12 +3774,13 @@ def build_route_graph( continue projected = _closest_point_on_segment(endpoint, right["start"], right["end"]) distance = _distance(endpoint, projected) - if distance <= tolerance or distance > adjoining_limit: + if distance > adjoining_limit: continue right["points"].append(projected) - projection_bridge_candidates.append( - (endpoint, projected, left_carrier, right_carrier) - ) + if distance > tolerance: + projection_bridge_candidates.append( + (endpoint, projected, left_carrier, right_carrier) + ) for segment in segments: ordered = _sorted_segment_points( @@ -2914,6 +3804,11 @@ def build_route_graph( current_point = nodes[current_key] weight = _distance(previous_point, current_point) if weight > tolerance: + if allowed_bboxes and not _segment_inside_any_bbox(previous_point, current_point, allowed_bboxes): + boundary_filtered_segment_count += 1 + previous_key = current_key + previous_point = current_point + continue if _segment_hits_blocked_bbox(previous_point, current_point, blocked_bboxes): blocked_segment_count += 1 previous_key = current_key @@ -2928,7 +3823,7 @@ def build_route_graph( bridged_pairs = set() def add_bridge_edge(left_key, left_point, left_carrier, right_key, right_point, right_carrier): - nonlocal blocked_segment_count, bridged_segment_count, segment_count + nonlocal blocked_segment_count, boundary_filtered_segment_count, bridged_segment_count, segment_count if left_key == right_key or left_carrier is right_carrier: return pair = tuple(sorted((left_key, right_key))) @@ -2939,6 +3834,9 @@ def build_route_graph( return if any(next_key == right_key for next_key, _weight, _carrier in edges.get(left_key, [])): return + if allowed_bboxes and not _segment_inside_any_bbox(left_point, right_point, allowed_bboxes): + boundary_filtered_segment_count += 1 + return if _segment_hits_blocked_bbox(left_point, right_point, blocked_bboxes): blocked_segment_count += 1 return @@ -2975,6 +3873,8 @@ def build_route_graph( "segment_count": segment_count, "bridged_segment_count": bridged_segment_count, "blocked_segment_count": blocked_segment_count, + "boundary_filtered": bool(allowed_bboxes), + "boundary_filtered_segment_count": boundary_filtered_segment_count, "tolerance": tolerance, } @@ -3182,6 +4082,7 @@ def _carrier_track_payload(carrier): ("source_name", "QetRouteSourceName"), ("source_label", "QetRouteSourceLabel"), ("source_kind", "QetRouteSourceKind"), + ("source_path_index", "QetRouteSourcePathIndex"), ) for payload_key, property_name in source_fields: value = (getattr(carrier, property_name, "") or "").strip() @@ -3211,6 +4112,78 @@ def _carrier_capacity(carrier): return 1 +def _normalized_text_set(values): + return { + str(value or "").strip() + for value in (values or []) + if str(value or "").strip() + } + + +def _carrier_forbidden( + carrier, + forbidden_carrier_names=None, + forbidden_carrier_labels=None, + forbidden_carrier_source_names=None, + forbidden_carrier_source_labels=None, + forbidden_carrier_kinds=None, +): + if carrier is None: + return False + names = _normalized_text_set(forbidden_carrier_names) + labels = _normalized_text_set(forbidden_carrier_labels) + source_names = _normalized_text_set(forbidden_carrier_source_names) + source_labels = _normalized_text_set(forbidden_carrier_source_labels) + kinds = _normalized_text_set(forbidden_carrier_kinds) + if names and (getattr(carrier, "Name", "") or "").strip() in names: + return True + if labels and (getattr(carrier, "Label", "") or "").strip() in labels: + return True + if source_names and (getattr(carrier, "QetRouteSourceName", "") or "").strip() in source_names: + return True + if source_labels and (getattr(carrier, "QetRouteSourceLabel", "") or "").strip() in source_labels: + return True + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + return bool(kinds and kind in kinds) + + +def _required_carrier_criteria( + required_carrier_names=None, + required_carrier_labels=None, + required_carrier_source_names=None, + required_carrier_source_labels=None, + required_carrier_kinds=None, +): + criteria = [] + for kind, values in ( + ("name", required_carrier_names), + ("label", required_carrier_labels), + ("source_name", required_carrier_source_names), + ("source_label", required_carrier_source_labels), + ("kind", required_carrier_kinds), + ): + for value in sorted(_normalized_text_set(values)): + criteria.append((kind, value)) + return criteria[:30] + + +def _carrier_required_mask(carrier, criteria): + if carrier is None or not criteria: + return 0 + values = { + "name": (getattr(carrier, "Name", "") or "").strip(), + "label": (getattr(carrier, "Label", "") or "").strip(), + "source_name": (getattr(carrier, "QetRouteSourceName", "") or "").strip(), + "source_label": (getattr(carrier, "QetRouteSourceLabel", "") or "").strip(), + "kind": (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND, + } + mask = 0 + for index, (kind, expected) in enumerate(criteria): + if values.get(kind, "") == expected: + mask |= 1 << index + return mask + + def shortest_path_with_carriers( network, start_key, @@ -3220,11 +4193,29 @@ def shortest_path_with_carriers( segment_usage_costs=None, segment_reuse_penalty=0.0, excluded_transit_carrier_kinds=None, + forbidden_carrier_names=None, + forbidden_carrier_labels=None, + forbidden_carrier_source_names=None, + forbidden_carrier_source_labels=None, + forbidden_carrier_kinds=None, + required_carrier_names=None, + required_carrier_labels=None, + required_carrier_source_names=None, + required_carrier_source_labels=None, + required_carrier_kinds=None, ): """Dijkstra search with a small extra cost when route direction changes.""" if start_key is None or end_key is None: return None - if start_key == end_key: + required_criteria = _required_carrier_criteria( + required_carrier_names=required_carrier_names, + required_carrier_labels=required_carrier_labels, + required_carrier_source_names=required_carrier_source_names, + required_carrier_source_labels=required_carrier_source_labels, + required_carrier_kinds=required_carrier_kinds, + ) + required_all_mask = (1 << len(required_criteria)) - 1 + if start_key == end_key and required_all_mask == 0: return { "path": [start_key], "segments": [], @@ -3242,17 +4233,17 @@ def shortest_path_with_carriers( } queue = [] counter = 0 - start_state = (start_key, None) + start_state = (start_key, None, 0) distances = {start_state: 0.0} previous = {} - heapq.heappush(queue, (0.0, counter, start_key, None)) + heapq.heappush(queue, (0.0, counter, start_key, None, 0)) while queue: - cost, _counter, key, previous_direction = heapq.heappop(queue) - state = (key, previous_direction) + cost, _counter, key, previous_direction, required_mask = heapq.heappop(queue) + state = (key, previous_direction, required_mask) if cost > distances.get(state, float("inf")): continue - if key == end_key: + if key == end_key and required_mask == required_all_mask: path = [key] segments = [] current_state = state @@ -3308,7 +4299,17 @@ def shortest_path_with_carriers( # TerminalAccess 是端子局部接入线,不能被其它导线当作柜内主路径或公共桥接段。 if carrier_kind in excluded_transit_kinds: continue + if _carrier_forbidden( + carrier, + forbidden_carrier_names=forbidden_carrier_names, + forbidden_carrier_labels=forbidden_carrier_labels, + forbidden_carrier_source_names=forbidden_carrier_source_names, + forbidden_carrier_source_labels=forbidden_carrier_source_labels, + forbidden_carrier_kinds=forbidden_carrier_kinds, + ): + continue direction = _direction_key(nodes[key], nodes[next_key]) + next_required_mask = required_mask | _carrier_required_mask(carrier, required_criteria) bend_cost = 0.0 if previous_direction is not None and direction != previous_direction: bend_cost = float(bend_penalty or 0.0) @@ -3318,7 +4319,7 @@ def shortest_path_with_carriers( capacity = float(_carrier_capacity(carrier)) excess_usage = max(usage_count - capacity + 1.0, 0.0) usage_cost = excess_usage * float(segment_reuse_penalty or 0.0) - next_state = (next_key, direction) + next_state = (next_key, direction, next_required_mask) next_cost = ( cost + float(weight) * _carrier_cost_factor(carrier, kind_cost_factors) @@ -3333,7 +4334,7 @@ def shortest_path_with_carriers( "weight": weight, } counter += 1 - heapq.heappush(queue, (next_cost, counter, next_key, direction)) + heapq.heappush(queue, (next_cost, counter, next_key, direction, next_required_mask)) return None @@ -3348,6 +4349,198 @@ def network_summary(doc, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERAN return _network_summary_from_graph(network) +def collect_route_constraint_options(doc): + """Collect global route constraints marked directly on route carrier objects.""" + payload = { + "required_route_carrier_names": [], + "required_route_carrier_source_names": [], + "required_route_carrier_source_labels": [], + "forbidden_route_carrier_names": [], + "forbidden_route_carrier_source_names": [], + "forbidden_route_carrier_source_labels": [], + } + + def append_once(key, value): + text = str(value or "").strip() + if text and text not in payload[key]: + payload[key].append(text) + + for carrier in collect_route_carriers(doc): + mode = _route_constraint_mode_value(getattr(carrier, "QetRouteConstraintMode", "")) + name = (getattr(carrier, "Name", "") or "").strip() + if not mode or not name: + continue + source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() + source_label = (getattr(carrier, "QetRouteSourceLabel", "") or "").strip() + if mode == ROUTE_CONSTRAINT_MODE_REQUIRED: + if source_name: + # 一个草图/Draft 源对象可能生成多条 UserPath;必经源对象表示经过其中任一相关路径即可。 + append_once("required_route_carrier_source_names", source_name) + append_once("required_route_carrier_source_labels", source_label) + else: + append_once("required_route_carrier_names", name) + elif mode == ROUTE_CONSTRAINT_MODE_FORBIDDEN: + if source_name: + append_once("forbidden_route_carrier_source_names", source_name) + append_once("forbidden_route_carrier_source_labels", source_label) + else: + append_once("forbidden_route_carrier_names", name) + return payload + + +def collect_route_constraint_source_counts(doc): + """Count Required/Forbidden modes stored on route source objects for UI summaries.""" + counts = { + "required": 0, + "forbidden": 0, + } + if doc is None: + return counts + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if obj is None or id(obj) in seen or is_route_carrier(obj): + continue + seen.add(id(obj)) + if not _source_kind_value(obj) and not _is_route_path_source_object(obj): + continue + mode = _route_constraint_mode_value(getattr(obj, "QetRouteConstraintMode", "")) + if mode == ROUTE_CONSTRAINT_MODE_REQUIRED: + counts["required"] += 1 + elif mode == ROUTE_CONSTRAINT_MODE_FORBIDDEN: + counts["forbidden"] += 1 + return counts + + +def _route_constraint_mode_value(mode): + text = str(mode or "").strip() + normalized = text.lower() + if normalized in { + ROUTE_CONSTRAINT_MODE_REQUIRED.lower(), + "must", + "mustpass", + "must_pass", + "requiredpass", + } or text in {"必须经过", "必经"}: + return ROUTE_CONSTRAINT_MODE_REQUIRED + if normalized in { + ROUTE_CONSTRAINT_MODE_FORBIDDEN.lower(), + "forbid", + "blocked", + "avoid", + } or text in {"禁止经过", "禁经", "禁止"}: + return ROUTE_CONSTRAINT_MODE_FORBIDDEN + return "" + + +def _set_route_constraint_mode(obj, mode): + TerminalObjects.ensure_string_property( + obj, + "QetRouteConstraintMode", + PROPERTY_GROUP, + "Route constraint mode for automatic routing", + mode, + ) + + +def _selected_route_carriers_for_constraint(doc, selection_ex): + carriers = [] + seen = set() + for item in selection_ex or []: + source = getattr(item, "Object", None) + if source is None: + continue + candidates = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) + for carrier in candidates: + if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen: + continue + seen.add(id(carrier)) + carriers.append(carrier) + return carriers + + +def mark_route_constraint_mode_from_selection(doc, selection_ex, mode): + normalized = _route_constraint_mode_value(mode) + marked = [] + seen_marked = set() + for item in selection_ex or []: + source = getattr(item, "Object", None) + if source is None: + continue + if not is_route_carrier(source): + _set_route_constraint_mode(source, normalized) + carriers = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) + for carrier in carriers: + if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen_marked: + continue + _set_route_constraint_mode(carrier, normalized) + source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() + source_obj = _document_object_by_name(doc, source_name) + if source_obj is not None: + _set_route_constraint_mode(source_obj, normalized) + seen_marked.add(id(carrier)) + marked.append(carrier) + return marked + + +def set_route_carrier_capacity_from_selection(doc, selection_ex, capacity): + normalized = _normalized_route_capacity(capacity) + marked = [] + seen_marked = set() + source_count = 0 + seen_sources = set() + for item in selection_ex or []: + source = getattr(item, "Object", None) + if source is None: + continue + if not is_route_carrier(source) and id(source) not in seen_sources: + _set_route_carrier_capacity_value(source, normalized) + seen_sources.add(id(source)) + source_count += 1 + carriers = [source] if is_route_carrier(source) else _live_source_carriers(doc, source) + for carrier in carriers: + if carrier is None or not is_route_carrier(carrier) or id(carrier) in seen_marked: + continue + _set_route_carrier_capacity_value(carrier, normalized) + source_name = (getattr(carrier, "QetRouteSourceName", "") or "").strip() + source_obj = _document_object_by_name(doc, source_name) + if source_obj is not None and id(source_obj) not in seen_sources: + _set_route_carrier_capacity_value(source_obj, normalized) + seen_sources.add(id(source_obj)) + source_count += 1 + seen_marked.add(id(carrier)) + marked.append(carrier) + return { + "route_capacity": normalized, + "route_capacity_carriers": len(marked), + "route_capacity_sources": source_count, + } + + +def clear_all_route_constraint_modes(doc): + """Clear global Required/Forbidden route constraints stored in the FreeCAD document.""" + report = { + "route_constraint_carriers": 0, + "route_constraint_sources": 0, + } + if doc is None: + return report + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if obj is None or id(obj) in seen: + continue + seen.add(id(obj)) + mode = str(getattr(obj, "QetRouteConstraintMode", "") or "").strip() + if not mode: + continue + _set_route_constraint_mode(obj, "") + # 源路径对象也可能保存约束;清空它才能避免重生成 carrier 后又继承旧规则。 + if is_route_carrier(obj): + report["route_constraint_carriers"] += 1 + else: + report["route_constraint_sources"] += 1 + return report + + def _network_summary_from_graph(network): kinds = {} for carrier in network.get("carriers", []) or []: @@ -3392,6 +4585,31 @@ def _routing_range_only_network_payload(summary): } +def _component_has_actionable_route_carriers(component): + kinds = component.get("carrier_kinds", {}) if isinstance(component, dict) else {} + if not isinstance(kinds, dict): + return False + actionable_kinds = { + ROUTE_CARRIER_KIND, + ROUTE_CARRIER_KIND_WIRE_DUCT, + ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, + ROUTE_CARRIER_KIND_WIRING_CUT_OUT, + ROUTE_CARRIER_KIND_USER_PATH, + ROUTE_CARRIER_KIND_AUXILIARY_PATH, + ROUTE_CARRIER_KIND_TERMINAL_ACCESS, + } + return any(int(kinds.get(kind, 0) or 0) > 0 for kind in actionable_kinds) + + +def _actionable_isolated_components(components): + actionable = [ + component + for component in components or [] + if isinstance(component, dict) and _component_has_actionable_route_carriers(component) + ] + return actionable if len(actionable) > 1 else [] + + def _route_graph_components(network): nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} @@ -3466,6 +4684,97 @@ def _wire_duct_endpoint_breaks(network): return breaks +def _route_component_bridge_suggestion(component, components, network): + carrier_by_name = { + getattr(carrier, "Name", ""): carrier + for carrier in network.get("carriers", []) or [] + if getattr(carrier, "Name", "") + } + source_names = set(component.get("carrier_names", []) or []) + source_carriers = [] + for name in source_names: + carrier = carrier_by_name.get(name) + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() if carrier is not None else "" + if kind in {ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END}: + source_carriers.append(carrier) + if not source_carriers: + source_carriers = [carrier_by_name.get(name) for name in source_names if carrier_by_name.get(name) is not None] + + target_carriers = [] + for target_component in components or []: + if target_component is component or not bool(target_component.get("has_terminal_access", False)): + continue + for name in target_component.get("carrier_names", []) or []: + carrier = carrier_by_name.get(name) + if carrier is not None: + target_carriers.append((target_component, carrier)) + + best = None + for source in source_carriers: + source_points = _carrier_points(source) + if len(source_points) < 2: + continue + for target_component, target in target_carriers: + target_points = _carrier_points(target) + if len(target_points) < 2: + continue + nearest = _nearest_points_between_route_point_runs(source_points, target_points) + if nearest is None: + continue + distance, source_point, target_point = nearest + if best is None or distance < best[0]: + best = (distance, source, target, source_point, target_point, target_component) + + if best is None: + return {} + distance, source, target, source_point, target_point, target_component = best + return { + "distance_mm": float(distance), + "from_component_index": component.get("index"), + "to_component_index": target_component.get("index"), + "from_carrier": _carrier_track_payload(source), + "to_carrier": _carrier_track_payload(target), + "from_point": _point_payload(source_point), + "to_point": _point_payload(target_point), + "suggested_action": "create_user_path_bridge", + } + + +def _wire_duct_components_without_terminal_access(components, network=None): + has_terminal_access_network = any( + bool(component.get("has_terminal_access", False)) + for component in components or [] + if isinstance(component, dict) + ) + if not has_terminal_access_network: + return [] + result = [] + for component in components or []: + kinds = component.get("carrier_kinds", {}) if isinstance(component, dict) else {} + if not isinstance(kinds, dict): + continue + has_wire_duct = ( + int(kinds.get(ROUTE_CARRIER_KIND_WIRE_DUCT, 0) or 0) > 0 + or int(kinds.get(ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, 0) or 0) > 0 + ) + if not has_wire_duct or bool(component.get("has_terminal_access", False)): + continue + payload = { + "index": component.get("index"), + "nodes": int(component.get("nodes", 0) or 0), + "segments": int(component.get("segments", 0) or 0), + "carrier_kinds": dict(kinds), + "carrier_names": list(component.get("carrier_names", []) or [])[:12], + "code": "wire_duct_without_terminal_access", + } + if isinstance(network, dict): + suggestion = _route_component_bridge_suggestion(component, components, network) + if suggestion: + payload["bridge_suggestion"] = suggestion + result.append(payload) + return result + + def _invalid_route_carriers(network): invalid = [] for carrier in network.get("carriers", []) or []: @@ -3484,6 +4793,96 @@ def _invalid_route_carriers(network): return invalid +def _cabinet_interior_boundary_bboxes(doc): + bboxes = [] + for obj in list(getattr(doc, "Objects", []) or []): + if not is_routing_boundary(obj): + continue + bbox = _bound_box_from_object(obj) + if bbox is not None: + bboxes.append(bbox) + return bboxes + + +def _route_carriers_outside_boundary(network, boundary_bboxes): + if not boundary_bboxes: + return [] + outside = [] + for carrier in network.get("carriers", []) or []: + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + if kind == ROUTE_CARRIER_KIND_TERMINAL_ACCESS: + continue + points = _carrier_points(carrier) + outside_points = [ + point + for point in points + if not _point_inside_any_bbox(point, boundary_bboxes) + ] + if not outside_points: + continue + # 这里只检查路径源是否已经跑出柜内空间,真实导线结果仍由 AutoRouting 再做候选评分。 + outside.append( + { + "carrier": _carrier_track_payload(carrier), + "point_count": len(points), + "outside_point_count": len(outside_points), + "outside_points": [_point_payload(point) for point in outside_points[:5]], + "code": "route_carrier_outside_boundary", + } + ) + return outside + + +def _terminals_outside_boundary(terminals, boundary_bboxes, terminal_exit_length=20.0): + if not boundary_bboxes: + return [] + outside = [] + for terminal in terminals or []: + check_points = [] + try: + check_points.append(_vector(TerminalObjects.terminal_origin(terminal))) + except Exception: + pass + try: + access_points = terminal_access_path_points(terminal, terminal_exit_length) + except Exception: + access_points = [] + if access_points: + check_points.append(_vector(access_points[-1])) + outside_points = [ + point + for point in check_points + if not _point_inside_any_bbox(point, boundary_bboxes) + ] + if not outside_points: + continue + # 端子在柜外通常表示设备还没装进真实柜内位置,后续求路很容易产生长接入或柜外线。 + payload = _terminal_diagnostic_payload(terminal) + payload.update( + { + "outside_point_count": len(outside_points), + "outside_points": [_point_payload(point) for point in outside_points[:3]], + "code": "terminal_outside_boundary", + } + ) + outside.append(payload) + return outside + + +def _diagnostic_issue_codes(issues): + codes = [] + seen = set() + for issue in issues or []: + if not isinstance(issue, dict): + continue + code = str(issue.get("code", "") or "").strip() + if not code or code in seen: + continue + seen.add(code) + codes.append(code) + return codes + + def _polyline_length(points): total = 0.0 previous = None @@ -3496,18 +4895,61 @@ def _polyline_length(points): def _terminal_diagnostic_payload(terminal): - return { + payload = { "name": getattr(terminal, "Name", ""), "label": getattr(terminal, "Label", ""), "terminal_uuid": (getattr(terminal, "QetTerminalUuid", "") or "").strip(), "instance_id": (getattr(terminal, "QetInstanceId", "") or "").strip(), } + try: + origin = TerminalObjects.terminal_origin(terminal) + payload["terminal_origin"] = _point_payload(origin) + except Exception: + pass + # 长接入通常和设备装配位置或端子局部出线路径有关,带上父设备便于手测时直接定位。 + for parent in _terminal_parent_chain(terminal): + payload["parent_device_name"] = getattr(parent, "Name", "") or "" + payload["parent_device_label"] = getattr(parent, "Label", "") or "" + payload["parent_device_instance_id"] = ( + getattr(parent, "QetInstanceId", "") or "" + ).strip() + payload["parent_device_element_uuid"] = ( + getattr(parent, "QetElementUuid", "") or "" + ).strip() + break + return payload + + +def _terminal_access_geometry_payload(access_points): + points = [_vector(point) for point in list(access_points or [])] + payload = { + "terminal_access_points": [_point_payload(point) for point in points], + "terminal_access_dominant_axis": "", + "terminal_access_axis_lengths_mm": {"x": 0.0, "y": 0.0, "z": 0.0}, + } + if len(points) < 2: + return payload + axis_lengths = {"x": 0.0, "y": 0.0, "z": 0.0} + previous = points[0] + for current in points[1:]: + axis_lengths["x"] += abs(float(current.x) - float(previous.x)) + axis_lengths["y"] += abs(float(current.y) - float(previous.y)) + axis_lengths["z"] += abs(float(current.z) - float(previous.z)) + previous = current + dominant_axis = max(axis_lengths, key=lambda axis: axis_lengths[axis]) + payload["terminal_access_axis_lengths_mm"] = { + axis: float(length) + for axis, length in axis_lengths.items() + } + payload["terminal_access_dominant_axis"] = dominant_axis if axis_lengths[dominant_axis] > 0.0 else "" + return payload def diagnose_routing_path_network( doc, terminal_exit_length=20.0, terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, + terminal_access_warning_distance=0.0, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): """Inspect the generated routing path network without routing wires.""" @@ -3517,15 +4959,21 @@ def diagnose_routing_path_network( network = build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance) components = _route_graph_components(network) summary = _network_summary_from_graph(network) - isolated_components = components if len(components) > 1 else [] + isolated_components = _actionable_isolated_components(components) unconnected_terminals = [] long_terminal_accesses = [] invalid_terminal_local_routes = [] routing_range_only_network = _routing_range_only_network_payload(summary) + boundary_bboxes = _cabinet_interior_boundary_bboxes(doc) + routable_terminals = _collect_routable_terminals(doc) max_distance = max(float(terminal_access_max_distance or 0.0), 0.0) - 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): + configured_warning_distance = max(float(terminal_access_warning_distance or 0.0), 0.0) + if configured_warning_distance > 0.0: + warning_distance = configured_warning_distance + else: + warning_distance = min(max(max_distance * 0.5, DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE), max_distance) if max_distance > 0.0 else DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE + for terminal in routable_terminals: local_route_issue = _terminal_local_route_issue(terminal) if local_route_issue is not None: invalid_terminal_local_routes.append(local_route_issue) @@ -3565,10 +5013,18 @@ def diagnose_routing_path_network( "code": "terminal_access_long", } ) + payload.update(_terminal_access_geometry_payload(access_points)) long_terminal_accesses.append(payload) possible_breaks = _wire_duct_endpoint_breaks(network) + wire_ducts_without_terminal_access = _wire_duct_components_without_terminal_access(components, network) invalid_route_carriers = _invalid_route_carriers(network) + route_carriers_outside_boundary = _route_carriers_outside_boundary(network, boundary_bboxes) + terminals_outside_boundary = _terminals_outside_boundary( + routable_terminals, + boundary_bboxes, + terminal_exit_length=terminal_exit_length, + ) issues = [] if int(summary.get("segments", 0) or 0) <= 0: issues.append( @@ -3606,6 +5062,15 @@ def diagnose_routing_path_network( "count": len(possible_breaks), } ) + if wire_ducts_without_terminal_access: + issues.append( + { + "severity": "warning", + "code": "wire_ducts_without_terminal_access", + "message": "Some wire duct components are not connected to terminal access carriers.", + "count": len(wire_ducts_without_terminal_access), + } + ) if long_terminal_accesses: issues.append( { @@ -3642,6 +5107,24 @@ def diagnose_routing_path_network( "count": len(invalid_route_carriers), } ) + if route_carriers_outside_boundary: + issues.append( + { + "severity": "warning", + "code": "route_carriers_outside_boundary", + "message": "Some route carriers have points outside cabinet interior boundaries.", + "count": len(route_carriers_outside_boundary), + } + ) + if terminals_outside_boundary: + issues.append( + { + "severity": "warning", + "code": "terminals_outside_boundary", + "message": "Some terminals are outside cabinet interior boundaries.", + "count": len(terminals_outside_boundary), + } + ) return { "summary": summary, @@ -3653,8 +5136,12 @@ def diagnose_routing_path_network( "invalid_terminal_local_routes": invalid_terminal_local_routes, "routing_range_only_network": routing_range_only_network, "invalid_route_carriers": invalid_route_carriers, + "route_carriers_outside_boundary": route_carriers_outside_boundary, + "terminals_outside_boundary": terminals_outside_boundary, "possible_breaks": possible_breaks, + "wire_ducts_without_terminal_access": wire_ducts_without_terminal_access, "issues": issues, + "issue_codes": _diagnostic_issue_codes(issues), "ok": not issues, } @@ -3681,6 +5168,12 @@ def _highlight_routing_network_diagnostics(doc, diagnostic): if item.get("name", "") ) unconnected_terminal_names.update(invalid_local_route_terminal_names) + outside_boundary_terminal_names = set( + item.get("name", "") + for item in diagnostic.get("terminals_outside_boundary", []) or [] + if item.get("name", "") + ) + unconnected_terminal_names.update(outside_boundary_terminal_names) break_carriers = set( item.get("carrier", {}).get("name", "") for item in diagnostic.get("possible_breaks", []) or [] @@ -3691,6 +5184,11 @@ def _highlight_routing_network_diagnostics(doc, diagnostic): for item in diagnostic.get("invalid_route_carriers", []) or [] if item.get("carrier", {}).get("name", "") ) + break_carriers.update( + item.get("carrier", {}).get("name", "") + for item in diagnostic.get("route_carriers_outside_boundary", []) or [] + if item.get("carrier", {}).get("name", "") + ) for obj in list(getattr(doc, "Objects", []) or []): name = getattr(obj, "Name", "") @@ -3723,17 +5221,179 @@ def _clear_routing_path_network_diagnostics(doc, group): return removed +def _diagnostic_items(value): + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _diagnostic_distance_text(value): + try: + return "{0:.1f} mm".format(float(value)) + except Exception: + return "未知距离" + + +def _diagnostic_int(value, fallback=0): + try: + return int(value or 0) + except Exception: + return int(fallback or 0) + + +def _diagnostic_terminal_text(sample): + if not isinstance(sample, dict): + return "未知端子" + return ( + str(sample.get("label", "") or "").strip() + or str(sample.get("terminal_display", "") or "").strip() + or str(sample.get("terminal_uuid", "") or "").strip() + or str(sample.get("name", "") or "").strip() + or "未知端子" + ) + + +def _routing_path_network_diagnostic_message(diagnostic): + if not isinstance(diagnostic, dict): + return "布线路径网络检查失败:诊断结果无效。" + summary = diagnostic.get("summary", {}) if isinstance(diagnostic.get("summary", {}), dict) else {} + issues = _diagnostic_items(diagnostic.get("issues", []) or []) + if not issues: + message = "布线路径网络检查通过:{0} 条 carrier / {1} 段 / {2} 个节点。".format( + summary.get("carriers", 0), + summary.get("segments", 0), + summary.get("nodes", 0), + ) + bridged_segments = _diagnostic_int(summary.get("bridged_segments", 0)) + if bridged_segments > 0: + message += " 自动桥接 {0} 段相邻/投影主路径。".format(bridged_segments) + return message + + message = "布线路径网络检查发现 {0} 类问题。".format(len(issues)) + if any(issue.get("code") == "empty_routing_path_network" for issue in issues): + message += "\n布线路径网络为空:没有可用路径段。" + unconnected = _diagnostic_items(diagnostic.get("unconnected_terminals", []) or []) + if unconnected: + sample = unconnected[0] + message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}。".format( + _diagnostic_terminal_text(sample), + _diagnostic_distance_text(sample.get("nearest_network_distance_mm")), + _diagnostic_distance_text(sample.get("terminal_access_max_distance_mm")), + ) + long_accesses = _diagnostic_items(diagnostic.get("long_terminal_accesses", []) or []) + if long_accesses: + sample = long_accesses[0] + message += "\n端子接入过长:{0},接入段 {1}。".format( + _diagnostic_terminal_text(sample), + _diagnostic_distance_text(sample.get("terminal_access_length_mm")), + ) + invalid_carriers = _diagnostic_items(diagnostic.get("invalid_route_carriers", []) or []) + if invalid_carriers: + sample = invalid_carriers[0] + carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} + message += "\n路径对象几何无效:{0}。".format( + carrier.get("label") or carrier.get("name") or "未知路径对象" + ) + outside_carriers = _diagnostic_items(diagnostic.get("route_carriers_outside_boundary", []) or []) + if outside_carriers: + sample = outside_carriers[0] + carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} + message += "\n路径越出柜内边界:{0},越界点 {1} 个。".format( + carrier.get("label") or carrier.get("name") or "未知路径对象", + _diagnostic_int(sample.get("outside_point_count", 0)), + ) + outside_terminals = _diagnostic_items(diagnostic.get("terminals_outside_boundary", []) or []) + if outside_terminals: + sample = outside_terminals[0] + message += "\n端子越出柜内边界:{0},越界点 {1} 个。".format( + _diagnostic_terminal_text(sample), + _diagnostic_int(sample.get("outside_point_count", 0)), + ) + possible_breaks = _diagnostic_items(diagnostic.get("possible_breaks", []) or []) + if possible_breaks: + sample = possible_breaks[0] + carrier = sample.get("carrier", {}) if isinstance(sample.get("carrier", {}), dict) else {} + message += "\n线槽端点疑似断开:{0}。".format( + carrier.get("label") or carrier.get("name") or "未知线槽" + ) + wire_duct_components = _diagnostic_items(diagnostic.get("wire_ducts_without_terminal_access", []) or []) + if wire_duct_components: + sample = wire_duct_components[0] + carriers = sample.get("carrier_names") or [] + carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知线槽" + suggestion = sample.get("bridge_suggestion", {}) + if isinstance(suggestion, dict) and suggestion: + target = suggestion.get("to_carrier", {}) if isinstance(suggestion.get("to_carrier", {}), dict) else {} + target_text = target.get("label") or target.get("name") or "主网络" + message += "\n线槽未接入端子主网络:{0},建议桥接到 {1},距离 {2}。".format( + carrier_text, + target_text, + _diagnostic_distance_text(suggestion.get("distance_mm")), + ) + else: + message += "\n线槽未接入端子主网络:{0}。".format(carrier_text) + isolated = _diagnostic_items(diagnostic.get("isolated_components", []) or []) + if isolated: + sample = isolated[0] + carriers = sample.get("carrier_labels") or sample.get("carrier_names") or [] + carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知 carrier" + message += "\n存在孤立路径网络:{0}。".format(carrier_text) + return message + + +def _diagnostic_issue_codes_text(issue_codes): + values = [] + seen = set() + for code in list(issue_codes or []): + text = str(code or "").strip() + if not text or text in seen: + continue + seen.add(text) + values.append(text) + return ", ".join(values) + + +_ROUTING_PATH_NETWORK_ISSUE_LABELS = { + "empty_routing_path_network": "布线路径网络为空", + "invalid_route_carriers": "路径对象几何无效", + "routing_range_only_network": "仅使用布线面兜底", + "invalid_terminal_local_routes": "端子局部路径无效", + "route_carriers_outside_boundary": "路径越出柜内边界", + "terminals_outside_boundary": "端子越出柜内边界", + "long_terminal_accesses": "端子接入过长", + "unconnected_terminals": "端子未接入", + "wire_duct_endpoint_breaks": "线槽端点疑似断开", + "wire_ducts_without_terminal_access": "线槽未接入端子主网络", + "isolated_network_components": "存在孤立路径网络", +} + + +def _diagnostic_issue_labels_text(issue_codes): + values = [] + seen = set() + for code in list(issue_codes or []): + text = str(code or "").strip() + label = _ROUTING_PATH_NETWORK_ISSUE_LABELS.get(text, text) + if not label or label in seen: + continue + seen.add(label) + values.append(label) + return "、".join(values) + + def write_routing_path_network_diagnostic( doc, project_uuid="", terminal_exit_length=20.0, terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, + terminal_access_warning_distance=0.0, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, ): diagnostic = diagnose_routing_path_network( doc, terminal_exit_length=terminal_exit_length, terminal_access_max_distance=terminal_access_max_distance, + terminal_access_warning_distance=terminal_access_warning_distance, adjoining_duct_tolerance=adjoining_duct_tolerance, ) group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) @@ -3748,6 +5408,41 @@ def write_routing_path_network_diagnostic( "QET diagnostic kind", "RoutingPathNetwork", ) + TerminalObjects.ensure_string_property( + obj, + "QetProjectUuid", + PROPERTY_GROUP, + "Project UUID", + project_uuid, + ) + TerminalObjects.ensure_bool_property( + obj, + "QetDiagnosticOk", + PROPERTY_GROUP, + "QET diagnostic pass state", + bool(diagnostic.get("ok", False)), + ) + TerminalObjects.ensure_string_property( + obj, + "QetDiagnosticIssueCodes", + PROPERTY_GROUP, + "QET routing diagnostic issue codes", + _diagnostic_issue_codes_text(diagnostic.get("issue_codes", [])), + ) + TerminalObjects.ensure_string_property( + obj, + "QetDiagnosticIssueLabels", + PROPERTY_GROUP, + "QET routing diagnostic issue labels", + _diagnostic_issue_labels_text(diagnostic.get("issue_codes", [])), + ) + TerminalObjects.ensure_string_property( + obj, + "QetDiagnosticMessage", + PROPERTY_GROUP, + "QET routing path network diagnostic message", + _routing_path_network_diagnostic_message(diagnostic), + ) TerminalObjects.ensure_string_property( obj, "QetDiagnosticJson", @@ -3768,9 +5463,6 @@ def write_routing_path_network_diagnostic( def carrier_payload(carrier): - return { - "name": getattr(carrier, "Name", ""), - "label": getattr(carrier, "Label", ""), - "kind": getattr(carrier, "QetRouteCarrierKind", ""), - "points": [_point_payload(point) for point in _carrier_points(carrier)], - } + payload = _carrier_track_payload(carrier) + payload["points"] = [_point_payload(point) for point in _carrier_points(carrier)] + return payload diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 8372a37..327d7fe 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -1,6 +1,9 @@ import importlib import json +import os +import sqlite3 import sys +import tempfile import types import unittest from pathlib import Path @@ -137,10 +140,11 @@ class FakeBoundBox: class FakeShape: - def __init__(self, bbox, edges=None, faces=None): + def __init__(self, bbox, edges=None, faces=None, wires=None): self.BoundBox = bbox self.Edges = edges or [] self.Faces = faces or [] + self.Wires = wires or [] self.Solids = [] self.Shells = [] @@ -157,6 +161,28 @@ class FakeEdge: self.Vertexes = [FakeVertex(start), FakeVertex(end)] +class FakeCurveEdge: + ShapeType = "Edge" + + def __init__(self, points): + self._points = list(points) + self.Vertexes = [FakeVertex(self._points[0]), FakeVertex(self._points[-1])] + + def discretize(self, *args, **kwargs): + return list(self._points) + + +class FakeWire: + ShapeType = "Wire" + + def __init__(self, points, edges=None): + self._points = list(points) + self.Edges = edges or [] + + def discretize(self, *args, **kwargs): + return list(self._points) + + class FakeFace: ShapeType = "Face" @@ -273,6 +299,8 @@ class AutoRoutingTest(unittest.TestCase): wire = result["wire"] payload = json.loads(wire.QetRouteDiagnosticsJson) + self.assertEqual("N4111: terminal-start -> terminal-end (Routed)", wire.Label) + self.assertEqual(wire.Label, result["wire_object_label"]) self.assertGreater(float(wire.QetRouteLengthMm), 0.0) self.assertEqual("42", wire.QetWireStyleId) self.assertEqual("42", payload["wire_style_id"]) @@ -281,35 +309,80 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("WireDuct", payload["route_track"]["segments"][0]["carrier"]["kind"]) self.assertTrue(json.loads(wire.QetRouteTrackJson)["carrier_names"]) - def test_route_track_preserves_generated_carrier_source_metadata(self): + def test_eplan_connection_route_applies_wire_style_from_wire_properties_database(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + 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") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "线槽A" - duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL, + diameter_mm REAL, + area_or_spec TEXT + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width, diameter_mm, area_or_spec) + VALUES + (42, 'project-1', '蓝色控制线', '#3366CC', 3.5, 1.25, '1.25mm2') + """ + ) + connection.commit() + finally: + connection.close() - auto_routing_panel.AutoRoutingController().generate_routing_paths() - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - route_track = json.loads(result["wire"].QetRouteTrackJson) - wire_duct_carriers = [ - segment["carrier"] - for segment in route_track["segments"] - if segment["carrier"]["kind"] == "WireDuct" - ] + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-1", + wire_label="N4111", + options={ + "wire_style_id": "42", + "wire_style_database_path": str(db_path), + }, + ) - self.assertTrue(wire_duct_carriers) - self.assertEqual("WireDuctA", wire_duct_carriers[0].get("source_name")) - self.assertEqual("线槽A", wire_duct_carriers[0].get("source_label")) - self.assertEqual("WireDuct", wire_duct_carriers[0].get("source_kind")) + wire = result["wire"] + style = json.loads(wire.QetWireStyleJson) + diagnostics = json.loads(wire.QetRouteDiagnosticsJson) - def test_route_track_records_carrier_capacity(self): + self.assertEqual((0.2, 0.4, 0.8), wire.ViewObject.LineColor) + self.assertEqual(3.5, wire.ViewObject.LineWidth) + self.assertEqual("Resolved", wire.QetWireStyleStatus) + self.assertEqual("蓝色控制线", wire.QetWireStyleName) + self.assertEqual("1.25mm2", wire.QetWireSpecText) + self.assertEqual("#3366CC", wire.QetWireColorText) + self.assertEqual("42", style["id"]) + self.assertEqual("蓝色控制线", style["name"]) + self.assertEqual("#3366CC", style["line_color"]) + self.assertEqual(3.5, style["line_width"]) + self.assertEqual("1.25mm2", style["area_or_spec"]) + self.assertEqual("Resolved", diagnostics["wire_style_status"]) + self.assertEqual(style, diagnostics["wire_style"]) + + def test_eplan_connection_route_reports_missing_wire_style(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -322,15 +395,30 @@ class AutoRoutingTest(unittest.TestCase): [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", - capacity=3, ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - route_track = json.loads(result["wire"].QetRouteTrackJson) + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-missing-style", + options={ + "wire_style_id": "404", + "wire_style_lookup": lambda _style_id, _project_uuid: {}, + }, + ) - self.assertEqual(3, route_track["segments"][0]["carrier"]["capacity"]) + wire = result["wire"] + diagnostics = json.loads(wire.QetRouteDiagnosticsJson) - def test_network_eplan_connection_route_offsets_lane_by_route_index(self): + self.assertEqual("404", wire.QetWireStyleId) + self.assertEqual("Missing", wire.QetWireStyleStatus) + self.assertEqual("Missing", diagnostics["wire_style_status"]) + self.assertNotIn("wire_style", diagnostics) + self.assertEqual((0.0, 0.35, 1.0), wire.ViewObject.LineColor) + self.assertEqual(5.0, wire.ViewObject.LineWidth) + + def test_eplan_connection_route_uses_wire_properties_database_from_environment(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -344,44 +432,55 @@ class AutoRoutingTest(unittest.TestCase): project_uuid="project-1", kind="WireDuct", ) + previous = os.environ.get("QET_WIRE_PROPERTIES_DB") + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width) + VALUES + (7, 'project-1', '红色动力线', 'rgb(255,0,0)', 6.0) + """ + ) + connection.commit() + finally: + connection.close() + os.environ["QET_WIRE_PROPERTIES_DB"] = str(db_path) + try: + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-env-style", + options={"wire_style_id": "7"}, + ) + finally: + if previous is None: + os.environ.pop("QET_WIRE_PROPERTIES_DB", None) + else: + os.environ["QET_WIRE_PROPERTIES_DB"] = previous - first = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - route_index=0, - wire_uuid="wire-1", - options={"lane_spacing": 12.0, "lane_axis": "y"}, - ) - second = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - route_index=1, - wire_uuid="wire-2", - options={"lane_spacing": 12.0, "lane_axis": "y"}, - ) - third = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - route_index=2, - wire_uuid="wire-3", - options={"lane_spacing": 12.0, "lane_axis": "y"}, - ) - payload = json.loads(second["wire"].QetRouteDiagnosticsJson) - third_payload = json.loads(third["wire"].QetRouteDiagnosticsJson) + wire = result["wire"] - self.assertTrue(any(abs(point.y - 0.0) <= 0.001 for point in first["points"][1:-1])) - self.assertTrue(any(abs(point.y - 12.0) <= 0.001 for point in second["points"][1:-1])) - self.assertTrue(any(abs(point.y + 12.0) <= 0.001 for point in third["points"][1:-1])) - self.assertEqual(1, payload["lane"]["index"]) - self.assertEqual("y", payload["lane"]["axis"]) - self.assertEqual(12.0, payload["lane"]["offset_mm"]) - self.assertEqual(2, third_payload["lane"]["index"]) - self.assertEqual(-12.0, third_payload["lane"]["offset_mm"]) + self.assertEqual((1.0, 0.0, 0.0), wire.ViewObject.LineColor) + self.assertEqual(6.0, wire.ViewObject.LineWidth) + self.assertEqual("红色动力线", json.loads(wire.QetWireStyleJson)["name"]) - def test_network_eplan_connection_route_removes_collinear_network_points(self): + def test_eplan_connection_route_applies_wire_style_line_type(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -391,65 +490,131 @@ class AutoRoutingTest(unittest.TestCase): end = _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(50, 0, 20), - app.Vector(100, 0, 20), - ], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_type TEXT, + line_width REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_type, line_width) + VALUES + (8, 'project-1', '虚线样式', '#00AA00', 'DashLine', 2.5) + """ + ) + connection.commit() + finally: + connection.close() - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "wire_style_id": "8", + "wire_style_database_path": str(db_path), + }, + ) - point_tuples = [(point.x, point.y, point.z) for point in result["points"]] - self.assertNotIn((50.0, 0.0, 20.0), point_tuples) - self.assertEqual( - [ - (0.0, 0.0, 0.0), - (0.0, 0.0, 20.0), - (100.0, 0.0, 20.0), - (100.0, 0.0, 0.0), - ], - point_tuples, - ) + wire = result["wire"] + style = json.loads(wire.QetWireStyleJson) - def test_eplan_connection_route_replaces_existing_wire_uuid_when_endpoints_change(self): + self.assertEqual("Dashed", wire.ViewObject.DrawStyle) + self.assertEqual("DashLine", style["line_type"]) + + def test_eplan_connection_route_accepts_bare_hex_color_and_diameter_width(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start_old = _terminal(doc, terminal_objects, "TerminalStartOld", "terminal-start-old", app.Vector(0, 0, 0)) - end_old = _terminal(doc, terminal_objects, "TerminalEndOld", "terminal-end-old", app.Vector(100, 0, 0)) - start_new = _terminal(doc, terminal_objects, "TerminalStartNew", "terminal-start-new", app.Vector(0, 40, 0)) - end_new = _terminal(doc, terminal_objects, "TerminalEndNew", "terminal-end-new", app.Vector(100, 40, 0)) + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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), - app.Vector(100, 40, 20), - app.Vector(0, 40, 20), - ], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL, + diameter_mm REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width, diameter_mm) + VALUES + (12, 'project-1', '无井号十六进制颜色', '3366CC', NULL, 2.25), + (13, 'project-1', '0x十六进制颜色', '0x3366CC', NULL, 1.5) + """ + ) + connection.commit() + finally: + connection.close() - auto_routing.route_eplan_connection_between_terminals(doc, start_old, end_old, wire_uuid="wire-1") - auto_routing.route_eplan_connection_between_terminals(doc, start_new, end_new, wire_uuid="wire-1") - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "wire_style_id": "12", + "wire_style_database_path": str(db_path), + }, + ) - self.assertEqual(1, len(routed_wires)) - self.assertEqual("terminal-start-new", routed_wires[0].QetStartTerminalUuid) - self.assertEqual("terminal-end-new", routed_wires[0].QetEndTerminalUuid) + wire = result["wire"] - def test_eplan_connection_route_keeps_existing_wire_when_replacement_fails(self): + self.assertEqual((0.2, 0.4, 0.8), wire.ViewObject.LineColor) + self.assertEqual(2.25, wire.ViewObject.LineWidth) + + second_result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "wire_style_id": "13", + "wire_style_database_path": str(db_path), + }, + ) + second_wire = second_result["wire"] + + self.assertEqual((0.2, 0.4, 0.8), second_wire.ViewObject.LineColor) + self.assertEqual(1.5, second_wire.ViewObject.LineWidth) + + def test_eplan_connection_route_estimates_width_from_wire_spec_area(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + 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") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) @@ -459,29 +624,53 @@ class AutoRoutingTest(unittest.TestCase): project_uuid="project-1", kind="WireDuct", ) - first = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - )["wire"] - routing_network.clear_route_carriers(doc) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL, + diameter_mm REAL, + area_or_spec TEXT + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width, diameter_mm, area_or_spec) + VALUES + (14, 'project-1', '按截面积估算', '#AA0000', NULL, NULL, '2.5mm2') + """ + ) + connection.commit() + finally: + connection.close() - with self.assertRaises(auto_routing.AutoRoutingError): - auto_routing.route_eplan_connection_between_terminals( + result = auto_routing.route_eplan_connection_between_terminals( doc, start, end, - wire_uuid="wire-1", + options={ + "wire_style_id": "14", + "wire_style_database_path": str(db_path), + }, ) - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) - self.assertEqual([first], routed_wires) - self.assertIsNotNone(doc.getObject(first.Name)) + wire = result["wire"] - def test_eplan_connection_route_keeps_existing_wire_when_new_geometry_creation_fails(self): + self.assertAlmostEqual(1.784, wire.ViewObject.LineWidth, places=3) + self.assertEqual("2.5mm2", wire.QetWireSpecText) + + def test_eplan_connection_route_accepts_argb_hex_color(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") @@ -493,36 +682,51 @@ class AutoRoutingTest(unittest.TestCase): project_uuid="project-1", kind="WireDuct", ) - first = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - )["wire"] - original_create_wire_geometry = auto_routing._create_wire_geometry + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width) + VALUES + (15, 'project-1', 'ARGB颜色', '#FF3366CC', 2.0) + """ + ) + connection.commit() + finally: + connection.close() - def failing_create_wire_geometry(_doc, _name, _points): - raise RuntimeError("create failed") + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "wire_style_id": "15", + "wire_style_database_path": str(db_path), + }, + ) - auto_routing._create_wire_geometry = failing_create_wire_geometry - try: - with self.assertRaises(RuntimeError): - auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - ) - finally: - auto_routing._create_wire_geometry = original_create_wire_geometry - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + wire = result["wire"] - self.assertEqual([first], routed_wires) - self.assertIsNotNone(doc.getObject(first.Name)) + self.assertEqual((0.2, 0.4, 0.8), wire.ViewObject.LineColor) + self.assertEqual("#FF3366CC", wire.QetWireColorText) - def test_eplan_connection_route_cleans_up_half_created_wire_when_draft_fallback_fails(self): + def test_eplan_connection_route_accepts_decimal_integer_color(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") @@ -534,260 +738,443 @@ class AutoRoutingTest(unittest.TestCase): project_uuid="project-1", kind="WireDuct", ) - first = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - )["wire"] - part_module = sys.modules["Part"] - draft_module = sys.modules.get("Draft") - if draft_module is None: - draft_module = types.ModuleType("Draft") - sys.modules["Draft"] = draft_module - original_make_polygon = part_module.makePolygon - original_make_wire = getattr(draft_module, "make_wire", None) - original_add_object = doc.addObject + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width) + VALUES + (16, 'project-1', '十进制颜色', '16711680', 2.0) + """ + ) + connection.commit() + finally: + connection.close() - def failing_make_polygon(*args, **kwargs): - raise RuntimeError("part unavailable") + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "wire_style_id": "16", + "wire_style_database_path": str(db_path), + }, + ) - def half_created_make_wire(points, closed=False, placement=None, face=None, support=None, bs2wire=False): - obj = doc.addObject("Part::FeaturePython", "Wire") - obj.Points = list(points) - raise RuntimeError("draft failed") + wire = result["wire"] - def failing_add_object(type_name, name): - if type_name == "App::FeaturePython": - raise RuntimeError("fallback failed") - return original_add_object(type_name, name) - - part_module.makePolygon = failing_make_polygon - draft_module.make_wire = half_created_make_wire - doc.addObject = failing_add_object - try: - with self.assertRaises(RuntimeError): - auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - ) - finally: - part_module.makePolygon = original_make_polygon - if original_make_wire is None: - delattr(draft_module, "make_wire") - else: - draft_module.make_wire = original_make_wire - doc.addObject = original_add_object - - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual((1.0, 0.0, 0.0), wire.ViewObject.LineColor) + self.assertEqual("16711680", wire.QetWireColorText) - self.assertEqual([first], routed_wires) - self.assertEqual(0, len([obj for obj in doc.Objects if obj.Name == "Wire"])) - - def test_eplan_connection_route_keeps_existing_wire_when_old_replacement_removal_fails(self): + def test_route_eplan_connections_reuses_wire_style_lookup_for_same_style_id(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 40, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 40, 0)) routing_network.create_route_carrier( doc, [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - first = auto_routing.route_eplan_connection_between_terminals( + routing_network.create_route_carrier( doc, - start, - end, - wire_uuid="wire-1", - )["wire"] - original_remove = auto_routing._remove_routing_connection_objects + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + calls = [] - def failing_remove(target_doc, objects): - if first in list(objects or []): - return 0 - return original_remove(target_doc, objects) + def lookup(style_id, project_uuid): + calls.append((style_id, project_uuid)) + return {"id": style_id, "name": "缓存样式", "line_color": "#00AA00", "line_width": 4.0} - auto_routing._remove_routing_connection_objects = failing_remove - try: - with self.assertRaises(auto_routing.AutoRoutingError): - auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="wire-1", - ) - finally: - auto_routing._remove_routing_connection_objects = original_remove + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + "wire_style_id": 9, + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + "wire_style_id": 9, + }, + ], + } - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"wire_style_lookup": lookup}, + ) - self.assertEqual([first], routed_wires) + self.assertEqual(2, report["routed"]) + self.assertEqual([("9", "project-1")], calls) - def test_route_carrier_styles_make_generated_objects_distinguishable(self): + def test_route_eplan_connections_reports_wire_style_status_counts(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - - wire_duct = routing_network.create_route_carrier( + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 40, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 40, 0)) + routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - routing_range = routing_network.create_route_carrier( + routing_network.create_route_carrier( doc, - [app.Vector(0, 10, 0), app.Vector(100, 10, 0)], + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], project_uuid="project-1", - kind="RoutingRange", + kind="WireDuct", ) - terminal_access = routing_network.create_route_carrier( + + def lookup(style_id, _project_uuid): + if style_id == "1": + return {"id": "1", "name": "绿色控制线", "line_color": "#00AA00"} + return {} + + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-resolved-style", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + "wire_style_id": 1, + }, + { + "wire_id": "wire-missing-style", + "wire_label": "N2", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + "wire_style_id": 404, + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( doc, - [app.Vector(0, 20, 0), app.Vector(100, 20, 0)], - project_uuid="project-1", - kind="TerminalAccess", + payload, + options={"wire_style_lookup": lookup}, ) + compact = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) - self.assertEqual((1.0, 0.55, 0.0), wire_duct.ViewObject.LineColor) - self.assertEqual(4.0, wire_duct.ViewObject.LineWidth) - self.assertEqual((0.0, 0.65, 0.35), routing_range.ViewObject.LineColor) - self.assertEqual("Solid", routing_range.ViewObject.DrawStyle) - self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor) - self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle) - - def test_set_route_carriers_visibility_toggles_only_route_helpers(self): + self.assertEqual(2, report["routed"]) + self.assertEqual({"Resolved": 1, "Missing": 1}, report["wire_style_status_counts"]) + self.assertEqual("Resolved", report["routes"][0]["wire_style_status"]) + self.assertEqual("绿色控制线", report["routes"][0]["wire_style"]["name"]) + self.assertEqual("Missing", report["routes"][1]["wire_style_status"]) + self.assertEqual({"Resolved": 1, "Missing": 1}, compact["wire_style_status_counts"]) + self.assertEqual("绿色控制线", compact["route_samples"][0]["wire_style"]["name"]) + self.assertEqual("Missing", compact["route_samples"][1]["wire_style_status"]) + self.assertEqual("404", compact["route_samples"][1]["wire_style_id"]) + self.assertEqual(1, compact["missing_wire_style_samples_count"]) + self.assertEqual("N2", compact["missing_wire_style_samples"][0]["wire_label"]) + self.assertEqual("404", compact["missing_wire_style_samples"][0]["wire_style_id"]) + self.assertIn("导线样式:缺失 1 条", message) + self.assertIn("示例导线 N2 样式 404", message) + + def test_route_eplan_connections_uses_wire_style_database_path_from_payload(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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( + _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, 0), app.Vector(100, 0, 0)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - device = doc.addObject("Part::Feature", "DeviceA") - device.ViewObject.Visibility = True + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.sqlite" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + name TEXT, + line_color TEXT, + line_width REAL + ) + """ + ) + connection.execute( + """ + INSERT INTO wire_properties + (id, project_uuid, name, line_color, line_width) + VALUES + (11, 'project-1', 'payload样式', '#8844FF', 4.5) + """ + ) + connection.commit() + finally: + connection.close() + payload = { + "project_uuid": "project-1", + "wire_style_database_path": str(db_path), + "wires": [ + { + "wire_id": "wire-payload-style", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": 11, + } + ], + } - hidden = routing_network.set_route_carriers_visibility(doc, False) - self.assertFalse(carrier.ViewObject.Visibility) - shown = routing_network.set_route_carriers_visibility(doc, True) + report = auto_routing.route_eplan_connections_from_payload(doc, payload) - self.assertEqual(1, hidden) - self.assertEqual(1, shown) - self.assertTrue(carrier.ViewObject.Visibility) - self.assertTrue(device.ViewObject.Visibility) + routed_group = doc.getObject("QETWiring_04_Routed") + wire = list(getattr(routed_group, "Group", []) or [])[0] + compact = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) - def test_collect_route_carriers_ignores_deleted_object_references(self): + self.assertEqual(1, report["routed"]) + self.assertEqual(str(db_path), report["wire_style_database_path"]) + self.assertEqual(str(db_path), compact["wire_style_database_path"]) + self.assertIn("导线样式库:{0}".format(str(db_path)), message) + self.assertEqual((0.533333, 0.266667, 1.0), wire.ViewObject.LineColor) + self.assertEqual(4.5, wire.ViewObject.LineWidth) + self.assertEqual("payload样式", json.loads(wire.QetWireStyleJson)["name"]) + + def test_route_eplan_connections_uses_fallback_style_database_when_payload_database_is_empty(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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( + _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, 0), app.Vector(100, 0, 0)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) + with tempfile.TemporaryDirectory() as temp_dir: + wrong_dir = Path(temp_dir) / "wrong" / "datafiles" + right_dir = Path(temp_dir) / "right" / "datafiles" + exchange_dir = Path(temp_dir) / "right" / ".qet_freecad" + wrong_dir.mkdir(parents=True) + right_dir.mkdir(parents=True) + exchange_dir.mkdir(parents=True) + wrong_db = wrong_dir / "project-local.db" + right_db = right_dir / "project-local.db" + for db_path, rows in ( + (wrong_db, []), + (right_db, [(1, "project-1", "红色动力线", "#ff0000", 4.0)]), + ): + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT, + line_width REAL + ) + """ + ) + connection.executemany( + "INSERT INTO wire_properties (id, project_uuid, name, line_color, line_width) VALUES (?, ?, ?, ?, ?)", + rows, + ) + connection.commit() + finally: + connection.close() + json_path = exchange_dir / "2d_to_3d.json" + json_path.write_text( + json.dumps({"project_uuid": "project-1", "wires": []}), + encoding="utf-8", + ) + app._qet_exchange_summary = {"json_path": str(json_path)} + payload = { + "project_uuid": "project-1", + "wire_style_database_path": str(wrong_db), + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "1", + } + ], + } - class DeletedObjectReference: - Name = "DeletedCarrier" + report = auto_routing.route_eplan_connections_from_payload(doc, payload) - def __getattr__(self, name): - if name == "QetRoutingRole": - raise RuntimeError("Cannot access attribute 'QetRoutingRole' of deleted object") - raise AttributeError(name) + routed_group = doc.getObject("QETWiring_04_Routed") + wire = list(getattr(routed_group, "Group", []) or [])[0] + compact = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) - doc.Objects.append(DeletedObjectReference()) + self.assertEqual(1, report["routed"]) + self.assertEqual(str(right_db), report["wire_style_database_path"]) + self.assertEqual(str(wrong_db), report["wire_style_database_fallback_from"]) + self.assertEqual(str(wrong_db), compact["wire_style_database_fallback_from"]) + self.assertIn("从备用库恢复", message) + self.assertIn(str(wrong_db), message) + self.assertEqual((1.0, 0.0, 0.0), wire.ViewObject.LineColor) + self.assertEqual(4.0, wire.ViewObject.LineWidth) + self.assertEqual("红色动力线", json.loads(wire.QetWireStyleJson)["name"]) - carriers = routing_network.collect_route_carriers(doc) + def test_route_track_preserves_generated_carrier_source_metadata(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "线槽A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) - self.assertEqual([carrier], carriers) + auto_routing_panel.AutoRoutingController().generate_routing_paths() + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + route_track = json.loads(result["wire"].QetRouteTrackJson) + wire_duct_carriers = [ + segment["carrier"] + for segment in route_track["segments"] + if segment["carrier"]["kind"] == "WireDuct" + ] - def test_route_carrier_exposes_capacity_property_for_auto_routing(self): + self.assertTrue(wire_duct_carriers) + self.assertEqual("WireDuctA", wire_duct_carriers[0].get("source_name")) + self.assertEqual("线槽A", wire_duct_carriers[0].get("source_label")) + self.assertEqual("WireDuct", wire_duct_carriers[0].get("source_kind")) + + def test_routed_wire_exposes_route_source_labels_for_manual_inspection(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) carrier = routing_network.create_route_carrier( doc, [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="主线槽A", project_uuid="project-1", kind="WireDuct", ) + carrier.QetRouteSourceLabel = "黄色主路径" + carrier.QetRouteSourcePathIndex = "2" - self.assertIn("QetRouteCarrierCapacity", carrier.PropertiesList) - self.assertEqual(1, carrier.QetRouteCarrierCapacity) + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) - def test_route_graph_connects_crossing_carriers_at_intersection(self): + wire = result["wire"] + self.assertEqual("黄色主路径(路径2)", wire.QetRouteSourceLabels) + self.assertEqual("QETRouteCarrier", wire.QetRouteCarrierNames) + payload = json.loads(wire.QetRouteDiagnosticsJson) + self.assertEqual(["黄色主路径(路径2)"], payload["route_source_labels"]) + self.assertEqual(["QETRouteCarrier"], payload["route_carrier_names"]) + + def test_route_track_preserves_user_path_source_path_index(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") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(50, 50, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + user_path = routing_network.create_route_carrier( doc, - [app.Vector(50, -50, 0), app.Vector(50, 50, 0)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="用户路径B", project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) + user_path.QetRouteSourceName = "MultiWireRouteSketch" + user_path.QetRouteSourceLabel = "多路径草图" + user_path.QetRouteSourceKind = "UserPath" + user_path.QetRouteSourcePathIndex = "2" - network = routing_network.build_route_graph(doc) result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + route_track = json.loads(result["wire"].QetRouteTrackJson) + user_path_carriers = [ + segment["carrier"] + for segment in route_track["segments"] + if segment["carrier"]["kind"] == "UserPath" + ] - self.assertEqual(5, len(network["nodes"])) - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertIn((50.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + self.assertTrue(user_path_carriers) + self.assertEqual("2", user_path_carriers[0].get("source_path_index")) - def test_route_graph_connects_overlapping_collinear_carriers(self): + def test_carrier_payload_preserves_source_metadata(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) - routing_network.create_route_carrier( + carrier = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 0), app.Vector(80, 0, 0)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="用户路径B", project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(40, 0, 0), app.Vector(120, 0, 0)], - project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) + carrier.QetRouteSourceName = "MultiWireRouteSketch" + carrier.QetRouteSourceLabel = "多路径草图" + carrier.QetRouteSourceKind = "UserPath" + carrier.QetRouteSourcePathIndex = "2" - network = routing_network.build_route_graph(doc) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + payload = routing_network.carrier_payload(carrier) - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertIn((40.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) - self.assertIn((80.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) - self.assertGreaterEqual(network["segment_count"], 3) + self.assertEqual("MultiWireRouteSketch", payload["source_name"]) + self.assertEqual("多路径草图", payload["source_label"]) + self.assertEqual("UserPath", payload["source_kind"]) + self.assertEqual("2", payload["source_path_index"]) - def test_route_graph_bridges_adjoining_wire_duct_gap_with_eplan_tolerance(self): + def test_route_track_records_carrier_capacity(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -797,195 +1184,211 @@ class AutoRoutingTest(unittest.TestCase): end = _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(50, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(54, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", + capacity=3, ) - network = routing_network.build_route_graph(doc) result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + route_track = json.loads(result["wire"].QetRouteTrackJson) - self.assertEqual(1, network["bridged_segment_count"]) - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertEqual("Routed", result["route_status"]) + self.assertEqual(3, route_track["segments"][0]["carrier"]["capacity"]) - def test_route_graph_bridges_adjoining_user_path_to_wire_duct_gap(self): + def test_network_eplan_connection_route_offsets_lane_by_route_index(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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(50, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( + + first = auto_routing.route_eplan_connection_between_terminals( doc, - [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="UserPath", + start, + end, + route_index=0, + wire_uuid="wire-1", + options={"lane_spacing": 12.0, "lane_axis": "y"}, ) + second = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + route_index=1, + wire_uuid="wire-2", + options={"lane_spacing": 12.0, "lane_axis": "y"}, + ) + third = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + route_index=2, + wire_uuid="wire-3", + options={"lane_spacing": 12.0, "lane_axis": "y"}, + ) + payload = json.loads(second["wire"].QetRouteDiagnosticsJson) + third_payload = json.loads(third["wire"].QetRouteDiagnosticsJson) - network = routing_network.build_route_graph(doc, adjoining_duct_tolerance=15.0) - start_key, _start_distance = routing_network.nearest_node(network, app.Vector(0, 0, 20)) - end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) - result = routing_network.shortest_path_with_carriers(network, start_key, end_key) - - self.assertEqual(1, network["bridged_segment_count"]) - self.assertIsNotNone(result) - self.assertIn("UserPath", result["carrier_kinds"]) + self.assertTrue(any(abs(point.y - 0.0) <= 0.001 for point in first["points"][1:-1])) + self.assertTrue(any(abs(point.y - 12.0) <= 0.001 for point in second["points"][1:-1])) + self.assertTrue(any(abs(point.y + 12.0) <= 0.001 for point in third["points"][1:-1])) + self.assertEqual(1, payload["lane"]["index"]) + self.assertEqual("y", payload["lane"]["axis"]) + self.assertEqual(12.0, payload["lane"]["offset_mm"]) + self.assertEqual(2, third_payload["lane"]["index"]) + self.assertEqual(-12.0, third_payload["lane"]["offset_mm"]) - def test_route_graph_bridges_endpoint_to_nearby_segment_projection(self): + def test_network_eplan_connection_route_removes_collinear_network_points(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + [ + app.Vector(0, 0, 20), + app.Vector(50, 0, 20), + app.Vector(100, 0, 20), + ], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( - doc, - [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], - project_uuid="project-1", - kind="UserPath", - ) - network = routing_network.build_route_graph(doc, adjoining_duct_tolerance=15.0) - start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) - end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) - result = routing_network.shortest_path_with_carriers(network, start_key, end_key) + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - self.assertEqual(1, network["bridged_segment_count"]) - self.assertIsNotNone(result) - self.assertIn((50000, 0, 20000), result["path"]) + point_tuples = [(point.x, point.y, point.z) for point in result["points"]] + self.assertNotIn((50.0, 0.0, 20.0), point_tuples) + self.assertEqual( + [ + (0.0, 0.0, 0.0), + (0.0, 0.0, 20.0), + (100.0, 0.0, 20.0), + (100.0, 0.0, 0.0), + ], + point_tuples, + ) - def test_auto_routing_uses_endpoint_to_segment_projection_bridge(self): + def test_eplan_connection_route_replaces_existing_wire_uuid_when_endpoints_change(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalBranch", "terminal-branch", app.Vector(50, 50, 0)) - end = _terminal(doc, terminal_objects, "TerminalMain", "terminal-main", app.Vector(100, 0, 0)) + start_old = _terminal(doc, terminal_objects, "TerminalStartOld", "terminal-start-old", app.Vector(0, 0, 0)) + end_old = _terminal(doc, terminal_objects, "TerminalEndOld", "terminal-end-old", app.Vector(100, 0, 0)) + start_new = _terminal(doc, terminal_objects, "TerminalStartNew", "terminal-start-new", app.Vector(0, 40, 0)) + end_new = _terminal(doc, terminal_objects, "TerminalEndNew", "terminal-end-new", app.Vector(100, 40, 0)) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + [ + app.Vector(0, 0, 20), + app.Vector(100, 0, 20), + app.Vector(100, 40, 20), + app.Vector(0, 40, 20), + ], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( - doc, - [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], - project_uuid="project-1", - kind="UserPath", - ) - result = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - options={"adjoining_duct_tolerance": 15.0}, - ) + auto_routing.route_eplan_connection_between_terminals(doc, start_old, end_old, wire_uuid="wire-1") + auto_routing.route_eplan_connection_between_terminals(doc, start_new, end_new, wire_uuid="wire-1") + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(1, result["network"]["bridged_segments"]) - self.assertEqual(1, result["route_track"]["bridged_segments"]) - self.assertTrue(any(segment.get("is_bridge") for segment in result["route_track"]["segments"])) - self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) - self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertEqual(1, len(routed_wires)) + self.assertEqual("terminal-start-new", routed_wires[0].QetStartTerminalUuid) + self.assertEqual("terminal-end-new", routed_wires[0].QetEndTerminalUuid) - def test_auto_routing_does_not_use_terminal_access_to_bridge_main_path_gap(self): + def test_eplan_connection_route_keeps_existing_wire_when_replacement_fails(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) end = _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(40, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="主线槽A", project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( - doc, - [app.Vector(40, 0, 20), app.Vector(60, 0, 20)], - project_uuid="project-1", - kind="TerminalAccess", - ) - routing_network.create_route_carrier( + first = auto_routing.route_eplan_connection_between_terminals( doc, - [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) + start, + end, + wire_uuid="wire-1", + )["wire"] + routing_network.clear_route_carriers(doc) with self.assertRaises(auto_routing.AutoRoutingError): auto_routing.route_eplan_connection_between_terminals( doc, start, end, - options={"terminal_access_max_distance": 5.0}, + wire_uuid="wire-1", ) + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) - def test_route_graph_projection_bridge_respects_blocked_bbox(self): + self.assertEqual([first], routed_wires) + self.assertIsNotNone(doc.getObject(first.Name)) + + def test_eplan_connection_route_keeps_existing_wire_when_new_geometry_creation_fails(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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="WireDuct", ) - routing_network.create_route_carrier( + first = auto_routing.route_eplan_connection_between_terminals( doc, - [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], - project_uuid="project-1", - kind="UserPath", - ) - blocked_bboxes = [ - { - "xmin": 45.0, - "xmax": 55.0, - "ymin": 2.0, - "ymax": 6.0, - "zmin": 15.0, - "zmax": 25.0, - } - ] + start, + end, + wire_uuid="wire-1", + )["wire"] + original_create_wire_geometry = auto_routing._create_wire_geometry - network = routing_network.build_route_graph( - doc, - blocked_bboxes=blocked_bboxes, - adjoining_duct_tolerance=15.0, - ) - start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) - end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) - result = routing_network.shortest_path_with_carriers(network, start_key, end_key) + def failing_create_wire_geometry(_doc, _name, _points): + raise RuntimeError("create failed") - self.assertEqual(0, network["bridged_segment_count"]) - self.assertGreaterEqual(network["blocked_segment_count"], 1) - self.assertIsNone(result) + auto_routing._create_wire_geometry = failing_create_wire_geometry + try: + with self.assertRaises(RuntimeError): + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-1", + ) + finally: + auto_routing._create_wire_geometry = original_create_wire_geometry + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) - def test_auto_routing_respects_adjoining_duct_tolerance_option(self): + self.assertEqual([first], routed_wires) + self.assertIsNotNone(doc.getObject(first.Name)) + + def test_eplan_connection_route_cleans_up_half_created_wire_when_draft_fallback_fails(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") @@ -993,30 +1396,65 @@ class AutoRoutingTest(unittest.TestCase): end = _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(44, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( - doc, - [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - - result = auto_routing.route_eplan_connection_between_terminals( + first = auto_routing.route_eplan_connection_between_terminals( doc, start, end, - options={"adjoining_duct_tolerance": 15.0}, - ) + wire_uuid="wire-1", + )["wire"] + part_module = sys.modules["Part"] + draft_module = sys.modules.get("Draft") + if draft_module is None: + draft_module = types.ModuleType("Draft") + sys.modules["Draft"] = draft_module + original_make_polygon = part_module.makePolygon + original_make_wire = getattr(draft_module, "make_wire", None) + original_add_object = doc.addObject - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(1, result["network"]["bridged_segments"]) + def failing_make_polygon(*args, **kwargs): + raise RuntimeError("part unavailable") - def test_auto_routing_uses_bridged_user_path_to_wire_duct_gap(self): + def half_created_make_wire(points, closed=False, placement=None, face=None, support=None, bs2wire=False): + obj = doc.addObject("Part::FeaturePython", "Wire") + obj.Points = list(points) + raise RuntimeError("draft failed") + + def failing_add_object(type_name, name): + if type_name == "App::FeaturePython": + raise RuntimeError("fallback failed") + return original_add_object(type_name, name) + + part_module.makePolygon = failing_make_polygon + draft_module.make_wire = half_created_make_wire + doc.addObject = failing_add_object + try: + with self.assertRaises(RuntimeError): + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-1", + ) + finally: + part_module.makePolygon = original_make_polygon + if original_make_wire is None: + delattr(draft_module, "make_wire") + else: + draft_module.make_wire = original_make_wire + doc.addObject = original_add_object + + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + + self.assertEqual([first], routed_wires) + self.assertEqual(0, len([obj for obj in doc.Objects if obj.Name == "Wire"])) + + def test_eplan_connection_route_keeps_existing_wire_when_old_replacement_removal_fails(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") @@ -1024,100 +1462,169 @@ class AutoRoutingTest(unittest.TestCase): end = _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(50, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( + first = auto_routing.route_eplan_connection_between_terminals( doc, - [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], + start, + end, + wire_uuid="wire-1", + )["wire"] + original_remove = auto_routing._remove_routing_connection_objects + + def failing_remove(target_doc, objects): + if first in list(objects or []): + return 0 + return original_remove(target_doc, objects) + + auto_routing._remove_routing_connection_objects = failing_remove + try: + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-1", + ) + finally: + auto_routing._remove_routing_connection_objects = original_remove + + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + + self.assertEqual([first], routed_wires) + + def test_route_carrier_styles_make_generated_objects_distinguishable(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") + + wire_duct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", - kind="UserPath", + kind="WireDuct", ) - - result = auto_routing.route_eplan_connection_between_terminals( + routing_range = routing_network.create_route_carrier( doc, - start, - end, - options={"adjoining_duct_tolerance": 15.0}, + [app.Vector(0, 10, 0), app.Vector(100, 10, 0)], + project_uuid="project-1", + kind="RoutingRange", + ) + terminal_access = routing_network.create_route_carrier( + doc, + [app.Vector(0, 20, 0), app.Vector(100, 20, 0)], + project_uuid="project-1", + kind="TerminalAccess", ) - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(1, result["network"]["bridged_segments"]) - self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) - self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + self.assertEqual((1.0, 0.55, 0.0), wire_duct.ViewObject.LineColor) + self.assertEqual(4.0, wire_duct.ViewObject.LineWidth) + self.assertEqual((0.0, 0.65, 0.35), routing_range.ViewObject.LineColor) + self.assertEqual("Solid", routing_range.ViewObject.DrawStyle) + self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor) + self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle) - def test_connect_point_to_network_replaces_bridged_edge_without_stale_reverse_edge(self): + def test_set_route_carriers_visibility_toggles_only_route_helpers(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() - routing_network.create_route_carrier( + terminal_objects.ensure_root_group(doc, "project-1") + carrier = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(50, 0, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( + device = doc.addObject("Part::Feature", "DeviceA") + device.ViewObject.Visibility = True + + hidden = routing_network.set_route_carriers_visibility(doc, False) + self.assertFalse(carrier.ViewObject.Visibility) + shown = routing_network.set_route_carriers_visibility(doc, True) + + self.assertEqual(1, hidden) + self.assertEqual(1, shown) + self.assertTrue(carrier.ViewObject.Visibility) + self.assertTrue(device.ViewObject.Visibility) + + def test_collect_route_carriers_ignores_deleted_object_references(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(54, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", ) - network = routing_network.build_route_graph(doc) - original_keys = set(network["nodes"].keys()) - bridge_keys = { - key - for key, point in network["nodes"].items() - if point.x in {50.0, 54.0} - } - projected_key, _distance, mode = routing_network.connect_point_to_network(network, app.Vector(52, 0, 20)) - new_keys = set(network["nodes"].keys()) - original_keys - stale_bridge_edges = [ - (left_key, right_key) - for left_key, neighbors in network["edges"].items() - for right_key, _weight, _carrier in neighbors - if left_key in bridge_keys and right_key in bridge_keys - ] + class DeletedObjectReference: + Name = "DeletedCarrier" - self.assertEqual("segment_projection", mode) - self.assertEqual(projected_key, next(iter(new_keys))) - self.assertEqual([], stale_bridge_edges) - self.assertEqual(4, network["segment_count"]) + def __getattr__(self, name): + if name == "QetRoutingRole": + raise RuntimeError("Cannot access attribute 'QetRoutingRole' of deleted object") + raise AttributeError(name) - def test_eplan_connection_route_prefers_wire_duct_over_auxiliary_range(self): + doc.Objects.append(DeletedObjectReference()) + + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual([carrier], carriers) + + def test_route_carrier_exposes_capacity_property_for_auto_routing(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)], + project_uuid="project-1", + kind="WireDuct", + ) + + self.assertIn("QetRouteCarrierCapacity", carrier.PropertiesList) + self.assertEqual(1, carrier.QetRouteCarrierCapacity) + + def test_route_graph_connects_crossing_carriers_at_intersection(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") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(50, 50, 0)) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", - kind="RoutingRange", + kind="WireDuct", ) routing_network.create_route_carrier( doc, - [ - app.Vector(0, 0, 20), - app.Vector(0, 40, 20), - app.Vector(120, 40, 20), - app.Vector(120, 0, 20), - ], + [app.Vector(50, -50, 0), app.Vector(50, 50, 0)], project_uuid="project-1", kind="WireDuct", ) + network = routing_network.build_route_graph(doc) result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + self.assertEqual(5, len(network["nodes"])) self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertTrue(any(point.y == 40.0 for point in result["points"])) + self.assertIn((50.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) - def test_surface_carrier_grid_supports_backplate_routing(self): + def test_route_graph_connects_overlapping_collinear_carriers(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -1125,1261 +1632,1400 @@ class AutoRoutingTest(unittest.TestCase): terminal_objects.ensure_root_group(doc, "project-1") start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) - face = FakeFace( - FakeBoundBox(0, 120, -20, 120, -1, -1), - app.Vector(0, 0, 1), + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(80, 0, 0)], + project_uuid="project-1", + kind="WireDuct", ) - - created = routing_network.create_surface_carriers_from_selection( + routing_network.create_route_carrier( doc, - [FakeSelectionItem([face])], + [app.Vector(40, 0, 0), app.Vector(120, 0, 0)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) + + network = routing_network.build_route_graph(doc) result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - self.assertGreater(len(created), 0) - self.assertEqual("RoutingRange", getattr(created[0], "QetRouteCarrierKind", "")) self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertTrue(any(point.z == 4.0 for point in result["points"])) + self.assertIn((40.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + self.assertIn((80.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + self.assertGreaterEqual(network["segment_count"], 3) - def test_auto_detect_support_surface_creates_routing_range(self): + def test_route_graph_bridges_adjoining_wire_duct_gap_with_eplan_tolerance(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "安装板A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - cabinet = doc.addObject("Part::Feature", "Cabinet") - cabinet.Label = "3D机柜" - cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - - created = routing_network.create_surface_carriers_from_document( + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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(50, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - created_again = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(54, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - self.assertEqual(6, len(created)) - self.assertEqual(0, len(created_again)) - self.assertTrue(all(carrier.QetRouteCarrierKind == "RoutingRange" for carrier in created)) - self.assertEqual("RoutingRange", panel.QetRoutingSourceKind) - self.assertEqual("SupportSurface", panel.QetRoutingObstacleMode) - self.assertFalse(hasattr(cabinet, "QetRoutingSourceKind")) - self.assertFalse(hasattr(duct, "QetRoutingSourceKind")) + network = routing_network.build_route_graph(doc) + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - def test_auto_detect_support_surface_refreshes_routing_range_geometry(self): + self.assertEqual(1, network["bridged_segment_count"]) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + + def test_route_graph_bridges_adjoining_user_path_to_wire_duct_gap(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") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "安装板A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - created = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(0, 0, 20), app.Vector(50, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - panel.Shape = FakeShape(FakeBoundBox(20, 140, 0, 5, 0, 100)) - created_again = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="UserPath", ) - carriers = routing_network.collect_route_carriers(doc) - x_values = [ - point.x - for carrier in carriers - if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" - for point in carrier.Points - ] - self.assertEqual(6, len(created)) - self.assertEqual(0, len(created_again)) - self.assertEqual(6, len([carrier for carrier in carriers if carrier.QetRouteCarrierKind == "RoutingRange"])) - self.assertEqual(20.0, min(x_values)) - self.assertEqual(140.0, max(x_values)) + network = routing_network.build_route_graph(doc, adjoining_duct_tolerance=15.0) + start_key, _start_distance = routing_network.nearest_node(network, app.Vector(0, 0, 20)) + end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) + result = routing_network.shortest_path_with_carriers(network, start_key, end_key) - def test_auto_detect_support_surface_adds_missing_routing_range_lanes_after_resize(self): + self.assertEqual(1, network["bridged_segment_count"]) + self.assertIsNotNone(result) + self.assertIn("UserPath", result["carrier_kinds"]) + + def test_route_graph_bridges_endpoint_to_nearby_segment_projection(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") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "安装板A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - created = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - panel.Shape = FakeShape(FakeBoundBox(0, 180, 0, 5, 0, 120)) - created_again = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="UserPath", ) - carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" - ] - x_values = [point.x for carrier in carriers for point in carrier.Points] - z_values = [point.z for carrier in carriers for point in carrier.Points] - self.assertEqual(6, len(created)) - self.assertEqual(1, len(created_again)) - self.assertEqual(7, len(carriers)) - self.assertEqual(180.0, max(x_values)) - self.assertEqual(120.0, max(z_values)) + network = routing_network.build_route_graph(doc, adjoining_duct_tolerance=15.0) + start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) + end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) + result = routing_network.shortest_path_with_carriers(network, start_key, end_key) - def test_auto_detect_support_surface_removes_stale_routing_range_lanes_after_resize(self): + self.assertEqual(1, network["bridged_segment_count"]) + self.assertIsNotNone(result) + self.assertIn((50000, 0, 20000), result["path"]) + + def test_auto_routing_uses_endpoint_to_segment_projection_bridge(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "安装板A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - created = routing_network.create_surface_carriers_from_document( + start = _terminal(doc, terminal_objects, "TerminalBranch", "terminal-branch", app.Vector(50, 50, 0)) + end = _terminal(doc, terminal_objects, "TerminalMain", "terminal-main", 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", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - panel.Shape = FakeShape(FakeBoundBox(0, 60, 0, 5, 0, 60)) - created_again = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="UserPath", ) - carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" - ] - x_values = [point.x for carrier in carriers for point in carrier.Points] - z_values = [point.z for carrier in carriers for point in carrier.Points] - self.assertEqual(6, len(created)) - self.assertEqual(0, len(created_again)) - self.assertEqual(4, len(carriers)) - self.assertEqual(60.0, max(x_values)) - self.assertEqual(60.0, max(z_values)) + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"adjoining_duct_tolerance": 15.0}, + ) - def test_auto_detect_support_surface_removes_carriers_and_obstacle_mode_when_source_invalid(self): + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual(1, result["network"]["bridged_segments"]) + self.assertEqual(1, result["route_track"]["bridged_segments"]) + self.assertTrue(any(segment.get("is_bridge") for segment in result["route_track"]["segments"])) + self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + + def test_auto_routing_does_not_use_terminal_access_to_bridge_main_path_gap(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "安装板A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - created = routing_network.create_surface_carriers_from_document( + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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(40, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 120, 0, 120)) - created_again = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(40, 0, 20), app.Vector(60, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", ) - self.assertEqual(6, len(created)) - self.assertEqual(0, len(created_again)) - self.assertEqual([], routing_network.collect_route_carriers(doc)) - self.assertEqual("", getattr(panel, "QetRoutingObstacleMode", "")) - self.assertEqual("", getattr(panel, "QetRouteCarrierNamesJson", "")) + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_access_max_distance": 5.0}, + ) - def test_eplan_connection_route_can_use_auto_detected_support_surface(self): + def test_route_graph_projection_bridge_respects_blocked_bbox(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 10, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 10, 0)) - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "Mounting Plate A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - created = routing_network.create_surface_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", - spacing=60.0, - offset=5.0, - margin=0.0, + kind="WireDuct", ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], + project_uuid="project-1", + kind="UserPath", + ) + blocked_bboxes = [ + { + "xmin": 45.0, + "xmax": 55.0, + "ymin": 2.0, + "ymax": 6.0, + "zmin": 15.0, + "zmax": 25.0, + } + ] - self.assertGreater(len(created), 0) - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(0, result["collision_count"]) - self.assertTrue(any(point.y == 10.0 for point in result["points"])) - - def test_prepare_layout_space_auto_detects_support_surface_sources(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"] - doc = FakeDocument() - app.ActiveDocument = doc - terminal_objects.ensure_root_group(doc, "project-1") - panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "Mounting Plate A" - panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - - result = auto_routing_panel.AutoRoutingController().generate_layout_space() + network = routing_network.build_route_graph( + doc, + blocked_bboxes=blocked_bboxes, + adjoining_duct_tolerance=15.0, + ) + start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) + end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) + result = routing_network.shortest_path_with_carriers(network, start_key, end_key) - self.assertGreater(result["support_surface_sources"], 0) - self.assertEqual("document", result["source_mode"]) + self.assertEqual(0, network["bridged_segment_count"]) + self.assertGreaterEqual(network["blocked_segment_count"], 1) + self.assertIsNone(result) - def test_generate_routing_paths_uses_selected_wire_duct_entity(self): + def test_auto_routing_respects_adjoining_duct_tolerance_option(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] - gui = sys.modules["FreeCADGui"] doc = FakeDocument() - app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") - duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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(44, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", ) - result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"adjoining_duct_tolerance": 15.0}, + ) - self.assertEqual(1, result["wire_duct_carriers"]) - self.assertEqual("selection", result["source_mode"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(1, result["network"]["bridged_segments"]) - def test_generate_routing_paths_uses_selected_route_path_as_user_path(self): + def test_auto_routing_uses_bridged_user_path_to_wire_duct_gap(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() 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", "UserRouteSketch") - route_path.Label = "用户主路径A" - route_path.Shape = FakeShape( - FakeBoundBox(0, 100, 0, 80, 20, 20), - edges=[ - FakeEdge(app.Vector(0, 0, 20), app.Vector(0, 80, 20)), - FakeEdge(app.Vector(0, 80, 20), app.Vector(100, 80, 20)), - ], + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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(50, 0, 20)], + project_uuid="project-1", + kind="WireDuct", ) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + routing_network.create_route_carrier( + doc, + [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="UserPath", ) - result = auto_routing_panel.AutoRoutingController().generate_routing_paths() - carriers = routing_network.collect_route_carriers(doc) - user_paths = [item for item in carriers if item.QetRouteCarrierKind == "UserPath"] + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"adjoining_duct_tolerance": 15.0}, + ) - self.assertEqual(1, result["user_path_carriers"]) - self.assertEqual(1, len(user_paths)) - self.assertEqual("UserRouteSketch", user_paths[0].QetRouteSourceName) - self.assertEqual("用户主路径A", user_paths[0].QetRouteSourceLabel) - self.assertEqual("selection", result["source_mode"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(1, result["network"]["bridged_segments"]) + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) - def test_controller_creates_selected_user_paths_without_full_network_generation(self): + def test_connect_point_to_network_replaces_bridged_edge_without_stale_reverse_edge(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + _terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() 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", "UserRouteSketch") - route_path.Shape = FakeShape( - FakeBoundBox(0, 100, 0, 80, 20, 20), - edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(50, 0, 20)], + project_uuid="project-1", + kind="WireDuct", ) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + routing_network.create_route_carrier( + doc, + [app.Vector(54, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", ) + network = routing_network.build_route_graph(doc) + original_keys = set(network["nodes"].keys()) + bridge_keys = { + key + for key, point in network["nodes"].items() + if point.x in {50.0, 54.0} + } - result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - carriers = routing_network.collect_route_carriers(doc) + projected_key, _distance, mode = routing_network.connect_point_to_network(network, app.Vector(52, 0, 20)) + new_keys = set(network["nodes"].keys()) - original_keys + stale_bridge_edges = [ + (left_key, right_key) + for left_key, neighbors in network["edges"].items() + for right_key, _weight, _carrier in neighbors + if left_key in bridge_keys and right_key in bridge_keys + ] - self.assertEqual(1, result["user_path_carriers"]) - self.assertEqual(1, result["network"]["kinds"]["UserPath"]) - self.assertEqual(1, len(carriers)) - self.assertEqual("UserPath", carriers[0].QetRouteCarrierKind) + self.assertEqual("segment_projection", mode) + self.assertEqual(projected_key, next(iter(new_keys))) + self.assertEqual([], stale_bridge_edges) + self.assertEqual(4, network["segment_count"]) - def test_selected_points_object_can_be_used_as_user_path(self): + def test_eplan_connection_route_prefers_wire_duct_over_auxiliary_range(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() 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)], + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 40, 20), + app.Vector(120, 40, 20), + app.Vector(120, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", ) - result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - carriers = routing_network.collect_route_carriers(doc) + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - 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], - ) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertTrue(any(point.y == 40.0 for point in result["points"])) - def test_selected_user_path_copies_source_capacity(self): + def test_surface_carrier_grid_supports_backplate_routing(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() 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)], + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + face = FakeFace( + FakeBoundBox(0, 120, -20, 120, -1, -1), + app.Vector(0, 0, 1), ) - auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - carrier = routing_network.collect_route_carriers(doc)[0] + created = routing_network.create_surface_carriers_from_selection( + doc, + [FakeSelectionItem([face])], + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - self.assertEqual(5, carrier.QetRouteCarrierCapacity) + self.assertGreater(len(created), 0) + self.assertEqual("RoutingRange", getattr(created[0], "QetRouteCarrierKind", "")) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertTrue(any(point.z == 4.0 for point in result["points"])) - def test_controller_create_user_paths_reports_removed_stale_source_carriers(self): + def test_auto_detect_support_surface_creates_routing_range(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", "UserRouteSketch") - route_path.Shape = FakeShape( - FakeBoundBox(0, 100, 0, 80, 20, 20), - edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], - ) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], - ) - auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - doc.removeObject("UserRouteSketch") - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [], - ) - - result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - - self.assertEqual(1, result["removed_stale_carriers"]) - self.assertEqual(0, result["network"]["carriers"]) - self.assertEqual([], routing_network.collect_route_carriers(doc)) + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + cabinet = doc.addObject("Part::Feature", "Cabinet") + cabinet.Label = "3D机柜" + cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - def test_terminal_access_uses_terminal_local_route_points_before_main_network(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 = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") - terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [10, 0, 0], [10, 30, 0]]) - routing_network.create_route_carrier( + created = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(10, 80, 0), app.Vector(110, 80, 0)], project_uuid="project-1", - kind="UserPath", + spacing=60.0, + offset=5.0, + margin=0.0, ) - - created = routing_network.create_terminal_access_carriers_from_document( + created_again = routing_network.create_surface_carriers_from_document( doc, project_uuid="project-1", - terminal_exit_length=20.0, - max_distance=100.0, + spacing=60.0, + offset=5.0, + margin=0.0, ) - self.assertEqual(1, len(created)) - self.assertEqual( - [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 30.0, 0.0)], - [(p.x, p.y, p.z) for p in created[0].Points[:3]], - ) + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertTrue(all(carrier.QetRouteCarrierKind == "RoutingRange" for carrier in created)) + self.assertEqual("RoutingRange", panel.QetRoutingSourceKind) + self.assertEqual("SupportSurface", panel.QetRoutingObstacleMode) + self.assertFalse(hasattr(cabinet, "QetRoutingSourceKind")) + self.assertFalse(hasattr(duct, "QetRoutingSourceKind")) - def test_generate_routing_paths_refreshes_selected_user_path_without_duplicate(self): + def test_auto_detect_support_surface_includes_cabinet_side_cover(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", "UserRouteSketch") - route_path.Shape = FakeShape( - FakeBoundBox(0, 100, 0, 80, 20, 20), - edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], - ) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], - ) + side_cover = doc.addObject("Part::Feature", "SideCover") + side_cover.Label = "SIDE COVER-1_P00" + side_cover.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - first = auto_routing_panel.AutoRoutingController().generate_routing_paths() - route_path.Shape = FakeShape( - FakeBoundBox(0, 200, 0, 80, 20, 20), - edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(200, 0, 20))], + created = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, ) - second = auto_routing_panel.AutoRoutingController().generate_routing_paths() - user_paths = [ - item - for item in routing_network.collect_route_carriers(doc) - if item.QetRouteCarrierKind == "UserPath" - ] - self.assertEqual(1, first["user_path_carriers"]) - self.assertEqual(0, second["user_path_carriers"]) - self.assertEqual(1, len(user_paths)) - self.assertEqual([(0.0, 0.0, 20.0), (200.0, 0.0, 20.0)], [(p.x, p.y, p.z) for p in user_paths[0].Points]) + self.assertGreater(len(created), 0) + self.assertEqual("RoutingRange", side_cover.QetRoutingSourceKind) + self.assertEqual("SupportSurface", side_cover.QetRoutingObstacleMode) - def test_eplan_connection_route_can_use_generated_user_path(self): + def test_support_surface_source_capacity_is_copied_to_generated_carriers(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(200, 0, 0)) - routing_network.create_route_carrier( + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + panel.QetRouteCarrierCapacity = 6 + + created = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(0, 0, 20), app.Vector(200, 0, 20)], project_uuid="project-1", - kind="UserPath", + spacing=60.0, + offset=5.0, + margin=0.0, ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - - self.assertEqual("Routed", result["route_status"]) - self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + self.assertGreater(len(created), 0) + self.assertTrue(all(carrier.QetRouteCarrierCapacity == 6 for carrier in created)) - def test_generate_routing_paths_does_not_duplicate_selected_wire_duct_carriers(self): + def test_cabinet_boundary_objects_are_not_route_sources(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") - duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") - duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], - ) - - first = auto_routing_panel.AutoRoutingController().generate_routing_paths() - second = auto_routing_panel.AutoRoutingController().generate_routing_paths() - carriers = routing_network.collect_route_carriers(doc) + boundary_duct = doc.addObject("Part::Feature", "BoundaryWireDuct") + boundary_duct.Label = "Wire Duct Boundary" + boundary_duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + boundary_duct.QetRoutingBoundaryKind = "CabinetInterior" + boundary_panel = doc.addObject("Part::Feature", "BoundaryMountingPlate") + boundary_panel.Label = "安装板边界" + boundary_panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + boundary_panel.QetRoutingBoundaryKind = "CabinetInterior" + boundary_path = doc.addObject("Part::Feature", "BoundaryPointPath") + boundary_path.Points = [app.Vector(0, 0, 20), app.Vector(100, 0, 20)] + boundary_path.QetRoutingBoundaryKind = "CabinetInterior" - self.assertEqual(1, first["selected_wire_duct_carriers"]) - self.assertEqual(0, second["selected_wire_duct_carriers"]) - self.assertEqual( - 1, - len([item for item in carriers if item.QetRouteCarrierKind == "WireDuct"]), + wire_ducts = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", ) - self.assertEqual( - 2, - len([item for item in carriers if item.QetRouteCarrierKind == "WireDuctOpenEnd"]), + surfaces = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + user_paths = routing_network.create_user_path_carriers_from_selection( + doc, + [FakeSelectionItem(obj=boundary_path)], + project_uuid="project-1", ) - def test_generate_routing_paths_refreshes_selected_wire_duct_geometry(self): + self.assertEqual([], wire_ducts) + self.assertEqual([], surfaces) + self.assertEqual([], user_paths) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + + def test_detect_user_path_sources_skips_origin_axes_with_huge_bbox(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") - duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") - duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + axis = doc.addObject("App::Line", "X_Axis") + axis.Label = "X轴" + axis.Shape = FakeShape( + FakeBoundBox(-1e100, 1e100, 0, 0, 0, 0), + edges=[FakeEdge(app.Vector(0, 0, 0), app.Vector(1, 0, 0))], ) + route_path = doc.addObject("Part::FeaturePython", "UserRoutePath") + route_path.Points = [app.Vector(0, 0, 20), app.Vector(100, 0, 20)] - auto_routing_panel.AutoRoutingController().generate_routing_paths() - duct.Shape = FakeShape(FakeBoundBox(0, 220, -10, 10, 0, 20)) - second = auto_routing_panel.AutoRoutingController().generate_routing_paths() - carriers = routing_network.collect_route_carriers(doc) - main = [item for item in carriers if item.QetRouteCarrierKind == "WireDuct"][0] - open_end_x_values = sorted( - point.x - for item in carriers - if item.QetRouteCarrierKind == "WireDuctOpenEnd" - for point in item.Points - ) + sources = routing_network.detect_user_path_sources(doc) - self.assertEqual(0, second["selected_wire_duct_carriers"]) - self.assertEqual([(20.0, 0.0, 10.0), (200.0, 0.0, 10.0)], [(p.x, p.y, p.z) for p in main.Points]) - self.assertEqual([20.0, 20.0, 200.0, 200.0], open_end_x_values) + self.assertEqual(["UserRoutePath"], [source.Name for source in sources]) - def test_generate_routing_paths_removes_generated_wire_duct_carriers_after_source_deleted(self): + def test_detect_user_path_sources_skips_existing_route_carriers(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"] doc = FakeDocument() - app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) - auto_routing_panel.AutoRoutingController().generate_routing_paths() - generated = [ - item - for item in routing_network.collect_route_carriers(doc) - if getattr(item, "QetRouteSourceName", "") == "WireDuctA" - ] - doc.removeObject("WireDuctA") - auto_routing_panel.AutoRoutingController().generate_routing_paths() + sources = routing_network.detect_user_path_sources(doc) - self.assertEqual(3, len(generated)) - self.assertEqual([], routing_network.collect_route_carriers(doc)) + self.assertEqual([], sources) - def test_prepare_layout_space_uses_whole_document_not_selected_face_workflow(self): + def test_auto_detect_support_surface_refreshes_routing_range_geometry(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"] + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() doc = FakeDocument() - app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") panel = doc.addObject("Part::Feature", "MountingPlateA") - panel.Label = "Mounting Plate A" + panel.Label = "安装板A" panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - gui.Selection = types.SimpleNamespace( - getSelection=lambda: [], - getSelectionEx=lambda: [FakeSelectionItem(obj=panel)], - ) - result = auto_routing_panel.AutoRoutingController().generate_layout_space() + created = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + panel.Shape = FakeShape(FakeBoundBox(20, 140, 0, 5, 0, 100)) + created_again = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + carriers = routing_network.collect_route_carriers(doc) + x_values = [ + point.x + for carrier in carriers + if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" + for point in carrier.Points + ] - self.assertGreater(result["support_surface_sources"], 0) - self.assertEqual("document", result["source_mode"]) + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual(6, len([carrier for carrier in carriers if carrier.QetRouteCarrierKind == "RoutingRange"])) + self.assertEqual(20.0, min(x_values)) + self.assertEqual(140.0, max(x_values)) - def test_generate_routing_path_network_adds_terminal_access_to_route_network(self): + def test_auto_detect_support_surface_adds_missing_routing_range_lanes_after_resize(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"] doc = FakeDocument() - app.ActiveDocument = doc 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)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - result = auto_routing_panel.AutoRoutingController().generate_routing_paths() - result_again = auto_routing_panel.AutoRoutingController().generate_routing_paths() - access_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" - ] - - self.assertEqual(1, result["wire_duct_carriers"]) - self.assertEqual(2, result["wire_duct_open_end_carriers"]) - self.assertEqual(2, result["terminal_access_carriers"]) - self.assertEqual(0, result_again["wire_duct_carriers"]) - self.assertEqual(0, result_again["wire_duct_open_end_carriers"]) - self.assertEqual(2, result_again["terminal_access_carriers"]) - self.assertEqual(2, len(access_carriers)) - self.assertGreater(result["network"]["segments"], 0) - - def test_generate_routing_path_network_connects_terminal_access_to_nearest_segment_point(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"] - doc = FakeDocument() - app.ActiveDocument = doc - terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalMid", "terminal-mid", app.Vector(50, 30, 0)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) - - auto_routing_panel.AutoRoutingController().generate_routing_paths() - access_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" - ] - - self.assertEqual(1, len(access_carriers)) - end_point = access_carriers[0].Points[-1] - self.assertEqual((50.0, 0.0, 20.0), (end_point.x, end_point.y, end_point.z)) - - def test_terminal_access_prefers_larger_connected_network_over_nearer_isolated_stub(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)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 1, 20), app.Vector(5, 1, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( + created = routing_network.create_surface_carriers_from_document( doc, - [ - app.Vector(0, 10, 20), - app.Vector(40, 10, 20), - app.Vector(80, 10, 20), - app.Vector(120, 10, 20), - ], project_uuid="project-1", - kind="WireDuct", + spacing=60.0, + offset=5.0, + margin=0.0, ) - - created = routing_network.create_terminal_access_carriers_from_document( + panel.Shape = FakeShape(FakeBoundBox(0, 180, 0, 5, 0, 120)) + created_again = routing_network.create_surface_carriers_from_document( doc, project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, ) + carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" + ] + x_values = [point.x for carrier in carriers for point in carrier.Points] + z_values = [point.z for carrier in carriers for point in carrier.Points] - self.assertEqual(1, len(created)) - end_point = created[0].Points[-1] - self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + self.assertEqual(6, len(created)) + self.assertEqual(1, len(created_again)) + self.assertEqual(7, len(carriers)) + self.assertEqual(180.0, max(x_values)) + self.assertEqual(120.0, max(z_values)) - def test_connection_entry_candidates_prefer_wire_duct_over_terminal_access(self): + def test_auto_detect_support_surface_removes_stale_routing_range_lanes_after_resize(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( + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + created = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(0, 0, 20), app.Vector(0, 10, 20)], project_uuid="project-1", - kind="TerminalAccess", + spacing=60.0, + offset=5.0, + margin=0.0, ) - routing_network.create_route_carrier( + panel.Shape = FakeShape(FakeBoundBox(0, 60, 0, 5, 0, 60)) + created_again = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], project_uuid="project-1", - kind="WireDuct", - ) - network = routing_network.build_route_graph(doc) - - ranked = routing_network.rank_connection_point_candidates( - network, - routing_network.connection_point_candidates(network, app.Vector(0, 0, 20), limit=0), + spacing=60.0, + offset=5.0, + margin=0.0, ) + carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "RoutingRange" + ] + x_values = [point.x for carrier in carriers for point in carrier.Points] + z_values = [point.z for carrier in carriers for point in carrier.Points] - first_kind = getattr(ranked[0]["carrier"], "QetRouteCarrierKind", "") - self.assertEqual("WireDuct", first_kind) + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual(4, len(carriers)) + self.assertEqual(60.0, max(x_values)) + self.assertEqual(60.0, max(z_values)) - def test_terminal_access_prefers_wire_duct_over_nearer_routing_range(self): + def test_auto_detect_support_surface_removes_carriers_and_obstacle_mode_when_source_invalid(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)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 1, 20), app.Vector(120, 1, 20)], - project_uuid="project-1", - kind="RoutingRange", - ) - routing_network.create_route_carrier( + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + created = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(0, 10, 20), app.Vector(120, 10, 20)], project_uuid="project-1", - kind="WireDuct", + spacing=60.0, + offset=5.0, + margin=0.0, ) - - created = routing_network.create_terminal_access_carriers_from_document( + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 120, 0, 120)) + created_again = routing_network.create_surface_carriers_from_document( doc, project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, ) - self.assertEqual(1, len(created)) - end_point = created[0].Points[-1] - self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + self.assertEqual("", getattr(panel, "QetRoutingObstacleMode", "")) + self.assertEqual("", getattr(panel, "QetRouteCarrierNamesJson", "")) - def test_eplan_connection_route_enters_network_at_segment_projection(self): + def test_eplan_connection_route_can_use_auto_detected_support_surface(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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(50, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(150, 0, 0)) - routing_network.create_route_carrier( + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 10, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 10, 0)) + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + created = routing_network.create_surface_carriers_from_document( doc, - [app.Vector(0, 0, 20), app.Vector(200, 0, 20)], project_uuid="project-1", - kind="WireDuct", + spacing=60.0, + offset=5.0, + margin=0.0, ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) - self.assertEqual("segment_projection", result["network"]["entry_point_mode"]) - self.assertEqual("segment_projection", result["network"]["exit_point_mode"]) - self.assertNotIn(0.0, [point.x for point in result["points"][1:-1]]) - self.assertNotIn(200.0, [point.x for point in result["points"][1:-1]]) - self.assertLess(result["length_mm"], 150.0) + self.assertGreater(len(created), 0) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue(any(point.y == 10.0 for point in result["points"])) - def test_generate_routing_path_network_adds_wiring_cut_out_carrier(self): + def test_prepare_layout_space_auto_detects_support_surface_sources(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - cut_out = doc.addObject("Part::Feature", "WiringCutoutA") - cut_out.Label = "Wiring Cut-Out A" - cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) - result = auto_routing_panel.AutoRoutingController().generate_routing_paths() - cut_out_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" - ] + result = auto_routing_panel.AutoRoutingController().generate_layout_space() - self.assertEqual(1, result["wiring_cut_out_carriers"]) - self.assertEqual(1, len(cut_out_carriers)) - self.assertEqual("PassThrough", cut_out.QetRoutingObstacleMode) + self.assertGreater(result["support_surface_sources"], 0) + self.assertEqual("document", result["source_mode"]) - def test_generate_routing_path_network_refreshes_wiring_cut_out_geometry(self): + def test_generate_routing_paths_uses_selected_wire_duct_entity(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - cut_out = doc.addObject("Part::Feature", "WiringCutoutA") - cut_out.Label = "Wiring Cut-Out A" - cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + ) - first = auto_routing_panel.AutoRoutingController().generate_routing_paths() - cut_out.Shape = FakeShape(FakeBoundBox(65, 75, -2, 2, 15, 25)) - second = auto_routing_panel.AutoRoutingController().generate_routing_paths() - cut_out_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" - ] + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() - self.assertEqual(1, first["wiring_cut_out_carriers"]) - self.assertEqual(0, second["wiring_cut_out_carriers"]) - self.assertEqual(1, len(cut_out_carriers)) - self.assertEqual([(70.0, -22.0, 20.0), (70.0, 22.0, 20.0)], [(p.x, p.y, p.z) for p in cut_out_carriers[0].Points]) + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual("selection", result["source_mode"]) - def test_wiring_cut_out_source_bridge_extension_controls_generated_path_length(self): + def test_generate_routing_paths_uses_selected_route_path_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") - cut_out = doc.addObject("Part::Feature", "WiringCutoutA") - cut_out.Label = "过线孔A" - cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) - cut_out.QetWiringCutOutBridgeExtensionMm = 8.0 - - auto_routing_panel.AutoRoutingController().generate_routing_paths() - cut_out_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" - ] - - self.assertEqual(1, len(cut_out_carriers)) - self.assertIn("QetWiringCutOutBridgeExtensionMm", cut_out.PropertiesList) - self.assertEqual(8.0, cut_out.QetWiringCutOutBridgeExtensionMm) - self.assertEqual([(50.0, -10.0, 20.0), (50.0, 10.0, 20.0)], [(p.x, p.y, p.z) for p in cut_out_carriers[0].Points]) + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Label = "用户主路径A" + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[ + FakeEdge(app.Vector(0, 0, 20), app.Vector(0, 80, 20)), + FakeEdge(app.Vector(0, 80, 20), app.Vector(100, 80, 20)), + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) - def test_wiring_cut_out_bridges_nearby_ducts_on_both_sides_of_panel(self): + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = routing_network.collect_route_carriers(doc) + user_paths = [item for item in carriers if item.QetRouteCarrierKind == "UserPath"] + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(1, len(user_paths)) + self.assertEqual("UserRouteSketch", user_paths[0].QetRouteSourceName) + self.assertEqual("用户主路径A", user_paths[0].QetRouteSourceLabel) + self.assertEqual("selection", result["source_mode"]) + + def test_controller_creates_selected_user_paths_without_full_network_generation(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, -20, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 20, 0)) - routing_network.create_route_carrier( + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 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(1, result["network"]["kinds"]["UserPath"]) + self.assertEqual(1, len(carriers)) + self.assertEqual("UserPath", carriers[0].QetRouteCarrierKind) + + def test_controller_creates_user_path_bridge_from_selected_route_carriers(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") + duct = routing_network.create_route_carrier( doc, - [app.Vector(0, -20, 20), app.Vector(50, -20, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", + label="线槽", ) - routing_network.create_route_carrier( + main_path = routing_network.create_route_carrier( doc, - [app.Vector(50, 20, 20), app.Vector(100, 20, 20)], + [app.Vector(120, 20, 0), app.Vector(200, 20, 0)], project_uuid="project-1", - kind="WireDuct", + kind="RoutingRange", + label="主网络", + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=duct), FakeSelectionItem(obj=main_path)], ) - cut_out = doc.addObject("Part::Feature", "WiringCutoutA") - cut_out.Label = "过线孔A" - cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) - auto_routing_panel.AutoRoutingController().generate_routing_paths() - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().create_user_path_bridge_from_selection() - self.assertEqual("Routed", result["route_status"]) - self.assertIn("WiringCutOut", result["route_track"]["carrier_kinds"]) - self.assertEqual(0, result["collision_count"]) + self.assertEqual(1, result["user_path_bridges"]) + self.assertEqual(1, result["network"]["kinds"]["UserPath"]) - def test_check_routing_path_network_writes_diagnostic_for_unconnected_terminal(self): + def test_controller_creates_user_path_bridge_from_path_network_diagnostic_suggestion(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalFar", "terminal-far", app.Vector(5000, 0, 0)) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", + label="孤立线槽", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1000, 100, 0)], + project_uuid="project-1", + kind="TerminalAccess", + label="端子接入", ) - 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) + result = auto_routing_panel.AutoRoutingController().create_user_path_bridges_from_diagnostic_suggestions() + carriers = routing_network.collect_route_carriers(doc) + user_paths = [carrier for carrier in carriers if carrier.QetRouteCarrierKind == "UserPath"] - self.assertFalse(result["ok"]) - self.assertEqual("RoutingPathNetwork", diagnostic_group.Group[0].QetDiagnosticKind) - self.assertEqual(1, len(payload["unconnected_terminals"])) - self.assertEqual("terminal-far", payload["unconnected_terminals"][0]["terminal_uuid"]) - self.assertEqual(1000.0, payload["unconnected_terminals"][0]["terminal_access_max_distance_mm"]) - message = auto_routing.format_routing_path_network_report(result["diagnostic"]) - self.assertIn("端子未接入", message) - self.assertIn("terminal-far", message) - self.assertIn("4900.0 mm", message) - self.assertIn("端子接入最大距离 1000.0 mm", message) - self.assertIn("补一段线槽/辅助路径", message) + self.assertEqual(1, result["user_path_bridges"]) + self.assertEqual(1, result["diagnostic_suggestions"]) + self.assertEqual(1, len(user_paths)) + self.assertEqual( + [(100.0, 0.0, 0.0), (1000.0, 0.0, 0.0)], + [(point.x, point.y, point.z) for point in user_paths[0].Points], + ) - def test_check_routing_path_network_warns_for_long_terminal_access(self): + def test_controller_creates_user_path_bridge_from_main_path_detour_pair(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") 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( + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + fallback_source = doc.addObject("Part::Feature", "DoorRoutingRangeSource") + fallback_source.QetRouteSourceLabel = "门板布线面" + current_source = doc.addObject("Part::Feature", "MainUserPathSource") + current_source.QetRouteSourceLabel = "主路径A" + fallback_carrier = routing_network.create_route_carrier( doc, - [app.Vector(900, 0, 20), app.Vector(1000, 0, 20)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", - kind="WireDuct", + kind="RoutingRange", + label="门板布线面 carrier", ) - routing_network.create_terminal_access_carriers_from_document( + current_carrier = routing_network.create_route_carrier( doc, + [app.Vector(140, 20, 0), app.Vector(200, 20, 0)], project_uuid="project-1", - terminal_exit_length=20.0, - max_distance=1000.0, + kind="UserPath", + label="主路径A carrier", + ) + fallback_carrier.QetRouteSourceName = fallback_source.Name + fallback_carrier.QetRouteSourceLabel = "门板布线面" + current_carrier.QetRouteSourceName = current_source.Name + current_carrier.QetRouteSourceLabel = "主路径A" + wire = doc.addObject("Part::Feature", "QETRoutedConnection_main_path_pair_bridge") + wire.Label = "N-PAIR-BRIDGE: A1 -> B1" + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-main-path-pair-bridge" + wire.QetRouteIssueCodes = "main_path_detour_missing" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + "QetRouteTrackJson", + ] + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["门板布线面"], + } + }, + ensure_ascii=False, + ) + wire.QetRouteTrackJson = json.dumps( + { + "segments": [ + {"carrier": {"kind": "UserPath", "source_label": "主路径A"}} + ] + }, + ensure_ascii=False, ) + routed_group.addObject(wire) - result = auto_routing.check_eplan_routing_path_network( - doc, - project_uuid="project-1", - options={"terminal_access_max_distance": 1000.0}, + result = auto_routing_panel.AutoRoutingController().create_user_path_bridges_from_diagnostic_suggestions() + user_paths = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] + created_bridges = [ + carrier + for carrier in user_paths + if "QET User Bridge" in str(getattr(carrier, "Label", "") or "") + ] + + self.assertEqual(1, result["main_path_detour_bridge_pairs"]) + self.assertEqual(1, result["main_path_detour_user_path_bridges"]) + self.assertEqual(0, result["main_path_detour_bridge_duplicates"]) + self.assertEqual(1, len(created_bridges)) + self.assertEqual("门板布线面 -> 主路径A", created_bridges[0].QetRouteBridgePairLabel) + self.assertEqual( + [(100.0, 0.0, 0.0), (140.0, 20.0, 0.0)], + [(point.x, point.y, point.z) for point in created_bridges[0].Points], ) - 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("TerminalLongAccess", message) - self.assertIn("terminal-long-access", message) - self.assertIn("900.0 mm", message) + second = auto_routing_panel.AutoRoutingController().create_user_path_bridges_from_diagnostic_suggestions() - def test_check_routing_path_network_warns_for_invalid_terminal_local_route_points(self): + self.assertEqual(0, second["main_path_detour_user_path_bridges"]) + self.assertEqual(1, second["main_path_detour_bridge_duplicates"]) + + def test_controller_does_not_duplicate_diagnostic_user_path_bridge(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") 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)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", ) - routing_network.create_terminal_access_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(1000, 0, 0), app.Vector(1000, 100, 0)], project_uuid="project-1", - terminal_exit_length=20.0, - max_distance=1000.0, + kind="TerminalAccess", ) + controller = auto_routing_panel.AutoRoutingController() - 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"]) + first = controller.create_user_path_bridges_from_diagnostic_suggestions() + second = controller.create_user_path_bridges_from_diagnostic_suggestions() + user_paths = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] - 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) + self.assertEqual(1, first["user_path_bridges"]) + self.assertEqual(0, second["user_path_bridges"]) + self.assertEqual(0, second["diagnostic_suggestions"]) + self.assertEqual(1, len(user_paths)) - def test_check_routing_path_network_uses_terminal_local_route_end_for_connectivity(self): + def test_route_eplan_connections_auto_creates_diagnostic_user_path_bridge(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)], + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", + label="孤立线槽", ) - created = routing_network.create_terminal_access_carriers_from_document( + routing_network.create_route_carrier( doc, + [app.Vector(1000, 0, 0), app.Vector(1000, 100, 0)], project_uuid="project-1", - terminal_exit_length=20.0, - max_distance=100.0, + kind="TerminalAccess", + label="端子接入", ) - result = auto_routing.check_eplan_routing_path_network( + report = auto_routing.route_eplan_connections( doc, + payload={"project_uuid": "project-1", "wires": []}, + options={"auto_create_diagnostic_bridges": True}, project_uuid="project-1", - options={"terminal_access_max_distance": 100.0}, + update_network=False, ) + message = auto_routing.format_eplan_connection_route_report(report) + user_paths = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] - self.assertEqual([], created) - self.assertEqual([], result["diagnostic"]["unconnected_terminals"]) + self.assertEqual(1, report["auto_diagnostic_bridges"]["created_count"]) + self.assertEqual(1, len(user_paths)) self.assertNotIn( - "unconnected_terminals", - [issue.get("code") for issue in result["diagnostic"]["issues"]], + "wire_ducts_without_terminal_access", + report["routing_path_network_diagnostic"]["issue_codes"], ) + self.assertIn("自动诊断桥接:生成 UserPath 1 条", message) - def test_format_routing_path_network_report_tolerates_malformed_samples(self): - _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - diagnostic = { - "issues": [{"code": "external_issue", "count": 1}], - "unconnected_terminals": ["bad-terminal-sample"], - "possible_breaks": ["bad-break-sample"], - "isolated_components": ["bad-component-sample"], - } - - message = auto_routing.format_routing_path_network_report(diagnostic) - - self.assertIn("布线路径网络检查发现", message) - self.assertIn("首个问题:external_issue", message) - - def test_format_routing_path_network_report_calls_out_wire_duct_break_point(self): + def test_route_eplan_connections_does_not_auto_create_diagnostic_bridge_by_default(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") routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="线槽A", + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], project_uuid="project-1", kind="WireDuct", + label="孤立线槽", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1000, 100, 0)], + project_uuid="project-1", + kind="TerminalAccess", + label="端子接入", ) - result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") - message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + report = auto_routing.route_eplan_connections( + doc, + payload={"project_uuid": "project-1", "wires": []}, + project_uuid="project-1", + update_network=False, + ) + user_paths = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] - self.assertIn("线槽端点疑似断开", message) - self.assertIn("线槽A", message) - self.assertIn("(0.0, 0.0, 20.0)", message) - self.assertIn("补齐相邻线槽", message) + self.assertFalse(report["auto_diagnostic_bridges"]["enabled"]) + self.assertEqual(0, report["auto_diagnostic_bridges"]["created_count"]) + self.assertEqual(0, len(user_paths)) - def test_check_routing_path_network_warns_when_network_is_empty(self): + def test_controller_repeats_diagnostic_bridge_until_no_new_bridge_is_created(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - doc = FakeDocument() - terminal_objects.ensure_root_group(doc, "project-1") + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + original_check = auto_routing.check_eplan_routing_path_network + original_create = routing_network.create_user_path_bridges_from_diagnostic_suggestions + calls = {"check": 0, "create": 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"]) + def fake_check(_doc, project_uuid="", options=None): + calls["check"] += 1 + return {"diagnostic": {"pass_index": calls["check"]}} - self.assertFalse(result["ok"]) - self.assertEqual("empty_routing_path_network", payload["issues"][0]["code"]) - self.assertEqual(0, payload["summary"]["segments"]) - self.assertIn("布线路径网络为空", message) + def fake_create(_doc, diagnostic, project_uuid=""): + calls["create"] += 1 + if int(diagnostic.get("pass_index", 0) or 0) <= 2: + return {"suggestions": 1, "created": [object()], "duplicates": 0, "stale_suggestions": 0} + return {"suggestions": 0, "created": [], "duplicates": 0, "stale_suggestions": 0} - def test_check_routing_path_network_warns_for_invalid_route_carrier_geometry(self): + try: + auto_routing.check_eplan_routing_path_network = fake_check + routing_network.create_user_path_bridges_from_diagnostic_suggestions = fake_create + + result = auto_routing_panel.AutoRoutingController().create_user_path_bridges_from_diagnostic_suggestions() + finally: + auto_routing.check_eplan_routing_path_network = original_check + routing_network.create_user_path_bridges_from_diagnostic_suggestions = original_create + + self.assertEqual(2, result["user_path_bridges"]) + self.assertEqual(2, result["diagnostic_suggestions"]) + self.assertEqual(3, result["diagnostic_passes"]) + self.assertEqual(3, calls["check"]) + self.assertEqual(3, calls["create"]) + + def test_selected_curve_edges_are_discretized_as_user_path_polyline(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - carrier = routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="坏用户路径", - project_uuid="project-1", - kind="UserPath", + route_path = doc.addObject("Sketcher::SketchObject", "CurvedRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 60, 20, 20), + edges=[ + FakeCurveEdge( + [ + app.Vector(0, 0, 20), + app.Vector(25, 40, 20), + app.Vector(75, 40, 20), + app.Vector(100, 0, 20), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], ) - 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"]) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - 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) + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual( + [(0.0, 0.0, 20.0), (25.0, 40.0, 20.0), (75.0, 40.0, 20.0), (100.0, 0.0, 20.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) - def test_check_routing_path_network_warns_when_only_routing_range_is_available(self): + def test_selected_shape_wires_are_used_as_user_path_polyline(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="RoutingRange", + route_path = doc.addObject("Sketcher::SketchObject", "WireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire( + [ + app.Vector(0, 0, 20), + app.Vector(0, 60, 20), + app.Vector(60, 80, 20), + app.Vector(120, 80, 20), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], ) - 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"]) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - self.assertFalse(result["ok"]) - self.assertEqual(1, payload["routing_range_only_network"]["routing_range_carriers"]) + self.assertEqual(1, result["user_path_carriers"]) self.assertEqual( - 0, - payload["routing_range_only_network"]["primary_route_carriers"], + [(0.0, 0.0, 20.0), (0.0, 60.0, 20.0), (60.0, 80.0, 20.0), (120.0, 80.0, 20.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], ) - 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): + def test_selected_user_path_shape_points_honor_object_placement(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - diagnostic = { - "summary": { - "carriers": 5, - "segments": 6, - "nodes": 5, - "bridged_segments": 1, - }, - "issues": [], - "ok": True, - } + 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("Sketcher::SketchObject", "MovedRouteSketch") + route_path.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation()) + route_path.Shape = FakeShape( + FakeBoundBox(0, 50, 0, 50, 20, 20), + wires=[ + FakeWire( + [ + app.Vector(0, 0, 20), + app.Vector(50, 0, 20), + app.Vector(50, 50, 20), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) - message = auto_routing.format_routing_path_network_report(diagnostic) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - self.assertIn("桥接 1 段相邻/投影主路径", message) + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual( + [(100.0, 10.0, 25.0), (150.0, 10.0, 25.0), (150.0, 60.0, 25.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) - def test_check_routing_path_network_uses_adjoining_duct_tolerance_option(self): + def test_disconnected_shape_wires_create_separate_user_paths(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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("Sketcher::SketchObject", "MultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 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(2, result["user_path_carriers"]) + self.assertEqual(2, len(carriers)) + self.assertEqual( + [ + [(0.0, 0.0, 20.0), (40.0, 0.0, 20.0)], + [(80.0, 80.0, 20.0), (120.0, 80.0, 20.0)], + ], + [[(point.x, point.y, point.z) for point in carrier.Points] for carrier in carriers], + ) + self.assertEqual(["1", "2"], [carrier.QetRouteSourcePathIndex for carrier in carriers]) + self.assertEqual(2, len(json.loads(route_path.QetRouteCarrierNamesJson))) + + def test_refreshing_multi_wire_user_path_removes_stale_carriers(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") - for index, points in enumerate( - ( - [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], - [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], - [app.Vector(100, 0, 20), app.Vector(100, 100, 20)], - [app.Vector(100, 100, 20), app.Vector(0, 100, 20)], - [app.Vector(0, 100, 20), app.Vector(0, 0, 20)], - ), - start=1, - ): - routing_network.create_route_carrier( - doc, - points, - label="线槽{0}".format(index), - project_uuid="project-1", - kind="WireDuct", - ) + route_path = doc.addObject("Sketcher::SketchObject", "EditableMultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), + ], + ) + selection = [FakeSelectionItem(obj=route_path)] + first = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + route_path.Shape = FakeShape( + FakeBoundBox(0, 60, 0, 20, 20, 20), + wires=[FakeWire([app.Vector(0, 0, 20), app.Vector(60, 0, 20)])], + ) - result = auto_routing.check_eplan_routing_path_network( + second = routing_network.create_user_path_carriers_from_selection( doc, + selection, project_uuid="project-1", - options={"adjoining_duct_tolerance": 15.0}, ) + carriers = routing_network.collect_route_carriers(doc) - self.assertTrue(result["ok"]) - self.assertEqual(1, result["diagnostic"]["summary"]["bridged_segments"]) - self.assertEqual([], result["diagnostic"]["possible_breaks"]) + self.assertEqual(2, len(first)) + self.assertEqual(1, len(second)) + self.assertEqual(1, len(carriers)) + self.assertEqual([(0.0, 0.0, 20.0), (60.0, 0.0, 20.0)], [(p.x, p.y, p.z) for p in carriers[0].Points]) + self.assertEqual("", carriers[0].QetRouteSourcePathIndex) + self.assertEqual(1, len(json.loads(route_path.QetRouteCarrierNamesJson))) - def test_generate_routing_path_network_skips_far_terminal_access_to_protect_view_bbox(self): + def test_refreshing_single_wire_user_path_adds_new_carriers_for_added_wires(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() - auto_routing_panel = importlib.import_module("AutoRoutingPanel") + 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, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - duct = doc.addObject("Part::Feature", "WireDuctFar") - duct.Label = "Wire Duct Far" - duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25)) + route_path = doc.addObject("Sketcher::SketchObject", "GrowingMultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 60, 0, 20, 20, 20), + wires=[FakeWire([app.Vector(0, 0, 20), app.Vector(60, 0, 20)])], + ) + selection = [FakeSelectionItem(obj=route_path)] + first = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(60, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), + ], + ) - result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + second = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + carriers = routing_network.collect_route_carriers(doc) - self.assertEqual(1, result["wire_duct_carriers"]) - self.assertEqual(2, result["wire_duct_open_end_carriers"]) - self.assertEqual(0, result["terminal_access_carriers"]) + self.assertEqual(1, len(first)) + self.assertEqual(2, len(second)) + self.assertEqual(2, len(carriers)) + self.assertEqual(["1", "2"], [carrier.QetRouteSourcePathIndex for carrier in carriers]) + self.assertEqual(2, len(json.loads(route_path.QetRouteCarrierNamesJson))) - def test_auto_routing_controller_exposes_terminal_access_max_distance(self): + def test_controller_marks_selected_object_as_cabinet_interior_boundary(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - duct = doc.addObject("Part::Feature", "WireDuctFar") - duct.Label = "Wire Duct Far" - duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25)) + cabinet_space = doc.addObject("Part::Feature", "CabinetInteriorSpace") + cabinet_space.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=cabinet_space)], + ) - controller = auto_routing_panel.AutoRoutingController() - controller.set_terminal_access_max_distance(6000.0) - result = controller.generate_routing_paths() + result = auto_routing_panel.AutoRoutingController().mark_cabinet_boundary_from_selection() - self.assertEqual(1, result["terminal_access_carriers"]) - self.assertEqual(6000.0, controller.routing_options()["terminal_access_max_distance"]) + self.assertEqual(1, result["cabinet_boundary_objects"]) + self.assertEqual("CabinetInterior", cabinet_space.QetRoutingBoundaryKind) + self.assertEqual("RoutingBoundary", cabinet_space.QetRoutingRole) + self.assertEqual("PassThrough", cabinet_space.QetRoutingObstacleMode) + self.assertEqual(1, len(auto_routing.collect_routing_boundaries(doc))) - def test_auto_routing_controller_exposes_terminal_exit_length(self): + def test_controller_marks_selected_object_obstacle_pass_through_and_restores(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(50, 0, 0)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) - + obstacle = doc.addObject("Part::Feature", "BracketObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(0, 100, 0, 20, 0, 20)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=obstacle)], + ) controller = auto_routing_panel.AutoRoutingController() - controller.set_terminal_exit_length(40.0) - controller.generate_routing_paths() - access_carriers = [ - carrier - for carrier in routing_network.collect_route_carriers(doc) - if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" - ] - self.assertEqual(1, len(access_carriers)) - self.assertEqual( - (50.0, 0.0, 40.0), - tuple(getattr(access_carriers[0].Points[0], axis) for axis in ("x", "y", "z")), - ) - self.assertEqual(40.0, controller.routing_options()["terminal_exit_length"]) + ignored = controller.mark_selected_objects_pass_through_obstacle() + self.assertEqual([], auto_routing.collect_obstacles(doc)) + restored = controller.restore_selected_objects_as_obstacles() - def test_route_eplan_connections_prepares_layout_space_like_eplan_route(self): + self.assertEqual(1, ignored["obstacle_mode_objects"]) + self.assertEqual(1, restored["obstacle_mode_objects"]) + self.assertEqual("", obstacle.QetRoutingObstacleMode) + self.assertEqual(["BracketObstacle"], [item["name"] for item in auto_routing.collect_obstacles(doc)]) + + def test_controller_summary_reports_pass_through_obstacle_count(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") @@ -2387,2471 +3033,11734 @@ class AutoRoutingTest(unittest.TestCase): doc = FakeDocument() app.ActiveDocument = doc 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)) - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "Wire Duct A" - duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) - app._qet_exchange_payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } - ], - } + bracket = doc.addObject("Part::Feature", "BracketObstacle") + bracket.Shape = FakeShape(FakeBoundBox(0, 100, 0, 20, 0, 20)) + bracket.QetRoutingObstacleMode = "PassThrough" - report = auto_routing_panel.AutoRoutingController().route_eplan_connections() + message = auto_routing_panel.AutoRoutingController().summary() - self.assertEqual(1, report["routed"]) - self.assertEqual("eplan-route-v1", report["routing_method"]) - self.assertTrue(report["routing_path_network_updated"]) - self.assertEqual(1, report["prepared_layout"]["wire_duct_carriers"]) - self.assertEqual(1, report["routing_path_network"]["wire_duct_carriers"]) - self.assertEqual(2, report["prepared_layout"]["terminal_access_carriers"]) - diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") - self.assertIsNotNone(diagnostic_group) - self.assertEqual(1, len(diagnostic_group.Group)) - diagnostic_payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) - self.assertEqual(1, diagnostic_payload["prepared_layout"]["wire_duct_carriers"]) - self.assertEqual(2, diagnostic_payload["prepared_layout"]["terminal_access_carriers"]) + self.assertIn("忽略碰撞:1", message) - def test_auto_routing_controller_passes_adjoining_duct_tolerance_to_batch_route(self): + def test_controller_selects_top_collision_obstacles_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(1000, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + obstacle = doc.addObject("Part::Feature", "Compound053") + obstacle.Label = "NAUO118" + other = doc.addObject("Part::Feature", "Compound039") + other.Label = "NAUO141" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + {"label": "NAUO118", "name": "Compound053", "count": 18}, + {"label": "NAUO141", "name": "Compound039", "count": 6}, + {"label": "NAUO404", "name": "Compound404", "count": 1}, + ] + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - app._qet_exchange_payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } - ], - } - report = auto_routing_panel.AutoRoutingController( - options={"adjoining_duct_tolerance": 15.0} - ).route_eplan_connections() + result = auto_routing_panel.AutoRoutingController().select_top_collision_obstacles() - self.assertEqual(1, report["routed"]) - self.assertEqual(1, report["routes"][0]["network"]["bridged_segments"]) + self.assertEqual(2, result["selected_collision_obstacles"]) + self.assertEqual(["Compound053", "Compound039"], result["selected_collision_obstacle_names"]) + self.assertEqual(["Compound404"], result["missing_collision_obstacle_names"]) + self.assertEqual([obstacle, other], selected) - def test_auto_routing_controller_summary_uses_adjoining_duct_tolerance(self): + def test_controller_selects_top_collision_parent_assemblies_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + door = doc.addObject("App::LinkGroup", "DoorAssembly") + door.Label = "FRONT DOOR-R ASS'Y" + cabinet = doc.addObject("App::LinkGroup", "CabinetAssembly") + cabinet.Label = "CABINET ASS'Y" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + { + "label": "NAUO141", + "name": "Compound039", + "count": 6, + "parent_names": ["DoorAssembly"], + "parent_labels": ["FRONT DOOR-R ASS'Y"], + }, + { + "label": "NAUO142", + "name": "Compound040", + "count": 3, + "parent_names": ["DoorAssembly"], + "parent_labels": ["FRONT DOOR-R ASS'Y"], + }, + { + "label": "NAUO118", + "name": "Compound053", + "count": 18, + "parent_names": ["CabinetAssembly"], + "parent_labels": ["CABINET ASS'Y"], + }, + { + "label": "NAUO404", + "name": "Compound404", + "count": 1, + "parent_names": ["MissingAssembly"], + "parent_labels": ["MISSING ASS'Y"], + }, + ] + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - summary = auto_routing_panel.AutoRoutingController( - options={"adjoining_duct_tolerance": 15.0} - ).summary() + result = auto_routing_panel.AutoRoutingController().select_top_collision_parent_assemblies() - self.assertIn("桥接:1", summary) + self.assertEqual(2, result["selected_collision_parent_assemblies"]) + self.assertEqual(["DoorAssembly", "CabinetAssembly"], result["selected_collision_parent_assembly_names"]) + self.assertEqual(["MissingAssembly"], result["missing_collision_parent_assembly_refs"]) + self.assertEqual([door, cabinet], selected) - def test_auto_routing_controller_exposes_lane_spacing(self): + def test_controller_selects_only_structural_collision_parent_assemblies(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", 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="WireDuct", + door = doc.addObject("App::LinkGroup", "DoorAssembly") + door.Label = "FRONT DOOR-R ASS'Y" + device_assembly = doc.addObject("App::LinkGroup", "DeviceAssembly") + device_assembly.Label = "DEVICE ASS'Y" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + { + "label": "NAUO141", + "name": "Compound039", + "count": 6, + "parent_names": ["DoorAssembly"], + "parent_labels": ["FRONT DOOR-R ASS'Y"], + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + { + "label": "ID:12", + "name": "QETDevice_A", + "count": 3, + "parent_names": ["DeviceAssembly"], + "parent_labels": ["DEVICE ASS'Y"], + "resolution_hint_code": "review_device_or_layout_collision", + }, + { + "label": "支架缺失", + "name": "MissingBracket", + "count": 1, + "parent_names": ["MissingStructure"], + "parent_labels": ["MISSING STRUCTURE"], + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - app._qet_exchange_payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } - controller = auto_routing_panel.AutoRoutingController() - controller.set_lane_spacing(14.0) - report = controller.route_eplan_connections() + result = auto_routing_panel.AutoRoutingController().select_structural_collision_parent_assemblies() - self.assertEqual(14.0, controller.routing_options()["lane_spacing"]) - self.assertEqual(14.0, report["routes"][1]["lane"]["spacing_mm"]) - self.assertEqual(14.0, report["routes"][1]["lane"]["offset_mm"]) + self.assertEqual(1, result["selected_structural_collision_parent_assemblies"]) + self.assertEqual(["DoorAssembly"], result["selected_structural_collision_parent_assembly_names"]) + self.assertEqual(["MissingStructure"], result["missing_structural_collision_parent_assembly_refs"]) + self.assertEqual([door], selected) + self.assertNotIn(device_assembly, selected) - def test_auto_routing_controller_exposes_lane_axis(self): + def test_controller_marks_only_structural_collision_parent_assemblies_pass_through(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], - project_uuid="project-1", - kind="WireDuct", + door = doc.addObject("App::LinkGroup", "DoorAssembly") + door.Label = "FRONT DOOR-R ASS'Y" + device_assembly = doc.addObject("App::LinkGroup", "DeviceAssembly") + device_assembly.Label = "DEVICE ASS'Y" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + { + "label": "NAUO141", + "name": "Compound039", + "count": 6, + "parent_names": ["DoorAssembly"], + "parent_labels": ["FRONT DOOR-R ASS'Y"], + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + { + "label": "ID:12", + "name": "QETDevice_A", + "count": 3, + "parent_names": ["DeviceAssembly"], + "parent_labels": ["DEVICE ASS'Y"], + "resolution_hint_code": "review_device_or_layout_collision", + }, + { + "label": "支架缺失", + "name": "MissingBracket", + "count": 1, + "parent_names": ["MissingStructure"], + "parent_labels": ["MISSING STRUCTURE"], + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + ] + }, + ensure_ascii=False, ) - app._qet_exchange_payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } + diagnostic_group.addObject(diagnostic) - controller = auto_routing_panel.AutoRoutingController() - controller.set_lane_spacing(8.0) - controller.set_lane_axis("z") - report = controller.route_eplan_connections() + result = auto_routing_panel.AutoRoutingController().mark_structural_collision_parent_assemblies_pass_through() - self.assertEqual("z", controller.routing_options()["lane_axis"]) - self.assertEqual("z", report["routes"][1]["lane"]["axis"]) - self.assertEqual(8.0, report["routes"][1]["lane"]["offset_mm"]) + self.assertEqual(1, result["marked_structural_collision_parent_assemblies"]) + self.assertEqual(["DoorAssembly"], result["marked_structural_collision_parent_assembly_names"]) + self.assertEqual(["MissingStructure"], result["missing_structural_collision_parent_assembly_refs"]) + self.assertEqual("PassThrough", door.QetRoutingObstacleMode) + self.assertEqual("", getattr(device_assembly, "QetRoutingObstacleMode", "")) - def test_auto_routing_controller_exposes_lane_max_offset(self): + def test_controller_marks_nearest_structural_parent_without_broad_root_group(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _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="WireDuct", + door = doc.addObject("App::LinkGroup", "DoorAssembly") + door.Label = "FRONT DOOR-R ASS'Y" + cabinet = doc.addObject("App::LinkGroup", "CabinetAssembly") + cabinet.Label = "MCCB CABINET ASS'Y" + project_root = doc.addObject("App::DocumentObjectGroup", "QETExchangeDevices") + project_root.Label = "QET Exchange Devices" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + { + "label": "NAUO141", + "name": "Compound039", + "count": 6, + "parent_names": [ + "DoorAssembly", + "CabinetAssembly", + "QETExchangeDevices", + ], + "parent_labels": [ + "FRONT DOOR-R ASS'Y", + "MCCB CABINET ASS'Y", + "QET Exchange Devices", + ], + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + ] + }, + ensure_ascii=False, ) + diagnostic_group.addObject(diagnostic) - controller = auto_routing_panel.AutoRoutingController() - controller.set_lane_spacing(10.0) - controller.set_lane_axis("y") - controller.set_lane_max_offset(18.0) - result = _auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - route_index=21, - options=controller.routing_options(), - ) + result = auto_routing_panel.AutoRoutingController().mark_structural_collision_parent_assemblies_pass_through() - self.assertEqual(18.0, controller.routing_options()["lane_max_offset"]) - self.assertEqual(18.0, result["lane"]["max_offset_mm"]) - self.assertEqual(18.0, result["lane"]["offset_mm"]) + self.assertEqual(1, result["marked_structural_collision_parent_assemblies"]) + self.assertEqual(["DoorAssembly"], result["marked_structural_collision_parent_assembly_names"]) + self.assertEqual("PassThrough", door.QetRoutingObstacleMode) + self.assertEqual("", getattr(cabinet, "QetRoutingObstacleMode", "")) + self.assertEqual("", getattr(project_root, "QetRoutingObstacleMode", "")) - def test_auto_routing_panel_command_button_style_keeps_text_visible(self): + def test_controller_selects_only_device_or_layout_collision_obstacles(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") - - class FakeButton: - def __init__(self): - self.text = "" - self.tooltip = "" - self.minimum_height = 0 - self.stylesheet = "" - - def setText(self, text): - self.text = text - - def setToolTip(self, tooltip): - self.tooltip = tooltip - - def setMinimumHeight(self, height): - self.minimum_height = height - - def setStyleSheet(self, stylesheet): - self.stylesheet = stylesheet - - button = FakeButton() - - auto_routing_panel._style_command_button(button, "生成布线连接", "按导线任务布线") - - self.assertEqual("生成布线连接", button.text) - self.assertEqual("按导线任务布线", button.tooltip) - self.assertGreaterEqual(button.minimum_height, 28) - self.assertIn("color", button.stylesheet) - - def test_eplan_connection_route_rejects_far_network_entry_to_avoid_huge_render_bbox(self): - _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] doc = FakeDocument() + app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(5000, 0, 20), app.Vector(5100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + device = doc.addObject("Part::Feature", "QETDevice_A") + device.Label = "ID:12" + bracket = doc.addObject("Part::Feature", "Bracket_A") + bracket.Label = "NAUO141" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "top_collision_obstacles": [ + { + "label": "ID:12", + "name": "QETDevice_A", + "count": 2, + "resolution_hint_code": "review_device_or_layout_collision", + }, + { + "label": "NAUO141", + "name": "Bracket_A", + "count": 6, + "resolution_hint_code": "review_pass_through_structural_obstacle", + }, + { + "label": "缺失设备", + "name": "MissingDevice", + "count": 1, + "resolution_hint_code": "review_device_or_layout_collision", + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - with self.assertRaises(auto_routing.AutoRoutingError): - auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_device_or_layout_collision_obstacles() - def test_route_eplan_connection_between_terminals_fails_without_network(self): + self.assertEqual(1, result["selected_device_or_layout_collision_obstacles"]) + self.assertEqual(["QETDevice_A"], result["selected_device_or_layout_collision_obstacle_names"]) + self.assertEqual(["MissingDevice"], result["missing_device_or_layout_collision_obstacle_names"]) + self.assertEqual([device], selected) + self.assertNotIn(bracket, selected) + + def test_controller_selects_collision_wires_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 30, 0)) + wire_a = doc.addObject("Part::Feature", "QETRoutedConnection_wire_a") + wire_a.Label = "N-COL-A: A1 -> B1 (CollisionWarning)" + wire_a.RouteType = "RoutedConnection" + wire_a.QetWireUuid = "wire-a" + wire_b = doc.addObject("Part::Feature", "QETRoutedConnection_wire_b") + wire_b.Label = "N-COL-B: A2 -> B2 (CollisionWarning)" + wire_b.RouteType = "RoutedConnection" + wire_b.QetWireUuid = "wire-b" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "collision_samples": [ + { + "wire_uuid": "wire-a", + "wire_object_label": "N-COL-A: A1 -> B1 (CollisionWarning)", + }, + { + "wire_uuid": "wire-missing", + "wire_object_label": "N-MISSING: A3 -> B3 (CollisionWarning)", + }, + ], + "route_samples": [ + { + "wire_uuid": "wire-b", + "wire_object_label": "N-COL-B: A2 -> B2 (CollisionWarning)", + "issue_codes": ["collision_warnings"], + } + ], + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], + ) - with self.assertRaises(auto_routing.AutoRoutingError): - auto_routing.route_eplan_connection_between_terminals(doc, start, end) - self.assertEqual(0, len(wiring_objects.iter_routed_wire_objects(doc))) + result = auto_routing_panel.AutoRoutingController().select_collision_wires() - def test_surface_carrier_grid_uses_actual_rotated_face_plane(self): + self.assertEqual(2, result["selected_collision_wires"]) + self.assertEqual( + ["QETRoutedConnection_wire_a", "QETRoutedConnection_wire_b"], + result["selected_collision_wire_names"], + ) + self.assertEqual(["wire-missing"], result["missing_collision_wire_refs"]) + self.assertEqual([wire_a, wire_b], selected) + + def test_controller_selects_collision_wires_from_wire_object_issue_codes(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - normal = app.Vector(0, 1, 1) - vertices = [ - app.Vector(0, 0, 0), - app.Vector(100, 0, 0), - app.Vector(0, 50, -50), - app.Vector(100, 50, -50), - ] - face = FakeFace( - FakeBoundBox(0, 100, 0, 50, -50, 0), - normal, - vertices=vertices, - center=app.Vector(50, 25, -25), + collision_wire = doc.addObject("Part::Feature", "QETRoutedConnection_third_party") + collision_wire.Label = "N-COL: A1 -> B1 (CollisionWarning)" + collision_wire.RouteType = "RoutedConnection" + collision_wire.QetWireUuid = "wire-third-party" + collision_wire.QetRouteIssueCodes = "collision_warnings, third_party_device_collisions" + normal_wire = doc.addObject("Part::Feature", "QETRoutedConnection_normal") + normal_wire.Label = "N-OK: A2 -> B2 (Routed)" + normal_wire.RouteType = "RoutedConnection" + normal_wire.QetWireUuid = "wire-ok" + normal_wire.QetRouteIssueCodes = "" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + {"route_samples": []}, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - created = routing_network.create_surface_carriers_from_selection( - doc, - [FakeSelectionItem([face])], - project_uuid="project-1", - spacing=50.0, - offset=10.0, - margin=0.0, - ) + result = auto_routing_panel.AutoRoutingController().select_collision_wires() - self.assertGreater(len(created), 0) - first_point = created[0].Points[0] - for carrier in created: - for point in carrier.Points: - # The rotated face is y + z = 0; after a 10 mm normal offset, - # all generated points must stay on one parallel plane. - self.assertAlmostEqual(first_point.y + first_point.z, point.y + point.z, places=6) + self.assertEqual(1, result["selected_collision_wires"]) + self.assertEqual( + ["QETRoutedConnection_third_party"], + result["selected_collision_wire_names"], + ) + self.assertEqual([], result["missing_collision_wire_refs"]) + self.assertEqual([collision_wire], selected) - def test_route_path_creation_ignores_whole_solid_object_edges(self): + def test_controller_selects_issue_wires_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - solid = doc.addObject("Part::Feature", "CabinetSolid") - solid.Shape = FakeShape( - FakeBoundBox(0, 100, 0, 100, 0, 10), - edges=[FakeEdge(app.Vector(0, 0, 0), app.Vector(100, 0, 0))], - faces=[object()], + long_wire = doc.addObject("Part::Feature", "QETRoutedConnection_long") + long_wire.Label = "N-LONG: A1 -> B1 (Routed)" + long_wire.RouteType = "RoutedConnection" + long_wire.QetWireUuid = "wire-long" + boundary_wire = doc.addObject("Part::Feature", "QETRoutedConnection_boundary") + boundary_wire.Label = "N-BOUNDARY: A2 -> B2 (BoundaryWarning)" + boundary_wire.RouteType = "RoutedConnection" + boundary_wire.QetWireUuid = "wire-boundary" + normal_wire = doc.addObject("Part::Feature", "QETRoutedConnection_normal") + normal_wire.Label = "N-OK: A3 -> B3 (Routed)" + normal_wire.RouteType = "RoutedConnection" + normal_wire.QetWireUuid = "wire-ok" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_samples": [ + { + "wire_uuid": "wire-long", + "wire_object_label": "N-LONG: A1 -> B1 (Routed)", + "issue_codes": ["long_terminal_access"], + }, + { + "wire_uuid": "wire-boundary", + "wire_object_label": "N-BOUNDARY: A2 -> B2 (BoundaryWarning)", + "issue_codes": ["boundary_warning"], + }, + { + "wire_uuid": "wire-ok", + "wire_object_label": "N-OK: A3 -> B3 (Routed)", + "issue_codes": [], + }, + { + "wire_uuid": "wire-missing", + "wire_object_label": "N-MISSING: A4 -> B4 (Routed)", + "issue_codes": ["route_capacity_pressure"], + }, + ] + }, + ensure_ascii=False, ) - - created = routing_network.create_carriers_from_selection( - doc, - [FakeSelectionItem(obj=solid)], - project_uuid="project-1", + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - self.assertEqual([], created) + result = auto_routing_panel.AutoRoutingController().select_issue_wires() - def test_route_path_creation_projects_line_to_selected_face(self): + self.assertEqual(2, result["selected_issue_wires"]) + self.assertEqual( + ["QETRoutedConnection_long", "QETRoutedConnection_boundary"], + result["selected_issue_wire_names"], + ) + self.assertEqual(["wire-missing"], result["missing_issue_wire_refs"]) + self.assertEqual([long_wire, boundary_wire], selected) + + def test_controller_selects_main_path_detour_missing_wires(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - face = FakeFace( - FakeBoundBox(0, 100, 0, 100, 0, 0), - app.Vector(0, 0, 1), + sampled_wire = doc.addObject("Part::Feature", "QETRoutedConnection_sampled") + sampled_wire.Label = "N-MAINPATH-A: A1 -> B1 (MainPathDetourMissing)" + sampled_wire.RouteType = "RoutedConnection" + sampled_wire.QetWireUuid = "wire-sampled" + object_issue_wire = doc.addObject("Part::Feature", "QETRoutedConnection_object_issue") + object_issue_wire.Label = "N-MAINPATH-B: A2 -> B2 (MainPathDetourMissing)" + object_issue_wire.RouteType = "RoutedConnection" + object_issue_wire.QetWireUuid = "wire-object-issue" + object_issue_wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + normal_wire = doc.addObject("Part::Feature", "QETRoutedConnection_normal") + normal_wire.Label = "N-OK: A3 -> B3 (Routed)" + normal_wire.RouteType = "RoutedConnection" + normal_wire.QetWireUuid = "wire-ok" + normal_wire.QetRouteIssueCodes = "collision_warnings" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_samples": [ + { + "wire_uuid": "wire-sampled", + "wire_object_label": "N-MAINPATH-A: A1 -> B1 (MainPathDetourMissing)", + "issue_codes": ["main_path_detour_missing"], + }, + { + "wire_uuid": "wire-missing", + "wire_object_label": "N-MISSING: A4 -> B4 (MainPathDetourMissing)", + "issue_codes": ["main_path_detour_missing"], + }, + { + "wire_uuid": "wire-ok", + "wire_object_label": "N-OK: A3 -> B3 (Routed)", + "issue_codes": ["collision_warnings"], + }, + ] + }, + ensure_ascii=False, ) - draft_line = doc.addObject("Part::Feature", "DraftLine") - draft_line.Shape = FakeShape( - FakeBoundBox(10, 90, 10, 90, 25, 35), - edges=[FakeEdge(app.Vector(10, 10, 25), app.Vector(90, 90, 35))], + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - created = routing_network.create_carriers_from_selection( - doc, - [ - FakeSelectionItem([face]), - FakeSelectionItem(obj=draft_line), - ], - project_uuid="project-1", - ) + result = auto_routing_panel.AutoRoutingController().select_main_path_detour_missing_wires() - self.assertEqual(1, len(created)) - self.assertEqual([2.0, 2.0], [point.z for point in created[0].Points]) + self.assertEqual(2, result["selected_main_path_detour_missing_wires"]) + self.assertEqual( + ["QETRoutedConnection_sampled", "QETRoutedConnection_object_issue"], + result["selected_main_path_detour_missing_wire_names"], + ) + self.assertEqual(["wire-missing"], result["missing_main_path_detour_missing_wire_refs"]) + self.assertEqual([sampled_wire, object_issue_wire], selected) - def test_wire_duct_entity_generates_centerline_and_marks_source_pass_through(self): + def test_controller_selects_issue_route_sources_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - duct = doc.addObject("Part::Feature", "WireDuct") - duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - - created = routing_network.create_wire_duct_carriers_from_selection( + source_path = doc.addObject("Part::Feature", "YellowMainRouteSketch") + source_path.Label = "黄色主路径" + carrier = routing_network.create_route_carrier( doc, - [FakeSelectionItem(obj=duct)], + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="QET User Route Path 黄色主路径", project_uuid="project-1", - margin=20.0, + kind="UserPath", + ) + carrier.QetRouteSourceName = "YellowMainRouteSketch" + carrier.QetRouteSourceLabel = "黄色主路径" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_samples": [ + { + "wire_uuid": "wire-boundary", + "wire_object_label": "N-BOUNDARY: A2 -> B2 (BoundaryWarning)", + "issue_codes": ["boundary_warning"], + "carrier_names": [carrier.Name], + "route_track": { + "segments": [ + { + "carrier": { + "name": carrier.Name, + "label": carrier.Label, + "source_name": "YellowMainRouteSketch", + "source_label": "黄色主路径", + } + } + ] + }, + }, + { + "wire_uuid": "wire-ok", + "wire_object_label": "N-OK: A3 -> B3 (Routed)", + "issue_codes": [], + "carrier_names": ["MissingCarrier"], + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - self.assertEqual(3, len(created)) - carrier = [item for item in created if item.QetRouteCarrierKind == "WireDuct"][0] - open_ends = [item for item in created if item.QetRouteCarrierKind == "WireDuctOpenEnd"] - self.assertEqual("WireDuct", carrier.QetRouteCarrierKind) - self.assertEqual(2, len(open_ends)) - self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) - self.assertEqual([(20.0, 0.0, 15.0), (100.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + result = auto_routing_panel.AutoRoutingController().select_issue_route_sources() - def test_wire_duct_source_end_margin_controls_generated_centerline_length(self): + self.assertEqual(2, result["selected_issue_route_objects"]) + self.assertEqual([carrier.Name], result["selected_issue_route_carrier_names"]) + self.assertEqual(["YellowMainRouteSketch"], result["selected_issue_route_source_names"]) + self.assertEqual([], result["missing_issue_route_refs"]) + self.assertEqual([carrier, source_path], selected) + + def test_controller_selects_main_path_detour_missing_route_sources_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "线槽A" - duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - duct.QetWireDuctEndMarginMm = 5.0 - - created = routing_network.create_wire_duct_carriers_from_document( + source_path = doc.addObject("Part::Feature", "YellowMainPathSketch") + source_path.Label = "黄色主路径" + carrier = routing_network.create_route_carrier( doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="QET User Route Path 黄色主路径", project_uuid="project-1", + kind="UserPath", + ) + carrier.QetRouteSourceName = source_path.Name + carrier.QetRouteSourceLabel = source_path.Label + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_samples": [ + { + "wire_uuid": "wire-main-path", + "wire_object_label": "N-MAINPATH: A1 -> B1 (CollisionWarning)", + "issue_codes": ["collision_warnings", "main_path_detour_missing"], + "carrier_names": [carrier.Name], + "route_track": { + "segments": [ + { + "carrier": { + "name": carrier.Name, + "label": carrier.Label, + "source_name": source_path.Name, + "source_label": source_path.Label, + } + } + ] + }, + }, + { + "wire_uuid": "wire-long", + "wire_object_label": "N-LONG: A2 -> B2 (LongAccessWarning)", + "issue_codes": ["long_terminal_access"], + "carrier_names": ["MissingLongAccessCarrier"], + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - carrier = [item for item in created if item.QetRouteCarrierKind == "WireDuct"][0] - self.assertIn("QetWireDuctEndMarginMm", duct.PropertiesList) - self.assertEqual(5.0, duct.QetWireDuctEndMarginMm) - self.assertEqual([(5.0, 0.0, 15.0), (115.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + result = auto_routing_panel.AutoRoutingController().select_main_path_detour_missing_route_sources() - def test_wire_duct_source_capacity_is_copied_to_generated_carriers(self): + self.assertEqual(2, result["selected_main_path_detour_route_objects"]) + self.assertEqual([carrier.Name], result["selected_main_path_detour_route_carrier_names"]) + self.assertEqual([source_path.Name], result["selected_main_path_detour_route_source_names"]) + self.assertEqual([], result["missing_main_path_detour_route_refs"]) + self.assertEqual([carrier, source_path], selected) + + def test_controller_selects_main_path_detour_route_sources_from_wire_object_track(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - duct = doc.addObject("Part::Feature", "WireDuct") - duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - duct.QetRouteCarrierCapacity = 4 - - created = routing_network.create_wire_duct_carriers_from_selection( + source_path = doc.addObject("Part::Feature", "YellowMainPathSketch") + source_path.Label = "黄色主路径" + carrier = routing_network.create_route_carrier( doc, - [FakeSelectionItem(obj=duct)], + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="QET User Route Path 黄色主路径", project_uuid="project-1", - margin=20.0, + kind="UserPath", + ) + carrier.QetRouteSourceName = source_path.Name + carrier.QetRouteSourceLabel = source_path.Label + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps({"route_samples": []}, ensure_ascii=False) + diagnostic_group.addObject(diagnostic) + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + wire = doc.addObject("Part::Feature", "QETRoutedConnection_main_path") + wire.Label = "N-MAINPATH: A1 -> B1 (CollisionWarning)" + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-main-path" + wire.PropertiesList = ["QetStartTerminalUuid", "QetEndTerminalUuid", "QetRouteTrackJson"] + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + wire.QetRouteTrackJson = json.dumps( + { + "carrier_names": [carrier.Name], + "segments": [ + { + "carrier": { + "name": carrier.Name, + "label": carrier.Label, + "source_name": source_path.Name, + "source_label": source_path.Label, + } + } + ], + }, + ensure_ascii=False, + ) + routed_group.addObject(wire) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - self.assertIn("QetRouteCarrierCapacity", duct.PropertiesList) - self.assertTrue(all(item.QetRouteCarrierCapacity == 4 for item in created)) + result = auto_routing_panel.AutoRoutingController().select_main_path_detour_missing_route_sources() - def test_auto_detect_wire_ducts_ignores_cabinet_models(self): + self.assertEqual(2, result["selected_main_path_detour_route_objects"]) + self.assertEqual([carrier.Name], result["selected_main_path_detour_route_carrier_names"]) + self.assertEqual([source_path.Name], result["selected_main_path_detour_route_source_names"]) + self.assertEqual([], result["missing_main_path_detour_route_refs"]) + self.assertEqual([carrier, source_path], selected) + + def test_controller_selects_route_sources_from_selected_wire_track(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + 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") - duct = doc.addObject("Part::Feature", "WireDuctA") - duct.Label = "线槽A" - duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) - cabinet = doc.addObject("Part::Feature", "Cabinet") - cabinet.Label = "3D机柜" - cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) - - created = routing_network.create_wire_duct_carriers_from_document( + source_path = doc.addObject("Part::Feature", "YellowMainRouteSketch") + source_path.Label = "黄色主路径" + carrier = routing_network.create_route_carrier( doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="QET User Route Path 黄色主路径", project_uuid="project-1", + kind="UserPath", ) - created_again = routing_network.create_wire_duct_carriers_from_document( - doc, - project_uuid="project-1", + routed_wire = doc.addObject("Part::Feature", "QETRoutedConnection_problem") + routed_wire.RouteType = "RoutedConnection" + routed_wire.QetRouteTrackJson = json.dumps( + { + "carrier_names": [carrier.Name], + "segments": [ + { + "carrier": { + "name": carrier.Name, + "label": carrier.Label, + "source_name": source_path.Name, + "source_label": source_path.Label, + } + } + ], + }, + ensure_ascii=False, + ) + selected = [routed_wire] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - self.assertEqual(3, len(created)) - self.assertEqual(0, len(created_again)) - self.assertEqual(1, len([item for item in created if item.QetRouteCarrierKind == "WireDuct"])) - self.assertEqual(2, len([item for item in created if item.QetRouteCarrierKind == "WireDuctOpenEnd"])) - self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) - self.assertFalse(hasattr(cabinet, "QetRoutingObstacleMode")) + result = auto_routing_panel.AutoRoutingController().select_selected_wire_route_sources() - def test_wire_duct_source_is_not_reported_as_collision(self): + self.assertEqual(2, result["selected_wire_route_objects"]) + self.assertEqual([carrier.Name], result["selected_wire_route_carrier_names"]) + self.assertEqual([source_path.Name], result["selected_wire_route_source_names"]) + self.assertEqual([carrier, source_path], selected) + + def test_controller_selects_rejected_fallback_sources_from_selected_wire_diagnostics(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) - duct = doc.addObject("Part::Feature", "WireDuct") - duct.Shape = FakeShape(FakeBoundBox(-10, 130, -10, 10, 15, 25)) - routing_network.create_wire_duct_carriers_from_selection( + fallback_source = doc.addObject("Part::Feature", "AuxiliaryRoutingRangeSource") + fallback_source.Label = "辅助面" + routing_network.create_route_carrier( doc, - [FakeSelectionItem(obj=duct)], + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="QET Routing Range 辅助面", project_uuid="project-1", - margin=0.0, + kind="RoutingRange", + ) + routed_wire = doc.addObject("Part::Feature", "QETRoutedConnection_missing_main_path") + routed_wire.RouteType = "RoutedConnection" + routed_wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + routed_wire.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["辅助面", "缺失辅助路径"], + } + }, + ensure_ascii=False, + ) + selected = [routed_wire] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_selected_wire_rejected_fallback_sources() - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(0, result["collision_count"]) + self.assertEqual(1, result["selected_rejected_fallback_sources"]) + self.assertEqual([fallback_source.Name], result["selected_rejected_fallback_source_names"]) + self.assertEqual(["辅助面", "缺失辅助路径"], result["rejected_fallback_source_labels"]) + self.assertEqual(["RoutingRange"], result["rejected_fallback_source_kinds"]) + self.assertEqual(["QETRoutedConnection_missing_main_path"], result["selected_rejected_fallback_wire_names"]) + self.assertEqual(["缺失辅助路径"], result["missing_rejected_fallback_source_refs"]) + self.assertEqual([fallback_source], selected) - def test_eplan_connection_route_uses_alternate_carrier_to_avoid_obstacle(self): + def test_controller_selects_main_path_detour_rejected_fallback_sources_from_summary(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _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="WireDuct", + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + fallback_source = doc.addObject("Part::Feature", "CabinetRoutingRangeSource") + fallback_source.Label = "QET Routing Range Carrier 001" + fallback_source.QetRouteSourceLabel = "安装板布线面" + fallback_source.QetRouteSourceName = "CabinetBackPlateSketch" + wire = doc.addObject("Part::Feature", "QETRoutedConnection_main_path") + wire.Label = "N-MAINPATH: A1 -> B1" + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-main-path" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + ] + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + wire.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["安装板布线面", "缺失补路位置"], + } + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [ - app.Vector(0, 0, 20), - app.Vector(0, 50, 20), - app.Vector(100, 50, 20), - app.Vector(100, 0, 20), - ], - project_uuid="project-1", - kind="WireDuct", + routed_group.addObject(wire) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - obstacle = doc.addObject("Part::Feature", "CabinetObstacle") - obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 15, 25)) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_main_path_detour_rejected_fallback_sources() - self.assertEqual("network-dijkstra-v1", result["algorithm"]) - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(0, result["collision_count"]) - self.assertTrue(result["network"]["obstacle_aware"]) - self.assertGreaterEqual(result["network"]["blocked_segments"], 1) - self.assertIn(50.0, [point.y for point in result["points"]]) + self.assertEqual(1, result["selected_main_path_detour_rejected_fallback_sources"]) + self.assertEqual([fallback_source.Name], result["selected_main_path_detour_rejected_fallback_source_names"]) + self.assertEqual(["安装板布线面", "缺失补路位置"], result["main_path_detour_rejected_fallback_labels"]) + self.assertEqual( + {"安装板布线面": 1, "缺失补路位置": 1}, + result["main_path_detour_rejected_fallback_label_counts"], + ) + self.assertEqual({"RoutingRange": 1}, result["main_path_detour_rejected_fallback_kind_counts"]) + self.assertEqual(["缺失补路位置"], result["missing_main_path_detour_rejected_fallback_refs"]) + self.assertEqual([fallback_source], selected) - def test_eplan_connection_route_prefers_entry_candidate_without_access_collision(self): + def test_controller_selects_main_path_detour_bridge_endpoint_sources_from_summary(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(20, 0, 0), app.Vector(100, 0, 0)], - label="Near Duct", - project_uuid="project-1", - kind="WireDuct", + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + fallback_source = doc.addObject("Part::Feature", "CabinetRoutingRangeSource") + fallback_source.QetRouteSourceLabel = "安装板布线面" + current_source = doc.addObject("Part::Feature", "MainWireDuctSource") + current_source.QetRouteSourceLabel = "主线槽A" + wire = doc.addObject("Part::Feature", "QETRoutedConnection_main_path_pair") + wire.Label = "N-PAIR: A1 -> B1" + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-main-path-pair" + wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + "QetRouteTrackJson", + ] + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["安装板布线面"], + } + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 30, 0), app.Vector(100, 30, 0)], - label="Clear Duct", - project_uuid="project-1", - kind="WireDuct", + wire.QetRouteTrackJson = json.dumps( + { + "segments": [ + { + "carrier": { + "kind": "WireDuct", + "source_label": "主线槽A", + "source_name": current_source.Name, + } + } + ] + }, + ensure_ascii=False, ) - obstacle = doc.addObject("Part::Feature", "AccessObstacle") - obstacle.Label = "Access Obstacle" - obstacle.Shape = FakeShape(FakeBoundBox(10, 15, -5, 5, -5, 5)) - - result = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + routed_group.addObject(wire) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - labels = [ - segment["carrier"]["label"] - for segment in result["route_track"]["segments"] - ] - self.assertIn("Clear Duct", labels) - self.assertNotIn("Near Duct", labels) - self.assertEqual(0, result["collision_count"]) + result = auto_routing_panel.AutoRoutingController().select_main_path_detour_rejected_fallback_sources() - def test_eplan_connection_route_chooses_clear_orthogonal_access_order(self): + self.assertEqual(2, result["selected_main_path_detour_bridge_endpoint_objects"]) + self.assertEqual(1, result["selected_main_path_detour_rejected_fallback_sources"]) + self.assertEqual(1, result["selected_main_path_detour_current_route_sources"]) + self.assertEqual([fallback_source.Name], result["selected_main_path_detour_rejected_fallback_source_names"]) + self.assertEqual([current_source.Name], result["selected_main_path_detour_current_route_source_names"]) + self.assertEqual({"主线槽A": 1}, result["main_path_detour_current_route_source_label_counts"]) + self.assertEqual({"安装板布线面 -> 主线槽A": 1}, result["main_path_detour_bridge_pair_counts"]) + self.assertEqual([], result["missing_main_path_detour_current_route_refs"]) + self.assertEqual([fallback_source, current_source], selected) + + def test_controller_selects_issue_wires_from_wire_object_issue_codes(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(30, 30, 0), app.Vector(100, 30, 0)], - label="Only Duct", - project_uuid="project-1", - kind="WireDuct", + sampled_wire = doc.addObject("Part::Feature", "QETRoutedConnection_sampled") + sampled_wire.Label = "N-SAMPLED: A1 -> B1 (BoundaryWarning)" + sampled_wire.RouteType = "RoutedConnection" + sampled_wire.QetWireUuid = "wire-sampled" + hidden_issue_wire = doc.addObject("Part::Feature", "QETRoutedConnection_hidden") + hidden_issue_wire.Label = "N-HIDDEN: A2 -> B2 (LongAccessWarning)" + hidden_issue_wire.RouteType = "RoutedConnection" + hidden_issue_wire.QetWireUuid = "wire-hidden" + hidden_issue_wire.QetRouteIssueCodes = "long_terminal_access" + normal_wire = doc.addObject("Part::Feature", "QETRoutedConnection_normal") + normal_wire.Label = "N-OK: A3 -> B3 (Routed)" + normal_wire.RouteType = "RoutedConnection" + normal_wire.QetWireUuid = "wire-ok" + normal_wire.QetRouteIssueCodes = "" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_samples": [ + { + "wire_uuid": "wire-sampled", + "wire_object_label": "N-SAMPLED: A1 -> B1 (BoundaryWarning)", + "issue_codes": ["boundary_warning"], + } + ] + }, + ensure_ascii=False, ) - obstacle = doc.addObject("Part::Feature", "AccessOrderObstacle") - obstacle.Shape = FakeShape(FakeBoundBox(10, 20, -5, 5, -5, 5)) - - result = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - point_tuples = [(point.x, point.y, point.z) for point in result["points"]] - self.assertIn((0.0, 30.0, 0.0), point_tuples) - self.assertNotIn((30.0, 0.0, 0.0), point_tuples) - self.assertEqual(0, result["collision_count"]) + result = auto_routing_panel.AutoRoutingController().select_issue_wires() - def test_eplan_connection_route_marks_collision_warning_against_obstacle_bbox(self): + self.assertEqual(2, result["selected_issue_wires"]) + self.assertEqual( + ["QETRoutedConnection_sampled", "QETRoutedConnection_hidden"], + result["selected_issue_wire_names"], + ) + self.assertEqual([], result["missing_issue_wire_refs"]) + self.assertEqual([sampled_wire, hidden_issue_wire], selected) + + def test_controller_selects_long_terminal_accesses_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], - project_uuid="project-1", + terminal_a = _terminal(doc, terminal_objects, "Terminal325", "terminal-325", app.Vector(0, 0, 0)) + terminal_b = _terminal(doc, terminal_objects, "Terminal326", "terminal-326", app.Vector(10, 0, 0)) + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "routing_path_network_diagnostic": { + "long_terminal_accesses": [ + {"terminal_uuid": "terminal-325", "name": "Terminal325", "label": "325"}, + {"terminal_uuid": "terminal-326", "name": "Terminal326", "label": "326"}, + {"terminal_uuid": "terminal-404", "name": "Terminal404", "label": "404"}, + ] + } + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - obstacle = doc.addObject("Part::Feature", "Obstacle") - obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110)) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_long_terminal_accesses() - self.assertEqual("CollisionWarning", result["route_status"]) - self.assertEqual("CollisionWarning", result["wire"].RouteStatus) - self.assertEqual(1, result["collision_count"]) - self.assertEqual("HardIntersection", result["collisions"][0]["collision_kind"]) + self.assertEqual(2, result["selected_long_terminal_accesses"]) + self.assertEqual(["Terminal325", "Terminal326"], result["selected_long_terminal_names"]) + self.assertEqual(["terminal-404"], result["missing_long_terminal_refs"]) + self.assertEqual([terminal_a, terminal_b], selected) - def test_eplan_connection_route_marks_clearance_warning_against_expanded_obstacle_bbox(self): + def test_controller_selects_long_terminal_access_devices_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], - project_uuid="project-1", + device_pen = doc.addObject("App::DocumentObjectGroup", "DevicePEN") + device_pen.Label = "PEN" + device_pe = doc.addObject("App::DocumentObjectGroup", "DevicePE") + device_pe.Label = "PE" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "routing_path_network_diagnostic": { + "long_terminal_accesses": [ + { + "terminal_uuid": "terminal-325", + "parent_device_name": "DevicePEN", + "parent_device_label": "PEN", + }, + { + "terminal_uuid": "terminal-326", + "parent_device_name": "DevicePEN", + "parent_device_label": "PEN", + }, + { + "terminal_uuid": "terminal-327", + "parent_device_label": "PE", + }, + { + "terminal_uuid": "terminal-404", + "parent_device_name": "Device404", + "parent_device_label": "404", + }, + ] + } + }, + ensure_ascii=False, ) - obstacle = doc.addObject("Part::Feature", "NearObstacle") - obstacle.Shape = FakeShape(FakeBoundBox(40, 60, 3, 6, 90, 110)) - - result = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - options={"obstacle_clearance": 5.0}, + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - self.assertEqual("CollisionWarning", result["route_status"]) - self.assertEqual(1, result["collision_count"]) - self.assertEqual("ClearanceWarning", result["collisions"][0]["collision_kind"]) - self.assertEqual(3.0, result["collisions"][0]["obstacle_bbox"]["ymin"]) - self.assertEqual(-2.0, result["collisions"][0]["collision_bbox"]["ymin"]) + result = auto_routing_panel.AutoRoutingController().select_long_terminal_access_devices() - def test_eplan_connection_route_ignores_terminal_exit_segment_collision(self): + self.assertEqual(2, result["selected_long_terminal_access_devices"]) + self.assertEqual(["DevicePEN", "DevicePE"], result["selected_long_terminal_access_device_names"]) + self.assertEqual(["Device404"], result["missing_long_terminal_access_device_refs"]) + self.assertEqual([device_pen, device_pe], selected) + + def test_controller_selects_missing_terminal_devices_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _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", + device_a = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + terminal_objects.ensure_string_property(device_a, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device_a, "QetInstanceId", "QET Exchange", "", "instance-a") + device_b = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_b") + terminal_objects.ensure_string_property(device_b, "QetElementUuid", "QET Exchange", "", "device-b") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-missing-a", + "start_instance_id": "instance-a", + "start_element_uuid": "device-a", + "end_found": True, + }, + { + "wire_uuid": "wire-b", + "start_found": False, + "start_terminal_uuid": "terminal-missing-b", + "start_instance_id": "instance-missing", + "start_element_uuid": "device-missing", + "end_found": False, + "end_terminal_uuid": "terminal-missing-c", + "end_element_uuid": "device-b", + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - terminal_body = doc.addObject("Part::Feature", "UngroupedTerminalBody") - terminal_body.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, -5, 15)) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_missing_terminal_devices() - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(0, result["collision_count"]) + self.assertEqual(2, result["selected_missing_terminal_devices"]) + self.assertEqual(["QETDevice_device_a", "QETDevice_device_b"], result["selected_missing_terminal_device_names"]) + self.assertEqual(["terminal-missing-b"], result["missing_terminal_device_refs"]) + self.assertEqual([device_a, device_b], selected) - def test_eplan_connection_route_ignores_endpoint_device_body_as_obstacle(self): + def test_controller_selects_missing_terminal_device_by_device_label_fallback(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - device = doc.addObject("App::DocumentObjectGroup", "QETDeviceStart") - device.QetInstanceId = start.QetInstanceId - device.addObject(start) - body = doc.addObject("Part::Feature", "StartDeviceBody") - body.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, -5, 15)) - device.addObject(body) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_no_uuid") + device.Label = "缺端子设备A" + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-missing-a", + "start_device_label": "缺端子设备A", + "end_found": True, + } + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_missing_terminal_devices() - self.assertEqual("Routed", result["route_status"]) - self.assertEqual(0, result["collision_count"]) + self.assertEqual(1, result["selected_missing_terminal_devices"]) + self.assertEqual(["QETDevice_no_uuid"], result["selected_missing_terminal_device_names"]) + self.assertEqual([], result["missing_terminal_device_refs"]) + self.assertEqual([device], selected) - def test_route_eplan_connections_from_payload_skips_missing_terminal(self): + def test_controller_reports_missing_terminal_device_reason_counts_when_device_not_in_scene(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - payload = { - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-missing", - } - ] - } + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-missing-a", + "start_element_uuid": "device-a", + "start_instance_id": "instance-a", + "start_device_label": "UD:8", + "start_terminal_display": "as", + "start_missing_endpoint_reason_code": "device_not_in_3d_scene", + "start_missing_endpoint_reason_label": "该 2D 设备未在 FreeCAD 场景中找到", + "end_found": True, + } + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], + ) - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + result = auto_routing_panel.AutoRoutingController().select_missing_terminal_devices() - self.assertEqual(0, report["routed"]) - self.assertEqual(1, report["skipped_missing_terminal"]) - self.assertEqual(1, report["available_terminals"]) - self.assertEqual(0, report["local_terminals"]) - self.assertEqual(["terminal-missing"], report["missing_endpoint_uuids"]) - self.assertEqual("terminal-start", report["missing_endpoint_samples"][0]["start_terminal_uuid"]) - self.assertTrue(report["missing_endpoint_samples"][0]["start_found"]) - self.assertFalse(report["missing_endpoint_samples"][0]["end_found"]) + self.assertEqual(0, result["selected_missing_terminal_devices"]) + self.assertEqual(["terminal-missing-a"], result["missing_terminal_device_refs"]) + self.assertEqual(["UD:8"], result["missing_terminal_device_labels"]) + self.assertEqual(["instance-a"], result["missing_terminal_device_instance_ids"]) + self.assertEqual(["device-a"], result["missing_terminal_device_element_uuids"]) + self.assertEqual({"device_not_in_3d_scene": 1}, result["missing_terminal_device_reason_counts"]) + self.assertEqual([], selected) - def test_route_eplan_connections_from_payload_skips_resolved_tasks_without_route_network(self): + def test_controller_selects_found_counterpart_terminals_from_missing_endpoint_samples(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(1000, 0, 0)) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-{0}".format(index), - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } - for index in range(3) - ], - } - original_route = auto_routing.route_eplan_connection_between_terminals - - def fail_if_called(*_args, **_kwargs): - raise AssertionError("batch route must not call per-wire routing without route carriers") + end_terminal = _terminal(doc, terminal_objects, "TerminalFoundEnd", "terminal-found-end", app.Vector(20, 0, 0)) + start_terminal = _terminal( + doc, + terminal_objects, + "TerminalFoundStart", + "terminal-found-start", + app.Vector(40, 0, 0), + ) + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "wire_label": "F6", + "start_found": False, + "start_terminal_uuid": "terminal-missing-start", + "end_found": True, + "end_terminal_uuid": "terminal-found-end", + "end_terminal_display": "6", + }, + { + "wire_uuid": "wire-b", + "wire_label": "N2", + "start_found": True, + "start_terminal_uuid": "terminal-found-start", + "start_terminal_display": "1", + "end_found": False, + "end_terminal_uuid": "terminal-missing-end", + }, + { + "wire_uuid": "wire-c", + "start_found": False, + "start_terminal_uuid": "terminal-missing-both-a", + "end_found": False, + "end_terminal_uuid": "terminal-missing-both-b", + }, + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], + ) - auto_routing.route_eplan_connection_between_terminals = fail_if_called - try: - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - finally: - auto_routing.route_eplan_connection_between_terminals = original_route + result = auto_routing_panel.AutoRoutingController().select_missing_terminal_counterpart_terminals() - self.assertEqual(0, report["routed"]) - self.assertEqual(3, report["skipped_missing_route_network"]) - self.assertEqual(3, report["route_status_counts"]["MissingRouteNetwork"]) - self.assertEqual([], report["errors"]) - self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual(2, result["selected_missing_terminal_counterpart_terminals"]) + self.assertEqual(["TerminalFoundEnd", "TerminalFoundStart"], result["selected_missing_terminal_counterpart_terminal_names"]) + self.assertEqual(["terminal-missing-both-a", "terminal-missing-both-b"], result["missing_terminal_counterpart_refs"]) + self.assertEqual([end_terminal, start_terminal], selected) - def test_route_eplan_connection_tasks_marks_task_missing_route_network_when_skipped(self): + def test_controller_selects_missing_terminal_candidate_terminals_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - task = wiring_objects.create_wire_task( - doc, - "project-1", - "wire-missing-network", - "N1", - "terminal-start", - "terminal-end", - "instance-a", - "instance-b", + candidate_a1 = _terminal(doc, terminal_objects, "TerminalA1", "terminal-a1", app.Vector(0, 0, 0)) + candidate_a2 = _terminal(doc, terminal_objects, "TerminalA2", "terminal-a2", app.Vector(10, 0, 0)) + found_b1 = _terminal(doc, terminal_objects, "TerminalB1", "terminal-b1", app.Vector(20, 0, 0)) + for terminal in (candidate_a1, candidate_a2): + terminal_objects.ensure_string_property(terminal, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(terminal, "QetInstanceId", "QET Exchange", "", "instance-a") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-missing-a", + "start_instance_id": "instance-a", + "start_element_uuid": "device-a", + "start_missing_endpoint_reason_code": "terminal_uuid_not_in_element", + "start_instance_terminal_samples": [ + {"name": "TerminalA1", "terminal_uuid": "terminal-a1"}, + {"name": "TerminalA2", "terminal_uuid": "terminal-a2"}, + ], + "end_found": True, + "end_terminal_uuid": "terminal-b1", + } + ] + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - task.RouteStatus = "Routed" - report = auto_routing.route_eplan_connection_tasks(doc) + result = auto_routing_panel.AutoRoutingController().select_missing_terminal_candidate_terminals() - self.assertEqual(0, report["routed"]) - self.assertEqual(1, report["skipped_missing_route_network"]) - self.assertEqual("MissingRouteNetwork", task.RouteStatus) + self.assertEqual(2, result["selected_missing_terminal_candidate_terminals"]) + self.assertEqual(["TerminalA1", "TerminalA2"], result["selected_missing_terminal_candidate_terminal_names"]) + self.assertEqual([], result["missing_terminal_candidate_terminal_refs"]) + self.assertEqual([candidate_a1, candidate_a2], selected) + self.assertNotIn(found_b1, selected) - def test_eplan_connection_route_prefers_wire_duct_over_shorter_routing_range(self): + def test_controller_selects_boundary_issue_route_carriers_and_terminals(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(300, 0, 0)) - routing_network.create_route_carrier( + route = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(300, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(140, 0, 20)], + label="柜内主路径A", project_uuid="project-1", - kind="RoutingRange", + kind="UserPath", ) - routing_network.create_route_carrier( - doc, - [ - app.Vector(0, 0, 20), - app.Vector(0, 1200, 20), - app.Vector(300, 1200, 20), - app.Vector(300, 0, 20), - ], - project_uuid="project-1", - kind="WireDuct", + terminal = _terminal(doc, terminal_objects, "TerminalOutside", "terminal-outside", app.Vector(140, 0, 0)) + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingPathNetwork") + diagnostic.QetDiagnosticKind = "RoutingPathNetwork" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "route_carriers_outside_boundary": [ + { + "carrier": { + "name": route.Name, + "label": "柜内主路径A", + }, + "outside_point_count": 1, + }, + { + "carrier": { + "name": "MissingRouteCarrier", + "label": "缺失路径", + }, + "outside_point_count": 1, + }, + ], + "terminals_outside_boundary": [ + { + "name": "TerminalOutside", + "label": "TerminalOutside", + "terminal_uuid": "terminal-outside", + "outside_point_count": 2, + }, + { + "name": "MissingTerminal", + "terminal_uuid": "terminal-missing", + "outside_point_count": 1, + }, + ], + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + selected = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selected.clear(), + addSelection=lambda obj: selected.append(obj), + getSelection=lambda: list(selected), + getSelectionEx=lambda: [], ) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + result = auto_routing_panel.AutoRoutingController().select_boundary_issue_route_carriers_and_terminals() - self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) - self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + self.assertEqual(1, result["selected_boundary_route_carriers"]) + self.assertEqual(1, result["selected_boundary_terminals"]) + self.assertEqual([route.Name], result["selected_boundary_route_carrier_names"]) + self.assertEqual(["TerminalOutside"], result["selected_boundary_terminal_names"]) + self.assertEqual(["MissingRouteCarrier"], result["missing_boundary_route_carrier_refs"]) + self.assertEqual(["MissingTerminal"], result["missing_boundary_terminal_refs"]) + self.assertEqual([route, terminal], selected) - def test_eplan_connection_route_prefers_wire_duct_when_routing_range_is_only_moderately_shorter(self): + def test_controller_marks_selected_route_carrier_constraint_modes(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(10, 0, 0)) - routing_network.create_route_carrier( + carrier = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="测试路径", project_uuid="project-1", - kind="RoutingRange", + kind="UserPath", ) - routing_network.create_route_carrier( - doc, - [ - app.Vector(0, 0, 20), - app.Vector(0, 145, 20), - app.Vector(10, 145, 20), - app.Vector(10, 0, 20), - ], - project_uuid="project-1", - kind="WireDuct", + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=carrier)], ) + controller = auto_routing_panel.AutoRoutingController() - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + forbidden = controller.mark_selected_route_carriers_forbidden() + required = controller.mark_selected_route_carriers_required() + cleared = controller.clear_selected_route_carrier_constraints() - self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) - self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + self.assertEqual(1, forbidden["route_constraint_carriers"]) + self.assertEqual(1, required["route_constraint_carriers"]) + self.assertEqual(1, cleared["route_constraint_carriers"]) + self.assertEqual("", carrier.QetRouteConstraintMode) - def test_eplan_connection_route_considers_primary_entry_beyond_nearest_surface_candidates(self): + def test_controller_sets_selected_route_carrier_capacity(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - for y in range(1, 11): - routing_network.create_route_carrier( - doc, - [app.Vector(0, y, 20), app.Vector(100, y, 20)], - project_uuid="project-1", - kind="RoutingRange", - ) - routing_network.create_route_carrier( + source = doc.addObject("Sketcher::SketchObject", "CapacityPathSketch") + source.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + carrier = routing_network.create_route_carrier( doc, - [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="测试路径", project_uuid="project-1", - kind="WireDuct", + kind="UserPath", + ) + routing_network._mark_user_path_source(source, carrier) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=source)], ) + controller = auto_routing_panel.AutoRoutingController(options={"selected_route_capacity": 5}) - result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + report = controller.set_selected_route_carriers_capacity() - self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) - self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + self.assertEqual(1, report["route_capacity_carriers"]) + self.assertEqual(1, report["route_capacity_sources"]) + self.assertEqual(5, source.QetRouteCarrierCapacity) + self.assertEqual(5, carrier.QetRouteCarrierCapacity) - def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_segments(self): + def test_controller_reports_selected_source_route_constraint_before_carrier_generation(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - broken_carrier = doc.addObject("Part::Feature", "BrokenCarrier") - terminal_objects.ensure_string_property( - broken_carrier, - "QetRoutingRole", - "QET Routing", - "Routing role marker", - "RoutingCarrier", - ) - terminal_objects.ensure_string_property( - broken_carrier, - "QetRouteCarrierKind", - "QET Routing", - "Route carrier kind", - "WireDuct", + route_path = doc.addObject("Sketcher::SketchObject", "FutureUserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], ) - terminal_objects.ensure_bool_property( - broken_carrier, - "CanRouteWire", - "QET Routing", - "Whether routing connections can use this path", - True, + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - }, - ], - } - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"network_entry_max_distance": 30.0}, - ) + report = auto_routing_panel.AutoRoutingController().mark_selected_route_carriers_required() - self.assertEqual(1, report["route_network_carriers"]) - self.assertEqual(0, report["route_network_segments"]) - self.assertEqual(0, report["route_network_nodes"]) - self.assertEqual(0, report["routed"]) - self.assertEqual(1, report["skipped_missing_route_network"]) - self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) - self.assertEqual([], report["errors"]) - self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual(0, report["route_constraint_carriers"]) + self.assertEqual(1, report["route_constraint_sources"]) + self.assertEqual("Required", route_path.QetRouteConstraintMode) - def test_route_eplan_connections_classifies_disconnected_network_as_missing_route_network(self): + def test_controller_clears_all_route_carrier_constraint_modes(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] doc = FakeDocument() + app.ActiveDocument = doc 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( + required = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="必经路径", project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) - routing_network.create_route_carrier( + forbidden = routing_network.create_route_carrier( doc, - [app.Vector(1000, 0, 20), app.Vector(1010, 0, 20)], + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="禁经路径", project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "wire_label": "N4111", - "start_terminal_uuid": "terminal-start", - "start_element_uuid": "QF1", - "start_terminal_display": "A1", - "end_terminal_uuid": "terminal-end", - "end_element_uuid": "KM1", - "end_terminal_display": "13", - }, - ], - } + required.QetRouteConstraintMode = "Required" + forbidden.QetRouteConstraintMode = "Forbidden" + controller = auto_routing_panel.AutoRoutingController() - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"network_entry_max_distance": 30.0}, - ) + report = controller.clear_all_route_carrier_constraints() - self.assertEqual(0, report["routed"]) - self.assertEqual(1, report["skipped_missing_route_network"]) - self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) - self.assertEqual([], report["errors"]) - self.assertEqual("wire-a", report["missing_route_network_samples"][0]["wire_uuid"]) - self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual(2, report["route_constraint_carriers"]) + self.assertEqual("", required.QetRouteConstraintMode) + self.assertEqual("", forbidden.QetRouteConstraintMode) + self.assertNotIn("路径约束", controller.summary()) - def test_network_entry_uses_terminal_access_max_distance_when_smaller(self): + def test_selected_source_route_constraint_survives_carrier_regeneration(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(500, 0, 0)) - routing_network.create_route_carrier( + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + selection = [FakeSelectionItem(obj=route_path)] + first = routing_network.create_user_path_carriers_from_selection( doc, - [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + selection, project_uuid="project-1", - kind="WireDuct", ) + routing_network.mark_route_constraint_mode_from_selection(doc, selection, "Required") + routing_network.clear_route_carriers(doc) - route = auto_routing.build_network_route( - start, - end, - options={"terminal_access_max_distance": 30.0}, - doc=doc, + second = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", ) - self.assertIsNone(route) + self.assertEqual(1, len(first)) + self.assertEqual(1, len(second)) + self.assertEqual("Required", route_path.QetRouteConstraintMode) + self.assertEqual("Required", second[0].QetRouteConstraintMode) - def test_route_eplan_connections_writes_diagnostic_object_for_missing_terminal(self): + def test_refreshing_user_path_clears_stale_constraint_when_source_is_cleared(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + 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)) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-missing", - } - ], - } + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + selection = [FakeSelectionItem(obj=route_path)] + carriers = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + route_path.QetRouteConstraintMode = "" + carriers[0].QetRouteConstraintMode = "Required" - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + refreshed = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) - self.assertEqual(1, report["skipped_missing_terminal"]) - self.assertIsNotNone(diagnostic_group) - self.assertEqual(1, len(diagnostic_group.Group)) - diagnostic = diagnostic_group.Group[0] - self.assertEqual("RoutingConnectionBatch", diagnostic.QetDiagnosticKind) - self.assertIn("terminal-missing", diagnostic.QetDiagnosticJson) + self.assertEqual(1, len(refreshed)) + self.assertEqual("", refreshed[0].QetRouteConstraintMode) - def test_route_eplan_connections_writes_compact_batch_diagnostic(self): + def test_selected_multi_wire_source_route_constraint_marks_all_user_paths(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 20, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 20, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + route_path = doc.addObject("Sketcher::SketchObject", "MultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), + ], ) - routing_network.create_route_carrier( + selection = [FakeSelectionItem(obj=route_path)] + carriers = routing_network.create_user_path_carriers_from_selection( doc, - [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + selection, project_uuid="project-1", - kind="WireDuct", ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "wire_label": "N1", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "wire_label": "N2", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } - 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) + marked = routing_network.mark_route_constraint_mode_from_selection(doc, selection, "Required") - self.assertEqual(2, len(report["routes"])) - self.assertNotIn("routes", diagnostic_payload) - self.assertEqual(2, diagnostic_payload["route_sample_count"]) - self.assertEqual(2, len(diagnostic_payload["route_samples"])) - self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"]) - self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"]) + self.assertEqual(2, len(carriers)) + self.assertEqual(2, len(marked)) + self.assertEqual("Required", route_path.QetRouteConstraintMode) + self.assertEqual(["Required", "Required"], [carrier.QetRouteConstraintMode for carrier in carriers]) - def test_compact_route_sample_prefers_route_track_bridged_segment_count(self): + def test_controller_clears_selected_multi_wire_source_route_constraints(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - sample = auto_routing._compact_route_sample( - { - "wire_uuid": "wire-bridge", - "route_track": { - "bridged_segments": 1, - }, - "network": { - "bridged_segments": 3, - }, - } + 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("Sketcher::SketchObject", "MultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), + ], ) - - self.assertEqual(1, sample["network"]["bridged_segments"]) - - def test_compact_route_sample_ignores_bridge_only_carrier_summary(self): - _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - sample = auto_routing._compact_route_sample( - { - "wire_uuid": "wire-bridge", - "route_track": { - "carrier_kinds": {"RoutingRange": 1}, - "carrier_names": ["VirtualBridge"], - "segments": [ - { - "is_bridge": True, - "carrier": {"name": "VirtualBridge", "kind": "RoutingRange"}, - }, - { - "carrier": {"name": "WireDuctA", "kind": "WireDuct"}, - }, - ], - }, - } + selection = [FakeSelectionItem(obj=route_path)] + carriers = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + routing_network.mark_route_constraint_mode_from_selection(doc, selection, "Required") + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: selection, ) - self.assertEqual({"WireDuct": 1}, sample["carrier_kinds"]) - self.assertEqual(["WireDuctA"], sample["carrier_names"]) + cleared = auto_routing_panel.AutoRoutingController().clear_selected_route_carrier_constraints() - def test_route_eplan_connections_batch_diagnostic_includes_quality_warnings(self): + self.assertEqual(2, cleared["route_constraint_carriers"]) + self.assertEqual("", route_path.QetRouteConstraintMode) + self.assertEqual(["", ""], [carrier.QetRouteConstraintMode for carrier in carriers]) + + def test_clear_all_route_constraints_clears_source_objects_too(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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( + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + selection = [FakeSelectionItem(obj=route_path)] + carriers = routing_network.create_user_path_carriers_from_selection( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + selection, 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", - } - ], - } + routing_network.mark_route_constraint_mode_from_selection(doc, selection, "Required") - 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) + report = routing_network.clear_all_route_constraint_modes(doc) - 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"], - ) + self.assertEqual(1, report["route_constraint_carriers"]) + self.assertEqual(1, report["route_constraint_sources"]) + self.assertEqual("", route_path.QetRouteConstraintMode) + self.assertEqual("", carriers[0].QetRouteConstraintMode) - def test_compact_batch_report_includes_entry_distance_warning_samples(self): + 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() - report = { - "routed": 1, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "terminal_access_warning_distance": 100.0, - "routes": [ - { - "wire_uuid": "wire-long-entry", - "wire_label": "N-LONG", - "network": { - "entry_distance": 125.0, - "exit_distance": 20.0, - }, - } - ], - } + 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)], + ) - payload = auto_routing._compact_routing_connection_batch_report(report) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - self.assertEqual(1, payload["route_entry_distance_warning_count"]) - self.assertEqual( - "wire-long-entry", - payload["route_entry_distance_warning_samples"][0]["wire_uuid"], - ) + self.assertEqual(1, result["user_path_carriers"]) self.assertEqual( - ["entry"], - payload["route_entry_distance_warning_samples"][0]["warning_sides"], + [(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_route_eplan_connections_reports_total_connection_route_length(self): + def test_selected_user_path_copies_source_capacity(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - _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="WireDuct", + 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)], ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } - ], - } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - message = auto_routing.format_eplan_connection_route_report(report) + auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carrier = routing_network.collect_route_carriers(doc)[0] - self.assertGreater(report["total_length_mm"], 0.0) - self.assertEqual(report["total_length_mm"], report["routes"][0]["length_mm"]) - self.assertIn("总长度", message) + self.assertEqual(5, carrier.QetRouteCarrierCapacity) - def test_route_eplan_connections_hides_route_carriers_after_routing_by_default(self): + def test_selected_multi_wire_user_path_copies_source_capacity_to_all_carriers(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - carrier = routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } + route_path = doc.addObject("Sketcher::SketchObject", "CapacityMultiWireRouteSketch") + route_path.QetRouteCarrierCapacity = 4 + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), ], - } - - report = auto_routing.route_eplan_connections( - doc, - payload=payload, - update_network=False, + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], ) - self.assertEqual(1, report["routed"]) - self.assertEqual(1, report["hidden_route_carriers"]) - self.assertFalse(carrier.ViewObject.Visibility) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - def test_route_eplan_connections_batch_recomputes_once_after_created_wires(self): + self.assertEqual(2, result["user_path_carriers"]) + self.assertEqual([4, 4], [carrier.QetRouteCarrierCapacity for carrier in carriers]) + + def test_selected_user_path_projects_line_to_selected_face(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", + face = FakeFace( + FakeBoundBox(0, 100, 0, 100, 0, 0), + app.Vector(0, 0, 1), ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], - project_uuid="project-1", - kind="WireDuct", + draft_line = doc.addObject("Part::Feature", "FloatingDraftLine") + draft_line.Shape = FakeShape( + FakeBoundBox(10, 90, 10, 90, 25, 35), + edges=[FakeEdge(app.Vector(10, 10, 25), app.Vector(90, 90, 35))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [ + FakeSelectionItem([face]), + FakeSelectionItem(obj=draft_line), + ], ) - recompute_count = {"value": 0} - def count_recompute(): - recompute_count["value"] += 1 + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) - doc.recompute = count_recompute - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual([2.0, 2.0], [point.z for point in carriers[0].Points]) - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + 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() + 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", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + doc.removeObject("UserRouteSketch") + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) - self.assertEqual(2, report["routed"]) - self.assertEqual(1, recompute_count["value"]) + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() - def test_route_eplan_connections_replaces_existing_routed_wires_for_same_batch(self): + self.assertEqual(1, result["removed_stale_carriers"]) + self.assertEqual(0, result["network"]["carriers"]) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + + def test_terminal_access_uses_terminal_local_route_points_before_main_network(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + 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)) + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [10, 0, 0], [10, 30, 0]]) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(10, 80, 0), app.Vector(110, 80, 0)], project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-repeat", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - }, - ], - } - first = auto_routing.route_eplan_connections_from_payload(doc, payload) - second = auto_routing.route_eplan_connections_from_payload(doc, payload) - routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=100.0, + ) - self.assertEqual(1, first["routed"]) - self.assertEqual(1, second["routed"]) - self.assertEqual(1, second["replaced_routed_connections"]) - self.assertEqual(1, len(routed_wires)) - self.assertEqual("wire-repeat", routed_wires[0].QetWireUuid) + self.assertEqual(1, len(created)) + self.assertEqual( + [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 30.0, 0.0)], + [(p.x, p.y, p.z) for p in created[0].Points[:3]], + ) - def test_clear_routing_connections_resets_task_status_and_batch_diagnostics(self): + def test_terminal_access_accepts_object_wrapped_local_route_points(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + 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)) + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps( + { + "points": [ + {"x": 0, "y": 0, "z": 0}, + {"x": 10, "y": 0, "z": 0}, + {"x": 10, "y": 30, "z": 0}, + ] + } + ) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(10, 80, 0), app.Vector(110, 80, 0)], project_uuid="project-1", - kind="WireDuct", + kind="UserPath", ) - task = wiring_objects.create_wire_task( + + created = routing_network.create_terminal_access_carriers_from_document( doc, - "project-1", - "wire-clear", - "N1", - "terminal-start", - "terminal-end", - "instance-a", - "instance-b", + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=100.0, ) - report = auto_routing.route_eplan_connection_tasks(doc) - diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertEqual(1, len(created)) + self.assertEqual( + [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 30.0, 0.0)], + [(p.x, p.y, p.z) for p in created[0].Points[:3]], + ) - self.assertEqual(1, report["routed"]) - self.assertEqual("Routed", task.RouteStatus) - self.assertEqual(1, len(list(getattr(diagnostic_group, "Group", []) or []))) + def test_controller_sets_selected_terminal_local_route_from_selected_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") + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(100, 10, 0)) + route_path = doc.addObject("Part::Feature", "LocalExitSketch") + route_path.Shape = FakeShape( + FakeBoundBox(100, 130, 10, 40, 0, 0), + edges=[ + FakeEdge(app.Vector(100, 10, 0), app.Vector(130, 10, 0)), + FakeEdge(app.Vector(130, 10, 0), app.Vector(130, 40, 0)), + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [ + FakeSelectionItem(obj=terminal), + FakeSelectionItem(obj=route_path), + ], + ) - removed = auto_routing.clear_routing_connections(doc) + result = auto_routing_panel.AutoRoutingController().set_selected_terminal_local_route_points() + points = json.loads(terminal.QetTerminalLocalRoutePointsJson) + access_points = routing_network.terminal_access_path_points(terminal, exit_length=20.0) - self.assertEqual(1, removed) - self.assertEqual("Task", task.RouteStatus) - self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) - self.assertEqual([], list(getattr(diagnostic_group, "Group", []) or [])) + self.assertEqual(1, result["terminal_local_routes"]) + self.assertEqual("TerminalStart", result["terminal_local_route_names"][0]) + self.assertEqual( + [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [30.0, 30.0, 0.0]], + points, + ) + self.assertEqual( + [(100.0, 10.0, 0.0), (130.0, 10.0, 0.0), (130.0, 40.0, 0.0)], + [(point.x, point.y, point.z) for point in access_points], + ) - def test_route_report_includes_route_source_sample_when_available(self): + def test_generate_routing_paths_refreshes_selected_user_path_without_duplicate(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "routed": 1, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ - { - "wire_label": "N4111", + 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", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + first = auto_routing_panel.AutoRoutingController().generate_routing_paths() + route_path.Shape = FakeShape( + FakeBoundBox(0, 200, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(200, 0, 20))], + ) + second = auto_routing_panel.AutoRoutingController().generate_routing_paths() + user_paths = [ + item + for item in routing_network.collect_route_carriers(doc) + if item.QetRouteCarrierKind == "UserPath" + ] + + self.assertEqual(1, first["user_path_carriers"]) + self.assertEqual(1, second["user_path_carriers"]) + self.assertEqual(1, len(user_paths)) + self.assertEqual([(0.0, 0.0, 20.0), (200.0, 0.0, 20.0)], [(p.x, p.y, p.z) for p in user_paths[0].Points]) + + def test_eplan_connection_route_can_use_generated_user_path(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(200, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(200, 0, 20)], + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + + def test_generate_routing_paths_does_not_duplicate_selected_wire_duct_carriers(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") + duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + ) + + first = auto_routing_panel.AutoRoutingController().generate_routing_paths() + second = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, first["selected_wire_duct_carriers"]) + self.assertEqual(0, second["selected_wire_duct_carriers"]) + self.assertEqual( + 1, + len([item for item in carriers if item.QetRouteCarrierKind == "WireDuct"]), + ) + self.assertEqual( + 2, + len([item for item in carriers if item.QetRouteCarrierKind == "WireDuctOpenEnd"]), + ) + + def test_generate_routing_paths_refreshes_selected_wire_duct_geometry(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") + duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + ) + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + duct.Shape = FakeShape(FakeBoundBox(0, 220, -10, 10, 0, 20)) + second = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = routing_network.collect_route_carriers(doc) + main = [item for item in carriers if item.QetRouteCarrierKind == "WireDuct"][0] + open_end_x_values = sorted( + point.x + for item in carriers + if item.QetRouteCarrierKind == "WireDuctOpenEnd" + for point in item.Points + ) + + self.assertEqual(0, second["selected_wire_duct_carriers"]) + self.assertEqual([(20.0, 0.0, 10.0), (200.0, 0.0, 10.0)], [(p.x, p.y, p.z) for p in main.Points]) + self.assertEqual([20.0, 20.0, 200.0, 200.0], open_end_x_values) + + def test_generate_routing_paths_removes_generated_wire_duct_carriers_after_source_deleted(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + generated = [ + item + for item in routing_network.collect_route_carriers(doc) + if getattr(item, "QetRouteSourceName", "") == "WireDuctA" + ] + doc.removeObject("WireDuctA") + auto_routing_panel.AutoRoutingController().generate_routing_paths() + + self.assertEqual(3, len(generated)) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + + def test_prepare_layout_space_uses_whole_document_not_selected_face_workflow(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") + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=panel)], + ) + + result = auto_routing_panel.AutoRoutingController().generate_layout_space() + + self.assertGreater(result["support_surface_sources"], 0) + self.assertEqual("document", result["source_mode"]) + + def test_generate_routing_path_network_adds_terminal_access_to_route_network(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"] + doc = FakeDocument() + app.ActiveDocument = doc + 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)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + result_again = auto_routing_panel.AutoRoutingController().generate_routing_paths() + access_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" + ] + + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual(2, result["wire_duct_open_end_carriers"]) + self.assertEqual(2, result["terminal_access_carriers"]) + self.assertEqual(0, result_again["wire_duct_carriers"]) + self.assertEqual(0, result_again["wire_duct_open_end_carriers"]) + self.assertEqual(2, result_again["terminal_access_carriers"]) + self.assertEqual(2, len(access_carriers)) + self.assertGreater(result["network"]["segments"], 0) + + def test_generate_routing_path_network_connects_terminal_access_to_nearest_segment_point(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalMid", "terminal-mid", app.Vector(50, 30, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + access_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" + ] + + self.assertEqual(1, len(access_carriers)) + end_point = access_carriers[0].Points[-1] + self.assertEqual((50.0, 0.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_terminal_access_prefers_larger_connected_network_over_nearer_isolated_stub(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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 1, 20), app.Vector(5, 1, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 10, 20), + app.Vector(40, 10, 20), + app.Vector(80, 10, 20), + app.Vector(120, 10, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + end_point = created[0].Points[-1] + self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_connection_entry_candidates_prefer_wire_duct_over_terminal_access(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(0, 10, 20)], + project_uuid="project-1", + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + network = routing_network.build_route_graph(doc) + + ranked = routing_network.rank_connection_point_candidates( + network, + routing_network.connection_point_candidates(network, app.Vector(0, 0, 20), limit=0), + ) + + first_kind = getattr(ranked[0]["carrier"], "QetRouteCarrierKind", "") + self.assertEqual("WireDuct", first_kind) + + def test_terminal_access_prefers_wire_duct_over_nearer_routing_range(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(50, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + project_uuid="project-1", + kind="RoutingRange", + label="近处布线面", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 100, 20), app.Vector(100, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + label="较远线槽", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=1000.0, + ) + + self.assertEqual(1, len(created)) + end_point = created[0].Points[-1] + self.assertEqual((50.0, 100.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_terminal_access_prefers_main_path_over_routing_range_in_same_component(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(50, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + project_uuid="project-1", + kind="RoutingRange", + label="近处布线面", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 100, 20), app.Vector(100, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + label="较远线槽", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 5, 20), app.Vector(50, 100, 20)], + project_uuid="project-1", + kind="UserPath", + label="线槽接入桥", + ) + network = routing_network.build_route_graph(doc) + ranked = routing_network.rank_connection_point_candidates( + network, + routing_network.connection_point_candidates(network, app.Vector(50, 0, 20), limit=0), + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=1000.0, + ) + + self.assertEqual(1, len(created)) + self.assertEqual("UserPath", getattr(ranked[0]["carrier"], "QetRouteCarrierKind", "")) + end_point = created[0].Points[-1] + self.assertEqual((50.0, 5.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_diverse_connection_entry_candidates_keep_multiple_components(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") + near = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + label="近组件", + ) + far = routing_network.create_route_carrier( + doc, + [app.Vector(0, 100, 0), app.Vector(100, 100, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="远组件", + ) + network = routing_network.build_route_graph(doc) + near_key = routing_network._point_key(app.Vector(0, 0, 0)) + far_key = routing_network._point_key(app.Vector(0, 100, 0)) + candidates = [ + { + "key": near_key, + "projected_key": routing_network._point_key(app.Vector(index, 0, 0)), + "point": app.Vector(index, 0, 0), + "distance": index, + "carrier": near, + } + for index in range(1, 6) + ] + candidates.append( + { + "key": far_key, + "projected_key": far_key, + "point": app.Vector(0, 100, 0), + "distance": 100.0, + "carrier": far, + } + ) + + selected = routing_network.select_diverse_connection_point_candidates(network, candidates, limit=3) + + self.assertEqual(3, len(selected)) + self.assertIn(far, [candidate.get("carrier") for candidate in selected]) + + def test_terminal_access_prefers_wire_duct_over_nearer_routing_range(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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 1, 20), app.Vector(120, 1, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(120, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + end_point = created[0].Points[-1] + self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_eplan_connection_route_enters_network_at_segment_projection(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(50, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(150, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(200, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("segment_projection", result["network"]["entry_point_mode"]) + self.assertEqual("segment_projection", result["network"]["exit_point_mode"]) + self.assertNotIn(0.0, [point.x for point in result["points"][1:-1]]) + self.assertNotIn(200.0, [point.x for point in result["points"][1:-1]]) + self.assertLess(result["length_mm"], 150.0) + + def test_generate_routing_path_network_adds_wiring_cut_out_carrier(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cut_out = doc.addObject("Part::Feature", "WiringCutoutA") + cut_out.Label = "Wiring Cut-Out A" + cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + cut_out_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" + ] + + self.assertEqual(1, result["wiring_cut_out_carriers"]) + self.assertEqual(1, len(cut_out_carriers)) + self.assertEqual("PassThrough", cut_out.QetRoutingObstacleMode) + + def test_generate_routing_path_network_refreshes_wiring_cut_out_geometry(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cut_out = doc.addObject("Part::Feature", "WiringCutoutA") + cut_out.Label = "Wiring Cut-Out A" + cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + + first = auto_routing_panel.AutoRoutingController().generate_routing_paths() + cut_out.Shape = FakeShape(FakeBoundBox(65, 75, -2, 2, 15, 25)) + second = auto_routing_panel.AutoRoutingController().generate_routing_paths() + cut_out_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" + ] + + self.assertEqual(1, first["wiring_cut_out_carriers"]) + self.assertEqual(0, second["wiring_cut_out_carriers"]) + self.assertEqual(1, len(cut_out_carriers)) + self.assertEqual([(70.0, -22.0, 20.0), (70.0, 22.0, 20.0)], [(p.x, p.y, p.z) for p in cut_out_carriers[0].Points]) + + def test_wiring_cut_out_source_bridge_extension_controls_generated_path_length(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + cut_out = doc.addObject("Part::Feature", "WiringCutoutA") + cut_out.Label = "过线孔A" + cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + cut_out.QetWiringCutOutBridgeExtensionMm = 8.0 + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + cut_out_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "WiringCutOut" + ] + + self.assertEqual(1, len(cut_out_carriers)) + self.assertIn("QetWiringCutOutBridgeExtensionMm", cut_out.PropertiesList) + self.assertEqual(8.0, cut_out.QetWiringCutOutBridgeExtensionMm) + self.assertEqual([(50.0, -10.0, 20.0), (50.0, 10.0, 20.0)], [(p.x, p.y, p.z) for p in cut_out_carriers[0].Points]) + + def test_wiring_cut_out_bridges_nearby_ducts_on_both_sides_of_panel(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, -20, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 20, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, -20, 20), app.Vector(50, -20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 20, 20), app.Vector(100, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + cut_out = doc.addObject("Part::Feature", "WiringCutoutA") + cut_out.Label = "过线孔A" + cut_out.Shape = FakeShape(FakeBoundBox(45, 55, -2, 2, 15, 25)) + + auto_routing_panel.AutoRoutingController().generate_routing_paths() + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertIn("WiringCutOut", result["route_track"]["carrier_kinds"]) + self.assertEqual(0, result["collision_count"]) + + def test_check_routing_path_network_writes_diagnostic_for_unconnected_terminal(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, "TerminalFar", "terminal-far", app.Vector(5000, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + 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) + + self.assertFalse(result["ok"]) + self.assertIn("unconnected_terminals", result["issue_codes"]) + self.assertEqual("RoutingPathNetwork", diagnostic_group.Group[0].QetDiagnosticKind) + self.assertEqual("project-1", diagnostic_group.Group[0].QetProjectUuid) + self.assertFalse(diagnostic_group.Group[0].QetDiagnosticOk) + self.assertIn("unconnected_terminals", diagnostic_group.Group[0].QetDiagnosticIssueCodes) + self.assertIn("端子未接入", diagnostic_group.Group[0].QetDiagnosticIssueLabels) + self.assertIn("端子未接入", diagnostic_group.Group[0].QetDiagnosticMessage) + self.assertIn("unconnected_terminals", payload["issue_codes"]) + self.assertEqual(1, len(payload["unconnected_terminals"])) + self.assertEqual("terminal-far", payload["unconnected_terminals"][0]["terminal_uuid"]) + self.assertEqual(1000.0, payload["unconnected_terminals"][0]["terminal_access_max_distance_mm"]) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + self.assertIn("端子未接入", message) + self.assertIn("terminal-far", message) + self.assertIn("4900.0 mm", message) + 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") + device = doc.addObject("App::Part", "DevicePEN") + device.Label = "PEN" + device.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) + terminal = _terminal(doc, terminal_objects, "TerminalLongAccess", "terminal-long-access", app.Vector(0, 0, 0)) + device.addObject(terminal) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 20), app.Vector(1100, 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"])) + long_access = payload["long_terminal_accesses"][0] + self.assertEqual("terminal-long-access", long_access["terminal_uuid"]) + self.assertEqual(900.0, long_access["terminal_access_length_mm"]) + self.assertEqual("PEN", long_access["parent_device_label"]) + self.assertEqual("DevicePEN", long_access["parent_device_name"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 0.0}, long_access["terminal_origin"]) + self.assertEqual("x", long_access["terminal_access_dominant_axis"]) + self.assertEqual(2, len(long_access["terminal_access_points"])) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 20.0}, long_access["terminal_access_points"][0]) + self.assertEqual({"x": 1000.0, "y": 0.0, "z": 20.0}, long_access["terminal_access_points"][1]) + self.assertIn("端子接入过长", message) + self.assertIn("TerminalLongAccess", message) + self.assertIn("terminal-long-access", message) + self.assertIn("900.0 mm", message) + + def test_check_routing_path_network_ignores_isolated_routing_range_only_components(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, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(0, 40, 0)], + project_uuid="project-1", + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1100, 0, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="孤立布线面", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + + self.assertNotIn("isolated_network_components", result["issue_codes"]) + self.assertEqual(0, len(result["diagnostic"]["isolated_components"])) + + def test_check_routing_path_network_warns_for_isolated_primary_route_components(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, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(0, 40, 0)], + project_uuid="project-1", + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1100, 0, 0)], + project_uuid="project-1", + kind="UserPath", + label="孤立用户路径", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + + self.assertIn("isolated_network_components", result["issue_codes"]) + self.assertEqual(2, len(result["diagnostic"]["isolated_components"])) + + def test_check_routing_path_network_warns_for_wire_duct_without_terminal_access(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, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + label="孤立线槽", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 0), app.Vector(1000, 100, 0)], + project_uuid="project-1", + kind="TerminalAccess", + label="端子接入", + ) + + 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.assertIn("wire_ducts_without_terminal_access", result["issue_codes"]) + self.assertEqual(1, len(result["diagnostic"]["wire_ducts_without_terminal_access"])) + suggestion = result["diagnostic"]["wire_ducts_without_terminal_access"][0]["bridge_suggestion"] + self.assertEqual("孤立线槽", suggestion["from_carrier"]["label"]) + self.assertEqual("端子接入", suggestion["to_carrier"]["label"]) + self.assertEqual(900.0, suggestion["distance_mm"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 0.0}, suggestion["from_point"]) + self.assertEqual({"x": 1000.0, "y": 0.0, "z": 0.0}, suggestion["to_point"]) + compact_suggestion = payload["wire_ducts_without_terminal_access"][0]["bridge_suggestion"] + self.assertEqual("端子接入", compact_suggestion["to_carrier"]["label"]) + self.assertIn("线槽未接入端子主网络", message) + self.assertIn("建议桥接到 端子接入", message) + self.assertIn("900.0 mm", message) + + def test_zero_distance_user_path_endpoint_splits_wire_duct_segment(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, 0), app.Vector(100, 100, 0)], + project_uuid="project-1", + kind="WireDuct", + label="斜向线槽", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 50, 0), app.Vector(50, 90, 0)], + project_uuid="project-1", + kind="UserPath", + label="零距离桥接", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 90, 0), app.Vector(50, 130, 0)], + project_uuid="project-1", + kind="TerminalAccess", + label="端子接入", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + + self.assertNotIn("wire_ducts_without_terminal_access", result["issue_codes"]) + + def test_create_user_path_bridge_from_selection_connects_nearest_route_points(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") + duct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + label="线槽", + ) + main_path = routing_network.create_route_carrier( + doc, + [app.Vector(120, 20, 0), app.Vector(200, 20, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="主网络", + ) + + created = routing_network.create_user_path_bridge_from_selection( + doc, + [ + types.SimpleNamespace(Object=duct), + types.SimpleNamespace(Object=main_path), + ], + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + self.assertEqual("UserPath", created[0].QetRouteCarrierKind) + self.assertEqual([(100.0, 0.0, 0.0), (120.0, 20.0, 0.0)], [ + (point.x, point.y, point.z) + for point in created[0].Points + ]) + + def test_create_user_path_bridge_between_source_objects_uses_nearest_carrier_pair(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") + fallback_source = doc.addObject("Part::Feature", "DoorRoutingRangeSource") + fallback_source.Label = "门板布线面" + current_source = doc.addObject("Part::Feature", "MainDuctSource") + current_source.Label = "主线槽" + far_fallback = routing_network.create_route_carrier( + doc, + [app.Vector(-500, 0, 0), app.Vector(-400, 0, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="远处布线面", + ) + near_fallback = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="近处布线面", + ) + main_path = routing_network.create_route_carrier( + doc, + [app.Vector(130, 20, 0), app.Vector(200, 20, 0)], + project_uuid="project-1", + kind="WireDuct", + label="主线槽路径", + ) + for carrier in (far_fallback, near_fallback): + carrier.QetRouteSourceName = fallback_source.Name + carrier.QetRouteSourceLabel = fallback_source.Label + main_path.QetRouteSourceName = current_source.Name + main_path.QetRouteSourceLabel = current_source.Label + + created = routing_network.create_user_path_bridge_between_objects( + doc, + fallback_source, + current_source, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + self.assertEqual("UserPath", created[0].QetRouteCarrierKind) + self.assertEqual( + [(100.0, 0.0, 0.0), (130.0, 20.0, 0.0)], + [(point.x, point.y, point.z) for point in created[0].Points], + ) + self.assertEqual("MainPathDetourBridge", created[0].QetRouteBridgeKind) + self.assertEqual("门板布线面 -> 主线槽", created[0].QetRouteBridgePairLabel) + self.assertEqual(fallback_source.Name, created[0].QetRouteBridgeLeftSourceName) + self.assertEqual(current_source.Name, created[0].QetRouteBridgeRightSourceName) + + duplicated = routing_network.create_user_path_bridge_between_objects( + doc, + fallback_source, + current_source, + project_uuid="project-1", + ) + + self.assertEqual([], duplicated) + + 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() + diagnostic = { + "issues": [{"code": "external_issue", "count": 1}], + "unconnected_terminals": ["bad-terminal-sample"], + "possible_breaks": ["bad-break-sample"], + "isolated_components": ["bad-component-sample"], + } + + message = auto_routing.format_routing_path_network_report(diagnostic) + + self.assertIn("布线路径网络检查发现", message) + self.assertIn("首个问题:external_issue", message) + + def test_format_routing_path_network_report_calls_out_wire_duct_break_point(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)], + label="线槽A", + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertIn("线槽端点疑似断开", message) + self.assertIn("线槽A", message) + 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() + diagnostic = { + "summary": { + "carriers": 5, + "segments": 6, + "nodes": 5, + "bridged_segments": 1, + }, + "issues": [], + "ok": True, + } + + message = auto_routing.format_routing_path_network_report(diagnostic) + + self.assertIn("桥接 1 段相邻/投影主路径", message) + + def test_routing_path_network_diagnostic_message_tolerates_malformed_bridge_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + diagnostic = { + "summary": { + "carriers": 1, + "segments": 1, + "nodes": 2, + "bridged_segments": "not-a-number", + }, + "issues": [], + } + + message = routing_network._routing_path_network_diagnostic_message(diagnostic) + + self.assertIn("布线路径网络检查通过", message) + + def test_check_routing_path_network_uses_adjoining_duct_tolerance_option(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") + for index, points in enumerate( + ( + [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], + [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], + [app.Vector(100, 0, 20), app.Vector(100, 100, 20)], + [app.Vector(100, 100, 20), app.Vector(0, 100, 20)], + [app.Vector(0, 100, 20), app.Vector(0, 0, 20)], + ), + start=1, + ): + routing_network.create_route_carrier( + doc, + points, + label="线槽{0}".format(index), + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.check_eplan_routing_path_network( + doc, + project_uuid="project-1", + options={"adjoining_duct_tolerance": 15.0}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(1, result["diagnostic"]["summary"]["bridged_segments"]) + self.assertEqual([], result["diagnostic"]["possible_breaks"]) + + def test_generate_routing_path_network_skips_far_terminal_access_to_protect_view_bbox(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctFar") + duct.Label = "Wire Duct Far" + duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25)) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual(2, result["wire_duct_open_end_carriers"]) + self.assertEqual(0, result["terminal_access_carriers"]) + + def test_auto_routing_controller_exposes_terminal_access_max_distance(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctFar") + duct.Label = "Wire Duct Far" + duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25)) + + controller = auto_routing_panel.AutoRoutingController() + controller.set_terminal_access_max_distance(6000.0) + result = controller.generate_routing_paths() + + self.assertEqual(1, result["terminal_access_carriers"]) + self.assertEqual(6000.0, controller.routing_options()["terminal_access_max_distance"]) + + def test_auto_routing_controller_exposes_terminal_access_warning_distance(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"] + 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, + ) + controller = auto_routing_panel.AutoRoutingController() + controller.set_terminal_access_max_distance(1000.0) + controller.set_terminal_access_warning_distance(950.0) + + result = controller.check_routing_path_network() + + self.assertNotIn("long_terminal_accesses", result["issue_codes"]) + self.assertEqual(950.0, controller.routing_options()["terminal_access_warning_distance"]) + + def test_auto_routing_controller_exposes_terminal_exit_length(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(50, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + + controller = auto_routing_panel.AutoRoutingController() + controller.set_terminal_exit_length(40.0) + controller.generate_routing_paths() + access_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" + ] + + self.assertEqual(1, len(access_carriers)) + self.assertEqual( + (50.0, 0.0, 40.0), + tuple(getattr(access_carriers[0].Points[0], axis) for axis in ("x", "y", "z")), + ) + self.assertEqual(40.0, controller.routing_options()["terminal_exit_length"]) + + def test_auto_routing_controller_readiness_writes_preflight_diagnostic(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + } + ], + } + + report = auto_routing_panel.AutoRoutingController().check_routing_readiness() + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + + self.assertIn("missing_endpoints", report["issue_codes"]) + self.assertIsNotNone(diagnostic_group) + self.assertEqual(1, len(diagnostic_group.Group)) + self.assertEqual("RoutingPreflight", diagnostic_group.Group[0].QetDiagnosticKind) + + def test_route_eplan_connections_prepares_layout_space_like_eplan_route(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"] + doc = FakeDocument() + app.ActiveDocument = doc + 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)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing_panel.AutoRoutingController().route_eplan_connections() + + self.assertEqual(1, report["routed"]) + self.assertEqual("eplan-route-v1", report["routing_method"]) + self.assertTrue(report["routing_path_network_updated"]) + self.assertEqual(1, report["prepared_layout"]["wire_duct_carriers"]) + self.assertEqual(1, report["routing_path_network"]["wire_duct_carriers"]) + self.assertEqual(2, report["prepared_layout"]["terminal_access_carriers"]) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertIsNotNone(diagnostic_group) + self.assertEqual(1, len(diagnostic_group.Group)) + diagnostic_payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + self.assertEqual(1, diagnostic_payload["prepared_layout"]["wire_duct_carriers"]) + self.assertEqual(2, diagnostic_payload["prepared_layout"]["terminal_access_carriers"]) + + def test_auto_routing_controller_passes_adjoining_duct_tolerance_to_batch_route(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"] + doc = FakeDocument() + app.ActiveDocument = doc + 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(1000, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing_panel.AutoRoutingController( + options={"adjoining_duct_tolerance": 15.0} + ).route_eplan_connections() + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, report["routes"][0]["network"]["bridged_segments"]) + + def test_auto_routing_controller_summary_uses_adjoining_duct_tolerance(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(56, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + summary = auto_routing_panel.AutoRoutingController( + options={"adjoining_duct_tolerance": 15.0} + ).summary() + + self.assertIn("桥接:1", summary) + + def test_auto_routing_controller_summary_includes_runtime_version(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("版本:{0}".format(auto_routing.AUTO_ROUTING_RUNTIME_VERSION), summary) + + def test_auto_routing_controller_summary_includes_cabinet_boundary_count(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + boundary = doc.addObject("Part::Feature", "CabinetInteriorSpace") + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + boundary.QetRoutingBoundaryKind = "CabinetInterior" + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("柜内边界:1", summary) + + def test_auto_routing_controller_summary_includes_wire_style_database_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"] + doc = FakeDocument() + app.ActiveDocument = doc + app._qet_exchange_summary = { + "wire_style_database_path": "D:/project/project-local.sqlite", + } + terminal_objects.ensure_root_group(doc, "project-1") + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("导线样式库:D:/project/project-local.sqlite", summary) + + def test_auto_routing_controller_summary_reads_wire_style_database_path_from_payload(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"] + doc = FakeDocument() + app.ActiveDocument = doc + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wire_style_database_path": "D:/project/payload-style.sqlite", + "wires": [], + } + terminal_objects.ensure_root_group(doc, "project-1") + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("导线样式库:D:/project/payload-style.sqlite", summary) + + def test_auto_routing_controller_summary_prefers_current_payload_style_database_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"] + doc = FakeDocument() + app.ActiveDocument = doc + app._qet_exchange_summary = { + "project_uuid": "project-old", + "wire_style_database_path": "D:/old/project-local.sqlite", + } + app._qet_exchange_payload = { + "project_uuid": "project-current", + "wire_style_database_path": "D:/current/project-local.sqlite", + "wires": [], + } + terminal_objects.ensure_root_group(doc, "project-current") + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("导线样式库:D:/current/project-local.sqlite", summary) + self.assertNotIn("D:/old/project-local.sqlite", summary) + + def test_auto_routing_controller_summary_includes_route_constraint_counts(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + required = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="UserPath", + ) + required.QetRouteConstraintMode = "Required" + forbidden = routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + project_uuid="project-1", + kind="UserPath", + ) + forbidden.QetRouteConstraintMode = "Forbidden" + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("路径约束:必经 1,禁经 1", summary) + + def test_auto_routing_controller_summary_includes_source_route_constraint_counts(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Sketcher::SketchObject", "FutureUserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + route_path.QetRouteConstraintMode = "Required" + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("源路径约束:必经 1,禁经 0", summary) + + def test_auto_routing_controller_summary_counts_wire_duct_source_route_constraints(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + wire_duct_source = doc.addObject("Part::Feature", "WireDuctBody") + wire_duct_source.Shape = FakeShape(FakeBoundBox(0, 100, 0, 20, 0, 20)) + wire_duct_source.Shape.Solids = [object()] + wire_duct_source.QetRoutingSourceKind = "WireDuct" + wire_duct_source.QetRouteConstraintMode = "Forbidden" + + summary = auto_routing_panel.AutoRoutingController().summary() + + self.assertIn("源路径约束:必经 0,禁经 1", summary) + + def test_auto_routing_controller_exposes_lane_spacing(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", 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="WireDuct", + ) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + controller = auto_routing_panel.AutoRoutingController() + controller.set_lane_spacing(14.0) + report = controller.route_eplan_connections() + + self.assertEqual(14.0, controller.routing_options()["lane_spacing"]) + self.assertEqual(14.0, report["routes"][1]["lane"]["spacing_mm"]) + self.assertEqual(14.0, report["routes"][1]["lane"]["offset_mm"]) + + def test_auto_routing_controller_exposes_lane_axis(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + controller = auto_routing_panel.AutoRoutingController() + controller.set_lane_spacing(8.0) + controller.set_lane_axis("z") + report = controller.route_eplan_connections() + + self.assertEqual("z", controller.routing_options()["lane_axis"]) + self.assertEqual("z", report["routes"][1]["lane"]["axis"]) + self.assertEqual(8.0, report["routes"][1]["lane"]["offset_mm"]) + + def test_auto_routing_controller_exposes_lane_max_offset(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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="WireDuct", + ) + + controller = auto_routing_panel.AutoRoutingController() + controller.set_lane_spacing(10.0) + controller.set_lane_axis("y") + controller.set_lane_max_offset(18.0) + result = _auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + route_index=21, + options=controller.routing_options(), + ) + + self.assertEqual(18.0, controller.routing_options()["lane_max_offset"]) + self.assertEqual(18.0, result["lane"]["max_offset_mm"]) + self.assertEqual(18.0, result["lane"]["offset_mm"]) + + def test_auto_routing_controller_exposes_obstacle_clearance(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "NearObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, 3, 6, 15, 25)) + + controller = auto_routing_panel.AutoRoutingController() + controller.set_obstacle_clearance(5.0) + result = _auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options=controller.routing_options(), + ) + + self.assertEqual(5.0, controller.routing_options()["obstacle_clearance"]) + self.assertEqual("CollisionWarning", result["route_status"]) + self.assertEqual("ClearanceWarning", result["collisions"][0]["collision_kind"]) + self.assertEqual(["QET Route Carrier"], result["collisions"][0]["route_source_labels"]) + diagnostics = json.loads(result["wire"].QetRouteDiagnosticsJson) + self.assertEqual(["QET Route Carrier"], diagnostics["collisions"][0]["route_source_labels"]) + + def test_auto_routing_controller_exposes_preflight_routeability_sample_limit(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + + controller = auto_routing_panel.AutoRoutingController() + controller.set_preflight_routeability_sample_limit(75) + + self.assertEqual(75, controller.routing_options()["preflight_routeability_sample_limit"]) + + def test_auto_routing_controller_exposes_segment_reuse_penalty(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"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Direct Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], + label="Left Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Alternate Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], + label="Right Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + controller = auto_routing_panel.AutoRoutingController() + controller.set_segment_reuse_penalty(0.0) + report = controller.route_eplan_connections() + + second_labels = [ + segment["carrier"]["label"] + for segment in report["routes"][1]["route_track"]["segments"] + ] + self.assertEqual(0.0, controller.routing_options()["segment_reuse_penalty"]) + self.assertIn("Direct Duct", second_labels) + self.assertNotIn("Alternate Duct", second_labels) + + def test_auto_routing_panel_command_button_style_keeps_text_visible(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + + class FakeButton: + def __init__(self): + self.text = "" + self.tooltip = "" + self.minimum_height = 0 + self.stylesheet = "" + + def setText(self, text): + self.text = text + + def setToolTip(self, tooltip): + self.tooltip = tooltip + + def setMinimumHeight(self, height): + self.minimum_height = height + + def setStyleSheet(self, stylesheet): + self.stylesheet = stylesheet + + button = FakeButton() + + auto_routing_panel._style_command_button(button, "生成布线连接", "按导线任务布线") + + self.assertEqual("生成布线连接", button.text) + self.assertEqual("按导线任务布线", button.tooltip) + self.assertGreaterEqual(button.minimum_height, 28) + self.assertIn("color", button.stylesheet) + + def test_eplan_connection_route_rejects_far_network_entry_to_avoid_huge_render_bbox(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(5000, 0, 20), app.Vector(5100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + def test_route_eplan_connection_between_terminals_fails_without_network(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 30, 0)) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_eplan_connection_between_terminals(doc, start, end) + self.assertEqual(0, len(wiring_objects.iter_routed_wire_objects(doc))) + + def test_surface_carrier_grid_uses_actual_rotated_face_plane(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") + normal = app.Vector(0, 1, 1) + vertices = [ + app.Vector(0, 0, 0), + app.Vector(100, 0, 0), + app.Vector(0, 50, -50), + app.Vector(100, 50, -50), + ] + face = FakeFace( + FakeBoundBox(0, 100, 0, 50, -50, 0), + normal, + vertices=vertices, + center=app.Vector(50, 25, -25), + ) + + created = routing_network.create_surface_carriers_from_selection( + doc, + [FakeSelectionItem([face])], + project_uuid="project-1", + spacing=50.0, + offset=10.0, + margin=0.0, + ) + + self.assertGreater(len(created), 0) + first_point = created[0].Points[0] + for carrier in created: + for point in carrier.Points: + # The rotated face is y + z = 0; after a 10 mm normal offset, + # all generated points must stay on one parallel plane. + self.assertAlmostEqual(first_point.y + first_point.z, point.y + point.z, places=6) + + def test_route_path_creation_ignores_whole_solid_object_edges(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") + solid = doc.addObject("Part::Feature", "CabinetSolid") + solid.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 100, 0, 10), + edges=[FakeEdge(app.Vector(0, 0, 0), app.Vector(100, 0, 0))], + faces=[object()], + ) + + created = routing_network.create_carriers_from_selection( + doc, + [FakeSelectionItem(obj=solid)], + project_uuid="project-1", + ) + + self.assertEqual([], created) + + def test_route_path_creation_splits_disconnected_shape_wires(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") + route_path = doc.addObject("Sketcher::SketchObject", "MultiWireRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(40, 0, 20)]), + FakeWire([app.Vector(80, 80, 20), app.Vector(120, 80, 20)]), + ], + ) + + created = routing_network.create_carriers_from_selection( + doc, + [FakeSelectionItem(obj=route_path)], + project_uuid="project-1", + ) + + self.assertEqual(2, len(created)) + self.assertEqual( + [ + [(0.0, 0.0, 20.0), (40.0, 0.0, 20.0)], + [(80.0, 80.0, 20.0), (120.0, 80.0, 20.0)], + ], + [[(point.x, point.y, point.z) for point in carrier.Points] for carrier in created], + ) + + def test_route_path_creation_projects_line_to_selected_face(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") + face = FakeFace( + FakeBoundBox(0, 100, 0, 100, 0, 0), + app.Vector(0, 0, 1), + ) + draft_line = doc.addObject("Part::Feature", "DraftLine") + draft_line.Shape = FakeShape( + FakeBoundBox(10, 90, 10, 90, 25, 35), + edges=[FakeEdge(app.Vector(10, 10, 25), app.Vector(90, 90, 35))], + ) + + created = routing_network.create_carriers_from_selection( + doc, + [ + FakeSelectionItem([face]), + FakeSelectionItem(obj=draft_line), + ], + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + self.assertEqual([2.0, 2.0], [point.z for point in created[0].Points]) + + def test_wire_duct_entity_generates_centerline_and_marks_source_pass_through(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + + created = routing_network.create_wire_duct_carriers_from_selection( + doc, + [FakeSelectionItem(obj=duct)], + project_uuid="project-1", + margin=20.0, + ) + + self.assertEqual(3, len(created)) + carrier = [item for item in created if item.QetRouteCarrierKind == "WireDuct"][0] + open_ends = [item for item in created if item.QetRouteCarrierKind == "WireDuctOpenEnd"] + self.assertEqual("WireDuct", carrier.QetRouteCarrierKind) + self.assertEqual(2, len(open_ends)) + self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) + self.assertEqual([(20.0, 0.0, 15.0), (100.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + + def test_wire_duct_source_end_margin_controls_generated_centerline_length(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "线槽A" + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + duct.QetWireDuctEndMarginMm = 5.0 + + created = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", + ) + + carrier = [item for item in created if item.QetRouteCarrierKind == "WireDuct"][0] + self.assertIn("QetWireDuctEndMarginMm", duct.PropertiesList) + self.assertEqual(5.0, duct.QetWireDuctEndMarginMm) + self.assertEqual([(5.0, 0.0, 15.0), (115.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + + def test_wire_duct_source_capacity_is_copied_to_generated_carriers(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + duct.QetRouteCarrierCapacity = 4 + + created = routing_network.create_wire_duct_carriers_from_selection( + doc, + [FakeSelectionItem(obj=duct)], + project_uuid="project-1", + margin=20.0, + ) + + self.assertIn("QetRouteCarrierCapacity", duct.PropertiesList) + self.assertTrue(all(item.QetRouteCarrierCapacity == 4 for item in created)) + + def test_auto_detect_wire_ducts_ignores_cabinet_models(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "线槽A" + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + cabinet = doc.addObject("Part::Feature", "Cabinet") + cabinet.Label = "3D机柜" + cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) + + created = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", + ) + created_again = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(3, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual(1, len([item for item in created if item.QetRouteCarrierKind == "WireDuct"])) + self.assertEqual(2, len([item for item in created if item.QetRouteCarrierKind == "WireDuctOpenEnd"])) + self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) + self.assertFalse(hasattr(cabinet, "QetRoutingObstacleMode")) + + def test_wire_duct_source_is_not_reported_as_collision(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuct") + duct.Shape = FakeShape(FakeBoundBox(-10, 130, -10, 10, 15, 25)) + routing_network.create_wire_duct_carriers_from_selection( + doc, + [FakeSelectionItem(obj=duct)], + project_uuid="project-1", + margin=0.0, + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + + def test_eplan_connection_route_uses_alternate_carrier_to_avoid_obstacle(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 50, 20), + app.Vector(100, 50, 20), + app.Vector(100, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "CabinetObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 15, 25)) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue(result["network"]["obstacle_aware"]) + self.assertGreaterEqual(result["network"]["blocked_segments"], 1) + self.assertIn(50.0, [point.y for point in result["points"]]) + + def test_eplan_connection_route_prefers_entry_candidate_without_access_collision(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(20, 0, 0), app.Vector(100, 0, 0)], + label="Near Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 30, 0), app.Vector(100, 30, 0)], + label="Clear Duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "AccessObstacle") + obstacle.Label = "Access Obstacle" + obstacle.Shape = FakeShape(FakeBoundBox(10, 15, -5, 5, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Clear Duct", labels) + self.assertNotIn("Near Duct", labels) + self.assertEqual(0, result["collision_count"]) + + def test_eplan_connection_route_keeps_clear_access_candidates_beyond_distance_limit(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + for index in range(9): + routing_network.create_route_carrier( + doc, + [app.Vector(20, index, 0), app.Vector(100, index, 0)], + label="Near Blocked Duct {0}".format(index + 1), + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 30, 0), app.Vector(100, 30, 0)], + label="Clear Duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "AccessObstacle") + obstacle.Label = "Access Obstacle" + obstacle.Shape = FakeShape(FakeBoundBox(10, 15, -5, 20, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Clear Duct", labels) + self.assertTrue(all(not label.startswith("Near Blocked Duct") for label in labels)) + self.assertEqual(0, result["network"]["route_candidate_obstacle_hits"]) + + def test_eplan_connection_route_prefers_carrier_inside_cabinet_boundary(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 49, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 49, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 51, 0), app.Vector(100, 51, 0)], + label="Outside Cabinet Path", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, -40, 0), app.Vector(100, -40, 0)], + label="Inside Cabinet Path", + project_uuid="project-1", + kind="UserPath", + ) + boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + boundary.QetRoutingBoundaryKind = "CabinetInterior" + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Inside Cabinet Path", labels) + self.assertNotIn("Outside Cabinet Path", labels) + self.assertEqual(0, result["network"]["route_candidate_boundary_violations"]) + + def test_eplan_connection_route_prefers_inside_detour_over_shorter_outside_shortcut(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 51, 0), app.Vector(100, 0, 0)], + label="Outside Shortcut", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(0, -40, 0), app.Vector(100, -40, 0), app.Vector(100, 0, 0)], + label="Inside Cabinet Detour", + project_uuid="project-1", + kind="UserPath", + ) + boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + boundary.QetRoutingBoundaryKind = "CabinetInterior" + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Inside Cabinet Detour", labels) + self.assertNotIn("Outside Shortcut", labels) + self.assertEqual(0, result["network"]["route_candidate_boundary_violations"]) + + def test_eplan_connection_wire_records_boundary_warning_when_route_leaves_cabinet(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 49, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 49, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 60, 0), app.Vector(100, 60, 0)], + label="Only Outside Cabinet Path", + project_uuid="project-1", + kind="UserPath", + ) + boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + boundary.QetRoutingBoundaryKind = "CabinetInterior" + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + self.assertGreater(result["network"]["route_candidate_boundary_violations"], 0) + self.assertTrue(result["wire"].QetRouteBoundaryAware) + self.assertEqual("BoundaryWarning", result["wire"].QetRouteBoundaryStatus) + self.assertEqual( + str(result["network"]["route_candidate_boundary_violations"]), + result["wire"].QetRouteBoundaryViolationCount, + ) + + def test_eplan_connection_wire_records_long_network_access_warning(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 125, 0), app.Vector(100, 125, 0)], + label="Far Cabinet Main Path", + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "terminal_exit_length": 0.0, + "lane_spacing": 0.0, + "terminal_access_warning_distance": 50.0, + }, + ) + + wire = result["wire"] + self.assertEqual("125.000", wire.QetRouteEntryDistanceMm) + self.assertEqual("125.000", wire.QetRouteExitDistanceMm) + self.assertEqual("node", wire.QetRouteEntryPointMode) + self.assertEqual("node", wire.QetRouteExitPointMode) + self.assertEqual("1", wire.QetRouteEntryCandidateRank) + self.assertEqual("1", wire.QetRouteExitCandidateRank) + self.assertEqual("50.000", wire.QetRouteAccessWarningDistanceMm) + self.assertEqual("LongAccessWarning", wire.QetRouteAccessStatus) + self.assertEqual("entry,exit", wire.QetRouteAccessWarningSides) + payload = json.loads(wire.QetRouteDiagnosticsJson) + self.assertEqual("LongAccessWarning", payload["access"]["access_status"]) + self.assertEqual(["entry", "exit"], payload["access"]["warning_sides"]) + + def test_eplan_connection_route_keeps_inside_boundary_candidates_beyond_distance_limit(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 49, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 49, 0)) + for index in range(9): + y = 51 + index + routing_network.create_route_carrier( + doc, + [app.Vector(0, y, 0), app.Vector(100, y, 0)], + label="Outside Candidate {0}".format(index + 1), + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, -40, 0), app.Vector(100, -40, 0)], + label="Inside Cabinet Path", + project_uuid="project-1", + kind="UserPath", + ) + boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, -50, 50, -10, 10)) + boundary.QetRoutingBoundaryKind = "CabinetInterior" + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Inside Cabinet Path", labels) + self.assertTrue(all(not label.startswith("Outside Candidate") for label in labels)) + self.assertEqual(0, result["network"]["route_candidate_boundary_violations"]) + + def test_eplan_connection_route_tolerates_missing_route_constraint_collector(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="主路径", + project_uuid="project-1", + kind="UserPath", + ) + collector = routing_network.collect_route_constraint_options + delattr(routing_network, "collect_route_constraint_options") + try: + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + finally: + routing_network.collect_route_constraint_options = collector + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual({}, result["network"].get("route_constraints", {})) + + def test_eplan_connection_route_avoids_forbidden_carrier_label(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="禁止路径", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="允许路径", + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "terminal_exit_length": 0.0, + "lane_spacing": 0.0, + "forbidden_route_carrier_labels": ["禁止路径"], + }, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("允许路径", labels) + self.assertNotIn("禁止路径", labels) + + def test_eplan_connection_route_avoids_carrier_marked_forbidden(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + forbidden = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="近路径", + project_uuid="project-1", + kind="UserPath", + ) + forbidden.QetRouteConstraintMode = "Forbidden" + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="远路径", + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("远路径", labels) + self.assertNotIn("近路径", labels) + self.assertIn( + forbidden.Name, + result["network"]["route_constraints"]["forbidden"]["names"], + ) + + def test_eplan_connection_route_accepts_chinese_constraint_mode_aliases(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + forbidden = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="近路径", + project_uuid="project-1", + kind="UserPath", + ) + forbidden.QetRouteConstraintMode = "禁止经过" + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="远路径", + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("远路径", labels) + self.assertNotIn("近路径", labels) + self.assertIn( + forbidden.Name, + result["network"]["route_constraints"]["forbidden"]["names"], + ) + + def test_eplan_connection_route_uses_carrier_marked_required(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="近路径", + project_uuid="project-1", + kind="UserPath", + ) + required = routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="远路径", + project_uuid="project-1", + kind="UserPath", + ) + required.QetRouteConstraintMode = "Required" + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("远路径", labels) + self.assertNotIn("近路径", labels) + self.assertIn( + required.Name, + result["network"]["route_constraints"]["required"]["names"], + ) + + def test_source_required_constraint_from_multi_wire_sketch_accepts_one_generated_path(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + route_path = doc.addObject("Sketcher::SketchObject", "YellowMainRouteSketch") + route_path.Label = "黄色主路径" + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + wires=[ + FakeWire([app.Vector(0, 0, 20), app.Vector(100, 0, 20)]), + FakeWire([app.Vector(0, 80, 20), app.Vector(100, 80, 20)]), + ], + ) + selection = [FakeSelectionItem(obj=route_path)] + routing_network.mark_route_constraint_mode_from_selection(doc, selection, "Required") + carriers = routing_network.create_user_path_carriers_from_selection( + doc, + selection, + project_uuid="project-1", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + route_carrier_names = [ + segment["carrier"]["name"] + for segment in result["route_track"]["segments"] + if not segment.get("is_bridge") + ] + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertIn(carriers[0].Name, route_carrier_names) + self.assertNotIn(carriers[1].Name, route_carrier_names) + self.assertEqual( + ["黄色主路径"], + result["network"]["route_constraints"]["required"]["source_labels"], + ) + + def test_eplan_connection_route_requires_carrier_label(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="普通路径", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="必经路径", + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "terminal_exit_length": 0.0, + "lane_spacing": 0.0, + "required_route_carrier_labels": ["必经路径"], + }, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("必经路径", labels) + self.assertNotIn("普通路径", labels) + self.assertEqual( + ["必经路径"], + result["network"]["route_constraints"]["required"]["labels"], + ) + + def test_eplan_connection_route_reports_unsatisfied_route_constraints(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="普通路径", + project_uuid="project-1", + kind="UserPath", + ) + + with self.assertRaises(auto_routing.AutoRoutingError) as context: + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "terminal_exit_length": 0.0, + "required_route_carrier_labels": ["不存在的必经路径"], + }, + ) + + self.assertIn("路径约束", str(context.exception)) + + def test_eplan_connection_route_chooses_clear_orthogonal_access_order(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(30, 30, 0), app.Vector(100, 30, 0)], + label="Only Duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "AccessOrderObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(10, 20, -5, 5, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + point_tuples = [(point.x, point.y, point.z) for point in result["points"]] + self.assertIn((0.0, 30.0, 0.0), point_tuples) + self.assertNotIn((30.0, 0.0, 0.0), point_tuples) + self.assertEqual(0, result["collision_count"]) + + def test_eplan_connection_route_marks_collision_warning_against_obstacle_bbox(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], + project_uuid="project-1", + ) + obstacle = doc.addObject("Part::Feature", "Obstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110)) + parent = doc.addObject("App::Part", "DoorAssembly") + parent.Label = "FRONT DOOR-R ASS'Y" + parent.addObject(obstacle) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("CollisionWarning", result["route_status"]) + self.assertEqual("CollisionWarning", result["wire"].RouteStatus) + self.assertEqual(1, result["collision_count"]) + self.assertEqual("HardIntersection", result["collisions"][0]["collision_kind"]) + self.assertEqual(["FRONT DOOR-R ASS'Y"], result["collisions"][0]["obstacle_parent_labels"]) + self.assertEqual(["DoorAssembly"], result["collisions"][0]["obstacle_parent_names"]) + self.assertEqual("1", result["wire"].QetRouteCollisionCount) + self.assertEqual("1", result["wire"].QetRouteHardIntersectionCount) + self.assertEqual("0", result["wire"].QetRouteClearanceWarningCount) + self.assertEqual("HardIntersectionWarning", result["wire"].QetRouteCollisionStatus) + + def test_eplan_connection_route_locally_detours_terminal_access_around_third_party_device(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 50), app.Vector(100, 0, 50)], + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "ThirdPartyDevice") + obstacle.Label = "第三方设备" + terminal_objects.ensure_string_property( + obstacle, + "QetElementUuid", + "QET Exchange", + "", + "device-obstacle", + ) + obstacle.Shape = FakeShape(FakeBoundBox(90, 110, 40, 60, -10, 60)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={ + "avoid_obstacles": False, + "avoid_local_access_obstacles": True, + "terminal_exit_length": 0.0, + }, + endpoint_metadata={ + "start_element_uuid": "device-start", + "end_element_uuid": "device-end", + }, + ) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue(any(abs(point.x - 75.0) <= 0.001 for point in result["points"])) + + def test_network_route_limits_local_access_obstacles_to_nearby_bboxes(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 50), app.Vector(100, 0, 50)], + project_uuid="project-1", + kind="WireDuct", + ) + near_obstacle = doc.addObject("Part::Feature", "NearThirdPartyDevice") + near_obstacle.Label = "近处第三方设备" + terminal_objects.ensure_string_property( + near_obstacle, + "QetElementUuid", + "QET Exchange", + "", + "device-near-obstacle", + ) + near_obstacle.Shape = FakeShape(FakeBoundBox(90, 110, 40, 60, -10, 60)) + for index in range(120): + far_obstacle = doc.addObject("Part::Feature", "FarDevice{0}".format(index)) + far_obstacle.Shape = FakeShape( + FakeBoundBox(10000 + index * 20, 10010 + index * 20, 10000, 10010, 10000, 10010) + ) + + calls = {"count": 0} + original_segment_intersects_bbox = auto_routing._segment_intersects_bbox + + def counted_segment_intersects_bbox(start_point, end_point, bbox): + calls["count"] += 1 + return original_segment_intersects_bbox(start_point, end_point, bbox) + + auto_routing._segment_intersects_bbox = counted_segment_intersects_bbox + try: + result = auto_routing.build_network_route( + start, + end, + options={ + "avoid_obstacles": False, + "avoid_local_access_obstacles": True, + "terminal_exit_length": 0.0, + }, + doc=doc, + ) + finally: + auto_routing._segment_intersects_bbox = original_segment_intersects_bbox + + self.assertIsNotNone(result) + self.assertTrue(any(abs(point.x - 75.0) <= 0.001 for point in result["points"])) + self.assertLess(calls["count"], 80) + + def test_network_route_ignores_unbound_structural_bboxes_for_local_access_avoidance(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 50), app.Vector(100, 0, 50)], + project_uuid="project-1", + kind="WireDuct", + ) + near_device = doc.addObject("Part::Feature", "BoundNearDevice") + terminal_objects.ensure_string_property( + near_device, + "QetElementUuid", + "QET Exchange", + "", + "device-near-obstacle", + ) + near_device.Shape = FakeShape(FakeBoundBox(90, 110, 40, 60, -10, 60)) + for index in range(80): + cabinet_part = doc.addObject("Part::Feature", "ImportedCabinetPart{0}".format(index)) + cabinet_part.Shape = FakeShape(FakeBoundBox(-1000, 1000, -1000, 1000, -1000, 1000)) + + calls = {"count": 0} + original_segment_intersects_bbox = auto_routing._segment_intersects_bbox + + def counted_segment_intersects_bbox(start_point, end_point, bbox): + calls["count"] += 1 + return original_segment_intersects_bbox(start_point, end_point, bbox) + + auto_routing._segment_intersects_bbox = counted_segment_intersects_bbox + try: + result = auto_routing.build_network_route( + start, + end, + options={ + "avoid_obstacles": False, + "avoid_local_access_obstacles": True, + "terminal_exit_length": 0.0, + }, + doc=doc, + ) + finally: + auto_routing._segment_intersects_bbox = original_segment_intersects_bbox + + self.assertIsNotNone(result) + self.assertTrue(any(abs(point.x - 75.0) <= 0.001 for point in result["points"])) + self.assertLess(calls["count"], 80) + + def test_network_route_caps_extra_entry_candidates_in_batch_mode(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + for index in range(8): + routing_network.create_route_carrier( + doc, + [app.Vector(0, index * 10, 50), app.Vector(100, index * 10, 50)], + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "BoundNearDevice") + terminal_objects.ensure_string_property( + obstacle, + "QetElementUuid", + "QET Exchange", + "", + "device-near-obstacle", + ) + obstacle.Shape = FakeShape(FakeBoundBox(15, 25, -5, 5, 40, 60)) + calls = {"shortest_path": 0} + original_shortest_path = routing_network.shortest_path_with_carriers + + def counted_shortest_path(*args, **kwargs): + calls["shortest_path"] += 1 + return original_shortest_path(*args, **kwargs) + + routing_network.shortest_path_with_carriers = counted_shortest_path + try: + result = auto_routing.build_network_route( + start, + end, + options={ + "network_entry_candidate_limit": 3, + "network_entry_candidate_total_limit": 4, + "avoid_obstacles": False, + "avoid_local_access_obstacles": True, + "terminal_exit_length": 0.0, + }, + doc=doc, + ) + finally: + routing_network.shortest_path_with_carriers = original_shortest_path + + self.assertIsNotNone(result) + self.assertLessEqual(calls["shortest_path"], 16) + + def test_eplan_connection_route_marks_clearance_warning_against_expanded_obstacle_bbox(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], + label="主线槽A", + project_uuid="project-1", + ) + obstacle = doc.addObject("Part::Feature", "NearObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, 3, 6, 90, 110)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"obstacle_clearance": 5.0}, + ) + + self.assertEqual("CollisionWarning", result["route_status"]) + self.assertEqual(1, result["collision_count"]) + self.assertEqual("ClearanceWarning", result["collisions"][0]["collision_kind"]) + self.assertEqual(3.0, result["collisions"][0]["obstacle_bbox"]["ymin"]) + self.assertEqual(-2.0, result["collisions"][0]["collision_bbox"]["ymin"]) + self.assertEqual("1", result["wire"].QetRouteCollisionCount) + self.assertEqual("0", result["wire"].QetRouteHardIntersectionCount) + self.assertEqual("1", result["wire"].QetRouteClearanceWarningCount) + self.assertEqual("ClearanceWarning", result["wire"].QetRouteCollisionStatus) + + def test_eplan_connection_route_ignores_terminal_exit_segment_collision(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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", + ) + terminal_body = doc.addObject("Part::Feature", "UngroupedTerminalBody") + terminal_body.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, -5, 15)) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + + def test_eplan_connection_route_ignores_explicit_start_local_route_collision(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + start.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + start.QetTerminalLocalRoutePointsJson = json.dumps( + [[0, 0, 0], [20, 0, 0], [20, 40, 0]] + ) + routing_network.create_route_carrier( + doc, + [app.Vector(20, 80, 0), app.Vector(120, 80, 0)], + label="Cabinet Main Path", + project_uuid="project-1", + ) + local_body = doc.addObject("Part::Feature", "StartDeviceLocalShell") + local_body.Shape = FakeShape(FakeBoundBox(15, 25, 15, 25, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"avoid_obstacles": False, "terminal_exit_length": 0.0}, + ) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + diagnostics = json.loads(result["wire"].QetRouteDiagnosticsJson) + self.assertEqual(3, len(diagnostics["endpoint_access"]["start_points"])) + + def test_eplan_connection_route_still_reports_main_path_collision_after_local_route(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + start.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + start.QetTerminalLocalRoutePointsJson = json.dumps( + [[0, 0, 0], [20, 0, 0], [20, 40, 0]] + ) + routing_network.create_route_carrier( + doc, + [app.Vector(20, 80, 0), app.Vector(120, 80, 0)], + label="Cabinet Main Path", + project_uuid="project-1", + ) + main_obstacle = doc.addObject("Part::Feature", "MainPathObstacle") + main_obstacle.Shape = FakeShape(FakeBoundBox(55, 65, 75, 85, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"avoid_obstacles": False, "terminal_exit_length": 0.0}, + ) + + self.assertEqual("CollisionWarning", result["route_status"]) + self.assertEqual(1, result["collision_count"]) + self.assertEqual("MainPathObstacle", result["collisions"][0]["obstacle_name"]) + + def test_eplan_connection_route_detours_local_access_segment_around_obstacle(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], + label="主线槽A", + project_uuid="project-1", + ) + obstacle = doc.addObject("Part::Feature", "AccessObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, 40, 60)) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue( + any(abs(point.x) > 5.0 or abs(point.y) > 5.0 for point in result["points"]), + "局部接入段应增加侧向绕障拐点,而不是直接穿过障碍盒。", + ) + + def test_eplan_connection_route_ignores_endpoint_device_body_as_obstacle(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + device = doc.addObject("App::DocumentObjectGroup", "QETDeviceStart") + device.QetInstanceId = start.QetInstanceId + device.addObject(start) + body = doc.addObject("Part::Feature", "StartDeviceBody") + body.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, -5, 15)) + device.addObject(body) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + + def test_route_eplan_connections_from_payload_skips_missing_terminal(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)) + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + "end_element_uuid": "device-missing", + "end_instance_id": "instance-missing", + "end_terminal_display": "A1", + } + ] + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_terminal"]) + self.assertEqual(1, report["available_terminals"]) + self.assertEqual(0, report["local_terminals"]) + self.assertEqual(["terminal-missing"], report["missing_endpoint_uuids"]) + self.assertEqual("terminal-start", report["missing_endpoint_samples"][0]["start_terminal_uuid"]) + self.assertTrue(report["missing_endpoint_samples"][0]["start_found"]) + self.assertFalse(report["missing_endpoint_samples"][0]["end_found"]) + self.assertEqual("instance-missing", report["missing_endpoint_samples"][0]["end_instance_id"]) + self.assertEqual("A1", report["missing_endpoint_samples"][0]["end_terminal_display"]) + self.assertEqual(0, report["missing_endpoint_samples"][0]["end_element_terminal_count"]) + self.assertEqual([], report["missing_endpoint_samples"][0]["end_element_terminal_samples"]) + self.assertEqual(0, report["missing_endpoint_samples"][0]["end_instance_terminal_count"]) + self.assertEqual([], report["missing_endpoint_samples"][0]["end_instance_terminal_samples"]) + self.assertEqual( + "device_not_in_3d_scene", + report["missing_endpoint_samples"][0]["end_missing_endpoint_reason_code"], + ) + self.assertIn("终点 element=device-missing, instance=instance-missing, terminal=A1", message) + self.assertIn("原因=该 2D 设备未在 FreeCAD 场景中找到", message) + + def test_route_eplan_connections_backfills_missing_endpoint_device_info_from_payload_devices(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)) + payload = { + "devices": [ + { + "element_uuid": "device-missing", + "instance_id": "instance-from-device-list", + "display_tag": "UD:8", + "terminals": [ + { + "terminal_uuid": "device-missing:terminal-a", + "terminal_display": "A1", + } + ], + } + ], + "wires": [ + { + "wire_id": "wire-1", + "wire_mark": "N-MISS", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "device-missing:terminal-a", + "end_element_uuid": "device-missing", + "end_terminal_display": "A1", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + sample = report["missing_endpoint_samples"][0] + + self.assertEqual("instance-from-device-list", sample["end_instance_id"]) + self.assertEqual("UD:8", sample["end_device_label"]) + self.assertEqual( + {"device_not_in_3d_scene": 1}, + report["missing_terminal_summary"]["reason_code_counts"], + ) + self.assertEqual(1, len(report["missing_terminal_summary"]["device_groups"])) + self.assertEqual("UD:8", report["missing_terminal_summary"]["device_groups"][0]["device_label"]) + self.assertEqual(["A1"], report["missing_terminal_summary"]["device_groups"][0]["terminal_displays"]) + self.assertIn("UD:8", message) + self.assertIn("需补端子设备:UD:8 缺 1 处(A1)", message) + self.assertIn("instance=instance-from-device-list", message) + + def test_route_eplan_connections_backfills_missing_endpoint_device_info_from_context_json_devices(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)) + with tempfile.TemporaryDirectory() as temp_dir: + json_path = Path(temp_dir) / "2d_to_3d.json" + json_path.write_text( + json.dumps( + { + "project_uuid": "project-1", + "devices": [ + { + "element_uuid": "device-missing", + "instance_id": "instance-from-context-json", + "display_tag": "UD:8", + } + ], + "wires": [ + { + "wire_id": "wire-1", + "wire_mark": "N-MISS", + "wire_style_id": "1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "device-missing:terminal-a", + "end_element_uuid": "device-missing", + "end_terminal_display": "A1", + } + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + app._qet_exchange_summary = {"json_path": str(json_path)} + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_mark": "N-MISS", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "device-missing:terminal-a", + "end_element_uuid": "device-missing", + "end_terminal_display": "A1", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + sample = report["missing_endpoint_samples"][0] + self.assertEqual("instance-from-context-json", sample["end_instance_id"]) + self.assertEqual("UD:8", sample["end_device_label"]) + self.assertEqual( + "instance-from-context-json", + report["missing_terminal_summary"]["device_groups"][0]["instance_id"], + ) + self.assertTrue(report["context_devices_loaded"]) + self.assertEqual(1, report["context_device_count"]) + self.assertEqual(str(json_path), report["context_devices_json_path"]) + + def test_route_eplan_connections_from_payload_reports_device_without_terminals(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)) + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_without_terminals") + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-no-terminals") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-no-terminals") + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + "end_element_uuid": "device-no-terminals", + "end_instance_id": "instance-no-terminals", + "end_terminal_display": "A1", + } + ] + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + sample = report["missing_endpoint_samples"][0] + self.assertEqual("QETDevice_without_terminals", sample["end_device_name"]) + self.assertTrue(sample["end_device_in_scene"]) + self.assertEqual("no_3d_terminals_for_element", sample["end_missing_endpoint_reason_code"]) + self.assertIn("原因=该 2D 设备在 FreeCAD 中没有工程端子", message) + + def test_route_eplan_connections_from_payload_reports_missing_device_binding_metadata(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)) + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + "end_terminal_display": "A1", + } + ] + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + sample = report["missing_endpoint_samples"][0] + self.assertEqual("missing_device_binding_metadata", sample["end_missing_endpoint_reason_code"]) + self.assertEqual("导线端点缺少 2D/3D 设备绑定信息", sample["end_missing_endpoint_reason_label"]) + self.assertIn("QET 导线端点缺少 element_uuid", message) + self.assertIn("第一版不要求 start/end_instance_id", message) + self.assertIn("原因=导线端点缺少 2D/3D 设备绑定信息", message) + + def test_route_eplan_connections_from_payload_applies_per_wire_required_route(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)], + label="普通路径", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="必经路径", + project_uuid="project-1", + kind="UserPath", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-required", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "required_route_carrier_labels": ["必经路径"], + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + self.assertIn("必经路径", labels) + self.assertNotIn("普通路径", labels) + + def test_route_eplan_connections_from_payload_applies_per_wire_required_source_name(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)) + direct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="普通路径", + project_uuid="project-1", + kind="UserPath", + ) + direct.QetRouteSourceName = "NormalSketch" + required = routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="黄色主路径", + project_uuid="project-1", + kind="UserPath", + ) + required.QetRouteSourceName = "RequiredSketch" + required.QetRouteSourceLabel = "黄色主路径草图" + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-required-source", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "required_route_carrier_source_names": ["RequiredSketch"], + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + self.assertIn("黄色主路径", labels) + self.assertNotIn("普通路径", labels) + self.assertEqual( + ["RequiredSketch"], + report["routes"][0]["network"]["route_constraints"]["required"]["source_names"], + ) + + def test_route_eplan_connections_from_payload_applies_per_wire_forbidden_route(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)], + label="禁止路径", + project_uuid="project-1", + kind="UserPath", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="允许路径", + project_uuid="project-1", + kind="UserPath", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-forbidden", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "forbidden_route_carrier_labels": ["禁止路径"], + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + self.assertIn("允许路径", labels) + self.assertNotIn("禁止路径", labels) + + def test_route_eplan_connections_from_payload_applies_per_wire_forbidden_source_name(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)) + forbidden = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="禁止源路径", + project_uuid="project-1", + kind="UserPath", + ) + forbidden.QetRouteSourceName = "ForbiddenSketch" + allowed = routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="允许源路径", + project_uuid="project-1", + kind="UserPath", + ) + allowed.QetRouteSourceName = "AllowedSketch" + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-forbidden-source", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "forbidden_route_carrier_source_names": ["ForbiddenSketch"], + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + self.assertIn("允许源路径", labels) + self.assertNotIn("禁止源路径", labels) + self.assertEqual( + ["ForbiddenSketch"], + report["routes"][0]["network"]["route_constraints"]["forbidden"]["source_names"], + ) + + def test_route_eplan_connections_from_payload_classifies_unsatisfied_route_constraints(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)], + label="普通路径", + project_uuid="project-1", + kind="UserPath", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-unsatisfied", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "required_route_carrier_labels": ["不存在的必经路径"], + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"terminal_exit_length": 0.0}, + ) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertIn("路径约束", report["missing_route_network_samples"][0]["error"]) + + def test_route_eplan_connections_from_payload_skips_resolved_tasks_without_route_network(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(1000, 0, 0)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-{0}".format(index), + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + for index in range(3) + ], + } + original_route = auto_routing.route_eplan_connection_between_terminals + + def fail_if_called(*_args, **_kwargs): + raise AssertionError("batch route must not call per-wire routing without route carriers") + + auto_routing.route_eplan_connection_between_terminals = fail_if_called + try: + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + finally: + auto_routing.route_eplan_connection_between_terminals = original_route + + self.assertEqual(0, report["routed"]) + self.assertEqual(3, report["skipped_missing_route_network"]) + self.assertEqual(3, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + + def test_route_eplan_connection_tasks_marks_task_missing_route_network_when_skipped(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)) + task = wiring_objects.create_wire_task( + doc, + "project-1", + "wire-missing-network", + "N1", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + task.RouteStatus = "Routed" + + report = auto_routing.route_eplan_connection_tasks(doc) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual("MissingRouteNetwork", task.RouteStatus) + + def test_route_eplan_connection_tasks_auto_creates_diagnostic_bridge_before_routing(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", + label="安装板兜底路径", + ) + wiring_objects.create_wire_task( + doc, + "project-1", + "wire-task-bridge", + "N1", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + original_diagnostic = routing_network.diagnose_routing_path_network + original_create = routing_network.create_user_path_bridges_from_diagnostic_suggestions + calls = {"diagnostic": 0} + + def fake_diagnostic(*_args, **_kwargs): + calls["diagnostic"] += 1 + if calls["diagnostic"] == 1: + return { + "ok": False, + "issues": [ + { + "severity": "warning", + "code": "wire_ducts_without_terminal_access", + "count": 1, + }, + ], + "summary": {"carriers": 1}, + "wire_ducts_without_terminal_access": [ + { + "index": 0, + "carrier_names": ["孤立线槽"], + "bridge_suggestion": {"distance_mm": 40.0}, + }, + ], + } + return {"ok": True, "issues": [], "summary": {"carriers": 2}} + + def fake_create(_doc, _diagnostic, project_uuid=""): + carrier = routing_network.create_route_carrier( + _doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid=project_uuid or "project-1", + kind="WireDuct", + label="诊断桥接后主路径", + ) + return {"suggestions": 1, "created": [carrier], "duplicates": 0, "stale_suggestions": 0} + + routing_network.diagnose_routing_path_network = fake_diagnostic + routing_network.create_user_path_bridges_from_diagnostic_suggestions = fake_create + try: + report = auto_routing.route_eplan_connection_tasks( + doc, + options={"auto_create_diagnostic_bridges": True}, + ) + finally: + routing_network.diagnose_routing_path_network = original_diagnostic + routing_network.create_user_path_bridges_from_diagnostic_suggestions = original_create + + self.assertEqual(1, report["auto_diagnostic_bridges"]["created_count"]) + self.assertEqual({"main_path_routes": 1, "fallback_routes": 0}, report["route_path_usage"]) + self.assertEqual(["Routed"], list(report["route_status_counts"].keys())) + self.assertNotIn("main_path_not_used", report["issue_codes"]) + + def test_eplan_connection_route_prefers_wire_duct_over_shorter_routing_range(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(300, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(300, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 1200, 20), + app.Vector(300, 1200, 20), + app.Vector(300, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_eplan_connection_wire_records_fallback_route_quality_warning(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="安装板兜底路径", + project_uuid="project-1", + kind="RoutingRange", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + wire = result["wire"] + self.assertEqual("FallbackPathWarning", wire.QetRouteQualityStatus) + self.assertEqual("RoutingRange", wire.QetRouteFallbackCarrierKinds) + self.assertEqual("安装板兜底路径", wire.QetRouteFallbackCarrierLabels) + self.assertEqual("route_quality_warnings", wire.QetRouteIssueCodes) + self.assertEqual("路径质量告警", wire.QetRouteIssueLabels) + payload = json.loads(wire.QetRouteDiagnosticsJson) + self.assertEqual(["route_quality_warnings"], payload["issue_codes"]) + self.assertEqual(["路径质量告警"], payload["issue_labels"]) + self.assertEqual("FallbackPathWarning", payload["quality"]["quality_status"]) + self.assertEqual(["RoutingRange"], payload["quality"]["fallback_carrier_kinds"]) + + def test_eplan_connection_wire_records_third_party_collision_issue(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + label="线槽主路径", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "ThirdPartyDevice") + obstacle.Label = "设备A" + terminal_objects.ensure_string_property( + obstacle, + "QetElementUuid", + "QET Exchange", + "", + "device-obstacle", + ) + obstacle.Shape = FakeShape(FakeBoundBox(50, 70, -10, 10, 15, 25)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"avoid_obstacles": False, "terminal_exit_length": 0.0}, + endpoint_metadata={ + "start_element_uuid": "device-start", + "end_element_uuid": "device-end", + }, + ) + + wire = result["wire"] + self.assertIn("collision_warnings", wire.QetRouteIssueCodes) + self.assertIn("third_party_device_collisions", wire.QetRouteIssueCodes) + self.assertIn("第三方设备/布局碰撞", wire.QetRouteIssueLabels) + payload = json.loads(wire.QetRouteDiagnosticsJson) + self.assertIn("third_party_device_collisions", payload["issue_codes"]) + self.assertEqual( + "third_party_device_collision", + payload["collisions"][0]["collision_relation"], + ) + + def test_collision_relation_marks_endpoint_device_collision(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + + relation = auto_routing._collision_relation( + { + "obstacle_element_uuid": "device-start", + "start_element_uuid": "device-start", + "end_element_uuid": "device-end", + } + ) + + self.assertEqual("endpoint_device_collision", relation) + + def test_unbound_structural_collision_can_be_auto_ignored_without_ignoring_devices(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + + structural = { + "obstacle_label": "NFB BRACKET_P00", + "obstacle_name": "Solid043", + "obstacle_element_uuid": "", + "obstacle_parent_labels": ["CABINET ASS'Y", "QET Exchange Devices"], + "obstacle_parent_names": ["LinkGroup005", "QETExchangeDevices"], + } + device = { + "obstacle_label": "3S001", + "obstacle_name": "Device3S001", + "obstacle_element_uuid": "device-uuid", + "obstacle_parent_labels": ["QET Exchange Devices"], + "obstacle_parent_names": ["QETExchangeDevices"], + } + + self.assertTrue(auto_routing._is_auto_ignorable_unbound_structural_collision(structural)) + self.assertFalse(auto_routing._is_auto_ignorable_unbound_structural_collision(device)) + kept, ignored = auto_routing._filter_auto_ignored_collisions([structural, device]) + self.assertEqual([device], kept) + self.assertEqual([structural], ignored) + + def test_eplan_connection_route_prefers_wire_duct_when_routing_range_is_only_moderately_shorter(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(10, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 145, 20), + app.Vector(10, 145, 20), + app.Vector(10, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_eplan_connection_route_considers_primary_entry_beyond_nearest_surface_candidates(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + for y in range(1, 11): + routing_network.create_route_carrier( + doc, + [app.Vector(0, y, 20), app.Vector(100, y, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_segments(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)) + broken_carrier = doc.addObject("Part::Feature", "BrokenCarrier") + terminal_objects.ensure_string_property( + broken_carrier, + "QetRoutingRole", + "QET Routing", + "Routing role marker", + "RoutingCarrier", + ) + terminal_objects.ensure_string_property( + broken_carrier, + "QetRouteCarrierKind", + "QET Routing", + "Route carrier kind", + "WireDuct", + ) + terminal_objects.ensure_bool_property( + broken_carrier, + "CanRouteWire", + "QET Routing", + "Whether routing connections can use this path", + True, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"network_entry_max_distance": 30.0}, + ) + + self.assertEqual(1, report["route_network_carriers"]) + self.assertEqual(0, report["route_network_segments"]) + self.assertEqual(0, report["route_network_nodes"]) + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + + def test_route_eplan_connections_from_payload_applies_batch_entry_candidate_limit(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + captured_options = [] + original = auto_routing.route_eplan_connection_between_terminals + + def fake_route(*args, **kwargs): + captured_options.append(dict(kwargs.get("options") or {})) + return { + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": {}, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a", + } + + auto_routing.route_eplan_connection_between_terminals = fake_route + try: + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={ + "network_entry_candidate_limit": 8, + "batch_network_entry_candidate_limit": 2, + "batch_network_entry_total_candidate_limit": 4, + }, + ) + finally: + auto_routing.route_eplan_connection_between_terminals = original + + self.assertEqual(1, report["routed"]) + self.assertEqual(2, report["batch_network_entry_candidate_limit"]) + self.assertEqual(4, report["batch_network_entry_total_candidate_limit"]) + self.assertFalse(report["batch_avoid_obstacles"]) + self.assertEqual(2, captured_options[0]["network_entry_candidate_limit"]) + self.assertEqual(4, captured_options[0]["network_entry_candidate_total_limit"]) + self.assertFalse(captured_options[0]["avoid_obstacles"]) + self.assertIsInstance(captured_options[0]["__base_route_network"], dict) + self.assertIsInstance(captured_options[0]["__obstacle_candidate_cache"], dict) + + def test_route_eplan_connections_retries_missing_route_with_wider_candidate_limit(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + captured_limits = [] + original = auto_routing.route_eplan_connection_between_terminals + + def fake_route(*args, **kwargs): + limit = int((kwargs.get("options") or {}).get("network_entry_candidate_limit", 0) or 0) + captured_limits.append(limit) + if limit < 8: + raise auto_routing.AutoRoutingError( + "没有可用的布线路径网络;请先生成布线布局空间和布线路径网络。" + ) + return { + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": {}, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a", + } + + auto_routing.route_eplan_connection_between_terminals = fake_route + try: + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={ + "network_entry_candidate_limit": 8, + "batch_network_entry_candidate_limit": 3, + "missing_route_retry_candidate_limit": 8, + }, + ) + finally: + auto_routing.route_eplan_connection_between_terminals = original + + self.assertEqual([3, 8], captured_limits) + self.assertEqual(1, report["routed"]) + self.assertEqual(0, report["skipped_missing_route_network"]) + self.assertEqual(1, report["missing_route_retries"]) + self.assertEqual(1, report["route_status_counts"]["Routed"]) + + def test_route_eplan_connections_selectively_reroutes_third_party_collisions(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_element_uuid": "device-start", + "start_terminal_uuid": "terminal-start", + "end_element_uuid": "device-end", + "end_terminal_uuid": "terminal-end", + }, + ], + } + captured_avoid = [] + original = auto_routing.route_eplan_connection_between_terminals + + def fake_route(*args, **kwargs): + route_options = dict(kwargs.get("options") or {}) + avoid = bool(route_options.get("avoid_obstacles", False)) + captured_avoid.append(avoid) + if avoid: + return { + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 12.0, + "lane": {"index": 0}, + "network": {}, + "route_track": {}, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a clean", + } + return { + "algorithm": "fake", + "route_status": "CollisionWarning", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + "collision_count": 1, + "collisions": [ + { + "collision_kind": "HardIntersection", + "obstacle_element_uuid": "device-obstacle", + "obstacle_label": "设备A", + } + ], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a collision", + } + + auto_routing.route_eplan_connection_between_terminals = fake_route + try: + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + finally: + auto_routing.route_eplan_connection_between_terminals = original + + self.assertEqual([False, True], captured_avoid) + self.assertEqual(1, report["selective_collision_reroute_attempts"]) + self.assertEqual(1, report["selective_collision_reroutes"]) + self.assertEqual(0, report["selective_collision_reroute_no_improvement"]) + self.assertEqual(1, report["routed"]) + self.assertEqual(0, report["collision_warnings"]) + self.assertEqual("Routed", report["routes"][0]["route_status"]) + + def test_route_eplan_connections_rejects_selective_reroute_when_it_uses_fallback_path(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_element_uuid": "device-start", + "start_terminal_uuid": "terminal-start", + "end_element_uuid": "device-end", + "end_terminal_uuid": "terminal-end", + }, + ], + } + original = auto_routing.route_eplan_connection_between_terminals + created_wires = [] + + def fake_route(*args, **kwargs): + route_doc = args[0] + avoid = bool((kwargs.get("options") or {}).get("avoid_obstacles", False)) + if avoid: + retry_wire = route_doc.addObject("Part::Feature", "RetryFallbackWire") + created_wires.append(retry_wire) + return { + "wire": retry_wire, + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 12.0, + "lane": {"index": 0}, + "network": {}, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "辅助面"}} + ] + }, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a fallback", + } + original_wire = route_doc.addObject("Part::Feature", "OriginalCollisionWire") + created_wires.append(original_wire) + return { + "wire": original_wire, + "algorithm": "fake", + "route_status": "CollisionWarning", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + "collision_count": 1, + "collisions": [ + { + "collision_kind": "HardIntersection", + "obstacle_element_uuid": "device-obstacle", + "obstacle_label": "设备A", + } + ], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a collision", + } + + auto_routing.route_eplan_connection_between_terminals = fake_route + try: + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + finally: + auto_routing.route_eplan_connection_between_terminals = original + + self.assertEqual(1, report["selective_collision_reroute_attempts"]) + self.assertEqual(0, report["selective_collision_reroutes"]) + self.assertEqual(1, report["selective_collision_reroute_rejected_fallback"]) + self.assertEqual(1, report["collision_warnings"]) + self.assertEqual("CollisionWarning", report["routes"][0]["route_status"]) + self.assertEqual("RejectedFallback", report["routes"][0]["selective_collision_reroute_status"]) + self.assertEqual( + ["RoutingRange"], + report["routes"][0]["selective_collision_reroute_rejected_fallback_kinds"], + ) + self.assertEqual( + ["辅助面"], + report["routes"][0]["selective_collision_reroute_rejected_fallback_labels"], + ) + self.assertIn("main_path_detour_missing", report["routes"][0]["issue_codes"]) + compact = auto_routing._compact_routing_connection_batch_report(report) + self.assertIn("main_path_detour_missing", compact["route_samples"][0]["issue_codes"]) + self.assertEqual( + ["辅助面"], + compact["route_samples"][0]["selective_collision_reroute"]["rejected_fallback_labels"], + ) + self.assertEqual(1, report["main_path_detour_missing_summary"]["wire_count"]) + self.assertEqual( + {"辅助面": 1}, + report["main_path_detour_missing_summary"]["rejected_fallback_label_counts"], + ) + self.assertEqual( + {"主线槽A": 1}, + report["main_path_detour_missing_summary"]["current_route_source_label_counts"], + ) + self.assertEqual( + {"辅助面 -> 主线槽A": 1}, + report["main_path_detour_missing_summary"]["bridge_pair_counts"], + ) + self.assertEqual( + ["点击“选择缺主路径补路位置”快速定位汇总需补区域"], + [ + action + for action in report["recommended_actions"] + if "选择缺主路径补路位置" in action + ], + ) + self.assertIn("main_path_detour_missing", created_wires[0].QetRouteIssueCodes) + wire_payload = json.loads(created_wires[0].QetRouteDiagnosticsJson) + self.assertEqual( + ["辅助面"], + wire_payload["selective_collision_reroute"]["rejected_fallback_labels"], + ) + self.assertIn("main_path_detour_missing", report["issue_codes"]) + message = auto_routing.format_eplan_connection_route_report(report) + self.assertIn("局部避障:尝试 1 条,接受 0 条,拒绝辅助路径 1 条", message) + self.assertIn("请补主路径/UserPath 或调整装配", message) + self.assertIn("缺主路径绕行:1 条,需补路径位置:辅助面 1 条", message) + self.assertIn("辅助面 -> 主线槽A 1 条", message) + + def test_route_eplan_connections_auto_bridges_main_path_detour_pairs_and_retries_once(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)) + fallback_source = doc.addObject("Part::Feature", "DoorRoutingRangeSource") + fallback_source.Label = "门板布线面" + current_source = doc.addObject("Part::Feature", "MainDuctSource") + current_source.Label = "主线槽A" + fallback_carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + label="门板布线面 carrier", + ) + current_carrier = routing_network.create_route_carrier( + doc, + [app.Vector(140, 20, 20), app.Vector(240, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + label="主线槽A carrier", + ) + fallback_carrier.QetRouteSourceName = fallback_source.Name + fallback_carrier.QetRouteSourceLabel = fallback_source.Label + current_carrier.QetRouteSourceName = current_source.Name + current_carrier.QetRouteSourceLabel = current_source.Label + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_element_uuid": "device-start", + "start_terminal_uuid": "terminal-start", + "end_element_uuid": "device-end", + "end_terminal_uuid": "terminal-end", + }, + ], + } + original = auto_routing.route_eplan_connection_between_terminals + calls = [] + + def fake_route(*args, **kwargs): + route_doc = args[0] + calls.append(bool((kwargs.get("options") or {}).get("avoid_obstacles", False))) + detour_path_exists = any( + getattr(carrier, "QetRouteBridgeKind", "") == "MainPathDetourPath" + for carrier in routing_network.collect_route_carriers(route_doc) + ) + wire = route_doc.addObject("Part::Feature", "WireAfterDetourPath" if detour_path_exists else "WireBeforeDetourPath") + if detour_path_exists: + return { + "wire": wire, + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a routed", + } + if bool((kwargs.get("options") or {}).get("avoid_obstacles", False)): + points = [ + app.Vector(0, 0, 0), + app.Vector(0, 0, 20), + app.Vector(80, 0, 20), + app.Vector(140, 20, 20), + app.Vector(100, 0, 0), + ] + wire.Points = points + return { + "wire": wire, + "algorithm": "fake", + "route_status": "Routed", + "length_mm": 12.0, + "lane": {"index": 0}, + "network": {}, + "points": points, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "门板布线面"}} + ] + }, + "collision_count": 0, + "collisions": [], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a fallback", + } + return { + "wire": wire, + "algorithm": "fake", + "route_status": "CollisionWarning", + "length_mm": 10.0, + "lane": {"index": 0}, + "network": {}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + "collision_count": 1, + "collisions": [ + { + "collision_kind": "HardIntersection", + "obstacle_element_uuid": "device-obstacle", + "obstacle_label": "设备A", + } + ], + "wire_style_status": "NotRequested", + "wire_object_label": "wire-a collision", + } + + auto_routing.route_eplan_connection_between_terminals = fake_route + try: + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + options={"auto_create_main_path_detour_bridges": True}, + project_uuid="project-1", + update_network=False, + ) + finally: + auto_routing.route_eplan_connection_between_terminals = original + + bridges = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteBridgeKind", "") == "MainPathDetourBridge" + ] + detour_paths = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteBridgeKind", "") == "MainPathDetourPath" + ] + + self.assertEqual(1, report["routed"]) + self.assertEqual(0, report["collision_warnings"]) + self.assertEqual({"Routed": 1}, report["route_status_counts"]) + self.assertEqual(1, report["auto_main_path_detour_bridges"]["created_count"]) + self.assertTrue(report["auto_main_path_detour_bridges"]["rerouted"]) + self.assertEqual(1, report["auto_main_path_detour_bridges"]["retry_wires"]) + self.assertEqual(1, report["auto_main_path_detour_bridges"]["retry_replaced_routes"]) + self.assertEqual("门板布线面 -> 主线槽A", bridges[0].QetRouteBridgePairLabel) + self.assertEqual("门板布线面 -> 主线槽A", detour_paths[0].QetRouteBridgePairLabel) + self.assertEqual([False, True, False], calls) + compact = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) + self.assertEqual(1, compact["auto_main_path_detour_bridges"]["created_count"]) + self.assertIn("自动主路径补桥:生成 UserPath 1 条并重跑布线", message) + + def test_auto_main_path_detour_user_path_raises_capacity_when_same_path_reused(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") + points = [ + app.Vector(0, 0, 0), + app.Vector(0, 0, 20), + app.Vector(100, 0, 20), + app.Vector(100, 0, 0), + ] + retry_result = { + "points": points, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "门板布线面"}} + ] + }, + } + original_result = { + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + } + + first = auto_routing._create_main_path_detour_user_path_from_retry( + doc, + retry_result, + original_result, + project_uuid="project-1", + ) + second = auto_routing._create_main_path_detour_user_path_from_retry( + doc, + retry_result, + original_result, + project_uuid="project-1", + ) + + self.assertIs(first, second) + self.assertEqual(2, first.QetRouteCarrierCapacity) + + def test_auto_main_path_detour_user_path_initial_capacity_matches_lane_parallel_count(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") + retry_result = { + "points": [ + app.Vector(0, 0, 0), + app.Vector(0, 0, 20), + app.Vector(100, 0, 20), + app.Vector(100, 0, 0), + ], + "lane": {"index": 1}, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "门板布线面"}} + ] + }, + } + original_result = { + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}} + ] + }, + } + + carrier = auto_routing._create_main_path_detour_user_path_from_retry( + doc, + retry_result, + original_result, + project_uuid="project-1", + ) + + self.assertEqual(2, carrier.QetRouteCarrierCapacity) + + def test_route_report_raises_auto_detour_path_capacity_from_final_lane_usage(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)], + project_uuid="project-1", + kind="UserPath", + capacity=1, + ) + carrier.QetRouteBridgeKind = "MainPathDetourPath" + report = { + "routes": [ + { + "wire_uuid": "wire-auto-detour", + "route_status": "Routed", + "lane": {"index": 1}, + "route_track": { + "segments": [ + { + "carrier": { + "name": carrier.Name, + "kind": "UserPath", + "capacity": 1, + } + } + ] + }, + } + ], + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, + "skipped_invalid": 0, + "errors": [], + } + + auto_routing._raise_main_path_detour_capacities_from_report(doc, report) + + self.assertEqual(2, carrier.QetRouteCarrierCapacity) + self.assertEqual(2, report["routes"][0]["route_track"]["segments"][0]["carrier"]["capacity"]) + self.assertEqual([], auto_routing._route_capacity_pressure_samples(report, limit=0)) + + def test_collect_obstacles_cache_preserves_endpoint_filters(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + + endpoint_body = doc.addObject("Part::Feature", "EndpointBody") + endpoint_body.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, -5, 5)) + endpoint_body.QetInstanceId = terminal.QetInstanceId + + near_body = doc.addObject("Part::Feature", "NearBody") + near_body.Shape = FakeShape(FakeBoundBox(1, 2, -1, 1, -1, 1)) + + far_body = doc.addObject("Part::Feature", "FarBody") + far_body.Shape = FakeShape(FakeBoundBox(80, 90, -1, 1, -1, 1)) + + options = {"terminal_exit_length": 20.0, "obstacle_clearance": 0.0} + uncached = auto_routing.collect_obstacles(doc, exclude=[terminal], options=options) + cache = auto_routing._obstacle_candidate_cache(doc, options=options) + cached = auto_routing.collect_obstacles( + doc, + exclude=[terminal], + options=dict(options, __obstacle_candidate_cache=cache), + ) + + self.assertEqual(["FarBody"], [item["name"] for item in uncached]) + self.assertEqual(["FarBody"], [item["name"] for item in cached]) + + def test_collect_obstacles_skips_parent_of_support_surface_source(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + parent = doc.addObject("App::LinkGroup", "DoorAssembly") + parent.Shape = FakeShape(FakeBoundBox(0, 100, 0, 100, 0, 20)) + panel = doc.addObject("Part::Feature", "DoorPanel") + panel.Shape = FakeShape(FakeBoundBox(0, 100, 0, 100, 0, 2)) + panel.QetRoutingObstacleMode = "SupportSurface" + parent.addObject(panel) + obstacle = doc.addObject("Part::Feature", "DeviceObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(20, 40, 20, 40, 0, 20)) + + obstacles = auto_routing.collect_obstacles(doc) + cache = auto_routing._obstacle_candidate_cache(doc) + cached = auto_routing.collect_obstacles(doc, options={"__obstacle_candidate_cache": cache}) + + self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) + self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) + + def test_collect_obstacles_skips_descendant_of_pass_through_ancestor(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + assembly = doc.addObject("App::LinkGroup", "DoorAssembly") + assembly.QetRoutingObstacleMode = "PassThrough" + compound = doc.addObject("Part::Compound2", "DoorCompound") + panel = doc.addObject("Part::Feature", "DoorPanel") + panel.Shape = FakeShape(FakeBoundBox(0, 40, 0, 40, 0, 40)) + assembly.addObject(compound) + compound.addObject(panel) + obstacle = doc.addObject("Part::Feature", "DeviceObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(20, 40, 20, 40, 0, 20)) + + obstacles = auto_routing.collect_obstacles(doc) + cache = auto_routing._obstacle_candidate_cache(doc) + cached = auto_routing.collect_obstacles(doc, options={"__obstacle_candidate_cache": cache}) + + self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) + self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) + + def test_collect_obstacles_reports_full_parent_chain_for_nested_import_parts(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + assembly = doc.addObject("App::LinkGroup", "DoorAssembly") + assembly.Label = "FRONT DOOR-R ASS'Y" + compound = doc.addObject("Part::Compound2", "DoorCompound") + compound.Label = "NAUO141" + panel = doc.addObject("Part::Feature", "DoorPanel") + panel.Shape = FakeShape(FakeBoundBox(0, 40, 0, 40, 0, 40)) + assembly.addObject(compound) + compound.addObject(panel) + + obstacles = auto_routing.collect_obstacles(doc) + + self.assertEqual(["DoorPanel"], [item["name"] for item in obstacles]) + self.assertEqual(["DoorCompound", "DoorAssembly"], obstacles[0]["parent_refs"]["names"]) + self.assertEqual(["NAUO141", "FRONT DOOR-R ASS'Y"], obstacles[0]["parent_refs"]["labels"]) + + def test_collect_obstacles_skips_auto_detected_support_surface_candidate(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + side_cover = doc.addObject("Part::Feature", "SideCover") + side_cover.Label = "SIDE COVER-1_P00" + side_cover.Shape = FakeShape(FakeBoundBox(0, 600, 0, 2148, 0, 30)) + obstacle = doc.addObject("Part::Feature", "DeviceObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(20, 40, 20, 40, 0, 20)) + + obstacles = auto_routing.collect_obstacles(doc) + cache = auto_routing._obstacle_candidate_cache(doc) + cached = auto_routing.collect_obstacles(doc, options={"__obstacle_candidate_cache": cache}) + + self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) + self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) + + def test_collect_obstacles_skips_outlist_ancestor_of_support_surface_source(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + parent = doc.addObject("App::LinkGroup", "DoorAssembly") + parent.Shape = FakeShape(FakeBoundBox(0, 100, 0, 100, 0, 20)) + compound = doc.addObject("Part::Compound2", "DoorCompound") + compound.Shape = FakeShape(FakeBoundBox(0, 100, 0, 100, 0, 20)) + panel = doc.addObject("Part::Feature", "DoorPanel") + panel.Shape = FakeShape(FakeBoundBox(0, 100, 0, 100, 0, 2)) + panel.QetRoutingObstacleMode = "SupportSurface" + parent.OutList = [compound] + compound.InList = [parent] + compound.OutList = [panel] + panel.InList = [compound] + obstacle = doc.addObject("Part::Feature", "DeviceObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(20, 40, 20, 40, 0, 20)) + + obstacles = auto_routing.collect_obstacles(doc) + cache = auto_routing._obstacle_candidate_cache(doc) + cached = auto_routing.collect_obstacles(doc, options={"__obstacle_candidate_cache": cache}) + + self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) + self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) + + def test_route_eplan_connections_classifies_disconnected_network_as_missing_route_network(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(10, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 20), app.Vector(1010, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "start_element_uuid": "QF1", + "start_terminal_display": "A1", + "end_terminal_uuid": "terminal-end", + "end_element_uuid": "KM1", + "end_terminal_display": "13", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"network_entry_max_distance": 30.0}, + ) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual("wire-a", report["missing_route_network_samples"][0]["wire_uuid"]) + self.assertEqual("N4111", report["missing_route_network_samples"][0]["wire_object_label"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + + def test_route_eplan_connections_from_payload_attaches_path_diagnostic_when_network_missing(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)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-no-network", + "wire_label": "N-NET", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertIn("routing_path_network_diagnostic", report) + self.assertFalse(report["routing_path_network_diagnostic"]["ok"]) + self.assertTrue(report["routing_path_network_diagnostic"]["issue_codes"]) + self.assertEqual(0, report["routing_sources"]["candidate_sources"]) + self.assertEqual(0, report["routing_sources"]["route_carriers"]) + self.assertIn("路径网络检查提示", message) + self.assertIn("未识别到线槽、布线面或用户路径源", message) + + def test_route_eplan_connections_from_payload_reports_sources_not_generated(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)) + panel = doc.addObject("Part::Feature", "MarkedRoutingSource") + panel.Label = "已标记布线面" + panel.Shape = FakeShape(FakeBoundBox(0, 300, 0, 200, 0, 5)) + panel.QetRoutingSourceKind = "RoutingRange" + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-source-only", + "wire_label": "N-SRC", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["routing_sources"]["candidate_sources"]) + self.assertEqual(0, report["routing_sources"]["route_carriers"]) + self.assertEqual({"RoutingRange": 1}, report["routing_sources"]["marked_source_counts"]) + self.assertIn("已识别到布线源 1 个,但还没有生成可用路径 carrier", message) + + def test_network_entry_uses_terminal_access_max_distance_when_smaller(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(500, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + route = auto_routing.build_network_route( + start, + end, + options={"terminal_access_max_distance": 30.0}, + doc=doc, + ) + + self.assertIsNone(route) + + def test_route_eplan_connections_writes_diagnostic_object_for_missing_terminal(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)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + + self.assertEqual(1, report["skipped_missing_terminal"]) + self.assertIsNotNone(diagnostic_group) + self.assertEqual(1, len(diagnostic_group.Group)) + diagnostic = diagnostic_group.Group[0] + self.assertEqual("RoutingConnectionBatch", diagnostic.QetDiagnosticKind) + self.assertEqual("project-1", diagnostic.QetProjectUuid) + self.assertFalse(diagnostic.QetDiagnosticOk) + self.assertIn("missing_terminals", diagnostic.QetDiagnosticIssueCodes) + self.assertIn("端子匹配失败", diagnostic.QetDiagnosticIssueLabels) + self.assertIn("批量生成布线连接完成", diagnostic.QetDiagnosticMessage) + self.assertIn("缺失端子 1 条", diagnostic.QetDiagnosticMessage) + self.assertIn("terminal-missing", diagnostic.QetDiagnosticJson) + + def test_route_eplan_connections_writes_diagnostic_object_when_no_wire_tasks(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + payload = {"project_uuid": "project-1", "wires": []} + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + + self.assertEqual(0, report["total_wires"]) + self.assertIn("no_wire_tasks", report["issue_codes"]) + self.assertIsNotNone(diagnostic_group) + self.assertEqual(1, len(diagnostic_group.Group)) + diagnostic = diagnostic_group.Group[0] + self.assertEqual("RoutingConnectionBatch", diagnostic.QetDiagnosticKind) + self.assertEqual("project-1", diagnostic.QetProjectUuid) + self.assertFalse(diagnostic.QetDiagnosticOk) + self.assertIn("routed=0", diagnostic.QetDiagnosticMessage) + self.assertIn("没有导线任务", diagnostic.QetDiagnosticMessage) + diagnostic_payload = json.loads(diagnostic.QetDiagnosticJson) + self.assertEqual(0, diagnostic_payload["total_wires"]) + self.assertIn("no_wire_tasks", diagnostic_payload["issue_codes"]) + + def test_route_eplan_connections_writes_compact_batch_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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 20, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 20, 0)) + 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_route_carrier( + doc, + [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "wire_label": "N2", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + 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(auto_routing.AUTO_ROUTING_RUNTIME_VERSION, report["runtime_version"]) + self.assertEqual(auto_routing.AUTO_ROUTING_RUNTIME_VERSION, diagnostic_payload["runtime_version"]) + self.assertEqual(2, len(report["routes"])) + self.assertNotIn("routes", diagnostic_payload) + self.assertEqual(2, diagnostic_payload["route_sample_count"]) + self.assertEqual(2, len(diagnostic_payload["route_samples"])) + self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"]) + self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"]) + + def test_compact_batch_report_prioritizes_problem_route_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 3, + "routed": 3, + "routes": [ + {"wire_uuid": "normal-a", "route_status": "Routed"}, + {"wire_uuid": "normal-b", "route_status": "Routed"}, + { + "wire_uuid": "problem-collision", + "route_status": "CollisionWarning", + "collisions": [ + { + "collision_kind": "HardIntersection", + "collision_relation": "third_party_device_collision", + } + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report( + report, + sample_limit=2, + ) + + self.assertEqual(3, payload["route_count"]) + self.assertEqual(2, payload["route_sample_count"]) + self.assertEqual("problem-collision", payload["route_samples"][0]["wire_uuid"]) + self.assertEqual( + ["collision_warnings", "third_party_device_collisions"], + payload["route_samples"][0]["issue_codes"], + ) + + def test_compact_route_sample_includes_wire_object_label(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-label", + "wire_label": "N4111", + "wire_object_label": "N4111: terminal-start -> terminal-end (Routed)", + } + ) + + self.assertEqual( + "N4111: terminal-start -> terminal-end (Routed)", + sample["wire_object_label"], + ) + + def test_compact_route_sample_prefers_route_track_bridged_segment_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-bridge", + "route_track": { + "bridged_segments": 1, + }, + "network": { + "bridged_segments": 3, + }, + } + ) + + self.assertEqual(1, sample["network"]["bridged_segments"]) + + def test_compact_route_sample_includes_candidate_obstacle_hits(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-obstacle-entry", + "network": { + "entry_candidate_rank": 3, + "exit_candidate_rank": 2, + "entry_candidate_score": 125.0, + "route_candidate_obstacle_hits": 2, + }, + } + ) + + self.assertEqual(3, sample["network"]["entry_candidate_rank"]) + self.assertEqual(2, sample["network"]["exit_candidate_rank"]) + self.assertEqual(125.0, sample["network"]["entry_candidate_score"]) + self.assertEqual(2, sample["network"]["route_candidate_obstacle_hits"]) + + def test_compact_route_sample_includes_candidate_boundary_metadata(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-boundary", + "network": { + "boundary_aware": True, + "route_candidate_boundary_violations": 2, + }, + } + ) + + self.assertTrue(sample["network"]["boundary_aware"]) + self.assertEqual(2, sample["network"]["route_candidate_boundary_violations"]) + + def test_compact_route_sample_includes_single_wire_status_summaries(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-status-summary", + "collisions": [ + {"collision_kind": "HardIntersection"}, + {"collision_kind": "ClearanceWarning"}, + ], + "lane": {"index": 2, "spacing_mm": 10.0, "axis": "y"}, + "network": { + "entry_distance": 125.0, + "exit_distance": 20.0, + "terminal_access_warning_distance": 100.0, + "boundary_aware": True, + "route_candidate_boundary_violations": 1, + }, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "RoutingRange", + "label": "安装板兜底路径", + "capacity": 1, + } + } + ] + }, + } + ) + + self.assertEqual("LongAccessWarning", sample["access"]["access_status"]) + self.assertEqual(["entry"], sample["access"]["warning_sides"]) + self.assertEqual("HardIntersectionWarning", sample["collision_summary"]["collision_status"]) + self.assertEqual(1, sample["collision_summary"]["hard_intersection_count"]) + self.assertEqual(1, sample["collision_summary"]["clearance_warning_count"]) + self.assertEqual("FallbackPathWarning", sample["quality"]["quality_status"]) + self.assertEqual(["RoutingRange"], sample["quality"]["fallback_carrier_kinds"]) + self.assertEqual("CapacityWarning", sample["capacity"]["capacity_status"]) + self.assertEqual(3, sample["capacity"]["parallel_wire_count"]) + self.assertEqual("BoundaryWarning", sample["boundary"]["boundary_status"]) + self.assertEqual( + [ + "long_terminal_access", + "collision_warnings", + "route_quality_warnings", + "route_capacity_pressure", + "route_candidate_boundary_violations", + ], + sample["issue_codes"], + ) + self.assertIn("端子接入过长", sample["issue_labels"]) + self.assertIn("碰撞告警", sample["issue_labels"]) + + def test_compact_route_sample_includes_route_constraints(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-constraints", + "network": { + "route_constraints": { + "required": {"labels": ["必经路径"]}, + "forbidden": {"labels": ["禁止路径"]}, + }, + }, + } + ) + + self.assertEqual( + ["必经路径"], + sample["network"]["route_constraints"]["required"]["labels"], + ) + self.assertEqual( + ["禁止路径"], + sample["network"]["route_constraints"]["forbidden"]["labels"], + ) + + def test_compact_route_sample_formats_user_path_source_index(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-source-index", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "UserPath", + "source_label": "多路径草图", + "source_path_index": "1", + } + }, + { + "carrier": { + "kind": "UserPath", + "source_label": "多路径草图", + "source_path_index": "2", + } + }, + ] + }, + } + ) + + self.assertEqual( + ["多路径草图(路径1)", "多路径草图(路径2)"], + sample["route_source_labels"], + ) + + def test_compact_route_sample_ignores_bridge_only_carrier_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-bridge", + "route_track": { + "carrier_kinds": {"RoutingRange": 1}, + "carrier_names": ["VirtualBridge"], + "segments": [ + { + "is_bridge": True, + "carrier": {"name": "VirtualBridge", "kind": "RoutingRange"}, + }, + { + "carrier": {"name": "WireDuctA", "kind": "WireDuct"}, + }, + ], + }, + } + ) + + self.assertEqual({"WireDuct": 1}, sample["carrier_kinds"]) + self.assertEqual(["WireDuctA"], sample["carrier_names"]) + + 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_compact_batch_report_includes_entry_distance_warning_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "terminal_access_warning_distance": 100.0, + "routes": [ + { + "wire_uuid": "wire-long-entry", + "wire_label": "N-LONG", + "wire_object_label": "N-LONG: T1 -> T2 (Routed)", + "network": { + "entry_distance": 125.0, + "exit_distance": 20.0, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "label": "主线槽A"}}, + ], + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_entry_distance_warning_count"]) + self.assertEqual( + "wire-long-entry", + payload["route_entry_distance_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + "N-LONG: T1 -> T2 (Routed)", + payload["route_entry_distance_warning_samples"][0]["wire_object_label"], + ) + self.assertEqual( + ["entry"], + payload["route_entry_distance_warning_samples"][0]["warning_sides"], + ) + self.assertEqual( + ["主线槽A"], + payload["route_entry_distance_warning_samples"][0]["route_source_labels"], + ) + + def test_compact_batch_report_quality_warning_includes_specific_carrier_labels(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-surface", + "wire_label": "N-SURFACE", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "RoutingRange", + "label": "安装板辅助路径", + } + } + ], + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + ["安装板辅助路径"], + payload["route_quality_warning_samples"][0]["route_carrier_labels"], + ) + + def test_compact_batch_report_includes_candidate_obstacle_warning_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-obstacle-entry", + "wire_label": "N-OBSTACLE", + "network": { + "route_candidate_obstacle_hits": 2, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "UserPath", "label": "绕行路径A"}}, + ], + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_candidate_obstacle_warning_count"]) + self.assertEqual( + "wire-obstacle-entry", + payload["route_candidate_obstacle_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual(2, payload["route_candidate_obstacle_warning_samples"][0]["hits"]) + self.assertEqual( + ["绕行路径A"], + payload["route_candidate_obstacle_warning_samples"][0]["route_source_labels"], + ) + + def test_compact_batch_report_includes_candidate_boundary_warning_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-outside-cabinet", + "wire_label": "N-OUT", + "network": { + "boundary_aware": True, + "route_candidate_boundary_violations": 3, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "UserPath", "label": "柜内主路径A"}}, + ], + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_candidate_boundary_warning_count"]) + self.assertEqual( + "wire-outside-cabinet", + payload["route_candidate_boundary_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + 3, + payload["route_candidate_boundary_warning_samples"][0]["violations"], + ) + self.assertEqual( + ["柜内主路径A"], + payload["route_candidate_boundary_warning_samples"][0]["route_source_labels"], + ) + + def test_compact_batch_report_includes_route_constraint_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-constrained", + "wire_label": "N-CONSTRAINT", + "network": { + "route_constraints": { + "required": {"labels": ["必经路径"]}, + "forbidden": {"labels": ["禁止路径"]}, + }, + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_constraint_warning_count"]) + self.assertEqual( + "wire-constrained", + payload["route_constraint_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + ["必经路径"], + payload["route_constraint_warning_samples"][0]["required"]["labels"], + ) + self.assertEqual( + ["禁止路径"], + payload["route_constraint_warning_samples"][0]["forbidden"]["labels"], + ) + + def test_compact_batch_report_includes_capacity_pressure_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-crowded", + "wire_label": "N-CROWDED", + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "name": "DuctA", "capacity": 2}}, + {"carrier": {"kind": "WireDuct", "name": "DuctB", "capacity": 4}}, + ] + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_capacity_pressure_warning_count"]) + self.assertEqual( + "wire-crowded", + payload["route_capacity_pressure_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + 3, + payload["route_capacity_pressure_warning_samples"][0]["max_parallel_wires"], + ) + self.assertEqual( + 2, + payload["route_capacity_pressure_warning_samples"][0]["min_capacity"], + ) + self.assertEqual( + ["DuctA", "DuctB"], + payload["route_capacity_pressure_warning_samples"][0]["carrier_names"], + ) + + def test_compact_batch_report_capacity_pressure_includes_user_path_source_labels(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-crowded", + "wire_label": "N-CROWDED", + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "UserPath", + "name": "QETRoutePath_001", + "capacity": 1, + "source_label": "黄色主路径", + "source_path_index": "1", + } + } + ] + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + ["黄色主路径(路径1)"], + payload["route_capacity_pressure_warning_samples"][0]["route_source_labels"], + ) + + def test_compact_batch_report_includes_collision_kind_counts(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 2, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-hard", + "collision_samples": [ + {"collision_kind": "HardIntersection", "obstacle_label": "设备A"}, + ], + }, + { + "wire_uuid": "wire-clearance", + "collision_samples": [ + {"collision_kind": "ClearanceWarning", "obstacle_label": "设备B"}, + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["collision_kind_counts"]["HardIntersection"]) + self.assertEqual(1, payload["collision_kind_counts"]["ClearanceWarning"]) + + def test_compact_batch_report_includes_collision_relation_counts(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 2, + "skipped_missing_terminal": 0, + "selective_collision_reroute": True, + "selective_collision_reroute_limit": 5, + "selective_collision_reroute_attempts": 2, + "selective_collision_reroutes": 1, + "selective_collision_reroute_no_improvement": 1, + "selective_collision_reroute_rejected_fallback": 1, + "selective_collision_reroute_errors": 0, + "routes": [ + { + "collision_samples": [ + { + "collision_kind": "HardIntersection", + "collision_relation": "third_party_device_collision", + "obstacle_label": "设备A", + }, + ], + }, + { + "collision_samples": [ + { + "collision_kind": "ClearanceWarning", + "collision_relation": "endpoint_device_collision", + "obstacle_label": "设备B", + }, + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["collision_relation_counts"]["third_party_device_collision"]) + self.assertEqual(1, payload["collision_relation_counts"]["endpoint_device_collision"]) + self.assertEqual(2, payload["selective_collision_reroute_attempts"]) + self.assertEqual(1, payload["selective_collision_reroutes"]) + self.assertEqual(1, payload["selective_collision_reroute_no_improvement"]) + self.assertEqual(1, payload["selective_collision_reroute_rejected_fallback"]) + self.assertIn("third_party_device_collisions", payload["issue_codes"]) + self.assertIn("endpoint_device_collisions", payload["issue_codes"]) + self.assertIn("main_path_detour_missing", payload["issue_codes"]) + self.assertEqual( + "selective_local_reroute_or_user_path", + payload["collision_reroute_recommendation"]["strategy"], + ) + self.assertFalse(payload["collision_reroute_recommendation"]["global_avoid_obstacles_recommended"]) + self.assertIn("局部", payload["collision_reroute_recommendation"]["reason"]) + + def test_compact_batch_report_includes_top_collision_obstacles(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 3, + "routed": 3, + "collision_warnings": 3, + "skipped_missing_terminal": 0, + "routes": [ + { + "collision_samples": [ + { + "collision_kind": "HardIntersection", + "obstacle_name": "DeviceAObject", + "obstacle_label": "设备A", + "obstacle_parent_labels": ["安装板A"], + "obstacle_parent_names": ["MountPanelA"], + }, + { + "collision_kind": "ClearanceWarning", + "obstacle_name": "DeviceAObject", + "obstacle_label": "设备A", + "obstacle_parent_labels": ["安装板A"], + "obstacle_parent_names": ["MountPanelA"], + }, + ], + }, + { + "collision_samples": [ + { + "collision_kind": "HardIntersection", + "obstacle_name": "BracketBObject", + "obstacle_label": "支架B", + "obstacle_parent_labels": ["柜体总成"], + "obstacle_parent_names": ["CabinetAssembly"], + }, + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + [ + { + "label": "设备A", + "name": "DeviceAObject", + "count": 2, + "collision_kind_counts": { + "HardIntersection": 1, + "ClearanceWarning": 1, + }, + "parent_labels": ["安装板A"], + "parent_names": ["MountPanelA"], + "resolution_hint_code": "review_device_or_layout_collision", + "resolution_hint_label": "疑似设备/安装区域碰撞,优先补柜内路径或调整装配", + }, + { + "label": "支架B", + "name": "BracketBObject", + "count": 1, + "collision_kind_counts": {"HardIntersection": 1}, + "parent_labels": ["柜体总成"], + "parent_names": ["CabinetAssembly"], + "resolution_hint_code": "review_pass_through_structural_obstacle", + "resolution_hint_label": "疑似柜体/门板/支架结构,确认可穿越后标记忽略碰撞", + }, + ], + payload["top_collision_obstacles"], + ) + + def test_compact_batch_report_summarizes_collision_resolution_categories(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 2, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-device", + "collision_samples": [ + { + "obstacle_label": "ID:12", + "obstacle_name": "QETDevice_A", + "collision_kind": "HardIntersection", + "obstacle_parent_labels": ["QET Exchange Devices"], + } + ], + }, + { + "wire_uuid": "wire-structure", + "collision_samples": [ + { + "obstacle_label": "NAUO141", + "obstacle_name": "Compound039", + "collision_kind": "HardIntersection", + "obstacle_parent_labels": ["FRONT DOOR-R ASS'Y"], + "obstacle_parent_names": ["DoorAssembly"], + }, + { + "obstacle_label": "支架B", + "obstacle_name": "BracketB", + "collision_kind": "ClearanceWarning", + }, + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + { + "review_device_or_layout_collision": 1, + "review_pass_through_structural_obstacle": 2, + }, + payload["collision_resolution_summary"]["counts"], + ) + self.assertEqual( + "先处理 2 个疑似结构件碰撞候选:确认后可标记 PassThrough;另有 1 个疑似设备/装配碰撞需要补路径或调整装配。", + payload["collision_resolution_summary"]["recommended_action"], + ) + + def test_compact_batch_report_issue_codes_include_collision_resolution_categories(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 2, + "skipped_missing_terminal": 0, + "routes": [ + { + "collision_samples": [ + { + "obstacle_label": "ID:12", + "obstacle_name": "QETDevice_A", + "collision_kind": "HardIntersection", + } + ], + }, + { + "collision_samples": [ + { + "obstacle_label": "NAUO141", + "obstacle_name": "Compound039", + "collision_kind": "HardIntersection", + "obstacle_parent_labels": ["FRONT DOOR-R ASS'Y"], + } + ], + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertIn("collision_warnings", payload["issue_codes"]) + self.assertIn("device_or_layout_collisions", payload["issue_codes"]) + self.assertIn("structural_collision_candidates", payload["issue_codes"]) + self.assertIn("设备/布局碰撞", payload["issue_labels"]) + self.assertIn("结构件碰撞候选", payload["issue_labels"]) + + def test_compact_batch_report_issue_codes_include_missing_endpoint_reasons(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 3, + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 2, + "missing_endpoint_uuids": ["terminal-missing-a", "terminal-missing-b"], + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-missing-device", + "wire_label": "N-MISSING", + "start_found": False, + "start_terminal_uuid": "terminal-missing-a", + "start_element_uuid": "device-a", + "start_terminal_display": "A1", + "start_missing_endpoint_reason_code": "device_not_in_3d_scene", + "start_missing_endpoint_reason_label": "该 2D 设备未在 FreeCAD 场景中找到", + }, + { + "wire_uuid": "wire-mismatch", + "wire_label": "N-MISMATCH", + "end_found": False, + "end_terminal_uuid": "terminal-missing-b", + "end_element_uuid": "device-b", + "end_device_label": "设备B", + "end_terminal_display": "B1", + "end_missing_endpoint_reason_code": "terminal_uuid_not_in_element", + "end_missing_endpoint_reason_label": "同设备存在端子,但没有匹配该 terminal_uuid", + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertIn("missing_terminals", payload["issue_codes"]) + self.assertIn("device_not_in_3d_scene", payload["issue_codes"]) + self.assertIn("terminal_uuid_not_in_element", payload["issue_codes"]) + self.assertIn("3D场景缺少设备", payload["issue_labels"]) + self.assertIn("端子UUID不匹配", payload["issue_labels"]) + self.assertEqual( + { + "device_not_in_3d_scene": 1, + "terminal_uuid_not_in_element": 1, + }, + payload["missing_terminal_summary"]["reason_code_counts"], + ) + self.assertEqual(2, len(payload["missing_terminal_summary"]["device_groups"])) + self.assertEqual("device-a", payload["missing_terminal_summary"]["device_groups"][0]["element_uuid"]) + self.assertEqual("设备B", payload["missing_terminal_summary"]["device_groups"][1]["device_label"]) + + def test_routing_diagnostic_recommended_actions_use_collision_resolution_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + summary = { + "issue_codes": ["collision_warnings"], + "batch_collision_resolution_summary": { + "counts": { + "review_pass_through_structural_obstacle": 2, + "review_device_or_layout_collision": 1, + }, + "recommended_action": ( + "先处理 2 个疑似结构件碰撞候选:确认后可标记 PassThrough;" + "另有 1 个疑似设备/装配碰撞需要补路径或调整装配。" + ), + }, + "diagnostics": { + "RoutingConnectionBatch": { + "payload": {"collision_warnings": 3}, + } + }, + "routed_wire_issue_summary": {"issue_code_counts": {}}, + } + + actions = auto_routing._routing_diagnostic_recommended_actions(summary) + + self.assertIn("先处理 2 个疑似结构件碰撞候选:确认后可标记 PassThrough", actions) + self.assertIn("另有 1 个疑似设备/装配碰撞需要补路径或调整装配", actions) + + def test_compact_batch_report_includes_route_path_usage_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "label": "线槽A"}}, + ], + }, + }, + { + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "安装板辅助路径"}}, + ], + }, + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + {"main_path_routes": 1, "fallback_routes": 1}, + payload["route_path_usage"], + ) + + def test_compact_batch_report_flags_when_no_main_path_is_used(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "route_network_carrier_kind_counts": { + "WireDuct": 2, + "WireDuctOpenEnd": 4, + "RoutingRange": 10, + }, + "routes": [ + { + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "安装板辅助路径"}}, + ], + }, + }, + { + "route_track": { + "segments": [ + {"carrier": {"kind": "AuxiliaryPath", "label": "门板辅助路径"}}, + ], + }, + }, + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + {"main_path_routes": 0, "fallback_routes": 2}, + payload["route_path_usage"], + ) + self.assertEqual( + {"WireDuct": 2, "WireDuctOpenEnd": 4, "RoutingRange": 10}, + payload["route_network_carrier_kind_counts"], + ) + self.assertEqual(6, payload["route_network_main_path_carriers"]) + self.assertIn("main_path_not_used", payload["issue_codes"]) + self.assertIn("未使用线槽或用户主路径", payload["issue_labels"]) + self.assertIn( + "主路径未采用:当前有线槽/UserPath/过线孔路径 6 条,但本批次 2 条导线都走了布线面/辅助路径。", + auto_routing.format_eplan_connection_route_report(report), + ) + + def test_route_eplan_connections_report_includes_top_level_path_usage_summary(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual( + {"main_path_routes": 1, "fallback_routes": 0}, + report["route_path_usage"], + ) + self.assertEqual([], report["top_collision_obstacles"]) + + def test_route_eplan_connections_attaches_path_diagnostic_when_main_path_exists_but_unused(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", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(500, 0, 20), app.Vector(600, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + label="孤立线槽", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + original_diagnostic = routing_network.diagnose_routing_path_network + + def fake_diagnostic(*_args, **_kwargs): + return { + "ok": False, + "issues": [ + { + "severity": "warning", + "code": "wire_ducts_without_terminal_access", + "count": 1, + }, + ], + "summary": {"carriers": 2}, + "wire_ducts_without_terminal_access": [ + { + "index": 0, + "nodes": 2, + "segments": 1, + "carrier_kinds": {"WireDuct": 1}, + "carrier_names": ["孤立线槽"], + "bridge_suggestion": {"distance_mm": 42.0}, + }, + ], + } + + routing_network.diagnose_routing_path_network = fake_diagnostic + try: + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + finally: + routing_network.diagnose_routing_path_network = original_diagnostic + + self.assertIn("main_path_not_used", report["issue_codes"]) + diagnostic = report["routing_path_network_diagnostic"] + self.assertIn("wire_ducts_without_terminal_access", diagnostic["issue_codes"]) + self.assertEqual( + "孤立线槽", + diagnostic["wire_ducts_without_terminal_access"][0]["carrier_names"][0], + ) + + def test_route_eplan_connections_reports_total_connection_route_length(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertGreater(report["total_length_mm"], 0.0) + self.assertEqual(report["total_length_mm"], report["routes"][0]["length_mm"]) + self.assertIn("总长度", message) + + def test_route_eplan_connections_hides_route_carriers_after_routing_by_default(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)) + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + ) + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, report["hidden_route_carriers"]) + self.assertFalse(carrier.ViewObject.Visibility) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + diagnostics = [ + item + for item in list(getattr(diagnostic_group, "Group", []) or []) + if getattr(item, "QetDiagnosticKind", "") == "RoutingConnectionBatch" + ] + diagnostic_payload = json.loads(diagnostics[0].QetDiagnosticJson) + self.assertEqual(1, diagnostic_payload["hidden_route_carriers"]) + self.assertTrue(diagnostic_payload["routing_path_network_updated"] is False) + + def test_route_eplan_connections_ignores_global_payload_from_other_project(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-current") + _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-current", + kind="WireDuct", + ) + wiring_objects.create_wire_task( + doc, + "project-current", + "wire-current", + "CURRENT", + "terminal-start", + "terminal-end", + "", + "", + ) + app._qet_exchange_payload = { + "project_uuid": "project-old", + "wires": [ + { + "wire_id": "wire-old", + "wire_label": "OLD", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections(doc, update_network=False) + + self.assertEqual("project-current", report["project_uuid"]) + self.assertEqual(1, report["routed"]) + self.assertEqual("wire-current", report["routes"][0]["wire_uuid"]) + + def test_route_eplan_connections_batch_recomputes_once_after_created_wires(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0)) + 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_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + recompute_count = {"value": 0} + + def count_recompute(): + recompute_count["value"] += 1 + + doc.recompute = count_recompute + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(2, report["routed"]) + self.assertEqual(1, recompute_count["value"]) + + def test_route_eplan_connections_replaces_existing_routed_wires_for_same_batch(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-repeat", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + first = auto_routing.route_eplan_connections_from_payload(doc, payload) + second = auto_routing.route_eplan_connections_from_payload(doc, payload) + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + + self.assertEqual(1, first["routed"]) + self.assertEqual(1, second["routed"]) + self.assertEqual(1, second["replaced_routed_connections"]) + self.assertEqual(1, len(routed_wires)) + self.assertEqual("wire-repeat", routed_wires[0].QetWireUuid) + + def test_clear_routing_connections_resets_task_status_and_batch_diagnostics(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="WireDuct", + ) + task = wiring_objects.create_wire_task( + doc, + "project-1", + "wire-clear", + "N1", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + + report = auto_routing.route_eplan_connection_tasks(doc) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + + self.assertEqual(1, report["routed"]) + self.assertEqual("Routed", task.RouteStatus) + self.assertEqual(1, len(list(getattr(diagnostic_group, "Group", []) or []))) + + removed = auto_routing.clear_routing_connections(doc) + + self.assertEqual(1, removed) + self.assertEqual("Task", task.RouteStatus) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual([], list(getattr(diagnostic_group, "Group", []) or [])) + + def test_route_report_includes_route_source_sample_when_available(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + {"carrier": {"kind": "TerminalAccess", "source_label": "QF1:A1"}}, + {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, + {"carrier": {"kind": "WiringCutOut", "source_label": "过线孔A"}}, + {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 QF1:A1、线槽A、过线孔A。", message) + + def test_route_report_source_sample_falls_back_to_carrier_label(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "WireDuct", + "label": "手动线槽", + "name": "ManualDuct", + } + }, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 手动线槽。", message) + + def test_route_report_source_sample_skips_bridge_segments(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, + {"is_bridge": True, "carrier": {"kind": "WireDuct", "source_label": "虚拟桥接"}}, + {"carrier": {"kind": "UserPath", "source_label": "用户路径B"}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 线槽A、用户路径B。", message) + self.assertNotIn("虚拟桥接", message) + + def test_route_report_source_sample_includes_user_path_source_index(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "UserPath", + "source_label": "多路径草图", + "source_path_index": "2", + } + }, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 多路径草图(路径2)。", message) + + def test_route_report_source_sample_includes_user_path_source_index_one_when_present(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "UserPath", + "source_label": "用户路径A", + "source_path_index": "1", + } + }, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 用户路径A(路径1)。", message) + + def test_route_track_segment_keys_skip_bridge_segments(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + route_track = { + "segments": [ + { + "from_key": [0, 0, 0], + "to_key": [100, 0, 0], + "carrier": {"name": "WireDuctA"}, + }, + { + "is_bridge": True, + "from_key": [100, 0, 0], + "to_key": [100, 10, 0], + "carrier": {"name": "VirtualBridge"}, + }, + ] + } + + keys = auto_routing._route_track_segment_keys(route_track) + + self.assertEqual(1, len(keys)) + self.assertEqual("WireDuctA", keys[0][0]) + + def test_route_quality_warning_ignores_bridge_only_routing_range(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "carrier_kinds": {"RoutingRange": 1}, + "segments": [ + { + "is_bridge": True, + "carrier": {"kind": "RoutingRange", "source_label": "虚拟布线面桥接"}, + } + ], + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertNotIn("路径质量提示", message) + + def test_route_quality_warning_includes_specific_carrier_label(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "RoutingRange", + "label": "安装板辅助路径", + } + } + ], + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("示例 N4111 使用布线面:安装板辅助路径。", message) + + def test_route_report_includes_main_path_and_fallback_usage_counts(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N-WD", + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "label": "线槽A"}}, + ], + }, + }, + { + "wire_label": "N-RANGE", + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "安装板辅助路径"}}, + ], + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径采用:线槽/主路径 1 条,布线面/辅助路径 1 条。", message) + + def test_route_report_includes_network_bridge_and_blocked_segment_counts(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "network": { + "bridged_segments": 1, + "blocked_segments": 2, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径,避障屏蔽 2 段。", message) + + def test_route_report_prefers_route_track_bridged_segment_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "network": { + "bridged_segments": 3, + }, + "route_track": { + "bridged_segments": 1, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径。", message) + self.assertNotIn("自动桥接 3 段", message) + + def test_route_report_includes_parallel_lane_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + {"lane": {"index": 0, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": 0.0}}, + {"lane": {"index": 2, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": -10.0}}, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("并行错位:最大 lane 2,间距 10.0 mm,最大偏移 30.0 mm。", message) + + def test_eplan_connection_lane_offset_is_capped_for_dense_parallel_routes(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + route_index=21, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(30.0, result["lane"]["offset_mm"]) + self.assertLessEqual( + max(abs(point.y) for point in result["points"]), + 30.0, + ) + + def test_route_report_includes_replaced_routed_connection_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "replaced_routed_connections": 2, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("已替换旧布线连接:2 条。", message) + + def test_route_report_includes_hidden_route_carrier_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "hidden_route_carriers": 3, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("已隐藏走线路径辅助对象:3 条。", message) + + def test_route_report_warns_when_routes_use_surface_or_auxiliary_paths(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "route_track": { + "carrier_kinds": { + "TerminalAccess": 2, + "WireDuct": 1, + "RoutingRange": 2, + }, + }, + }, + { + "wire_label": "N2", + "route_track": { + "carrier_kinds": { + "TerminalAccess": 2, + "WireDuct": 3, + }, + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径质量提示:1 条导线使用布线面/辅助路径", message) + self.assertIn("示例 N1 使用布线面。", message) + + def test_route_report_warns_when_parallel_lanes_exceed_track_capacity(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "capacity": 2}}, + {"carrier": {"kind": "WireDuct", "capacity": 4}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("容量提示:最大并行线数 3,路径最小容量 2。", message) + + def test_route_report_capacity_pressure_includes_sample_wire(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N-CROWDED", + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "name": "DuctA", "capacity": 2}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("示例导线 N-CROWDED", message) + self.assertIn("DuctA", message) + + def test_route_report_capacity_pressure_prefers_user_path_source_label(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N-CROWDED", + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "UserPath", + "name": "QETRoutePath_001", + "capacity": 1, + "source_label": "黄色主路径", + "source_path_index": "1", + } + } + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径 黄色主路径(路径1)", message) + + def test_route_report_ignores_bridge_segments_for_capacity_pressure(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"is_bridge": True, "carrier": {"kind": "UserPath", "capacity": 1}}, + {"carrier": {"kind": "WireDuct", "capacity": 4}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertNotIn("容量提示", message) + + def test_route_report_includes_entry_candidate_rank_when_route_uses_fallback_entry(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "entry_candidate_rank": 3, + "exit_candidate_rank": 1, + "entry_candidate_score": 125.0, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("接入候选", message) + self.assertIn("起点第 3 个", message) + + def test_route_report_includes_missing_route_retry_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 75, + "routed": 71, + "collision_warnings": 0, + "skipped_missing_terminal": 4, + "missing_route_retries": 12, + "missing_route_retry_candidate_limit": 8, + "routes": [], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("候选放宽重试:12 条导线", message) + self.assertIn("候选上限 8", message) + + def test_route_report_warns_when_network_entry_distance_is_long(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "terminal_access_warning_distance": 100.0, + "routes": [ + { + "wire_label": "N1", + "network": { + "entry_distance": 125.0, + "exit_distance": 20.0, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "label": "主线槽A"}}, + ], + }, + }, + { + "wire_label": "N2", + "network": { + "entry_distance": 20.0, + "exit_distance": 150.0, + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("接入距离提示:2 条导线", message) + self.assertIn("示例导线 N1", message) + self.assertIn("起点接入 125.0 mm", message) + self.assertIn("路径 主线槽A", message) + + def test_route_report_warns_when_candidate_route_still_hits_obstacles(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "route_candidate_obstacle_hits": 2, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "UserPath", "label": "绕行路径A"}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("接入避障提示:1 条导线候选路径仍穿过障碍", message) + self.assertIn("示例导线 N1 2 处", message) + self.assertIn("路径 绕行路径A", message) + + def test_route_report_warns_when_candidate_route_still_leaves_cabinet_boundary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "boundary_aware": True, + "route_candidate_boundary_violations": 3, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "UserPath", "label": "柜内主路径A"}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("柜内边界提示:1 条导线最终路径仍越出柜内区域", message) + self.assertIn("示例导线 N1 3 个越界点", message) + self.assertIn("路径 柜内主路径A", message) + + def test_route_report_includes_route_constraint_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "route_constraints": { + "required": {"labels": ["必经路径"]}, + "forbidden": {"labels": ["禁止路径"]}, + }, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径约束提示:1 条导线应用必经/禁经规则", message) + self.assertIn("示例导线 N1", message) + self.assertIn("必须经过 必经路径", message) + self.assertIn("禁止经过 禁止路径", message) + + def test_route_report_prefers_constraint_source_label_over_internal_source_name(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "route_constraints": { + "required": { + "source_names": ["YellowMainRouteSketch"], + "source_labels": ["黄色主路径"], + }, + }, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("必须经过 源标签 黄色主路径", message) + self.assertNotIn("YellowMainRouteSketch", message) + + def test_compact_batch_report_does_not_treat_route_constraints_as_issue(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 1, + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_uuid": "wire-required", + "network": { + "route_constraints": { + "required": { + "source_labels": ["黄色主路径"], + }, + }, + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertNotIn("route_constraints", payload["issue_codes"]) + self.assertEqual(1, payload["route_constraint_warning_count"]) + + def test_route_report_capacity_pressure_is_checked_per_route(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "lane": {"index": 2, "spacing_mm": 10.0}, "route_track": { "segments": [ - {"carrier": {"kind": "TerminalAccess", "source_label": "QF1:A1"}}, - {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, - {"carrier": {"kind": "WiringCutOut", "source_label": "过线孔A"}}, - {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, + {"carrier": {"kind": "WireDuct", "capacity": 4}}, ] }, + }, + { + "lane": {"index": 0, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "capacity": 1}}, + ] + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertNotIn("容量提示", message) + + def test_route_eplan_connections_report_keeps_route_identity_and_diagnostics(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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "wire_style_id": "42", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 12.0, "lane_axis": "y"}, + ) + route = report["routes"][0] + + self.assertEqual("wire-1", route["wire_uuid"]) + self.assertEqual("N4111", route["wire_label"]) + self.assertEqual("42", route["wire_style_id"]) + self.assertEqual("terminal-start", route["start_terminal_uuid"]) + self.assertEqual("terminal-end", route["end_terminal_uuid"]) + self.assertEqual(0, route["lane"]["index"]) + self.assertEqual("network-dijkstra-v1", route["algorithm"]) + self.assertEqual(1, route["network"]["carriers"]) + self.assertEqual("WireDuct", route["route_track"]["segments"][0]["carrier"]["kind"]) + + def test_route_eplan_connections_can_skip_nearer_isolated_entry_network(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, 1, 20), app.Vector(5, 1, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(1, report["routed"]) + self.assertEqual(0, len(report["errors"])) + route = report["routes"][0] + self.assertEqual("network-dijkstra-v1", route["algorithm"]) + self.assertGreater(route["network"]["entry_distance"], 1.0) + self.assertGreater(route["network"]["entry_candidate_rank"], 1) + + 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_path_network_diagnostic_uses_terminal_access_warning_distance(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(1000, 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, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-long-access", + "wire_label": "N-LONG", + "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, + "terminal_access_max_distance": 1000.0, + "terminal_access_warning_distance": 950.0, + }, + project_uuid="project-1", + update_network=False, + ) + + self.assertNotIn( + "long_terminal_accesses", + report["routing_path_network_diagnostic"]["issue_codes"], + ) + + def test_route_eplan_connections_path_network_diagnostic_keeps_long_access_samples(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") + device = doc.addObject("App::Part", "DevicePEN") + device.Label = "PEN" + device.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + device.addObject(start) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(1000, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 20), app.Vector(1100, 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, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-long-access", + "wire_label": "N-LONG", + "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, + "terminal_access_max_distance": 1000.0, + }, + project_uuid="project-1", + update_network=False, + ) + + diagnostic = report["routing_path_network_diagnostic"] + self.assertIn("long_terminal_accesses", diagnostic["issue_codes"]) + self.assertEqual(1, len(diagnostic["long_terminal_accesses"])) + sample = diagnostic["long_terminal_accesses"][0] + self.assertEqual("terminal-start", sample["terminal_uuid"]) + self.assertEqual("PEN", sample["parent_device_label"]) + self.assertEqual("x", sample["terminal_access_dominant_axis"]) + self.assertEqual(2, len(sample["terminal_access_points"])) + + def test_route_report_includes_outside_boundary_path_network_sample(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)) + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(0, 120, -20, 20, 0, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20), app.Vector(140, 0, 20)], + label="柜内主路径A", + project_uuid="project-1", + kind="UserPath", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-outside-source", + "wire_label": "N-OUT", + "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", + update_network=False, + ) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn( + "route_carriers_outside_boundary", + report["routing_path_network_diagnostic"]["issue_codes"], + ) + self.assertEqual( + "柜内主路径A", + report["routing_path_network_diagnostic"]["route_carriers_outside_boundary"][0]["carrier"]["label"], + ) + self.assertIn("路径网络检查提示:路径越出柜内边界", message) + self.assertIn("越界路径:柜内主路径A 1 个越界点", message) + + def test_route_report_includes_outside_boundary_terminal_sample(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, "TerminalInside", "terminal-inside", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalOutside", "terminal-outside", app.Vector(140, 0, 0)) + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(-20, 120, -20, 20, -10, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="柜内主路径A", + project_uuid="project-1", + kind="UserPath", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-outside-terminal", + "wire_label": "N-OUT-TERM", + "start_terminal_uuid": "terminal-inside", + "end_terminal_uuid": "terminal-outside", + } + ], + } + + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + options={"hide_route_carriers_after_route": False}, + project_uuid="project-1", + update_network=False, + ) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn( + "terminals_outside_boundary", + report["routing_path_network_diagnostic"]["issue_codes"], + ) + self.assertEqual( + "terminal-outside", + report["routing_path_network_diagnostic"]["terminals_outside_boundary"][0]["terminal_uuid"], + ) + self.assertIn("路径网络检查提示:端子未接入、端子越出柜内边界", message) + self.assertIn("越界端子:TerminalOutside(terminal-outside) 2 个越界点", 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() + 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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_element_uuid": "device-a", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "A1", + "end_element_uuid": "device-b", + "end_terminal_uuid": "terminal-end", + "end_terminal_display": "B1", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + routed_group = doc.getObject("QETWiring_04_Routed") + wire = routed_group.Group[0] + diagnostics = json.loads(wire.QetRouteDiagnosticsJson) + + self.assertEqual("device-a", wire.QetStartElementUuid) + self.assertEqual("A1", wire.QetStartTerminalDisplay) + self.assertEqual("device-b", wire.QetEndElementUuid) + self.assertEqual("B1", wire.QetEndTerminalDisplay) + self.assertEqual("device-a", report["routes"][0]["start_element_uuid"]) + self.assertEqual("B1", report["routes"][0]["end_terminal_display"]) + self.assertEqual("A1", diagnostics["endpoint_metadata"]["start_terminal_display"]) + + def test_route_eplan_connection_tasks_preserve_task_endpoint_labels_on_routed_wire(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="WireDuct", + ) + task = wiring_objects.create_wire_task( + doc, + "project-1", + "wire-1", + "N4111", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + terminal_objects.ensure_string_property(task, "QetStartDeviceLabel", "QET Wiring", "", "QF1") + terminal_objects.ensure_string_property(task, "QetEndDeviceLabel", "QET Wiring", "", "X1") + terminal_objects.ensure_string_property(task, "QetEndpointLabel", "QET Wiring", "", "QF1:A1 -> X1:B1") + + report = auto_routing.route_eplan_connection_tasks(doc) + routed_group = doc.getObject("QETWiring_04_Routed") + wire = routed_group.Group[0] + diagnostics = json.loads(wire.QetRouteDiagnosticsJson) + + self.assertEqual("QF1", wire.QetStartDeviceLabel) + self.assertEqual("X1", wire.QetEndDeviceLabel) + self.assertEqual("QF1:A1 -> X1:B1", wire.QetEndpointLabel) + self.assertEqual("QF1:A1 -> X1:B1", report["routes"][0]["endpoint_label"]) + self.assertEqual("QF1", diagnostics["endpoint_metadata"]["start_device_label"]) + + def test_route_eplan_connections_records_wire_identity_for_errors(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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-bad", + "wire_label": "N500", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-start", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(1, len(report["errors"])) + self.assertIn("error_samples", report) + self.assertEqual("wire-bad", report["error_samples"][0]["wire_uuid"]) + self.assertEqual("N500", report["error_samples"][0]["wire_label"]) + self.assertEqual("N500", report["error_samples"][0]["wire_object_label"]) + self.assertEqual("terminal-start", report["error_samples"][0]["start_terminal_uuid"]) + self.assertEqual("terminal-start", report["error_samples"][0]["end_terminal_uuid"]) + self.assertIn("different", report["error_samples"][0]["error"]) + + def test_route_eplan_connections_report_includes_readable_error_sample(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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-bad", + "wire_label": "N500", + "start_element_uuid": "device-a", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "A1", + "end_element_uuid": "device-a", + "end_terminal_uuid": "terminal-start", + "end_terminal_display": "A1", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual("device-a", report["error_samples"][0]["start_element_uuid"]) + self.assertIn("错误示例:导线 N500", message) + self.assertIn("device-a/A1 (terminal-start) -> device-a/A1 (terminal-start)", message) + + def test_route_eplan_connections_counts_route_statuses_for_summary(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, "RouteStart", "route-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "RouteEnd", "route-end", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "CollisionStart", "collision-start", app.Vector(0, 100, 0)) + _terminal(doc, terminal_objects, "CollisionEnd", "collision-end", app.Vector(100, 100, 0)) + 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_route_carrier( + doc, + [app.Vector(0, 100, 100), app.Vector(100, 100, 100)], + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "CollisionObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, 90, 110, 90, 110)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-ok", + "start_terminal_uuid": "route-start", + "end_terminal_uuid": "route-end", + }, + { + "wire_id": "wire-collision", + "start_terminal_uuid": "collision-start", + "end_terminal_uuid": "collision-end", + }, + { + "wire_id": "wire-error", + "start_terminal_uuid": "route-start", + "end_terminal_uuid": "route-start", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"avoid_obstacles": False, "avoid_local_access_obstacles": False}, + ) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(1, report["route_status_counts"]["Routed"]) + self.assertEqual(1, report["route_status_counts"]["CollisionWarning"]) + self.assertEqual(1, report["route_status_counts"]["Error"]) + self.assertIn("结果状态", message) + self.assertIn("正常 1 条", message) + self.assertIn("碰撞告警 1 条", message) + self.assertIn("错误 1 条", message) + + def test_route_eplan_connections_lane_index_is_per_terminal_pair(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 100, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 100, 0)) + 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_route_carrier( + doc, + [app.Vector(0, 100, 20), app.Vector(100, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + { + "wire_id": "wire-a-repeat", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(0, report["routes"][0]["lane"]["index"]) + self.assertEqual(0, report["routes"][1]["lane"]["index"]) + self.assertEqual(1, report["routes"][2]["lane"]["index"]) + + def test_route_eplan_connections_lane_index_increments_for_shared_route_segments(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", 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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(0, report["routes"][0]["lane"]["index"]) + self.assertEqual(1, report["routes"][1]["lane"]["index"]) + routed_group = doc.getObject("QETWiring_04_Routed") + self.assertEqual(2, len(list(getattr(routed_group, "Group", []) or []))) + second_wire = [ + wire + for wire in list(getattr(routed_group, "Group", []) or []) + if getattr(wire, "QetWireUuid", "") == "wire-b" + ][0] + self.assertEqual("1", second_wire.QetRouteLaneIndex) + self.assertEqual("y", second_wire.QetRouteLaneAxis) + self.assertEqual("10.000", second_wire.QetRouteLaneOffsetMm) + self.assertEqual("CapacityWarning", second_wire.QetRouteCapacityStatus) + self.assertEqual("2", second_wire.QetRouteParallelWireCount) + self.assertEqual("1", second_wire.QetRouteMinCarrierCapacity) + self.assertTrue(any(abs(point.y - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) + + def test_route_eplan_connections_lane_index_accounts_for_existing_routed_segments(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") + start = _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", 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="WireDuct", + ) + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="existing-wire", + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "new-wire", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(1, report["routes"][0]["lane"]["index"]) + routed_group = doc.getObject("QETWiring_04_Routed") + new_wire = [ + wire + for wire in list(getattr(routed_group, "Group", []) or []) + if getattr(wire, "QetWireUuid", "") == "new-wire" + ][0] + self.assertEqual("1", new_wire.QetRouteLaneIndex) + self.assertTrue(any(abs(point.y - 10.0) <= 0.001 for point in new_wire.Points[1:-1])) + + def test_route_eplan_connections_auto_lane_axis_offsets_perpendicular_to_shared_segment(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0}, + ) + + self.assertEqual(1, report["routes"][1]["lane"]["index"]) + self.assertEqual("x", report["routes"][1]["lane"]["axis"]) + routed_group = doc.getObject("QETWiring_04_Routed") + second_wire = [ + wire + for wire in list(getattr(routed_group, "Group", []) or []) + if getattr(wire, "QetWireUuid", "") == "wire-b" + ][0] + self.assertTrue(any(abs(point.x - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) + self.assertFalse(all(abs(point.x) <= 0.001 for point in second_wire.Points[1:-1])) + + def test_route_eplan_connections_prefers_unused_alternate_route_segments(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Direct Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], + label="Left Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Alternate Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], + label="Right Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + first_labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + second_labels = [ + segment["carrier"]["label"] + for segment in report["routes"][1]["route_track"]["segments"] + ] + self.assertIn("Direct Duct", first_labels) + self.assertIn("Alternate Duct", second_labels) + self.assertNotIn("Direct Duct", second_labels) + + def test_route_eplan_connections_respects_route_segment_capacity_before_detouring(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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartC", "terminal-start-c", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndC", "terminal-end-c", app.Vector(100, 0, 0)) + direct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Direct Duct", + project_uuid="project-1", + kind="WireDuct", + ) + direct.QetRouteCarrierCapacity = 2 + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], + label="Left Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Alternate Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], + label="Right Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + { + "wire_id": "wire-c", + "start_terminal_uuid": "terminal-start-c", + "end_terminal_uuid": "terminal-end-c", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + route_labels = [ + [segment["carrier"]["label"] for segment in route["route_track"]["segments"]] + for route in report["routes"] + ] + self.assertIn("Direct Duct", route_labels[0]) + self.assertIn("Direct Duct", route_labels[1]) + self.assertIn("Alternate Duct", route_labels[2]) + self.assertNotIn("Direct Duct", route_labels[2]) + + def test_route_eplan_connections_prefers_unused_segments_occupied_by_existing_wires(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _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)], + label="Direct Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], + label="Left Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Alternate Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], + label="Right Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="existing-wire", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "new-wire", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + route_labels = [ + segment["carrier"]["label"] + for segment in report["routes"][0]["route_track"]["segments"] + ] + self.assertIn("Alternate Duct", route_labels) + self.assertNotIn("Direct Duct", route_labels) + + def test_route_eplan_connections_report_includes_collision_samples(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, 100), app.Vector(100, 0, 100)], + project_uuid="project-1", + ) + obstacle = doc.addObject("Part::Feature", "MiddleObstacle") + obstacle.Label = "Middle Obstacle" + terminal_objects.ensure_string_property( + obstacle, + "QetElementUuid", + "QET Exchange", + "", + "device-obstacle", + ) + terminal_objects.ensure_string_property( + obstacle, + "QetInstanceId", + "QET Exchange", + "", + "instance-obstacle", + ) + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "wire_style_id": "style-1", + "start_element_uuid": "device-start", + "start_terminal_uuid": "terminal-start", + "end_element_uuid": "device-end", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual(1, report["collision_warnings"]) + self.assertEqual("wire-1", report["collision_samples"][0]["wire_uuid"]) + self.assertEqual("N4111", report["collision_samples"][0]["wire_label"]) + self.assertEqual("device-start", report["collision_samples"][0]["start_element_uuid"]) + self.assertEqual("device-end", report["collision_samples"][0]["end_element_uuid"]) + self.assertEqual( + "N4111: terminal-start -> terminal-end (CollisionWarning)", + report["collision_samples"][0]["wire_object_label"], + ) + self.assertEqual("MiddleObstacle", report["collision_samples"][0]["obstacle_name"]) + self.assertEqual( + "device-obstacle", + report["collision_samples"][0]["obstacle_element_uuid"], + ) + self.assertEqual( + "instance-obstacle", + report["collision_samples"][0]["obstacle_instance_id"], + ) + self.assertEqual( + "third_party_device_collision", + report["collision_samples"][0]["collision_relation"], + ) + self.assertEqual("HardIntersection", report["collision_samples"][0]["collision_kind"]) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 100.0}, report["collision_samples"][0]["segment_start"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 100.0}, report["collision_samples"][0]["segment_end"]) + self.assertEqual(40.0, report["collision_samples"][0]["obstacle_bbox"]["xmin"]) + self.assertEqual(35.0, report["collision_samples"][0]["collision_bbox"]["xmin"]) + self.assertEqual("Middle Obstacle", report["routes"][0]["collision_samples"][0]["obstacle_label"]) + self.assertEqual( + "device-obstacle", + report["routes"][0]["collision_samples"][0]["obstacle_element_uuid"], + ) + self.assertEqual( + "instance-obstacle", + report["routes"][0]["collision_samples"][0]["obstacle_instance_id"], + ) + self.assertEqual( + "N4111: terminal-start -> terminal-end (CollisionWarning)", + report["routes"][0]["collision_samples"][0]["wire_object_label"], + ) + self.assertEqual( + "device-start", + report["routes"][0]["collision_samples"][0]["start_element_uuid"], + ) + self.assertEqual( + "device-end", + report["routes"][0]["collision_samples"][0]["end_element_uuid"], + ) + self.assertEqual( + "third_party_device_collision", + report["routes"][0]["collision_samples"][0]["collision_relation"], + ) + self.assertEqual(["QET Route Carrier"], report["collision_samples"][0]["route_source_labels"]) + self.assertIn("碰撞示例", message) + self.assertIn("路径 QET Route Carrier", message) + self.assertIn("Middle Obstacle", message) + + def test_route_eplan_connections_report_calls_out_local_unbound_terminals(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, + "LocalTerminal", + "local:instance-1:p1", + app.Vector(0, 0, 0), + ) + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "qet-terminal-start", + "end_terminal_uuid": "qet-terminal-end", } - ], + ] } + report = auto_routing.route_eplan_connections_from_payload(doc, payload) message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径示例:导线 N4111 经过 QF1:A1、线槽A、过线孔A。", message) + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["available_terminals"]) + self.assertEqual(1, report["local_terminals"]) + self.assertIn("端子匹配失败", message) + self.assertIn("local:", message) - def test_route_report_source_sample_skips_bridge_segments(self): + def test_route_eplan_connections_report_includes_network_and_first_error(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { + "total_wires": 2, "routed": 1, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ + "collision_warnings": 1, + "skipped_missing_terminal": 1, + "prepared_layout": { + "wire_duct_carriers": 2, + "surface_carriers": 4, + "terminal_access_carriers": 6, + }, + "missing_endpoint_samples": [ { - "wire_label": "N4111", - "route_track": { - "segments": [ - {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, - {"is_bridge": True, "carrier": {"kind": "WireDuct", "source_label": "虚拟桥接"}}, - {"carrier": {"kind": "UserPath", "source_label": "用户路径B"}}, - ] - }, + "start_terminal_uuid": "terminal-a", + "end_terminal_uuid": "terminal-b", } ], + "errors": ["没有可用的线槽/路由路径网络"], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径示例:导线 N4111 经过 线槽A、用户路径B。", message) - self.assertNotIn("虚拟桥接", message) + self.assertIn("routed=1", message) + self.assertIn("线槽路径 2 条", message) + self.assertIn("首个错误:没有可用的线槽/路由路径网络", message) + self.assertIn("缺失示例:terminal-a -> terminal-b", message) - def test_route_track_segment_keys_skip_bridge_segments(self): + def test_route_eplan_connections_report_includes_current_route_network_counts(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - route_track = { - "segments": [ - { - "from_key": [0, 0, 0], - "to_key": [100, 0, 0], - "carrier": {"name": "WireDuctA"}, - }, - { - "is_bridge": True, - "from_key": [100, 0, 0], - "to_key": [100, 10, 0], - "carrier": {"name": "VirtualBridge"}, - }, - ] + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "prepared_layout": { + "wire_duct_carriers": 0, + "surface_carriers": 0, + "terminal_access_carriers": 4, + }, + "route_network_carrier_kind_counts": { + "WireDuct": 3, + "UserPath": 2, + "TerminalAccess": 4, + }, } - keys = auto_routing._route_track_segment_keys(route_track) + message = auto_routing.format_eplan_connection_route_report(report) - self.assertEqual(1, len(keys)) - self.assertEqual("WireDuctA", keys[0][0]) + self.assertIn("布线布局空间:线槽路径 0 条,布线面 0 条,端子接入 4 条。", message) + self.assertIn("当前路径网络:线槽路径 3 条,用户路径 2 条,端子接入 4 条。", message) - def test_route_quality_warning_ignores_bridge_only_routing_range(self): + def test_route_eplan_connections_report_calls_out_missing_device_when_some_routes_succeed(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, + "total_wires": 3, + "routed": 2, "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ { - "wire_label": "N4111", - "route_track": { - "carrier_kinds": {"RoutingRange": 1}, - "segments": [ - { - "is_bridge": True, - "carrier": {"kind": "RoutingRange", "source_label": "虚拟布线面桥接"}, - } - ], - }, + "wire_uuid": "wire-missing-device", + "wire_label": "F6", + "start_found": False, + "start_terminal_uuid": "device-a:terminal-as", + "start_element_uuid": "device-a", + "start_terminal_display": "as", + "start_missing_endpoint_reason_code": "device_not_in_3d_scene", + "start_missing_endpoint_reason_label": "该 2D 设备未在 FreeCAD 场景中找到", + "end_found": True, } ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertNotIn("路径质量提示", message) + self.assertIn("routed=2", message) + self.assertIn("设备未在当前 FreeCAD 场景中找到", message) + self.assertIn("缺失示例:导线 F6", message) - def test_route_report_includes_network_bridge_and_blocked_segment_counts(self): + def test_route_eplan_connections_report_tolerates_malformed_total_wires(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, + "total_wires": "not-a-number", + "routed": 0, "collision_warnings": 0, "skipped_missing_terminal": 0, - "routes": [ - { - "network": { - "bridged_segments": 1, - "blocked_segments": 2, - }, - } - ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径,避障屏蔽 2 段。", message) + self.assertIn("批量生成布线连接完成", message) + self.assertIn("没有导线任务", message) - def test_route_report_prefers_route_track_bridged_segment_count(self): + def test_route_eplan_connections_report_calls_out_missing_route_network(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, + "total_wires": 3, + "routed": 0, "collision_warnings": 0, "skipped_missing_terminal": 0, - "routes": [ - { - "network": { - "bridged_segments": 3, - }, - "route_track": { - "bridged_segments": 1, - }, - } - ], + "skipped_missing_route_network": 3, + "route_status_counts": { + "MissingRouteNetwork": 3, + }, } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径。", message) - self.assertNotIn("自动桥接 3 段", message) + self.assertIn("缺少布线路径网络 3 条", message) + self.assertIn("缺少或未连通布线路径网络", message) + self.assertIn("是否已生成 carrier", message) + self.assertIn("路径约束是否过严", message) - def test_route_report_includes_parallel_lane_summary(self): + def test_route_eplan_connections_report_calls_out_zero_routed_after_attempt(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 2, + "total_wires": 75, + "routed": 0, "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ - {"lane": {"index": 0, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": 0.0}}, - {"lane": {"index": 2, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": -10.0}}, - ], + "skipped_missing_terminal": 4, + "route_network_segments": 1828, + "route_network_carriers": 477, + "route_network_nodes": 706, + "route_status_counts": { + "Error": 71, + "MissingTerminal": 4, + }, + "errors": ["module 'RoutingNetwork' has no attribute 'collect_route_constraint_options'"], } + issue_codes = auto_routing._routing_connection_batch_issue_codes(report) + payload = auto_routing._compact_routing_connection_batch_report(report) message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("并行错位:最大 lane 2,间距 10.0 mm,最大偏移 30.0 mm。", message) + self.assertIn("no_routed_connections", issue_codes) + self.assertIn("no_routed_connections", payload["issue_codes"]) + self.assertIn("未生成有效导线", message) + self.assertIn("本次只有路径承载/诊断对象,未生成 RoutedConnection 导线", message) - def test_eplan_connection_lane_offset_is_capped_for_dense_parallel_routes(self): + def test_route_eplan_connections_report_treats_error_status_count_as_routing_error(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") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _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="WireDuct", - ) + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 75, + "routed": 0, + "collision_warnings": 0, + "skipped_missing_terminal": 4, + "route_status_counts": { + "Error": 71, + "MissingTerminal": 4, + }, + } - result = auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - route_index=21, - options={"lane_spacing": 10.0, "lane_axis": "y"}, - ) + issue_codes = auto_routing._routing_connection_batch_issue_codes(report) + payload = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) - self.assertEqual(30.0, result["lane"]["offset_mm"]) - self.assertLessEqual( - max(abs(point.y) for point in result["points"]), - 30.0, - ) + self.assertIn("routing_errors", issue_codes) + self.assertIn("routing_errors", payload["issue_codes"]) + self.assertIn("错误 71 条", message) - def test_route_report_includes_replaced_routed_connection_count(self): + def test_route_eplan_connections_report_includes_missing_route_network_sample(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, + "total_wires": 1, + "routed": 0, "collision_warnings": 0, "skipped_missing_terminal": 0, - "replaced_routed_connections": 2, + "skipped_missing_route_network": 1, + "missing_route_network_samples": [ + { + "wire_uuid": "wire-1", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "start_element_uuid": "QF1", + "start_terminal_display": "A1", + "end_terminal_uuid": "terminal-end", + "end_element_uuid": "KM1", + "end_terminal_display": "13", + "error": "没有可用的布线路径网络:起点和终点无法连通", + } + ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("已替换旧布线连接:2 条。", message) + self.assertIn("缺路径网络示例:导线 N4111", message) + self.assertIn("QF1/A1 (terminal-start) -> KM1/13 (terminal-end)", message) + self.assertIn("原因:没有可用的布线路径网络:起点和终点无法连通", message) - def test_route_report_includes_hidden_route_carrier_count(self): + def test_route_eplan_connections_report_includes_readable_missing_endpoint_labels(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, + "routed": 0, "collision_warnings": 0, - "skipped_missing_terminal": 0, - "hidden_route_carriers": 3, + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ + { + "start_terminal_uuid": "terminal-a", + "start_element_uuid": "device-a", + "start_terminal_display": "A1", + "end_terminal_uuid": "terminal-b", + "end_element_uuid": "device-b", + "end_terminal_display": "B1", + } + ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("已隐藏走线路径辅助对象:3 条。", message) + self.assertIn("缺失示例:device-a/A1 (terminal-a) -> device-b/B1 (terminal-b)", message) - def test_route_report_warns_when_routes_use_surface_or_auxiliary_paths(self): + def test_route_eplan_connections_report_includes_wire_object_label_for_missing_endpoint(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 2, + "routed": 0, "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ - { - "wire_label": "N1", - "route_track": { - "carrier_kinds": { - "TerminalAccess": 2, - "WireDuct": 1, - "RoutingRange": 2, - }, - }, - }, + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ { - "wire_label": "N2", - "route_track": { - "carrier_kinds": { - "TerminalAccess": 2, - "WireDuct": 3, - }, - }, - }, + "wire_uuid": "wire-missing", + "wire_label": "N-MISS", + "wire_object_label": "N-MISS: QF1/A1 -> KM1/13 (MissingTerminal)", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + } ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径质量提示:1 条导线使用布线面/辅助路径", message) - self.assertIn("示例 N1 使用布线面。", message) + self.assertIn( + "缺失示例:导线 N-MISS: QF1/A1 -> KM1/13 (MissingTerminal),terminal-start -> terminal-missing", + message, + ) - def test_route_report_warns_when_parallel_lanes_exceed_track_capacity(self): + def test_route_eplan_connections_report_identifies_which_endpoint_is_missing(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 3, + "routed": 0, "collision_warnings": 0, - "skipped_missing_terminal": 0, - "routes": [ + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ { - "lane": {"index": 2, "spacing_mm": 10.0}, - "route_track": { - "segments": [ - {"carrier": {"kind": "WireDuct", "capacity": 2}}, - {"carrier": {"kind": "WireDuct", "capacity": 4}}, - ] - }, + "start_terminal_uuid": "terminal-start", + "start_found": True, + "end_terminal_uuid": "terminal-missing", + "end_found": False, } ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("容量提示:最大并行线数 3,路径最小容量 2。", message) + self.assertIn("缺失示例:terminal-start -> terminal-missing", message) + self.assertIn("缺失:终点", message) - def test_route_report_ignores_bridge_segments_for_capacity_pressure(self): + def test_route_eplan_connections_report_uses_wire_object_label_for_collision_sample(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 3, - "collision_warnings": 0, + "routed": 1, + "collision_warnings": 1, "skipped_missing_terminal": 0, - "routes": [ + "collision_samples": [ { - "lane": {"index": 2, "spacing_mm": 10.0}, - "route_track": { - "segments": [ - {"is_bridge": True, "carrier": {"kind": "UserPath", "capacity": 1}}, - {"carrier": {"kind": "WireDuct", "capacity": 4}}, - ] - }, + "wire_uuid": "wire-collision", + "wire_label": "N-COL", + "wire_object_label": "N-COL: QF1/A1 -> KM1/13 (CollisionWarning)", + "obstacle_label": "柜体侧板", + "collision_kind": "HardIntersection", } ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertNotIn("容量提示", message) + self.assertIn( + "碰撞示例:导线 N-COL: QF1/A1 -> KM1/13 (CollisionWarning) 碰到 柜体侧板", + message, + ) - def test_route_report_includes_entry_candidate_rank_when_route_uses_fallback_entry(self): + def test_route_eplan_connections_report_calls_out_clearance_collision_kind(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { "routed": 1, - "collision_warnings": 0, + "collision_warnings": 1, "skipped_missing_terminal": 0, - "routes": [ + "collision_samples": [ { - "wire_label": "N1", - "network": { - "entry_candidate_rank": 3, - "exit_candidate_rank": 1, - "entry_candidate_score": 125.0, - }, + "wire_label": "N4111", + "obstacle_label": "柜体侧板", + "collision_kind": "ClearanceWarning", } ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("接入候选", message) - self.assertIn("起点第 3 个", message) + self.assertIn("安全间隙", message) + self.assertIn("柜体侧板", message) - def test_route_report_warns_when_network_entry_distance_is_long(self): + def test_route_report_includes_collision_kind_summary(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 1, - "collision_warnings": 0, + "routed": 2, + "collision_warnings": 2, "skipped_missing_terminal": 0, - "terminal_access_warning_distance": 100.0, "routes": [ { - "wire_label": "N1", - "network": { - "entry_distance": 125.0, - "exit_distance": 20.0, - }, + "collision_samples": [ + {"collision_kind": "HardIntersection", "obstacle_label": "设备A"}, + ], }, { - "wire_label": "N2", - "network": { - "entry_distance": 20.0, - "exit_distance": 150.0, - }, + "collision_samples": [ + {"collision_kind": "ClearanceWarning", "obstacle_label": "设备B"}, + ], }, ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("接入距离提示:2 条导线", message) - self.assertIn("示例导线 N1", message) - self.assertIn("起点接入 125.0 mm", message) + self.assertIn("碰撞分类:硬碰撞 1 处,安全间隙 1 处。", message) - def test_route_report_capacity_pressure_is_checked_per_route(self): + def test_route_report_includes_top_collision_obstacles(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() report = { - "routed": 2, - "collision_warnings": 0, + "routed": 3, + "collision_warnings": 3, "skipped_missing_terminal": 0, "routes": [ { - "lane": {"index": 2, "spacing_mm": 10.0}, - "route_track": { - "segments": [ - {"carrier": {"kind": "WireDuct", "capacity": 4}}, - ] - }, + "collision_samples": [ + { + "collision_kind": "HardIntersection", + "obstacle_label": "设备A", + "obstacle_name": "DeviceA", + "obstacle_element_uuid": "device-a", + "obstacle_instance_id": "instance-a", + "collision_relation": "third_party_device_collision", + "obstacle_parent_labels": ["安装板A"], + }, + { + "collision_kind": "ClearanceWarning", + "obstacle_label": "设备A", + "obstacle_name": "DeviceA", + "obstacle_element_uuid": "device-a", + "obstacle_instance_id": "instance-a", + "collision_relation": "third_party_device_collision", + "obstacle_parent_labels": ["安装板A"], + }, + ], }, { - "lane": {"index": 0, "spacing_mm": 10.0}, - "route_track": { - "segments": [ - {"carrier": {"kind": "WireDuct", "capacity": 1}}, - ] - }, + "collision_samples": [ + {"collision_kind": "HardIntersection", "obstacle_label": "支架B"}, + ], }, ], } message = auto_routing.format_eplan_connection_route_report(report) + top_obstacles = auto_routing._top_collision_obstacles(report) - self.assertNotIn("容量提示", message) + self.assertEqual("device-a", top_obstacles[0]["element_uuid"]) + self.assertEqual("instance-a", top_obstacles[0]["instance_id"]) + self.assertEqual( + {"third_party_device_collision": 2}, + top_obstacles[0]["collision_relation_counts"], + ) + self.assertIn("碰撞关系:第三方设备/布局 2 处。", message) + self.assertIn("后续处理:优先对第三方设备/布局碰撞做局部二次避障", message) + self.assertIn("碰撞高发对象:设备A(安装板A) 2 处,支架B 1 处。", message) + self.assertIn( + "碰撞处理建议:设备A:疑似设备/安装区域碰撞,优先补柜内路径或调整装配;支架B:疑似柜体/门板/支架结构,确认可穿越后标记忽略碰撞。", + message, + ) - def test_route_eplan_connections_report_keeps_route_identity_and_diagnostics(self): + def test_route_eplan_connections_report_ignores_non_numeric_status_counts(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="WireDuct", - ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-1", - "wire_label": "N4111", - "wire_style_id": "42", - "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-end", - } - ], + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "route_status_counts": { + "Routed": "1", + "ExternalStatus": "not-a-number", + }, } - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 12.0, "lane_axis": "y"}, - ) - route = report["routes"][0] + message = auto_routing.format_eplan_connection_route_report(report) - self.assertEqual("wire-1", route["wire_uuid"]) - self.assertEqual("N4111", route["wire_label"]) - self.assertEqual("42", route["wire_style_id"]) - self.assertEqual("terminal-start", route["start_terminal_uuid"]) - self.assertEqual("terminal-end", route["end_terminal_uuid"]) - self.assertEqual(0, route["lane"]["index"]) - self.assertEqual("network-dijkstra-v1", route["algorithm"]) - self.assertEqual(1, route["network"]["carriers"]) - self.assertEqual("WireDuct", route["route_track"]["segments"][0]["carrier"]["kind"]) + self.assertIn("正常 1 条", message) + self.assertNotIn("ExternalStatus", message) - def test_route_eplan_connections_can_skip_nearer_isolated_entry_network(self): + def test_routing_preflight_reports_missing_route_network_and_style_database(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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, 1, 20), app.Vector(5, 1, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], - project_uuid="project-1", - kind="WireDuct", - ) payload = { "project_uuid": "project-1", + "wire_style_database_path": "D:/missing/project-local.db", "wires": [ { "wire_id": "wire-1", "wire_label": "N4111", "start_terminal_uuid": "terminal-start", "end_terminal_uuid": "terminal-end", + "wire_style_id": "42", } ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertEqual(1, report["routed"]) - self.assertEqual(0, len(report["errors"])) - route = report["routes"][0] - self.assertEqual("network-dijkstra-v1", route["algorithm"]) - self.assertGreater(route["network"]["entry_distance"], 1.0) - self.assertGreater(route["network"]["entry_candidate_rank"], 1) + self.assertFalse(report["ok"]) + self.assertEqual(1, report["total_wires"]) + self.assertEqual(2, report["available_terminals"]) + self.assertEqual(0, report["route_network_segments"]) + self.assertEqual("Missing", report["wire_style_database"]["status"]) + self.assertIn("no_route_network", report["issue_codes"]) + self.assertIn("wire_style_database_missing", report["issue_codes"]) + self.assertIn("路径网络:0 段", message) + self.assertIn("导线样式库:文件不存在", message) - def test_route_eplan_connections_report_includes_routing_path_network_diagnostic(self): + def test_routing_preflight_report_identifies_qet_session_payload_source(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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", + "wire_id": "wire-1", "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) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_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) + self.assertEqual("payload", report["source"]) + self.assertEqual(auto_routing.AUTO_ROUTING_RUNTIME_VERSION, report["runtime_version"]) + self.assertIn("导线来源:QET 会话交换数据", message) + self.assertIn("运行版本:{0}".format(auto_routing.AUTO_ROUTING_RUNTIME_VERSION), message) - def test_route_eplan_connections_preserves_endpoint_metadata_on_routed_wire(self): + def test_routing_preflight_missing_endpoint_sample_includes_instance_details(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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="WireDuct", - ) payload = { "project_uuid": "project-1", "wires": [ { - "wire_id": "wire-1", - "start_element_uuid": "device-a", - "start_terminal_uuid": "terminal-start", + "wire_id": "wire-missing", + "wire_label": "N-MISS", + "start_terminal_uuid": "terminal-missing", + "start_element_uuid": "device-missing", + "start_instance_id": "instance-missing", "start_terminal_display": "A1", - "end_element_uuid": "device-b", "end_terminal_uuid": "terminal-end", - "end_terminal_display": "B1", + "end_element_uuid": "device-end", + "end_instance_id": "instance-end", + "end_terminal_display": "13", } - ], - } - - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - routed_group = doc.getObject("QETWiring_04_Routed") - wire = routed_group.Group[0] - diagnostics = json.loads(wire.QetRouteDiagnosticsJson) - - self.assertEqual("device-a", wire.QetStartElementUuid) - self.assertEqual("A1", wire.QetStartTerminalDisplay) - self.assertEqual("device-b", wire.QetEndElementUuid) - self.assertEqual("B1", wire.QetEndTerminalDisplay) - self.assertEqual("device-a", report["routes"][0]["start_element_uuid"]) - self.assertEqual("B1", report["routes"][0]["end_terminal_display"]) - self.assertEqual("A1", diagnostics["endpoint_metadata"]["start_terminal_display"]) + ], + } - def test_route_eplan_connection_tasks_preserve_task_endpoint_labels_on_routed_wire(self): + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + sample = report["missing_endpoint_samples"][0] + + self.assertEqual("device-missing", sample["start_element_uuid"]) + self.assertEqual("instance-missing", sample["start_instance_id"]) + self.assertEqual("A1", sample["start_terminal_display"]) + self.assertEqual(0, sample["start_element_terminal_count"]) + self.assertEqual([], sample["start_element_terminal_samples"]) + self.assertEqual(0, sample["start_instance_terminal_count"]) + self.assertEqual([], sample["start_instance_terminal_samples"]) + self.assertEqual("device_not_in_3d_scene", sample["start_missing_endpoint_reason_code"]) + self.assertEqual("该 2D 设备未在 FreeCAD 场景中找到", sample["start_missing_endpoint_reason_label"]) + self.assertIn("device-missing/A1 (terminal-missing)", message) + self.assertIn("起点 element=device-missing, instance=instance-missing, terminal=A1", message) + self.assertIn("FreeCAD同设备端子=0", message) + self.assertIn("FreeCAD同实例端子=0", message) + self.assertIn("原因=该 2D 设备未在 FreeCAD 场景中找到", message) + + def test_routing_preflight_reports_missing_runtime_route_constraint_collector(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + 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="WireDuct", - ) - task = wiring_objects.create_wire_task( - doc, - "project-1", - "wire-1", - "N4111", - "terminal-start", - "terminal-end", - "instance-a", - "instance-b", - ) - terminal_objects.ensure_string_property(task, "QetStartDeviceLabel", "QET Wiring", "", "QF1") - terminal_objects.ensure_string_property(task, "QetEndDeviceLabel", "QET Wiring", "", "X1") - terminal_objects.ensure_string_property(task, "QetEndpointLabel", "QET Wiring", "", "QF1:A1 -> X1:B1") - - report = auto_routing.route_eplan_connection_tasks(doc) - routed_group = doc.getObject("QETWiring_04_Routed") - wire = routed_group.Group[0] - diagnostics = json.loads(wire.QetRouteDiagnosticsJson) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + collector = routing_network.collect_route_constraint_options + delattr(routing_network, "collect_route_constraint_options") + try: + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + finally: + routing_network.collect_route_constraint_options = collector - self.assertEqual("QF1", wire.QetStartDeviceLabel) - self.assertEqual("X1", wire.QetEndDeviceLabel) - self.assertEqual("QF1:A1 -> X1:B1", wire.QetEndpointLabel) - self.assertEqual("QF1:A1 -> X1:B1", report["routes"][0]["endpoint_label"]) - self.assertEqual("QF1", diagnostics["endpoint_metadata"]["start_device_label"]) + self.assertFalse(report["ok"]) + self.assertIn("runtime_route_constraint_collector_missing", report["issue_codes"]) + self.assertFalse(report["runtime_capabilities"]["route_constraint_collector"]) + self.assertIn("运行模块能力", message) + self.assertIn("路径约束收集函数缺失", message) - def test_route_eplan_connections_records_wire_identity_for_errors(self): + def test_routing_preflight_writes_compact_diagnostic_object(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) payload = { "project_uuid": "project-1", "wires": [ { - "wire_id": "wire-bad", - "wire_label": "N500", + "wire_id": "wire-1", + "wire_label": "N4111", "start_terminal_uuid": "terminal-start", - "end_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + "wire_style_id": "42", } ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + first = auto_routing.write_routing_preflight_diagnostic(doc, report) + second = auto_routing.write_routing_preflight_diagnostic(doc, report) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + diagnostic_payload = json.loads(second.QetDiagnosticJson) - self.assertEqual(1, len(report["errors"])) - self.assertIn("error_samples", report) - self.assertEqual("wire-bad", report["error_samples"][0]["wire_uuid"]) - self.assertEqual("N500", report["error_samples"][0]["wire_label"]) - self.assertEqual("terminal-start", report["error_samples"][0]["start_terminal_uuid"]) - self.assertEqual("terminal-start", report["error_samples"][0]["end_terminal_uuid"]) - self.assertIn("different", report["error_samples"][0]["error"]) + self.assertIsNotNone(first) + self.assertIsNotNone(second) + self.assertIsNot(first, second) + self.assertEqual(1, len(diagnostic_group.Group)) + self.assertEqual("RoutingPreflight", diagnostic_group.Group[0].QetDiagnosticKind) + self.assertEqual("project-1", diagnostic_group.Group[0].QetProjectUuid) + self.assertFalse(diagnostic_group.Group[0].QetDiagnosticOk) + self.assertIn("missing_endpoints", diagnostic_group.Group[0].QetDiagnosticIssueCodes) + self.assertIn("缺失端点", diagnostic_group.Group[0].QetDiagnosticIssueLabels) + self.assertIn("布线准备度:未通过", diagnostic_group.Group[0].QetDiagnosticMessage) + self.assertEqual("project-1", diagnostic_payload["project_uuid"]) + self.assertEqual(1, diagnostic_payload["total_wires"]) + self.assertEqual(1, diagnostic_payload["available_terminals"]) + self.assertIn("missing_endpoints", diagnostic_payload["issue_codes"]) + self.assertEqual(1, diagnostic_payload["missing_endpoint_uuid_count"]) + self.assertEqual("terminal-missing", diagnostic_payload["missing_endpoint_uuids"][0]) + self.assertIn( + "端点缺失示例:导线 N4111,terminal-start -> terminal-missing", + message, + ) + self.assertIn("routing_sources", diagnostic_payload) + self.assertIn("routing_boundaries", diagnostic_payload) + self.assertIn("wire_style_database", diagnostic_payload) + self.assertIn("wire_style", diagnostic_payload) + self.assertEqual(auto_routing.AUTO_ROUTING_RUNTIME_VERSION, diagnostic_payload["runtime_version"]) - def test_route_eplan_connections_report_includes_readable_error_sample(self): + def test_routing_preflight_reports_obstacle_mode_summary(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + 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)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - project_uuid="project-1", - kind="WireDuct", - ) + ignored = doc.addObject("Part::Feature", "IgnoredBracket") + ignored.Label = "忽略支架" + ignored.Shape = FakeShape(FakeBoundBox(0, 100, 0, 20, 0, 20)) + ignored.QetRoutingObstacleMode = "PassThrough" payload = { "project_uuid": "project-1", "wires": [ { - "wire_id": "wire-bad", - "wire_label": "N500", - "start_element_uuid": "device-a", + "wire_id": "wire-1", + "wire_label": "N4111", "start_terminal_uuid": "terminal-start", - "start_terminal_display": "A1", - "end_element_uuid": "device-a", - "end_terminal_uuid": "terminal-start", - "end_terminal_display": "A1", + "end_terminal_uuid": "terminal-missing", } ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertEqual("device-a", report["error_samples"][0]["start_element_uuid"]) - self.assertIn("错误示例:导线 N500", message) - self.assertIn("device-a/A1 (terminal-start) -> device-a/A1 (terminal-start)", message) + self.assertEqual(1, report["routing_obstacle_modes"]["PassThrough"]["count"]) + self.assertEqual("忽略支架", report["routing_obstacle_modes"]["PassThrough"]["samples"][0]["label"]) + self.assertIn("忽略碰撞对象:1", message) - def test_route_eplan_connections_counts_route_statuses_for_summary(self): + def test_collect_routing_diagnostic_summary_merges_latest_diagnostic_objects(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "RouteStart", "route-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "RouteEnd", "route-end", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "CollisionStart", "collision-start", app.Vector(0, 100, 0)) - _terminal(doc, terminal_objects, "CollisionEnd", "collision-end", app.Vector(100, 100, 0)) - 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_route_carrier( - doc, - [app.Vector(0, 100, 100), app.Vector(100, 100, 100)], - project_uuid="project-1", - kind="WireDuct", - ) - obstacle = doc.addObject("Part::Feature", "CollisionObstacle") - obstacle.Shape = FakeShape(FakeBoundBox(40, 60, 90, 110, 90, 110)) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-ok", - "start_terminal_uuid": "route-start", - "end_terminal_uuid": "route-end", - }, + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + + for kind, ok, message, payload in ( + ( + "RoutingPreflight", + False, + "布线准备度:未通过。", { - "wire_id": "wire-collision", - "start_terminal_uuid": "collision-start", - "end_terminal_uuid": "collision-end", + "issue_codes": ["missing_endpoints"], + "total_wires": 2, + "runtime_version": "preflight-version", }, + ), + ( + "RoutingPathNetwork", + False, + "布线路径网络检查发现 1 类问题。", + {"issue_codes": ["unconnected_terminals"], "summary": {"segments": 4}}, + ), + ( + "RoutingConnectionBatch", + True, + "自动布线完成:已生成 2 条。", { - "wire_id": "wire-error", - "start_terminal_uuid": "route-start", - "end_terminal_uuid": "route-start", + "issue_codes": [], + "routed": 2, + "route_path_usage": {"main_path_routes": 1, "fallback_routes": 1}, + "top_collision_obstacles": [ + {"label": "设备A", "count": 2, "parent_labels": ["安装板A"]} + ], + "runtime_version": auto_routing.AUTO_ROUTING_RUNTIME_VERSION, }, - ], - } + ), + ): + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_{0}".format(kind)) + diagnostic.QetDiagnosticKind = kind + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = ok + diagnostic.QetDiagnosticMessage = message + diagnostic.QetDiagnosticJson = json.dumps(payload, ensure_ascii=False) + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertFalse(summary["ok"]) + self.assertEqual("project-1", summary["project_uuid"]) + self.assertEqual(3, summary["diagnostic_count"]) + self.assertEqual( + ["missing_endpoints", "unconnected_terminals"], + summary["issue_codes"], + ) + self.assertEqual([], summary["missing_diagnostic_kinds"]) + self.assertEqual(auto_routing.AUTO_ROUTING_RUNTIME_VERSION, summary["runtime_version"]) + self.assertEqual(2, summary["diagnostics"]["RoutingConnectionBatch"]["payload"]["routed"]) + self.assertEqual( + {"main_path_routes": 1, "fallback_routes": 1}, + summary["batch_route_path_usage"], + ) + self.assertEqual( + [{"label": "设备A", "count": 2, "parent_labels": ["安装板A"]}], + summary["batch_top_collision_obstacles"], + ) + self.assertIn("汇总诊断:未通过", message) + self.assertIn("运行版本:{0}".format(auto_routing.AUTO_ROUTING_RUNTIME_VERSION), message) + self.assertIn("路径采用:线槽/主路径 1 条,布线面/辅助路径 1 条。", message) + self.assertIn("碰撞高发对象:设备A(安装板A) 2 处。", message) + self.assertIn("缺失端点", message) + self.assertIn("端子未接入", message) - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"avoid_obstacles": False}, + def test_write_routing_diagnostic_summary_replaces_previous_summary_object(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingPreflight") + diagnostic.QetDiagnosticKind = "RoutingPreflight" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "布线准备度:未通过。" + diagnostic.QetDiagnosticJson = json.dumps( + {"issue_codes": ["missing_endpoints"], "total_wires": 2}, + ensure_ascii=False, ) - message = auto_routing.format_eplan_connection_route_report(report) + diagnostic_group.addObject(diagnostic) - self.assertEqual(1, report["route_status_counts"]["Routed"]) - self.assertEqual(1, report["route_status_counts"]["CollisionWarning"]) - self.assertEqual(1, report["route_status_counts"]["Error"]) - self.assertIn("结果状态", message) - self.assertIn("正常 1 条", message) + first = auto_routing.write_routing_diagnostic_summary(doc) + second = auto_routing.write_routing_diagnostic_summary(doc) + summary_objects = [ + item + for item in diagnostic_group.Group + if getattr(item, "QetDiagnosticKind", "") == "RoutingDiagnosticSummary" + ] + payload = json.loads(second.QetDiagnosticJson) + + self.assertIsNotNone(first) + self.assertIsNotNone(second) + self.assertIsNot(first, second) + self.assertEqual(1, len(summary_objects)) + self.assertEqual("RoutingDiagnosticSummary", second.QetDiagnosticKind) + self.assertEqual("project-1", second.QetProjectUuid) + self.assertFalse(second.QetDiagnosticOk) + self.assertEqual("missing_endpoints", second.QetDiagnosticIssueCodes) + self.assertEqual("缺失端点", second.QetDiagnosticIssueLabels) + self.assertIn("汇总诊断:未通过", second.QetDiagnosticMessage) + self.assertEqual(["missing_endpoints"], payload["issue_codes"]) + self.assertIn("RoutingPathNetwork", payload["missing_diagnostic_kinds"]) + self.assertIn("RoutingConnectionBatch", payload["missing_diagnostic_kinds"]) + + def test_collect_routing_diagnostic_summary_falls_back_to_issue_code_property(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticIssueCodes = "missing_terminals, collision_warnings" + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = "{broken-json" + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + + self.assertIn("missing_terminals", summary["issue_codes"]) + self.assertIn("collision_warnings", summary["issue_codes"]) + self.assertIn("diagnostic_json_invalid", summary["issue_codes"]) + + def test_collect_routing_diagnostic_summary_accepts_batch_as_final_diagnostic(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = True + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成:routed=2, collision_warnings=0, missing_terminals=0" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": [], + "routed": 2, + "skipped_missing_terminal": 0, + "collision_warnings": 0, + "route_path_usage": {"main_path_routes": 2, "fallback_routes": 0}, + "routing_path_network_diagnostic": {"issue_codes": [], "summary": {"segments": 4}}, + "runtime_version": auto_routing.AUTO_ROUTING_RUNTIME_VERSION, + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertTrue(summary["ok"]) + self.assertEqual([], summary["missing_diagnostic_kinds"]) + self.assertNotIn("未生成", message) + self.assertIn("汇总诊断:通过", message) + self.assertIn("路径采用:线槽/主路径 2 条,布线面/辅助路径 0 条。", message) + + def test_collect_routing_diagnostic_summary_reports_empty_diagnostic_json(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticIssueCodes = "" + diagnostic.QetDiagnosticMessage = "" + diagnostic.QetDiagnosticJson = "" + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertIn("diagnostic_json_empty", summary["issue_codes"]) + self.assertIn("诊断 JSON 为空", summary["issue_labels"]) + self.assertIn("诊断 JSON 为空", message) + + def test_collect_routing_diagnostic_summary_reports_routed_wires_missing_diagnostics(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + wire = doc.addObject("Part::Feature", "QETRoutedConnection_legacy") + wire.Label = "N-OLD: A1 -> B1" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + ] + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-old" + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteDiagnosticsJson = "" + routed_group.addObject(wire) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertIn("routed_wire_diagnostics_missing", summary["issue_codes"]) + self.assertEqual(1, summary["routed_wire_diagnostic_gaps"]["count"]) + self.assertEqual("N-OLD: A1 -> B1", summary["routed_wire_diagnostic_gaps"]["samples"][0]["label"]) + self.assertIn("导线诊断缺失", summary["issue_labels"]) + self.assertIn("导线诊断缺失:1 条", message) + self.assertIn("N-OLD: A1 -> B1", message) + + def test_collect_routing_diagnostic_summary_reports_invalid_routed_wire_diagnostics(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + wire = doc.addObject("Part::Feature", "QETRoutedConnection_invalid_diag") + wire.Label = "N-BAD: A1 -> B1" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + ] + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-bad" + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteDiagnosticsJson = "{broken-json" + routed_group.addObject(wire) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertIn("routed_wire_diagnostics_invalid", summary["issue_codes"]) + self.assertEqual(1, summary["routed_wire_diagnostic_gaps"]["invalid_count"]) + self.assertEqual("N-BAD: A1 -> B1", summary["routed_wire_diagnostic_gaps"]["invalid_samples"][0]["label"]) + self.assertIn("导线诊断 JSON 无效", summary["issue_labels"]) + self.assertIn("导线诊断 JSON 无效:1 条", message) + self.assertIn("N-BAD: A1 -> B1", message) + + def test_collect_routing_diagnostic_summary_reports_main_path_detour_missing_details(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + wire_a = doc.addObject("Part::Feature", "QETRoutedConnection_main_path_a") + wire_a.Label = "N-A: A1 -> B1" + wire_a.RouteType = "RoutedConnection" + wire_a.QetWireUuid = "wire-a" + wire_a.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + "QetRouteTrackJson", + ] + wire_a.QetStartTerminalUuid = "terminal-a" + wire_a.QetEndTerminalUuid = "terminal-b" + wire_a.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + wire_a.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["安装板布线面", "辅助路径A"], + } + }, + ensure_ascii=False, + ) + wire_a.QetRouteTrackJson = json.dumps( + { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "主线槽A"}}, + ] + }, + ensure_ascii=False, + ) + routed_group.addObject(wire_a) + wire_b = doc.addObject("Part::Feature", "QETRoutedConnection_main_path_b") + wire_b.Label = "N-B: A2 -> B2" + wire_b.RouteType = "RoutedConnection" + wire_b.QetWireUuid = "wire-b" + wire_b.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + "QetRouteTrackJson", + ] + wire_b.QetStartTerminalUuid = "terminal-c" + wire_b.QetEndTerminalUuid = "terminal-d" + wire_b.QetRouteIssueCodes = "main_path_detour_missing" + wire_b.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["AuxiliaryPath"], + "rejected_fallback_labels": ["辅助路径A", "门板附近辅助路径"], + } + }, + ensure_ascii=False, + ) + wire_b.QetRouteTrackJson = json.dumps( + { + "segments": [ + {"carrier": {"kind": "UserPath", "source_label": "主路径B"}}, + ] + }, + ensure_ascii=False, + ) + routed_group.addObject(wire_b) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + detour = summary["main_path_detour_missing_summary"] + self.assertEqual(2, detour["wire_count"]) + self.assertEqual(["安装板布线面", "辅助路径A", "门板附近辅助路径"], detour["rejected_fallback_labels"]) + self.assertEqual( + {"安装板布线面": 1, "辅助路径A": 2, "门板附近辅助路径": 1}, + detour["rejected_fallback_label_counts"], + ) + self.assertEqual({"AuxiliaryPath": 1, "RoutingRange": 1}, detour["rejected_fallback_kind_counts"]) + self.assertEqual({"主线槽A": 1, "主路径B": 1}, detour["current_route_source_label_counts"]) + self.assertEqual( + { + "安装板布线面 -> 主线槽A": 1, + "辅助路径A -> 主线槽A": 1, + "辅助路径A -> 主路径B": 1, + "门板附近辅助路径 -> 主路径B": 1, + }, + detour["bridge_pair_counts"], + ) + self.assertEqual(["主线槽A"], detour["samples"][0]["current_route_source_labels"]) + self.assertEqual(["N-A: A1 -> B1", "N-B: A2 -> B2"], [item["label"] for item in detour["samples"]]) + self.assertIn("缺主路径绕行:2 条", message) + self.assertIn("需补路径位置:辅助路径A 2 条、安装板布线面 1 条、门板附近辅助路径 1 条", message) + self.assertIn("辅助路径A -> 主线槽A 1 条", message) + + def test_collect_routing_diagnostic_summary_counts_routed_wire_issue_codes(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_diagnostic_group(doc, "project-1") + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + long_wire = doc.addObject("Part::Feature", "QETRoutedConnection_long") + long_wire.Label = "N-LONG: A1 -> B1" + long_wire.RouteType = "RoutedConnection" + long_wire.QetWireUuid = "wire-long" + long_wire.PropertiesList = ["QetStartTerminalUuid", "QetEndTerminalUuid"] + long_wire.QetStartTerminalUuid = "terminal-a" + long_wire.QetEndTerminalUuid = "terminal-b" + long_wire.QetRouteIssueCodes = "long_terminal_access" + routed_group.addObject(long_wire) + collision_wire = doc.addObject("Part::Feature", "QETRoutedConnection_collision") + collision_wire.Label = "N-COL: A2 -> B2" + collision_wire.RouteType = "RoutedConnection" + collision_wire.QetWireUuid = "wire-col" + collision_wire.PropertiesList = ["QetStartTerminalUuid", "QetEndTerminalUuid"] + collision_wire.QetStartTerminalUuid = "terminal-c" + collision_wire.QetEndTerminalUuid = "terminal-d" + collision_wire.QetRouteIssueCodes = "collision_warnings, route_capacity_pressure" + routed_group.addObject(collision_wire) + normal_wire = doc.addObject("Part::Feature", "QETRoutedConnection_ok") + normal_wire.Label = "N-OK: A3 -> B3" + normal_wire.RouteType = "RoutedConnection" + normal_wire.QetWireUuid = "wire-ok" + normal_wire.PropertiesList = ["QetStartTerminalUuid", "QetEndTerminalUuid"] + normal_wire.QetStartTerminalUuid = "terminal-e" + normal_wire.QetEndTerminalUuid = "terminal-f" + normal_wire.QetRouteIssueCodes = "" + routed_group.addObject(normal_wire) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + issue_summary = summary["routed_wire_issue_summary"] + self.assertEqual(2, issue_summary["issue_wire_count"]) + self.assertEqual(3, issue_summary["total_wire_count"]) + self.assertEqual( + { + "collision_warnings": 1, + "long_terminal_access": 1, + "route_capacity_pressure": 1, + }, + issue_summary["issue_code_counts"], + ) + self.assertEqual("N-LONG: A1 -> B1", issue_summary["samples"][0]["label"]) + self.assertIn("异常导线:2/3 条", message) + self.assertIn("端子接入过长 1 条", message) self.assertIn("碰撞告警 1 条", message) - self.assertIn("错误 1 条", message) - def test_route_eplan_connections_lane_index_is_per_terminal_pair(self): + def test_collect_routing_diagnostic_summary_counts_missing_terminal_reasons(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals"], + "skipped_missing_terminal": 4, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "wire_label": "N-A", + "start_found": False, + "start_terminal_uuid": "terminal-a", + "start_element_uuid": "device-a", + "start_device_label": "设备A", + "start_terminal_display": "A1", + "start_missing_endpoint_reason_code": "no_3d_terminals_for_element", + "start_missing_endpoint_reason_label": "该 2D 设备在 FreeCAD 中没有工程端子", + "end_found": True, + }, + { + "wire_uuid": "wire-b", + "wire_label": "N-B", + "start_found": False, + "start_terminal_uuid": "terminal-b", + "start_element_uuid": "device-a", + "start_device_label": "设备A", + "start_terminal_display": "A2", + "start_missing_endpoint_reason_code": "no_3d_terminals_for_element", + "start_missing_endpoint_reason_label": "该 2D 设备在 FreeCAD 中没有工程端子", + "end_found": False, + "end_terminal_uuid": "terminal-c", + "end_element_uuid": "device-b", + "end_device_label": "设备B", + "end_terminal_display": "B1", + "end_missing_endpoint_reason_code": "terminal_uuid_not_in_element", + "end_missing_endpoint_reason_label": "同设备存在端子,但没有匹配该 terminal_uuid", + }, + ], + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + missing_summary = summary["batch_missing_terminal_summary"] + self.assertEqual(4, missing_summary["skipped_missing_terminal"]) + self.assertEqual(2, missing_summary["sample_wire_count"]) + self.assertEqual(3, missing_summary["missing_endpoint_count"]) + self.assertEqual( + { + "no_3d_terminals_for_element": 2, + "terminal_uuid_not_in_element": 1, + }, + missing_summary["reason_code_counts"], + ) + self.assertEqual(2, len(missing_summary["device_groups"])) + self.assertEqual("设备A", missing_summary["device_groups"][0]["device_label"]) + self.assertEqual(2, missing_summary["device_groups"][0]["missing_endpoint_count"]) + self.assertEqual(["A1", "A2"], missing_summary["device_groups"][0]["terminal_displays"]) + self.assertEqual(["terminal-a", "terminal-b"], missing_summary["device_groups"][0]["terminal_uuids"]) + self.assertEqual("设备B", missing_summary["device_groups"][1]["device_label"]) + self.assertIn("缺端子:4 条", message) + self.assertIn("该 2D 设备在 FreeCAD 中没有工程端子 2 处", message) + self.assertIn("需补端子设备:设备A 缺 2 处(A1、A2),设备B 缺 1 处(B1)", message) + + def test_collect_routing_diagnostic_summary_reports_missing_terminal_samples_without_reason_codes(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 100, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 100, 0)) - 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_route_carrier( - doc, - [app.Vector(0, 100, 20), app.Vector(100, 100, 20)], - project_uuid="project-1", - kind="WireDuct", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals"], + "skipped_missing_terminal": 2, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-a", + "end_found": True, + }, + { + "wire_uuid": "wire-b", + "start_found": True, + "end_found": False, + "end_terminal_uuid": "terminal-b", + }, + ], + }, + ensure_ascii=False, ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - { - "wire_id": "wire-a-repeat", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - ], - } + diagnostic_group.addObject(diagnostic) - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 10.0, "lane_axis": "y"}, - ) + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) - self.assertEqual(0, report["routes"][0]["lane"]["index"]) - self.assertEqual(0, report["routes"][1]["lane"]["index"]) - self.assertEqual(1, report["routes"][2]["lane"]["index"]) + missing_summary = summary["batch_missing_terminal_summary"] + self.assertEqual({"missing_device_binding_metadata": 2}, missing_summary["reason_code_counts"]) + self.assertEqual({"导线端点缺少 2D/3D 设备绑定信息": 2}, missing_summary["reason_label_counts"]) + self.assertIn("导线端点缺少 2D/3D 设备绑定信息 2 处", message) + self.assertIn( + "检查 QET 导线端点是否提供 element_uuid 和 terminal_uuid(第一版不要求 start/end_instance_id)", + summary["recommended_actions"], + ) - def test_route_eplan_connections_lane_index_increments_for_shared_route_segments(self): + def test_collect_routing_diagnostic_summary_backfills_missing_endpoint_reason_from_old_batch(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", 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="WireDuct", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals"], + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-a", + "start_element_uuid": "device-a", + "start_instance_id": "instance-a", + "start_terminal_display": "A1", + "end_found": True, + } + ], + }, + ensure_ascii=False, ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } + diagnostic_group.addObject(diagnostic) - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 10.0, "lane_axis": "y"}, - ) + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) - self.assertEqual(0, report["routes"][0]["lane"]["index"]) - self.assertEqual(1, report["routes"][1]["lane"]["index"]) - routed_group = doc.getObject("QETWiring_04_Routed") - self.assertEqual(2, len(list(getattr(routed_group, "Group", []) or []))) - second_wire = [ - wire - for wire in list(getattr(routed_group, "Group", []) or []) - if getattr(wire, "QetWireUuid", "") == "wire-b" - ][0] - self.assertTrue(any(abs(point.y - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) + missing_summary = summary["batch_missing_terminal_summary"] + self.assertEqual({"device_not_in_3d_scene": 1}, missing_summary["reason_code_counts"]) + self.assertEqual({"该 2D 设备未在 FreeCAD 场景中找到": 1}, missing_summary["reason_label_counts"]) + self.assertIn("该 2D 设备未在 FreeCAD 场景中找到 1 处", message) + self.assertNotIn("重新生成布线连接,刷新缺端子原因诊断", summary["recommended_actions"]) - def test_route_eplan_connections_auto_lane_axis_offsets_perpendicular_to_shared_segment(self): + def test_collect_routing_diagnostic_summary_backfills_issue_codes_from_legacy_missing_batch(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], - project_uuid="project-1", - kind="WireDuct", - ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticIssueCodes = "" + diagnostic.QetDiagnosticJson = json.dumps( + { + "skipped_missing_terminal": 1, + "route_status_counts": { + "Error": 2, + "MissingTerminal": 1, }, - ], - } - - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 10.0}, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "wire_label": "N1", + "start_found": False, + "start_terminal_uuid": "terminal-a", + "start_element_uuid": "device-a", + "start_terminal_display": "A1", + "end_found": True, + } + ], + }, + ensure_ascii=False, ) + diagnostic_group.addObject(diagnostic) - self.assertEqual(1, report["routes"][1]["lane"]["index"]) - self.assertEqual("x", report["routes"][1]["lane"]["axis"]) - routed_group = doc.getObject("QETWiring_04_Routed") - second_wire = [ - wire - for wire in list(getattr(routed_group, "Group", []) or []) - if getattr(wire, "QetWireUuid", "") == "wire-b" - ][0] - self.assertTrue(any(abs(point.x - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) - self.assertFalse(all(abs(point.x) <= 0.001 for point in second_wire.Points[1:-1])) + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) - def test_route_eplan_connections_prefers_unused_alternate_route_segments(self): + self.assertIn("missing_terminals", summary["issue_codes"]) + self.assertIn("missing_endpoints", summary["issue_codes"]) + self.assertIn("routing_errors", summary["issue_codes"]) + self.assertIn("端子匹配失败", summary["issue_labels"]) + self.assertIn("缺失端点", summary["issue_labels"]) + self.assertIn("布线计算错误", summary["issue_labels"]) + self.assertEqual({"Error": 2, "MissingTerminal": 1}, summary["batch_route_status_counts"]) + self.assertIn("结果状态:错误 2 条,缺失端子 1 条", message) + + def test_collect_routing_diagnostic_summary_recommends_device_binding_when_3d_device_missing(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="Direct Duct", - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], - label="Left Bridge", - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], - label="Alternate Duct", - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], - label="Right Bridge", - project_uuid="project-1", - kind="WireDuct", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals"], + "skipped_missing_terminal": 4, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "wire_label": "F6", + "start_found": False, + "start_terminal_uuid": "device-missing:terminal-a", + "start_element_uuid": "device-missing", + "start_terminal_display": "as", + "start_missing_endpoint_reason_code": "device_not_in_3d_scene", + "start_missing_endpoint_reason_label": "该 2D 设备未在 FreeCAD 场景中找到", + "end_found": True, + } + ], + }, + ensure_ascii=False, ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - ], - } + diagnostic_group.addObject(diagnostic) - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) - first_labels = [ - segment["carrier"]["label"] - for segment in report["routes"][0]["route_track"]["segments"] - ] - second_labels = [ - segment["carrier"]["label"] - for segment in report["routes"][1]["route_track"]["segments"] - ] - self.assertIn("Direct Duct", first_labels) - self.assertIn("Alternate Duct", second_labels) - self.assertNotIn("Direct Duct", second_labels) + self.assertIn( + "检查缺失 3D 设备是否已导入、装配并完成 2D/3D 绑定", + summary["recommended_actions"], + ) + self.assertIn("该 2D 设备未在 FreeCAD 场景中找到 1 处", message) + self.assertIn("建议:检查缺失 3D 设备是否已导入", message) - def test_route_eplan_connections_respects_route_segment_capacity_before_detouring(self): + def test_collect_routing_diagnostic_summary_recommends_qet_endpoint_binding_metadata(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartC", "terminal-start-c", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndC", "terminal-end-c", app.Vector(100, 0, 0)) - direct = routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="Direct Duct", - project_uuid="project-1", - kind="WireDuct", - ) - direct.QetRouteCarrierCapacity = 2 - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], - label="Left Bridge", - project_uuid="project-1", - kind="WireDuct", - ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], - label="Alternate Duct", - project_uuid="project-1", - kind="WireDuct", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals"], + "skipped_missing_terminal": 1, + "missing_endpoint_samples": [ + { + "wire_uuid": "wire-a", + "start_found": False, + "start_terminal_uuid": "terminal-a", + "start_terminal_display": "A1", + "start_missing_endpoint_reason_code": "missing_device_binding_metadata", + "start_missing_endpoint_reason_label": "导线端点缺少 2D/3D 设备绑定信息", + "end_found": True, + } + ], + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], - label="Right Bridge", - project_uuid="project-1", - kind="WireDuct", + diagnostic_group.addObject(diagnostic) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertIn( + "检查 QET 导线端点是否提供 element_uuid 和 terminal_uuid(第一版不要求 start/end_instance_id)", + summary["recommended_actions"], ) - payload = { - "project_uuid": "project-1", - "wires": [ - { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - { - "wire_id": "wire-c", - "start_terminal_uuid": "terminal-start-c", - "end_terminal_uuid": "terminal-end-c", - }, - ], - } + self.assertIn("导线端点缺少 2D/3D 设备绑定信息 1 处", message) - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + def test_collect_routing_diagnostic_summary_recommends_manual_followup_actions(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + { + "issue_codes": ["missing_terminals", "collision_warnings"], + "skipped_missing_terminal": 1, + "collision_warnings": 1, + "top_collision_obstacles": [ + {"label": "NAUO141", "count": 3, "parent_names": ["DoorAssembly"]} + ], + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + long_wire = doc.addObject("Part::Feature", "QETRoutedConnection_long") + long_wire.Label = "N-LONG: A1 -> B1" + long_wire.RouteType = "RoutedConnection" + long_wire.QetWireUuid = "wire-long" + long_wire.PropertiesList = ["QetStartTerminalUuid", "QetEndTerminalUuid"] + long_wire.QetStartTerminalUuid = "terminal-a" + long_wire.QetEndTerminalUuid = "terminal-b" + long_wire.QetRouteIssueCodes = "long_terminal_access" + routed_group.addObject(long_wire) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) - route_labels = [ - [segment["carrier"]["label"] for segment in route["route_track"]["segments"]] - for route in report["routes"] - ] - self.assertIn("Direct Duct", route_labels[0]) - self.assertIn("Direct Duct", route_labels[1]) - self.assertIn("Alternate Duct", route_labels[2]) - self.assertNotIn("Direct Duct", route_labels[2]) + self.assertEqual( + [ + "点击“选择缺端子设备”定位需要补工程端子的设备", + "点击“选择异常导线”定位带问题码的导线", + "点击“选择长接入端子/设备”检查设备高度和局部出线路径", + "点击“选择碰撞父装配”确认结构件后再标记忽略碰撞", + ], + summary["recommended_actions"], + ) + self.assertIn("建议:点击“选择缺端子设备”", message) + self.assertIn("点击“选择碰撞父装配”", message) - def test_route_eplan_connections_prefers_unused_segments_occupied_by_existing_wires(self): + def test_collect_routing_diagnostic_summary_recommends_main_path_detour_missing_wires(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - end = _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)], - label="Direct Duct", - project_uuid="project-1", - kind="WireDuct", + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") + diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticOk = False + diagnostic.QetDiagnosticMessage = "批量生成布线连接完成。" + diagnostic.QetDiagnosticJson = json.dumps( + {"issue_codes": ["collision_warnings", "main_path_detour_missing"]}, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + routed_group = wiring_objects.ensure_routed_group(doc, "project-1") + wire = doc.addObject("Part::Feature", "QETRoutedConnection_main_path") + wire.Label = "N-MAINPATH: A1 -> B1" + wire.RouteType = "RoutedConnection" + wire.QetWireUuid = "wire-main-path" + wire.PropertiesList = [ + "QetStartTerminalUuid", + "QetEndTerminalUuid", + "QetRouteDiagnosticsJson", + ] + wire.QetStartTerminalUuid = "terminal-a" + wire.QetEndTerminalUuid = "terminal-b" + wire.QetRouteIssueCodes = "collision_warnings, main_path_detour_missing" + wire.QetRouteDiagnosticsJson = json.dumps( + { + "selective_collision_reroute": { + "status": "RejectedFallback", + "rejected_fallback_kinds": ["RoutingRange"], + "rejected_fallback_labels": ["安装板布线面"], + } + }, + ensure_ascii=False, ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], - label="Left Bridge", - project_uuid="project-1", - kind="WireDuct", + routed_group.addObject(wire) + + summary = auto_routing.collect_routing_diagnostic_summary(doc) + message = auto_routing.format_routing_diagnostic_summary(summary) + + self.assertIn( + "点击“选择缺主路径导线”定位需要补 UserPath 或主路径桥接的导线", + summary["recommended_actions"], ) - routing_network.create_route_carrier( - doc, - [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], - label="Alternate Duct", - project_uuid="project-1", - kind="WireDuct", + self.assertIn( + "选中缺主路径导线后点击“选择拒绝兜底路径”查看需补路径位置", + summary["recommended_actions"], ) - routing_network.create_route_carrier( - doc, - [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], - label="Right Bridge", - project_uuid="project-1", - kind="WireDuct", + self.assertIn( + "点击“选择缺主路径补路位置”快速定位汇总需补区域", + summary["recommended_actions"], ) - auto_routing.route_eplan_connection_between_terminals( - doc, - start, - end, - wire_uuid="existing-wire", + self.assertIn( + "点击“选择缺主路径线路径”对照当前实际路径", + summary["recommended_actions"], ) + self.assertIn("点击“选择缺主路径导线”", message) + self.assertIn("选择缺主路径补路位置", message) + self.assertIn("选择缺主路径线路径", message) + self.assertIn("选择拒绝兜底路径", message) + + def test_routing_preflight_reports_no_routing_sources_when_network_is_empty(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)) payload = { "project_uuid": "project-1", "wires": [ { - "wire_id": "new-wire", + "wire_id": "wire-1", "start_terminal_uuid": "terminal-start", "end_terminal_uuid": "terminal-end", } ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - route_labels = [ - segment["carrier"]["label"] - for segment in report["routes"][0]["route_track"]["segments"] - ] - self.assertIn("Alternate Duct", route_labels) - self.assertNotIn("Direct Duct", route_labels) + self.assertIn("no_routing_sources", report["issue_codes"]) + self.assertEqual(0, report["routing_sources"]["candidate_sources"]) + self.assertIn("布线源:未识别到线槽/布线面/用户路径", message) + self.assertEqual(0, report["routing_boundaries"]["count"]) + self.assertIn("柜内边界:未标记", message) - def test_route_eplan_connections_report_includes_collision_samples(self): + def test_routing_preflight_reports_cabinet_boundary_count(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] @@ -4859,235 +14768,691 @@ class AutoRoutingTest(unittest.TestCase): 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, 100), app.Vector(100, 0, 100)], - project_uuid="project-1", + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(0, 300, 0, 200, 0, 500)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] ) - obstacle = doc.addObject("Part::Feature", "MiddleObstacle") - obstacle.Label = "Middle Obstacle" - obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110)) payload = { "project_uuid": "project-1", "wires": [ { "wire_id": "wire-1", - "wire_label": "N4111", "start_terminal_uuid": "terminal-start", "end_terminal_uuid": "terminal-end", } ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertEqual(1, report["collision_warnings"]) - self.assertEqual("wire-1", report["collision_samples"][0]["wire_uuid"]) - self.assertEqual("N4111", report["collision_samples"][0]["wire_label"]) - self.assertEqual("MiddleObstacle", report["collision_samples"][0]["obstacle_name"]) - self.assertEqual("HardIntersection", report["collision_samples"][0]["collision_kind"]) - self.assertEqual({"x": 0.0, "y": 0.0, "z": 100.0}, report["collision_samples"][0]["segment_start"]) - self.assertEqual({"x": 100.0, "y": 0.0, "z": 100.0}, report["collision_samples"][0]["segment_end"]) - self.assertEqual(40.0, report["collision_samples"][0]["obstacle_bbox"]["xmin"]) - self.assertEqual(35.0, report["collision_samples"][0]["collision_bbox"]["xmin"]) - self.assertEqual("Middle Obstacle", report["routes"][0]["collision_samples"][0]["obstacle_label"]) - self.assertIn("碰撞示例", message) - self.assertIn("Middle Obstacle", message) + self.assertEqual(1, report["routing_boundaries"]["count"]) + self.assertEqual("柜内空间", report["routing_boundaries"]["samples"][0]["label"]) + self.assertIn("柜内边界:1 个", message) - def test_route_eplan_connections_report_calls_out_local_unbound_terminals(self): + def test_routing_preflight_reports_path_network_boundary_issues(self): _install_fake_freecad() - terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + 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( + _terminal(doc, terminal_objects, "TerminalInside", "terminal-inside", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalOutside", "terminal-outside", app.Vector(140, 0, 0)) + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(-20, 120, -20, 20, -10, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] + ) + routing_network.create_route_carrier( doc, - terminal_objects, - "LocalTerminal", - "local:instance-1:p1", - app.Vector(0, 0, 0), + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="柜内主路径A", + project_uuid="project-1", + kind="UserPath", ) payload = { + "project_uuid": "project-1", "wires": [ { - "wire_id": "wire-1", - "start_terminal_uuid": "qet-terminal-start", - "end_terminal_uuid": "qet-terminal-end", + "wire_id": "wire-outside-terminal", + "wire_label": "N-OUT-TERM", + "start_terminal_uuid": "terminal-inside", + "end_terminal_uuid": "terminal-outside", } - ] + ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertEqual(0, report["routed"]) - self.assertEqual(1, report["available_terminals"]) - self.assertEqual(1, report["local_terminals"]) - self.assertIn("端子匹配失败", message) - self.assertIn("local:", message) + self.assertFalse(report["ok"]) + self.assertIn("terminals_outside_boundary", report["issue_codes"]) + self.assertIn( + "terminals_outside_boundary", + report["routing_path_network_diagnostic"]["issue_codes"], + ) + self.assertEqual( + "terminal-outside", + report["routing_path_network_diagnostic"]["terminals_outside_boundary"][0]["terminal_uuid"], + ) + self.assertIn("路径网络检查提示", message) + self.assertIn("端子越出柜内边界", message) + self.assertIn("越界端子:TerminalOutside(terminal-outside) 2 个越界点", message) - def test_route_eplan_connections_report_includes_network_and_first_error(self): + def test_check_routing_path_network_warns_when_route_carrier_leaves_cabinet_boundary(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "total_wires": 2, - "routed": 1, - "collision_warnings": 1, - "skipped_missing_terminal": 1, - "prepared_layout": { - "wire_duct_carriers": 2, - "surface_carriers": 4, - "terminal_access_carriers": 6, - }, - "missing_endpoint_samples": [ - { - "start_terminal_uuid": "terminal-a", - "end_terminal_uuid": "terminal-b", - } - ], - "errors": ["没有可用的线槽/路由路径网络"], - } + 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, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(0, 120, -20, 20, 0, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] + ) + route = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20), app.Vector(140, 0, 20)], + label="柜内主路径A", + project_uuid="project-1", + kind="UserPath", + ) - message = auto_routing.format_eplan_connection_route_report(report) + 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.assertIn("routed=1", message) - self.assertIn("线槽路径 2 条", message) - self.assertIn("首个错误:没有可用的线槽/路由路径网络", message) - self.assertIn("缺失示例:terminal-a -> terminal-b", message) + self.assertFalse(result["ok"]) + self.assertIn("route_carriers_outside_boundary", result["issue_codes"]) + self.assertEqual(1, len(payload["route_carriers_outside_boundary"])) + self.assertEqual(route.Name, payload["route_carriers_outside_boundary"][0]["carrier"]["name"]) + self.assertEqual(1, payload["route_carriers_outside_boundary"][0]["outside_point_count"]) + self.assertIn("路径越出柜内边界", message) + self.assertIn("柜内主路径A", message) + self.assertEqual((1.0, 0.0, 0.0), route.ViewObject.LineColor) - def test_route_eplan_connections_report_calls_out_missing_route_network(self): + def test_check_routing_path_network_warns_when_terminal_leaves_cabinet_boundary(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "total_wires": 3, - "routed": 0, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "skipped_missing_route_network": 3, - "route_status_counts": { - "MissingRouteNetwork": 3, - }, - } + 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") + inside = _terminal(doc, terminal_objects, "TerminalInside", "terminal-inside", app.Vector(0, 0, 0)) + outside = _terminal(doc, terminal_objects, "TerminalOutside", "terminal-outside", app.Vector(140, 0, 0)) + boundary = doc.addObject("Part::Feature", "CabinetBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(-20, 120, -20, 20, -10, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="柜内主路径A", + project_uuid="project-1", + kind="UserPath", + ) - message = auto_routing.format_eplan_connection_route_report(report) + 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.assertIn("缺少布线路径网络 3 条", message) - self.assertIn("请先生成线槽、布线面或布线路径网络", message) + self.assertFalse(result["ok"]) + self.assertIn("terminals_outside_boundary", result["issue_codes"]) + self.assertEqual(1, len(payload["terminals_outside_boundary"])) + self.assertEqual("terminal-outside", payload["terminals_outside_boundary"][0]["terminal_uuid"]) + self.assertIn("端子越出柜内边界", message) + self.assertIn("terminal-outside", message) + self.assertEqual((1.0, 0.0, 0.0), outside.ViewObject.LineColor) + self.assertNotEqual((1.0, 0.0, 0.0), inside.ViewObject.LineColor) - def test_route_eplan_connections_report_includes_missing_route_network_sample(self): + def test_routing_preflight_reports_detected_sources_not_generated(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "total_wires": 1, - "routed": 0, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "skipped_missing_route_network": 1, - "missing_route_network_samples": [ + 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)) + panel = doc.addObject("Part::Feature", "MountingPlate") + panel.Label = "安装板" + panel.Shape = FakeShape(FakeBoundBox(0, 300, 0, 200, 0, 5)) + payload = { + "project_uuid": "project-1", + "wires": [ { - "wire_uuid": "wire-1", - "wire_label": "N4111", + "wire_id": "wire-1", "start_terminal_uuid": "terminal-start", - "start_element_uuid": "QF1", - "start_terminal_display": "A1", - "end_terminal_uuid": "terminal-end", - "end_element_uuid": "KM1", - "end_terminal_display": "13", - "error": "没有可用的布线路径网络:起点和终点无法连通", + "end_terminal_uuid": "terminal-end", } ], } - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertIn("缺路径网络示例:导线 N4111", message) - self.assertIn("QF1/A1 (terminal-start) -> KM1/13 (terminal-end)", message) - self.assertIn("原因:没有可用的布线路径网络:起点和终点无法连通", message) + self.assertIn("routing_sources_not_generated", report["issue_codes"]) + self.assertEqual(1, report["routing_sources"]["support_surface_sources"]) + self.assertEqual(1, report["routing_sources"]["candidate_sources"]) + self.assertIn("布线源:线槽 0 个,布线面 1 个", message) + self.assertIn("请先生成布线路径网络", message) - def test_route_eplan_connections_report_includes_readable_missing_endpoint_labels(self): + def test_routing_preflight_reports_unrouteable_wire_sample(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "routed": 0, - "collision_warnings": 0, - "skipped_missing_terminal": 1, - "missing_endpoint_samples": [ + 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(5000, 0, 20), app.Vector(5100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ { - "start_terminal_uuid": "terminal-a", - "start_element_uuid": "device-a", - "start_terminal_display": "A1", - "end_terminal_uuid": "terminal-b", - "end_element_uuid": "device-b", - "end_terminal_display": "B1", + "wire_id": "wire-far", + "wire_label": "N-FAR", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", } ], } - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections( + doc, + payload, + options={ + "terminal_access_max_distance": 50.0, + "preflight_routeability_sample_limit": 1, + }, + ) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertIn("缺失示例:device-a/A1 (terminal-a) -> device-b/B1 (terminal-b)", message) + self.assertFalse(report["ok"]) + self.assertEqual(1, report["routeability_checked"]) + self.assertEqual(1, report["unrouteable_wires"]) + self.assertEqual("wire-far", report["unrouteable_samples"][0]["wire_uuid"]) + self.assertIn("unrouteable_wires", report["issue_codes"]) + self.assertIn("导线不可达", message) - def test_route_eplan_connections_report_identifies_which_endpoint_is_missing(self): + def test_routing_preflight_checks_routeability_for_complete_wires_when_other_wires_miss_endpoints(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "routed": 0, - "collision_warnings": 0, - "skipped_missing_terminal": 1, - "missing_endpoint_samples": [ + 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(5000, 0, 20), app.Vector(5100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ { + "wire_id": "wire-missing", + "wire_label": "N-MISS", "start_terminal_uuid": "terminal-start", - "start_found": True, "end_terminal_uuid": "terminal-missing", - "end_found": False, - } + }, + { + "wire_id": "wire-far", + "wire_label": "N-FAR", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, ], } - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections( + doc, + payload, + options={ + "terminal_access_max_distance": 50.0, + "preflight_routeability_sample_limit": 1, + }, + ) - self.assertIn("缺失示例:terminal-start -> terminal-missing", message) - self.assertIn("缺失:终点", message) + self.assertFalse(report["ok"]) + self.assertEqual(["terminal-missing"], report["missing_endpoint_uuids"]) + self.assertEqual(1, report["routeability_checked"]) + self.assertEqual(1, report["unrouteable_wires"]) + self.assertIn("missing_endpoints", report["issue_codes"]) + self.assertIn("unrouteable_wires", report["issue_codes"]) - def test_route_eplan_connections_report_calls_out_clearance_collision_kind(self): + def test_routing_preflight_disables_routeability_sampling_by_default(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "routed": 1, - "collision_warnings": 1, - "skipped_missing_terminal": 0, - "collision_samples": [ + 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="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ { - "wire_label": "N4111", - "obstacle_label": "柜体侧板", - "collision_kind": "ClearanceWarning", - } + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, ], } - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertIn("安全间隙", message) - self.assertIn("柜体侧板", message) + self.assertEqual(0, report["routeability_sample_limit"]) + self.assertEqual(0, report["routeability_checked"]) + self.assertNotIn("可达性抽样", message) - def test_route_eplan_connections_report_ignores_non_numeric_status_counts(self): + def test_routing_preflight_report_shows_routeability_sample_coverage(self): _install_fake_freecad() - _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() - report = { - "routed": 1, - "collision_warnings": 0, - "skipped_missing_terminal": 0, - "route_status_counts": { - "Routed": "1", - "ExternalStatus": "not-a-number", - }, + 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, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], } - message = auto_routing.format_eplan_connection_route_report(report) + report = auto_routing.preflight_eplan_connections( + doc, + payload, + options={"preflight_routeability_sample_limit": 1}, + ) + message = auto_routing.format_eplan_routing_preflight_report(report) - self.assertIn("正常 1 条", message) - self.assertNotIn("ExternalStatus", message) + self.assertEqual(1, report["routeability_checked"]) + self.assertEqual(1, report["routeability_unchecked_wires"]) + self.assertIn("可达性抽样:已检查 1 条,未检查 1 条", message) + + def test_routing_preflight_checks_wire_style_ids_before_routing(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="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.db" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT + ) + """ + ) + connection.execute( + "INSERT INTO wire_properties (id, project_uuid, name, line_color) VALUES (?, ?, ?, ?)", + (7, "project-1", "绿色控制线", "#00ff00"), + ) + connection.commit() + finally: + connection.close() + payload = { + "project_uuid": "project-1", + "wire_style_database_path": str(db_path), + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "7", + }, + { + "wire_id": "wire-2", + "wire_label": "N2", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "404", + }, + { + "wire_id": "wire-3", + "wire_label": "N3", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + + self.assertFalse(report["ok"]) + self.assertEqual("Available", report["wire_style_database"]["status"]) + self.assertEqual(1, report["wire_style"]["resolved"]) + self.assertEqual(1, report["wire_style"]["missing"]) + self.assertEqual(1, report["wire_style"]["without_style_id"]) + self.assertEqual("404", report["wire_style"]["missing_samples"][0]["wire_style_id"]) + self.assertIn("missing_wire_styles", report["issue_codes"]) + self.assertIn("wires_without_style_id", report["issue_codes"]) + self.assertIn("导线样式库:可用", message) + self.assertIn("已解析 1 条", message) + self.assertIn("缺失样式 1 条", message) + self.assertIn("未设置样式 1 条", message) + + def test_routing_preflight_reports_empty_wire_style_database(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="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "project-local.db" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT + ) + """ + ) + connection.commit() + finally: + connection.close() + payload = { + "project_uuid": "project-1", + "wire_style_database_path": str(db_path), + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "1", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + + self.assertFalse(report["ok"]) + self.assertEqual("EmptyWirePropertiesTable", report["wire_style_database"]["status"]) + self.assertEqual(0, report["wire_style_database"]["wire_properties_count"]) + self.assertIn("wire_style_database_empty", report["issue_codes"]) + self.assertIn("导线样式库:wire_properties 为空", message) + + def test_routing_preflight_uses_matching_fallback_style_database_when_payload_database_is_empty(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="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + wrong_dir = Path(temp_dir) / "wrong" / "datafiles" + right_dir = Path(temp_dir) / "right" / "datafiles" + exchange_dir = Path(temp_dir) / "right" / ".qet_freecad" + wrong_dir.mkdir(parents=True) + right_dir.mkdir(parents=True) + exchange_dir.mkdir(parents=True) + wrong_db = wrong_dir / "project-local.db" + right_db = right_dir / "project-local.db" + for db_path, rows in ( + (wrong_db, []), + (right_db, [(1, "project-1", "红色动力线", "#ff0000")]), + ): + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT + ) + """ + ) + connection.executemany( + "INSERT INTO wire_properties (id, project_uuid, name, line_color) VALUES (?, ?, ?, ?)", + rows, + ) + connection.commit() + finally: + connection.close() + json_path = exchange_dir / "2d_to_3d.json" + json_path.write_text( + json.dumps({"project_uuid": "project-1", "wires": []}), + encoding="utf-8", + ) + app._qet_exchange_summary = {"json_path": str(json_path)} + payload = { + "project_uuid": "project-1", + "wire_style_database_path": str(wrong_db), + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "1", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + + self.assertTrue(report["ok"]) + self.assertEqual(str(right_db), report["wire_style_database"]["path"]) + self.assertEqual(str(wrong_db), report["wire_style_database_fallback_from"]) + self.assertEqual(1, report["wire_style"]["resolved"]) + self.assertNotIn("wire_style_database_empty", report["issue_codes"]) + self.assertIn("从备用库", message) + + def test_routing_preflight_does_not_backfill_styles_from_other_project_context(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-current") + _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-current", + kind="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + json_path = Path(temp_dir) / "2d_to_3d.json" + json_path.write_text( + json.dumps( + { + "project_uuid": "project-old", + "wires": [ + { + "wire_id": "wire-old", + "wire_label": "OLD", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "99", + } + ], + } + ), + encoding="utf-8", + ) + app._qet_exchange_summary = {"json_path": str(json_path)} + payload = { + "project_uuid": "project-current", + "wires": [ + { + "wire_id": "wire-current", + "wire_label": "CURRENT", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + + self.assertEqual("project-current", report["project_uuid"]) + self.assertEqual(1, report["total_wires"]) + self.assertEqual(0, report["wire_style"]["with_style_id"]) + self.assertNotIn("OLD", message) + self.assertNotIn("99", message) + + def test_routing_preflight_discovers_style_database_from_exchange_summary_json_path(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="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = Path(temp_dir) / "project-a" + exchange_dir = project_dir / ".qet_freecad" + data_dir = project_dir / "datafiles" + exchange_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + json_path = exchange_dir / "2d_to_3d.json" + json_path.write_text( + json.dumps( + { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "1", + } + ], + } + ), + encoding="utf-8", + ) + db_path = data_dir / "project-local.db" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT + ) + """ + ) + connection.execute( + "INSERT INTO wire_properties (id, project_uuid, name, line_color) VALUES (?, ?, ?, ?)", + (1, "project-1", "红色动力线", "#ff0000"), + ) + connection.commit() + finally: + connection.close() + app._qet_exchange_summary = {"json_path": str(json_path)} + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + + self.assertTrue(report["ok"]) + self.assertEqual(str(db_path), report["wire_style_database"]["path"]) + self.assertEqual("Available", report["wire_style_database"]["status"]) + self.assertEqual(1, report["wire_style"]["resolved"]) def test_bind_wire_task_terminals_from_payload_does_not_create_wires(self): _install_fake_freecad() diff --git a/tests/python/freecad_exchange_bootstrap_wiring_test.py b/tests/python/freecad_exchange_bootstrap_wiring_test.py index 570d5a8..1bdad7f 100644 --- a/tests/python/freecad_exchange_bootstrap_wiring_test.py +++ b/tests/python/freecad_exchange_bootstrap_wiring_test.py @@ -1,5 +1,6 @@ import importlib import json +import sqlite3 import sys import tempfile import types @@ -132,6 +133,132 @@ class ExchangeBootstrapWiringTest(unittest.TestCase): self.assertEqual("wire-1", normalized["wires"][0]["wire_id"]) self.assertEqual("W001", normalized["wires"][0]["wire_mark"]) + def test_load_exchange_payload_preserves_wire_label_and_style_id(self): + _install_fake_modules() + sys.modules.pop("ExchangeBootstrap", None) + bootstrap = importlib.import_module("ExchangeBootstrap") + payload = { + "schema_version": "1.2", + "project_uuid": "project-1", + "devices": [], + "terminals": [], + "device_models": [], + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "wire_style_id": 1, + "start_terminal_uuid": "terminal-a", + "end_terminal_uuid": "terminal-b", + } + ], + } + + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "2d_to_3d.json" + path.write_text(json.dumps(payload), encoding="utf-8") + normalized = bootstrap.load_exchange_payload(str(path)) + + self.assertEqual("N4111", normalized["wires"][0]["wire_label"]) + self.assertEqual("1", normalized["wires"][0]["wire_style_id"]) + + def test_load_exchange_payload_detects_wire_properties_database_next_to_json(self): + _install_fake_modules() + sys.modules.pop("ExchangeBootstrap", None) + bootstrap = importlib.import_module("ExchangeBootstrap") + payload = { + "schema_version": "1.2", + "project_uuid": "project-1", + "devices": [], + "terminals": [], + "device_models": [], + "wires": [], + } + + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "2d_to_3d.json" + db_path = Path(temp_dir) / "project-local.sqlite" + path.write_text(json.dumps(payload), encoding="utf-8") + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + line_color TEXT + ) + """ + ) + connection.commit() + finally: + connection.close() + + normalized = bootstrap.load_exchange_payload(str(path)) + + self.assertEqual(str(db_path), normalized["wire_style_database_path"]) + + def test_load_exchange_payload_detects_project_datafiles_wire_properties_database(self): + _install_fake_modules() + sys.modules.pop("ExchangeBootstrap", None) + bootstrap = importlib.import_module("ExchangeBootstrap") + payload = { + "schema_version": "1.2", + "project_uuid": "project-1", + "devices": [], + "terminals": [], + "device_models": [], + "wires": [{"wire_id": "wire-1", "wire_style_id": "1"}], + } + + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = Path(temp_dir) / "project-a" + exchange_dir = project_dir / ".qet_freecad" + data_dir = project_dir / "datafiles" + exchange_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + path = exchange_dir / "2d_to_3d.json" + db_path = data_dir / "project-local.db" + path.write_text(json.dumps(payload), encoding="utf-8") + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT NOT NULL, + line_color TEXT + ) + """ + ) + connection.commit() + finally: + connection.close() + + normalized = bootstrap.load_exchange_payload(str(path)) + + self.assertEqual(str(db_path), normalized["wire_style_database_path"]) + + def test_exchange_summary_includes_wire_style_database_path(self): + _install_fake_modules() + sys.modules.pop("ExchangeBootstrap", None) + bootstrap = importlib.import_module("ExchangeBootstrap") + payload = { + "project_uuid": "project-1", + "devices": [], + "terminals": [], + "device_models": [], + "wires": [], + "wire_style_database_path": "D:/project/project-local.sqlite", + } + + summary = bootstrap._build_summary(payload, "D:/project/2d_to_3d.json") + + self.assertEqual( + "D:/project/project-local.sqlite", + summary["wire_style_database_path"], + ) + if __name__ == "__main__": unittest.main()