feat(freecad): 完善自动布线第一阶段验收

dev
Zhaowenlong 3 days ago
parent 8262a258d2
commit c5147407cb

@ -147,6 +147,10 @@ terminal_uuid
点击自动布线面板中的 `检查布线准备度`FreeCAD 还会在树目录 `QETWiring_05_Diagnostics` 下写入一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示本次诊断是否通过;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要;`QetDiagnosticJson` 保存压缩后的最新预检结果,包括导线任务数量、工程端子数量、路径网络段数、布线源摘要、柜内边界摘要、导线样式库状态、问题码 `issue_codes`、缺失端点样例等。重复检查时旧的 `RoutingPreflight` 会被替换,只保留最新一次结果。
如果当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`,预检会追加 `duplicate_3d_terminal_uuids / 3D端子UUID重复`。这表示单靠 `terminal_uuid` 已经不足以唯一定位 3D 端子,后续匹配必须依赖导线端点里的 `element_uuid`、当前 3D 设备 `instance_id`、端子显示名/脚号等上下文。当前第一阶段会按这些上下文消歧;如果导线任务缺少设备上下文,可能会进入缺端点或端子 UUID 不匹配诊断,而不是随便连到第一个同 UUID 端子。
预检还会对 `devices[].terminals[]``wires[]` 做一致性检查。如果 JSON 中某个设备端子没有被任何导线端点引用,会追加 `payload_terminals_without_wires / 输入端子未被导线引用`,中文报告显示“未被 wires[] 引用的端子”,并在 `QetDiagnosticJson.unreferenced_payload_terminal_samples[]` 中保留设备标签、端子显示名、端子 UUID 和端子实例 ID。这个诊断用于暴露类似 `ID:27/as` 这类“现场预期有线,但当前 wires[] 没有任务”的疑点;它不是硬错误,因为端子也可能本来就是未接线端子。若现场确认该端子确实应该接线,应把样例发给 QET 侧核对导线导出逻辑或原理图连接数据。
`RoutingPreflight` 还会附带 compact 路径网络诊断。若已标记 `CabinetInterior`,但主路径 carrier 或工程端子越出柜内边界,预检报告会直接追加 `route_carriers_outside_boundary``terminals_outside_boundary`,并在中文摘要中给出“越界路径”或“越界端子”样例。这样用户在生成导线前就能发现装配态问题。
预检的端点缺失示例会同时显示导线标签和端子对,例如 `导线 N4111terminal-start -> terminal-missing`。这用于第一时间判断问题来自哪条 QET 导线任务、哪个端子 UUID 没有绑定到 FreeCAD 工程端子。
@ -206,6 +210,8 @@ QetTemplateSlotName
4. 如果同一设备下 2D 端子数量和 3D 模板槽位数量一致,允许按顺序兜底匹配,但必须写诊断提示。
5. 仍无法匹配时,保留为 `local:*` 本地端子,不参与可靠自动布线。
这里的“端子显示名/脚号”就是 QET 设备属性窗口和“编辑接线处”里看到的端子号,对应 `2d_to_3d.json` 中的 `terminal_display`,以及导线端点里的 `start_terminal_display / end_terminal_display`。例如 QET 接线处端子号为 `1`、`2`,导出后应分别作为对应端子的 `terminal_display`FreeCAD 用它匹配 3D 模板中的 `QetTemplateSlotName=1/2`,也用它在 `terminal_uuid` 重复时辅助判断应该连接到哪一个 3D 端子。
#### 1.5.4 QET 需要配合提供的数据
第一版数据库仍只使用 `project_2d3d_symbol_binding``project_2d3d_terminal_binding`。交换 JSON 中当前应优先使用已有的端子显示字段作为匹配提示:
@ -221,8 +227,12 @@ QetTemplateSlotName
这些字段只作为 FreeCAD 匹配模板槽位的提示,不写入第一版绑定表,也不能替代 `terminal_uuid`
其中 `terminal_display` 的来源就是 QET 端子号;它是人可读的端子/接线处编号,也是 3D 模板槽位匹配和重复端子消歧的重要提示字段。
`slot_name_hint` 只是 FreeCAD 侧预留的可选扩展字段。当前 QET 如果没有该字段,不需要为了第一版专门增加;只要 `terminal_display` / `start_terminal_display` / `end_terminal_display` 能稳定表示设备脚号,就可以完成槽位匹配。
当前测试工程的 v2 JSON 已经暴露出一个长期边界:`terminal_uuid` 和输入侧 `terminal_instance_id` 可能重复。FreeCAD 第一阶段会短期兜底,用 `device_instance_id / element_uuid / terminal_uuid / terminal_display` 识别具体 3D 端子,并在回写时生成不重复的 `terminal_instance_id`。但是如果 QET 侧仍按 `project_2d3d_terminal_binding(project_uuid, terminal_uuid)` 作为唯一键落库,就无法完整保存同一个 `terminal_uuid` 下的多个 3D 端子绑定。第一阶段 FreeCAD 不要求立即修改 QET 代码;后续 QET 若要完整消费 `3d_to_2d.json.terminals[]`,需要提供真正唯一的 2D 端子实例标识,或明确以 `terminal_instance_id` 作为 3D 端子绑定写回键。
QET 侧还需要保证导线任务中继续提供:
```json
@ -332,7 +342,7 @@ FreeCAD 的 `3D 布线连接` 面板提供“主路径桥接容差 mm”数值
`TerminalAccess` 定位为端子局部接入线,只用于把端子出口引到柜内主路径附近。最终导线的主路径搜索不会把 `TerminalAccess` 当作公共 transit carrier也不会用它桥接两段线槽或 `UserPath` 的缺口;入口候选排序也会优先选择线槽、`UserPath`、过线孔等真实主路径,避免导线贴到其它端子的局部接入线上起步。这类缺口应通过线槽、`UserPath`、过线孔或主路径自动桥接来解决。
路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。
路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,并显示接入目标路径,例如远处线槽、`UserPath` 或 `RoutingRange` 布线面。`long_terminal_accesses[]` 会保留 `target_kind / target_name / target_label / target_rule / target_distance_mm`,用于判断问题是主路径入口离端子太远、局部出线路径缺失,还是当前只能退回布线面。处理建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。
面板还提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。最大偏移用于限制密集共路时的显示错位范围,避免 lane 序号过大时把导线显示到线槽或柜体外。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。
@ -528,7 +538,9 @@ terminal_access_max_distance = 1000.0
`terminal_exit_length` 决定端子出线段长度;`terminal_access_max_distance` 决定端子出线点到最近路由网络的最大允许接入距离,并同时约束最终导线路由入口候选,避免用户调小端子接入距离后,最终求路仍跨很远接入孤立网络。两个参数都只保存在当前 FreeCAD 面板/调用选项中,不写数据库。
网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。
网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;同时保留 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_carrier_kind/name/label` 和接入折线的轴向长度。这样手动验收时可以直接判断问题来自端子 LCS/出线方向、设备摆放距离、主路径入口缺失,还是最近的线槽/UserPath/过线孔已经存在但没有接到端子局部路径。面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。
网络检查和批量布线摘要也会记录端子出线校正/截断样例。`corrected_terminal_exits[]` 与 `capped_terminal_exits[]` 会保留 `origin`、`exit_point`、`exit_direction`、`original_exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`max_exit_length_mm`、`device_exit_required_length_mm`、`local_route_used` 和 `local_route_point_count`。这样可以直接判断问题是默认 LCS 朝向不合适、显式方向朝向设备内部、设备包围盒过大,还是已经使用局部出线路径但局部路径本身不合理。
### 4.4 悬空线策略
@ -639,11 +651,11 @@ QetWiringCutOutBridgeExtensionMm = 20.0
`route_samples[]` 不是简单截取前几条导线,而是优先保留带 `issue_codes` 的问题路线;问题数量相同或没有问题时,再按原生成顺序保留。这样当一次布线有很多正常线、少量异常线时,压缩诊断对象仍会优先给出异常样例,避免手动测试复制 JSON 后看不到真正需要处理的导线。
一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。
一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。`auto_diagnostic_bridges` 摘要会保留未接入端子接入段的目标数、生成数、重复数和配对标签;中文报告会显示“未接入端子接入段 X 个,生成 Y 条”,便于判断自动补的是端子接入段到最近路径的短桥,而不是普通线槽孤岛桥接。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `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 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。
线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 `unconnected_terminals[]` 中已经明确记录 `access_carrier``nearest_network_carrier_name/label` 的样例,诊断桥接会生成 `UnconnectedTerminalAccessBridge`,把该端子的 `TerminalAccess` 接入段补到最近路径;报告会单独输出 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,用于审计这类桥接和普通线槽桥接、端子退回补桥的区别。这个动作仍只依赖 FreeCAD 当前几何网络,不要求 QET 提供 3D 路径。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。
孤立路径网络诊断只针对可行动的路径组件。线槽、UserPath、过线孔、辅助路径和端子接入如果分成多个组件会继续输出 `isolated_network_components`;但纯 `RoutingRange` 布线面孤岛只作为兜底网格保留在 `components` 明细中,不再单独触发“存在孤立路径网络”问题码。这样可以避免真实工程中安装板/布线面网格被误当作主路径断网问题,手测时优先处理线槽、用户路径和端子局部接入。
@ -834,7 +846,11 @@ tests/python/freecad_exchange_auto_routing_test.py
29. 自动布线支持通过 `adjoining_duct_tolerance` 调整相邻主路径端点桥接和端点到中段投影桥接容差,并在网络结果中记录桥接段数量。
30. `3D 布线连接` 面板提供“主路径桥接容差 mm”设置面板生成/检查/布线流程会使用该值;网络检查报告会显示自动桥接段数。
31. `3D 布线连接` 面板提供“端子接入最大距离 mm”和“端子出线长度 mm”设置用于适配真实机柜里端子离线槽远近不同、设备端子方向不同的情况。
32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离和端子出线长度,并在中文报告里显示最大接入距离。
32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离、端子出线长度、端子出口点、端子接入折线、最近网络点和最近网络对象 kind/name/label并在中文报告里显示最大接入距离。
32.1. 当 `TerminalAccess` 只能退回 `RoutingRange` 布线面时,路径网络诊断会记录退回目标、最近线槽/UserPath/过线孔主路径、最近主路径距离和当前最大接入距离;这些字段只来自 FreeCAD 当前几何网络,不要求 QET 提供。
32.2. 当 `TerminalAccess` 为了避开端点设备包围盒而绕行时,路径网络诊断会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`,用于判断接入段是否仍可能穿过设备或是否需要修正设备模板局部路径。
32.3. `按诊断建议生成桥接` 处理 `terminal_access_fallback_targets[]` 时,会优先使用诊断样例中的 `nearest_main_path_name / nearest_main_path_label` 作为桥接目标;如果该对象在当前文档中找不到,才退回到最近主路径。这样能让“诊断看到的问题”和“自动补桥的对象”保持一致。
32.4. `unconnected_terminals[]` 如果同时记录 `access_carrier``nearest_network_carrier_name/label`,推荐动作会提示点击 `按诊断建议生成桥接`,并生成 `UnconnectedTerminalAccessBridge` 把该端子的接入段补到最近路径;控制器报告会保留目标数、生成数、重复数和 `接入段 -> 最近路径` 配对标签,方便手动验收时确认补的是哪个端子的局部接入缺口。
33. 批量布线报告会显示路径网络自动桥接段数和主动避障屏蔽段数,方便核对调参和避障是否实际参与求路。
34. 批量布线报告会显示最大 lane 编号、lane 间距和最大偏移,方便确认多根线共路时是否发生了可视错位,以及偏移上限是否参与显示。
35. `QetRouteTrackJson` 的 carrier payload 会记录 `capacity`,方便后续分析线槽容量偏好和共路绕行。
@ -881,7 +897,7 @@ tests/python/freecad_exchange_auto_routing_test.py
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[]` 中反向选择端子对象,便于检查端子高度、设备装配和局部出线路径。该功能只定位端子,不修改端子或路径数据。
69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象;当样例记录了 `access_carrier``target_name/target_label` 时,会同时选中端子的 `TerminalAccess` 接入段和目标路径,便于直接判断长接入是设备局部路径缺失、主路径入口过远,还是退回了布线面。该功能只定位对象,不修改端子或路径数据。
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 不匹配”的问题。该功能只定位候选端子,不自动改绑定、不写数据库。
@ -1123,7 +1139,7 @@ QetTerminalLocalRoutePointsJson
导入/更新工程端子时FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。
路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。
路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。如果局部路径点数有效,但最后一个局部出口点仍落在端点所属设备包围盒内,诊断原因会记录为 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point``endpoint_device_bbox`;实际生成 TerminalAccess 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部接入主路径。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。
如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数:
@ -1300,6 +1316,14 @@ PE 线优先路径
13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。
14. 如果 `wires[].wire_style_id` 能在 `wire_properties` 中解析,生成导线会使用对应的显示颜色、线宽和线型;解析失败时诊断显示 `Missing`,但仍按默认蓝色样式生成导线。
15. “生成布线连接”后的 `RoutingConnectionBatch` 诊断对象保存最终 report包括 `hidden_route_carriers`、`routing_path_network_updated`、路径网络检查结果和 `no_routed_connections` 等问题码。
16. “生成布线连接”后会默认隐藏 `WireDuct` / `RoutingRange` / `TerminalAccess` 等 route carrier 辅助对象,同时强制显示 `QETWiring_04_Routed``RoutedConnection` 导线对象;批量诊断会写入 `routed_wire_visibility``route_carrier_visibility`
17. 如果导线带有 `wire_style_id` 或已解析的 `QetWireStyleJson`,但没有实际写入 `ViewObject` 显示样式,诊断会写入 `wire_style_application.missing_application` 并追加 `wire_styles_not_applied` 问题码。
18. 黑色导线通过 `QetWireStyleApplied``QetAppliedWireLineColorRgb` 判断:`QetWireStyleApplied=true` 且 RGB 为 `0,0,0` 表示数据库样式本身是黑色;有样式 ID 但 `missing_application>0` 才表示样式未实际渲染。
19. 对当前 v2 JSON 中 `terminal_uuid``terminal_instance_id` 重复的短期风险FreeCAD 会在导入和布线预检时按 `device_instance_id / element_uuid / terminal_uuid / terminal_display / slot_name_hint` 消歧,并为重复或缺失的 `terminal_instance_id` 生成稳定 3D 端子实例 ID该 ID 不依赖 QET 导出顺序,`3d_to_2d.json` 回写时禁止退化成设备 `device_instance_id`
20. 正式 QET 工程中的端子排、断路器批量装配优先走 `待装配设备 -> 批量插入同组到选中目标`,沿导轨按显示编号顺序插入真实 QET 设备并同步工程端子;`3D手动布线` 面板中的批量端子排/断路器按钮只作为旧流程或无 QET 数据时的演示兜底。
21. 对已保存的旧工程或现场调试工程,`整理验收视图` 可以不重跑布线地隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线样式;它用于 GUI 验收视图整理,不改变 QET 数据和布线路径结果。
22. 端子导入和重导入时FreeCAD 只把 `QETDevice_*` 设备组当作父设备;历史端子对象即使带有相同 `QetElementUuid`,也不能被误当成设备组。这样可以避免多 2D 元件共用同一 3D 设备实例时,把新端子挂到旧端子对象下面。
23. 写回 `3d_to_2d.json` 前,如果能从传入 payload、`QET_2D_TO_3D_JSON` 或场景同目录读取当前 `2d_to_3d.json`FreeCAD 会先按该快照同步工程端子,再收集 `instances[] / terminals[]`。保存 FCStd 时会在保存开始阶段先执行同一同步,使端子修复也进入 FreeCAD 工程文件。这样旧工程保存时也能回写完整端子绑定,例如当前测试工程应输出 `instances=86 / terminals=142`,且 `terminal_instance_id` 不重复。
## 10. 开发验证命令

@ -468,14 +468,16 @@ QET模板 -> 导入模板实例
1. 从 QET 点击 `3D视图` 打开 FreeCAD确认树目录中已经有断路器设备。
2. 选中要安装断路器的导轨。
3. 切换到 `QET模板`
4. 打开 `3D手动布线`。
5. 点击 `批量断路器`。
6. `QET断路器前缀` 中输入实际设备前缀,例如 `QF`。
7. 输入断路器间距和起始偏移
8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布
9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留
4. 打开 `待装配设备`。
5. 在清单中选中同组断路器中的任意一个,例如 `QF1``QF:1`。
6. 设置 `批量间距``起点偏移`。
7. 点击 `批量插入同组到选中目标`
8. 系统会按显示编号顺序,把同一前缀的 QET 真实设备沿导轨排布,并在插入后同步工程端子
9. 如果只想插入单个设备,仍使用 `插入到选中目标`
只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。
批量插入完成后,状态栏会显示本次匹配到的同组前缀、插入数量和前几个设备标签,例如 `已批量插入同组 QF 待装配设备 8 个QF1、QF2...`。如果只插入了 1 个,通常表示当前待装配清单中只有这个前缀的一个设备,或选中的设备标签没有和其它同组设备形成同一前缀,应先刷新清单并检查 QET 设备显示名。
`3D手动布线` 面板里的 `批量断路器` 属于旧流程/演示兜底:它会优先排布已有 QET 设备,但找不到匹配设备时会生成本地演示对象。正式 QET 工程优先使用 `待装配设备`,避免生成没有真实 2D 端子绑定的假设备。
常见间距:
@ -495,12 +497,14 @@ QET模板 -> 导入模板实例
2. 确认树目录中已经有 `UD`、`ID` 等端子排相关设备。
3. 选中要安装端子排的导轨。
4. 切换到 `QET模板`
5. 打开 `3D手动布线`
6. 点击 `批量端子排`
7. 在 `QET端子排名称/前缀` 中输入 `UD``ID`
8. 输入端子片间距,例如 `5.2 mm`,以及起始偏移。
9. 确认后,系统会把匹配的 QET 真实端子片沿导轨按顺序排布。
10. 如果状态提示 `已排布 QET 端子排`,说明工程端子和 `terminal_uuid` 没有被替换成本地端子。
5. 打开 `待装配设备`
6. 在清单中选中同组端子排中的任意一个,例如 `UD:1`、`UD:8` 或 `ID:6`
7. 设置 `批量间距`,端子片常用 `5.2 mm`,再设置 `起点偏移`
8. 点击 `批量插入同组到选中目标`
9. 系统会按显示编号顺序,把同一前缀的 QET 真实端子片沿导轨排布,并在插入后同步工程端子。
10. 如果只想插入单个端子片,仍使用 `插入到选中目标`
状态栏会显示端子排前缀和前几个端子片标签,便于确认是否插入了正确的 `UD`、`ID` 等同组真实 QET 设备。这个批量入口不会生成本地假端子片;它只排布仍处于待装配状态的 QET 设备,并在插入后同步工程端子。
例如本仓库生成的端子片:
@ -522,6 +526,8 @@ data/examples/qet_terminal_block/qet_terminal_slice.FCStd
4. X 方向间距设为 `5.2 mm`
5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程。
`3D手动布线` 面板里的 `批量端子排` 仍保留,主要用于旧工程和无 QET 数据的演示兜底。第一阶段正式验收时,端子排批量装配应优先走 `待装配设备 -> 批量插入同组到选中目标`,这样 2D 端子、3D 设备和工程端子仍然来自同一份 QET v2 交换数据。
### 9.4 摆放电流互感器
电流互感器一般装在安装板或导轨附近。
@ -747,7 +753,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
11. 选择线槽,点击 `标记为线槽`
12. 用 `QET模板 -> 导入模板实例` 导入断路器、互感器、端子排等 FCStd 模板。
13. 用 `Assembly``Placement` 把设备摆到导轨上。
14. 点击 `QET模板 -> 生成工程端子`
14. 正常 QET 流程下FreeCAD 会在导入 `2d_to_3d.json` 时自动生成/更新工程端子;只有预检提示缺工程端子,或设备是手工导入模板时,才点击 `QET模板 -> 生成工程端子` 兜底
15. 打开 `3D手动布线`
16. 选择导线任务,或手动选起点端子。
17. 沿线槽添加折点。
@ -763,7 +769,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
2. 导轨已经贴到安装板或背板上。
3. 线槽已经放到柜内,或已经用草图/Draft 线定义用户主路径。
4. QET 导入的真实设备实例已经摆到导轨或安装板上。
5. 已点击 `生成工程端子`工程端子能在 `QETTerminals_*` 分组中看到。
5. 工程端子能在 `QETTerminals_*` 分组中看到;正常 QET 导入会自动生成,`生成工程端子` 只作为异常兜底按钮
6. 如需限制导线不能跑出柜外,选择柜内空间、柜体或辅助包围盒,点击 `选中对象作为柜内边界`
完成后按下面顺序检查:
@ -789,6 +795,12 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
每次点击 `检查布线准备度`,树目录 `QETWiring_05_Diagnostics` 下会刷新一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid``QetDiagnosticOk` 表示预检是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 是中文摘要;展开属性中的 `QetDiagnosticJson`,可以查看缺失端点、路径源数量、柜内边界数量、路径网络诊断和导线样式库状态。这个对象只保存最新一次预检结果,避免多次测试后诊断对象堆积。
如果报告出现 `duplicate_3d_terminal_uuids / 3D端子UUID重复`,说明当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`。这不是立刻禁止布线的硬错误,但说明不能只靠端子 UUID 定位端子FreeCAD 会继续使用导线端点的 `element_uuid`、3D 设备 `instance_id` 和端子显示名/脚号来消歧。如果后续同时出现缺端点、端子 UUID 不匹配或只接到错误设备,应优先检查 QET 导线端点是否带有 `element_uuid`,以及当前 3D 设备是否已经正确装配和绑定。
这里的端子显示名/脚号就是 QET 界面“编辑接线处”里的端子号,对应 `2d_to_3d.json``terminal_display`,以及 `wires[]` 端点中的 `start_terminal_display / end_terminal_display`。手测缺端子或接错端子时,可以直接用诊断里的 `*_terminal_display` 回到 QET 界面核对端子号。
如果报告出现 `payload_terminals_without_wires / 输入端子未被导线引用`,说明 `2d_to_3d.json``devices[].terminals[]` 中存在某个端子,但当前 `wires[]` 没有任何导线端点引用它。面板中文报告会显示“未被 wires[] 引用的端子”,诊断 JSON 的 `unreferenced_payload_terminal_samples[]` 会给出设备标签和端子显示名,例如 `ID:27/as`。这不一定是错误,因为端子可能本来未接线;如果现场确认该端子应该有线,应把这个样例交给 QET 侧检查原理图连接或 `wires[]` 导出。
`检查布线准备度` 默认不再抽样求解导线可达性,避免真实机柜中大量设备、路径 carrier 和障碍对象导致预检长时间卡住。需要排查少量导线是否能连通时,再把面板里的 `可达性抽样` 数量从 `0` 调到 1、5 或更高;这个抽样只用于诊断,不影响正式点击 `生成布线连接` 时的全量布线。
预检阶段也会读取路径网络诊断摘要。如果已经标记 `CabinetInterior`,但工程端子或主路径 carrier 越出柜内边界,`检查布线准备度` 会显示“路径网络检查提示”,并带出“越界端子”或“越界路径”样例。这样可以在生成导线前发现装配位置、端子 LCS 或用户路径本身的问题。
@ -805,7 +817,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 mm`,也不会让超过最大距离的端子强行接入。
如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;状态栏会显示本次样例里的最大最近网络距离。选中后先确认设备是否已经装配到柜内正确位置,再看端子附近是否缺线槽入口、过线孔、黄色 `UserPath` 或设备局部出线路径;如果装配和路径都合理,但实际柜型允许更长的局部接入,再考虑调大 `端子接入最大距离 mm`
如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;如果诊断样例里有 `access_carrier`,会同时选中该端子的 `TerminalAccess` 接入段;如果诊断样例里有 `nearest_network_carrier_name/label`,也会同时选中最近的线槽、过线孔、黄色 `UserPath` 或其它路径 carrier。状态栏会显示本次样例里的最大最近网络距离、接入段数量和最近路径数量。`QetDiagnosticJson.unconnected_terminals[]` 会保存 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_distance_mm`、`nearest_network_carrier_kind/name/label` 和接入折线的主轴长度,便于判断是端子 LCS/出线方向问题、设备摆放距离过远,还是最近线槽、过线孔、黄色 `UserPath` 或设备局部出线路径没有接上。选中后先确认设备是否已经装配到柜内正确位置,再看端子附近是否缺主路径入口;如果装配和路径都合理,但实际柜型允许更长的局部接入,再考虑调大 `端子接入最大距离 mm`
如果有线槽但导线仍大量走布线面,优先看 `RoutingPathNetwork.QetDiagnosticIssueCodes` 是否包含 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`。这个问题表示线槽已经识别成路径 carrier但它所在的路径组件没有任何 `TerminalAccess`,导线很难自然进入线槽。中文报告会尽量显示“建议桥接到哪个主网络”和最近距离;`QetDiagnosticJson.wire_ducts_without_terminal_access[].bridge_suggestion` 会保存建议连接的两段 carrier、两个最近点和距离。处理方式是在 FreeCAD 中用 UserPath、线槽开口或桥接路径把线槽组件接到端子接入所在的主网络再重新生成布线路径网络和导线。
@ -913,10 +925,12 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
选中生成的导线对象后,可以在 `QetRouteDiagnosticsJson.endpoint_access.start_diagnostics / end_diagnostics` 中查看每侧端子的 `exit_rule`、`exit_direction_source`、`exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`device_exit_required_length_mm` 和 `exit_length_capped`。如果 `exit_rule=local_route`,说明该端子正在使用 `QetTerminalLocalRoutePointsJson` 局部出线路径;如果 `exit_length_capped=true`,说明这侧端子按当前显式方向无法在合理长度内离开设备包围盒,后续容易出现端子附近悬空过长或穿模,应优先修正端子方向或给该端子设置局部出线路径。
点击 `检查布线路径网络` 时,也会提前汇总端子出线问题。`corrected_terminal_exits[]` 表示默认 LCS 出线方向被系统自动改到最近侧向出口,通常说明设备模板端子方向还需要复查;`capped_terminal_exits[]` 表示端子按当前显式方向或默认方向无法在最大出线长度内离开设备包围盒,系统已经截断出线段。两个数组都会保留端子名、端子 UUID、父设备、原始方向、实际方向、请求长度、实际长度和上限,便于手动验收时先定位设备端子,再决定是修模板 CPoint、设置工程端子局部出线还是补主路径入口。
点击 `检查布线路径网络` 时,也会提前汇总端子出线问题。`corrected_terminal_exits[]` 表示默认 LCS 出线方向被系统自动改到最近侧向出口,通常说明设备模板端子方向还需要复查;`capped_terminal_exits[]` 表示端子按当前显式方向或默认方向无法在最大出线长度内离开设备包围盒,系统已经截断出线段。两个数组都会保留端子名、端子 UUID、父设备、原始方向、实际方向、端子原点 `origin`、实际出线点 `exit_point`、请求长度、实际长度、上限、是否使用局部路径 `local_route_used` 和局部路径点数,批量布线摘要也会保留这些字段。手动验收时可以先定位设备端子,再决定是修模板 CPoint、设置工程端子局部出线还是补主路径入口。
如果 `QetTerminalExitDirectionJson` 格式错误、方向向量无法解析或方向长度为 0路径网络诊断会额外输出 `invalid_terminal_exit_directions[]`。这种情况不会让 FreeCAD 依赖 QET 计算 3D 路径,而是明确提示当前 FreeCAD 文档或设备模板中的 CPoint 方向元数据需要修正;可以用 `选中端子设置出线方向` 重写当前工程端子的显式方向,或回到设备模板中修正后重新导入。
如果 `QetTerminalLocalRoutePointsJson` 能解析出点,但局部路径终点仍在端点所属设备包围盒内,路径网络诊断会把该端子记入 `invalid_terminal_local_routes[]`,原因是 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point``endpoint_device_bbox`。实际生成 `TerminalAccess` 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部拉线。手动验收时应重新画一条真正离开设备外轮廓的局部路径,或修正设备模板端子 CPoint/RPoint。
如果要直接定位这些端子,点击 `选择出线问题端子`。系统会从最新 `RoutingPathNetwork` 诊断中合并选择 `corrected_terminal_exits[]`、`capped_terminal_exits[]`、`invalid_terminal_exit_directions[]` 和 `invalid_terminal_local_routes[]` 对应的端子及父设备;这个操作只负责定位,不会自动改端子方向或重新布线。选中后先看端子 LCS 朝向、显式 `QetTerminalExitDirectionJson`、局部路径 `QetTerminalLocalRoutePointsJson`、设备包围盒是否过大,再决定是否设置显式出线方向、设置局部出线路径或回到设备模板修正 CPoint。
每个自动生成的 `TerminalAccess` carrier 会记录接入目标:`QetTerminalAccessTargetKind / Name / Label / DistanceMm` 表示端子局部出口接到哪条线槽、`UserPath`、过线孔或面板路径;`QetTerminalAccessTargetRule` 表示选择规则,`main_path_nearest` 是直接接入最近主路径,`main_path_preferred_over_fallback` 是附近虽有 `RoutingRange` 等兜底路径但系统仍优先接入主路径,`fallback_only` 表示当前找不到线槽/UserPath/过线孔等主路径,只能退回面板路径或辅助路径。`QetTerminalAccessFallbackTarget=1` 时,应优先补线槽入口、黄色草图 `UserPath`、过线孔或设备局部路径,再重新生成布线路径网络。
@ -925,15 +939,15 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。
点击 `检查布线路径网络` 时,诊断 JSON 也会汇总 `terminal_access_fallback_targets[]``terminal_access_endpoint_device_avoidance[]`。前者表示某些端子接入只能退回 `RoutingRange` 等兜底路径,通常需要补线槽入口、`UserPath` 或过线孔;后者表示某些端子接入段已经为了避开端点设备做了绕行,后续我进行手动验收时会优先检查这些端子附近是否缺设备局部出线路径或主路径入口。这两个数组都包含端子名、端子 UUID、父设备、`TerminalAccess` 接入段对象名、目标路径类型、目标路径对象名、`access_points[]` 和 `access_length_mm`,便于自动定位对象并判断接入段是否过长、是否绕回设备附近。
如果要定位端子接入退回到布线面的对象,点击 `选择端子退回位置`。该按钮既能读取独立 `RoutingPathNetwork.terminal_access_fallback_targets[]`,也能读取批量布线诊断里的端子退回样例;只执行 `检查布线路径网络`、还没有生成导线时,也可以先选中端子、父设备、`TerminalAccess` 接入段和退回目标,判断应该补线槽入口、黄色 `UserPath`、过线孔还是设备局部出线路径。
如果要定位端子接入退回到布线面的对象,点击 `选择端子退回位置`。该按钮既能读取独立 `RoutingPathNetwork.terminal_access_fallback_targets[]`,也能读取批量布线诊断里的端子退回样例;只执行 `检查布线路径网络`、还没有生成导线时,也可以先选中端子、父设备、`TerminalAccess` 接入段和退回目标,判断应该补线槽入口、黄色 `UserPath`、过线孔还是设备局部出线路径。退回样例会记录 `target_kind / target_label / target_distance_mm`,并在存在主路径时记录 `nearest_main_path_kind / nearest_main_path_label / nearest_main_path_distance_mm / terminal_access_max_distance_mm`;如果最近主路径距离大于最大接入距离,通常说明需要在端子和线槽/UserPath 之间补桥接路径,或在确认工程允许时调大 `端子接入最大距离 mm`
如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`。生成后重新执行 `生成布线路径网络``生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面,说明需要补更明确的主路径入口或设备局部出线路径。
如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`如果诊断样例里已经记录了 `nearest_main_path_name / nearest_main_path_label`,桥接会优先连接这条诊断推荐主路径;找不到推荐对象时才退回到当前文档里最近的主路径。对于 `unconnected_terminals[]` 中同时存在 `access_carrier``nearest_network_carrier_name/label` 的样例,该按钮也会在端子 `TerminalAccess` 接入段和最近路径之间生成 `UnconnectedTerminalAccessBridge`,用于补齐端子接入段到线槽/UserPath 的短缺口;这类样例存在时,汇总诊断的推荐动作会明确提示点击 `按诊断建议生成桥接`。按钮报告会单独统计 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,状态栏会显示“未接入端子接入段 X 个,生成 Y 条”,便于区分这是未接端子补桥,不是线槽孤岛补桥或端子退回布线面补桥。生成后重新执行 `生成布线路径网络``生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面或未接入,说明需要补更明确的主路径入口或设备局部出线路径。
如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。
如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。诊断样例会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`;如果后续仍看到导线穿设备,可以对照这些点和包围盒判断是端子局部路径方向不合理、父设备包围盒过大,还是主路径入口仍在设备背后。
`检查布线路径网络` 和批量布线的 `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[]` 会保留长接入样例。样例里包含 `parent_device_label / parent_device_name`、`terminal_origin`、`terminal_access_points`、`terminal_access_dominant_axis`、`terminal_access_axis_lengths_mm`,以及接入目标 `target_kind / target_label / target_distance_mm`。如果目标是远处线槽或远处 `UserPath`,优先补设备局部出线路径或把主路径入口靠近端子;如果目标是 `RoutingRange` 布线面,优先补线槽入口、过线孔或黄色 `UserPath`。如果 `terminal_access_dominant_axis``z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中;如果样例里记录了 `access_carrier``target_name/target_label`,会同时选中该端子的 `TerminalAccess` 接入段和目标线槽/`UserPath`/布线面。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。
如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。
@ -1097,7 +1111,7 @@ FreeCAD 版本/运行目录:
3. 校验端子。
4. 保存为 FCStd。
5. 回到工程场景重新导入或更新设备。
6. 点击 `生成工程端子`
6. 如果设备是通过 `待装配设备` 面板插入,当前版本会在插入后自动同步工程端子;如果设备是旧流程手工导入、模板刚修改过,或诊断提示缺工程端子,再点击 `生成工程端子` 做一次补生成
### 15.2 为什么不能布线?
@ -1160,7 +1174,29 @@ scene.FCStd
QET 侧只依赖最小绑定字段找到对应设备和端子。
### 15.5 当前截图里的 `Gears` 应该怎么处理?
### 15.5 为什么黑色线和辅助网格要看诊断字段?
生成布线连接后,系统会把导线显示状态和辅助路径显示状态写入 `RoutingConnectionBatch` 诊断:
- `routed_wire_visibility`:统计 `QETWiring_04_Routed` 下导线是否可见。GUI 中如果 `hidden>0`,说明生成后仍有导线不可见。
- `route_carrier_visibility`:统计 `WireDuct`、`RoutingRange`、`TerminalAccess` 等辅助 carrier 是否仍可见。默认应隐藏,避免手动验收时把网格误认为导线。
- `wire_style_application`:统计有样式数据的导线是否已经实际应用到 `ViewObject`
- `available_terminals` / `available_terminal_objects`:统计当前 FreeCAD 工程里的实际 3D 工程端子对象数量。`unique_terminal_uuids` 只表示去重后的 `terminal_uuid` 数量;当前 QET v2 数据里同一个 `terminal_uuid` 可能对应多个设备/脚号,所以手测时应优先看 `available_terminals` 是否接近 `2d_to_3d.json` 中的端子总数。
- `duplicate_payload_terminal_instance_id_count`:统计 `2d_to_3d.json` 输入里重复的 `terminal_instance_id` 组数。第一阶段 FreeCAD 会按设备实例、端子 UUID、端子显示名生成稳定兜底 ID保证 `3d_to_2d.json` 不把端子 ID 退化成设备 ID但这仍表示 QET 输入数据有重复风险,后续建议 QET 侧提供真正唯一的端子实例 ID。
保存或手动执行写回时,如果 FreeCAD 能找到当前 `2d_to_3d.json`,会先按 JSON 快照同步工程端子,再生成 `3d_to_2d.json`。保存流程会在写入 FCStd 前执行这一步,因此保存下来的 FreeCAD 工程也会带上端子修复结果,而不是只修正回写 JSON。因此正常 QET 流程下不需要为了回写再单独点一次 `生成工程端子`;只有旧流程手工导入模型、模板刚改过、或诊断明确提示缺工程端子时,才把 `生成工程端子` 当作兜底修复按钮。
端子对象本身也会带 `QetElementUuid`,但它不能作为设备父级。当前版本查找父设备时只接受 `QETDevice_*` 设备组,避免旧工程里同 UUID 端子对象被误当作设备,导致新端子挂到旧端子下面。手测时如果发现 `QETTerminal_*` 下面又出现 `QETTerminals_*` 分组,说明工程可能来自旧版本,需要重新导入或保存触发端子同步后再检查。
黑色导线不一定是错误。若导线对象上 `QetWireStyleApplied=true`,并且 `QetAppliedWireLineColorRgb=0,0,0`,表示 `wire_properties` 中的线色本来就是黑色;若 `wire_style_application.missing_application>0`,才表示样式没有真正渲染。
批量布线中文报告也会显示 `黑色导线N 条来自 wire_properties 样式`。看到这条提示时,表示这些黑线已经成功从导线样式库解析并应用,不是默认未渲染;只有同时出现 `导线样式实际应用异常``wire_styles_not_applied` 时,才按样式渲染失败处理。
在 FreeCADCmd 这类无 GUI 验证环境中,可见性可能显示为 `unknown_visibility`,这是因为 headless 模式读不到 `ViewObject.Visibility`,不代表 GUI 里一定隐藏。
如果打开旧工程后仍能看到很多布线路径网格、`RoutingRange` 面网格或 `TerminalAccess` 辅助线,但导线已经存在于 `04_Routed` 下,可以在 `3D布线连接` 面板点击 `整理验收视图`。该按钮不会重新布线、不会删除对象、不会写数据库;它只隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线和导线样式,适合手动验收前整理最终视图。
### 15.6 当前截图里的 `Gears` 应该怎么处理?
这是 Assembly 的齿轮约束任务,不适合机柜装配。
@ -1181,7 +1217,7 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。
4. 导轨、线槽、机柜可作为纯几何资产。
5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例Draft 阵列只作为无 QET 数据时的手工演示方式。
6. 每完成一段装配就保存一次 `scene.FCStd`
7. 布线前先生成工程端子
7. 布线前确认工程端子已经生成:通过 `待装配设备` 插入的设备会自动同步工程端子;旧流程手工导入或诊断提示缺端子时,再点击 `生成工程端子`
8. 生成布线连接前先建立布线路径网络。
9. 不要手动改工程绑定 UUID。
10. 不要依赖旧 3D 场景表保存位姿。

File diff suppressed because it is too large Load Diff

@ -419,11 +419,24 @@ class AutoRoutingController:
def check_routing_readiness(self):
doc = _active_document()
payload = getattr(App, "_qet_exchange_payload", None)
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
prepared_layout = None
try:
# 手动验收时用户常直接点“检查布线准备度”。这里先按当前文档刷新一次
# 路径网络,让预检反映真实可布线状态,而不是停留在“尚未点生成路径网络”。
prepared_layout = AutoRouting.generate_eplan_routing_path_network(
doc,
project_uuid=project_uuid,
options=self.routing_options(),
)
except Exception as exc:
prepared_layout = {"error": str(exc)}
self.last_report = AutoRouting.preflight_eplan_connections(
doc,
payload=payload if isinstance(payload, dict) else None,
options=self.routing_options(),
)
self.last_report["prepared_layout"] = prepared_layout
AutoRouting.write_routing_preflight_diagnostic(doc, self.last_report)
return self.last_report
@ -433,6 +446,11 @@ class AutoRoutingController:
AutoRouting.write_routing_diagnostic_summary(doc, self.last_report)
return self.last_report
def apply_phase1_acceptance_view(self):
doc = _active_document()
self.last_report = AutoRouting.apply_phase1_acceptance_view(doc)
return self.last_report
def select_top_collision_obstacles(self):
doc = _active_document()
summary = AutoRouting.collect_routing_diagnostic_summary(doc)
@ -1891,6 +1909,8 @@ class AutoRoutingController:
selected = []
selected_terminals = []
selected_devices = []
selected_access_carriers = []
selected_nearest_paths = []
selected_names = set()
missing_refs = []
max_distance = 0.0
@ -1967,16 +1987,45 @@ class AutoRoutingController:
else:
add_selection(parent_device, selected_devices)
access_carrier_name = str(sample.get("access_carrier", "") or "").strip()
if access_carrier_name:
access_carrier = doc.getObject(access_carrier_name)
if access_carrier is None:
remember_missing(access_carrier_name)
else:
add_selection(access_carrier, selected_access_carriers)
nearest_path_name = str(sample.get("nearest_network_carrier_name", "") or "").strip()
nearest_path_label = str(sample.get("nearest_network_carrier_label", "") or "").strip()
if nearest_path_name or nearest_path_label:
nearest_path = self._find_object_by_name_or_label(
doc,
nearest_path_name,
nearest_path_label,
)
if nearest_path is None:
remember_missing(nearest_path_name or nearest_path_label)
else:
add_selection(nearest_path, selected_nearest_paths)
self.last_report = {
"selected_unconnected_terminal_access_objects": len(selected),
"selected_unconnected_terminal_access_terminals": len(selected_terminals),
"selected_unconnected_terminal_access_devices": len(selected_devices),
"selected_unconnected_terminal_access_carriers": len(selected_access_carriers),
"selected_unconnected_terminal_access_nearest_paths": len(selected_nearest_paths),
"selected_unconnected_terminal_access_terminal_names": [
getattr(obj, "Name", "") for obj in selected_terminals
],
"selected_unconnected_terminal_access_device_names": [
getattr(obj, "Name", "") for obj in selected_devices
],
"selected_unconnected_terminal_access_carrier_names": [
getattr(obj, "Name", "") for obj in selected_access_carriers
],
"selected_unconnected_terminal_access_nearest_path_names": [
getattr(obj, "Name", "") for obj in selected_nearest_paths
],
"missing_unconnected_terminal_access_refs": missing_refs,
"max_unconnected_terminal_access_distance_mm": float(max_distance),
}
@ -2172,7 +2221,29 @@ class AutoRoutingController:
return None
selected = []
selected_names = set()
selected_access_carriers = []
selected_access_carrier_names = set()
selected_targets = []
selected_target_names = set()
missing_refs = []
def add_selection_object(obj):
if obj is None:
return False
obj_name = getattr(obj, "Name", "") or ""
if obj_name in selected_names:
return True
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)
return True
try:
Gui.Selection.clearSelection()
except Exception:
@ -2186,17 +2257,39 @@ class AutoRoutingController:
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
if not add_selection_object(obj):
continue
selected.append(obj)
access_name = str(sample.get("access_carrier", "") or "").strip()
access_obj = doc.getObject(access_name) if access_name else None
if access_obj is not None and add_selection_object(access_obj):
access_obj_name = getattr(access_obj, "Name", "") or ""
if access_obj_name not in selected_access_carrier_names:
selected_access_carrier_names.add(access_obj_name)
selected_access_carriers.append(access_obj)
target_obj = self._find_object_by_name_or_label(
doc,
str(sample.get("target_name", "") or "").strip(),
str(sample.get("target_label", "") or "").strip(),
)
if target_obj is not None and add_selection_object(target_obj):
target_obj_name = getattr(target_obj, "Name", "") or ""
if target_obj_name not in selected_target_names:
selected_target_names.add(target_obj_name)
selected_targets.append(target_obj)
self.last_report = {
"selected_long_terminal_accesses": len(selected),
"selected_long_terminal_names": [getattr(obj, "Name", "") for obj in selected],
"selected_long_terminal_access_carriers": len(selected_access_carriers),
"selected_long_terminal_access_carrier_names": [
getattr(obj, "Name", "") for obj in selected_access_carriers
],
"selected_long_terminal_access_targets": len(selected_targets),
"selected_long_terminal_access_target_names": [
getattr(obj, "Name", "") for obj in selected_targets
],
"missing_long_terminal_refs": missing_refs,
}
return self.last_report
@ -2857,6 +2950,10 @@ class AutoRoutingController:
total_suggestions = 0
total_duplicates = 0
total_stale = 0
unconnected_targets = 0
unconnected_created = 0
unconnected_duplicates = 0
unconnected_pair_labels = []
diagnostic_passes = 0
max_passes = 5
for _index in range(max_passes):
@ -2876,6 +2973,18 @@ class AutoRoutingController:
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)
unconnected_targets += int(
bridge_report.get("unconnected_terminal_access_bridge_targets", 0) or 0
)
unconnected_created += int(
bridge_report.get("unconnected_terminal_access_user_path_bridges", 0) or 0
)
unconnected_duplicates += int(
bridge_report.get("unconnected_terminal_access_bridge_duplicates", 0) or 0
)
unconnected_pair_labels.extend(
list(bridge_report.get("unconnected_terminal_access_bridge_pair_labels", []) or [])
)
# 新桥接会改变路径组件关系;继续诊断一轮,处理链式接入建议。
if created_count <= 0:
break
@ -2889,6 +2998,10 @@ class AutoRoutingController:
"duplicate_bridges": total_duplicates,
"stale_suggestions": total_stale,
"diagnostic_passes": diagnostic_passes,
"unconnected_terminal_access_bridge_targets": unconnected_targets,
"unconnected_terminal_access_user_path_bridges": unconnected_created,
"unconnected_terminal_access_bridge_duplicates": unconnected_duplicates,
"unconnected_terminal_access_bridge_pair_labels": unconnected_pair_labels,
"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),
@ -3596,6 +3709,12 @@ class AutoRoutingTaskPanel:
"汇总预检、路径网络和批量布线的最新诊断对象,便于手动测试后统一复盘。",
)
self.acceptance_view_button = _style_command_button(
QtWidgets.QPushButton(),
"整理验收视图",
"不重新布线,只隐藏 route carrier 辅助对象,并显示/重刷 04_Routed 导线和样式。",
)
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(), "保存")
@ -3650,6 +3769,7 @@ class AutoRoutingTaskPanel:
self.check_readiness_button,
self.route_connections_button,
self.diagnostic_summary_button,
self.acceptance_view_button,
self.clear_routes_button,
self.clear_carriers_button,
self.save_button,
@ -3728,6 +3848,7 @@ class AutoRoutingTaskPanel:
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.acceptance_view_button.clicked.connect(self.apply_phase1_acceptance_view)
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)
@ -3993,6 +4114,9 @@ class AutoRoutingTaskPanel:
terminal_fallback_created = result.get("terminal_access_fallback_user_path_bridges", 0)
terminal_fallback_duplicates = result.get("terminal_access_fallback_bridge_duplicates", 0)
missing_terminal_fallback_refs = list(result.get("missing_terminal_access_fallback_bridge_refs", []) or [])
unconnected_targets = result.get("unconnected_terminal_access_bridge_targets", 0)
unconnected_created = result.get("unconnected_terminal_access_user_path_bridges", 0)
unconnected_duplicates = result.get("unconnected_terminal_access_bridge_duplicates", 0)
if created <= 0:
detour_text = ""
if detour_pairs:
@ -4006,6 +4130,12 @@ class AutoRoutingTaskPanel:
int(terminal_fallback_targets or 0),
int(terminal_fallback_duplicates or 0),
)
unconnected_text = ""
if unconnected_targets:
unconnected_text = " 未接入端子接入段 {0} 个,已存在 {1} 条。".format(
int(unconnected_targets or 0),
int(unconnected_duplicates or 0),
)
missing_text = ""
if missing_detour_pairs:
missing_text = " 未找到配对:{0}".format("".join(missing_detour_pairs[:3]))
@ -4014,12 +4144,13 @@ class AutoRoutingTaskPanel:
"".join(missing_terminal_fallback_refs[:3])
)
self._set_status(
"未按诊断建议生成桥接。建议 {0} 条,已存在 {1} 条,失效 {2} 条。{3}{4}{5}请先点击“检查布线路径网络”或“汇总布线诊断”确认是否存在可桥接建议。{6}".format(
"未按诊断建议生成桥接。建议 {0} 条,已存在 {1} 条,失效 {2} 条。{3}{4}{5}{6}请先点击“检查布线路径网络”或“汇总布线诊断”确认是否存在可桥接建议。{7}".format(
suggestions,
duplicates,
stale,
detour_text,
terminal_fallback_text,
unconnected_text,
missing_text,
self.controller.summary(),
)
@ -4037,14 +4168,21 @@ class AutoRoutingTaskPanel:
int(terminal_fallback_targets or 0),
int(terminal_fallback_created or 0),
)
unconnected_text = ""
if unconnected_targets or unconnected_created:
unconnected_text = " 未接入端子接入段 {0} 个,生成 {1} 条。".format(
int(unconnected_targets or 0),
int(unconnected_created or 0),
)
self._set_status(
"已按诊断建议生成桥接 UserPath{0} 条。建议 {1} 条,已存在 {2} 条,失效 {3} 条。{4}{5}请重新生成布线路径网络/布线连接验证效果。{6}".format(
"已按诊断建议生成桥接 UserPath{0} 条。建议 {1} 条,已存在 {2} 条,失效 {3} 条。{4}{5}{6}请重新生成布线路径网络/布线连接验证效果。{7}".format(
created,
suggestions,
duplicates,
stale,
detour_text,
terminal_fallback_text,
unconnected_text,
self.controller.summary(),
)
)
@ -4401,17 +4539,21 @@ class AutoRoutingTaskPanel:
result = self.controller.select_unconnected_terminal_access_issues()
terminals = result.get("selected_unconnected_terminal_access_terminals", 0)
devices = result.get("selected_unconnected_terminal_access_devices", 0)
access_carriers = result.get("selected_unconnected_terminal_access_carriers", 0)
nearest_paths = result.get("selected_unconnected_terminal_access_nearest_paths", 0)
max_distance = float(result.get("max_unconnected_terminal_access_distance_mm", 0.0) or 0.0)
missing = list(result.get("missing_unconnected_terminal_access_refs", []) or [])
if terminals <= 0 and devices <= 0:
if terminals <= 0 and devices <= 0 and access_carriers <= 0 and nearest_paths <= 0:
self._set_status(
"未选择未接入端子。请先检查布线路径网络,确认存在 unconnected_terminals。"
+ self.controller.summary()
)
return
message = "已选择未接入端子:端子 {0} 个,设备 {1};最大最近网络距离 {2:.1f} mm。".format(
message = "已选择未接入端子:端子 {0} 个,设备 {1},接入段 {2} 条,最近路径 {3} 条;最大最近网络距离 {4:.1f} mm。".format(
terminals,
devices,
access_carriers,
nearest_paths,
max_distance,
)
if missing:
@ -4537,6 +4679,8 @@ class AutoRoutingTaskPanel:
try:
result = self.controller.select_long_terminal_accesses()
selected = result.get("selected_long_terminal_accesses", 0)
access_carriers = result.get("selected_long_terminal_access_carriers", 0)
targets = result.get("selected_long_terminal_access_targets", 0)
missing = list(result.get("missing_long_terminal_refs", []) or [])
if selected <= 0:
self._set_status(
@ -4544,7 +4688,11 @@ class AutoRoutingTaskPanel:
+ self.controller.summary()
)
return
message = "已选择长接入端子:{0} 个。".format(selected)
message = "已选择长接入端子:{0} 个,接入段 {1} 条,目标路径 {2} 条。".format(
selected,
int(access_carriers or 0),
int(targets or 0),
)
if missing:
message += " 未找到:{0}".format("".join(missing[:5]))
message += "请检查端子位置、设备装配高度,或为设备补局部出线路径。"
@ -4825,6 +4973,25 @@ class AutoRoutingTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def apply_phase1_acceptance_view(self):
try:
report = self.controller.apply_phase1_acceptance_view()
routed = report.get("routed_wire_visibility", {}) if isinstance(report, dict) else {}
carriers = report.get("route_carrier_visibility", {}) if isinstance(report, dict) else {}
styles = report.get("wire_style_application", {}) if isinstance(report, dict) else {}
self._set_status(
"已整理验收视图:显示导线 {0}/{1} 条,隐藏辅助路径 {2}/{3} 条,导线样式已应用 {4}/{5} 条。".format(
routed.get("visible", 0),
routed.get("routed", 0),
report.get("hidden_route_carriers", 0),
carriers.get("total", 0),
styles.get("applied", 0),
styles.get("expected", 0),
)
)
except Exception as exc:
self._set_error(str(exc))
def clear_routing_connections(self):
try:
removed = self.controller.clear_routing_connections()

@ -471,10 +471,17 @@ def _find_device_group(doc, element_uuid):
preferred_name = DEVICE_GROUP_PREFIX + _safe_token(target_uuid)
obj = doc.getObject(preferred_name)
if obj is not None:
if obj is not None and getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return obj
for candidate in doc.Objects:
if not getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
continue
try:
if not candidate.isDerivedFrom("App::DocumentObjectGroup"):
continue
except Exception:
continue
if "QetElementUuid" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetElementUuid", "").strip() == target_uuid:
return candidate
@ -1224,6 +1231,11 @@ def _is_exchange_sidecar_group(obj):
child_name = _object_name(obj)
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
return True
try:
if TerminalObjects.is_terminal_hint_object(obj):
return True
except Exception:
pass
return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}

@ -5,12 +5,18 @@ import os
from datetime import datetime
from pathlib import Path
import traceback
import uuid
import FreeCAD as App
import DeviceImport
import TerminalObjects as TerminalObjects
try:
import TerminalImport
except ImportError:
TerminalImport = None
try:
import FreeCADGui as Gui
except ImportError:
@ -140,6 +146,68 @@ def _output_path_for_exchange_json():
return str(Path(json_path).with_name("3d_to_2d.json"))
def _input_path_for_scene(scene_path):
scene_path = (scene_path or "").strip()
if not scene_path:
return ""
path = Path(scene_path)
if path.suffix.lower() == ".fcstd":
return str(path.with_name("2d_to_3d.json"))
if path.is_dir():
return str(path / "2d_to_3d.json")
return str(path.parent / "2d_to_3d.json")
def _load_json_payload(path):
path_text = (path or "").strip()
if not path_text:
return None
try:
candidate = Path(path_text)
if not candidate.is_file():
return None
return json.loads(candidate.read_text(encoding="utf-8"))
except Exception as exc:
_append_debug_log("write-back could not load payload {0}: {1}".format(path_text, exc))
return None
def _payload_for_writeback(scene_path, payload=None):
if isinstance(payload, dict):
return payload
env_path = os.environ.get(ENV_JSON_PATH, "").strip()
loaded = _load_json_payload(env_path)
if isinstance(loaded, dict):
return loaded
loaded = _load_json_payload(_input_path_for_scene(scene_path))
if isinstance(loaded, dict):
return loaded
return payload
def _sync_terminals_for_writeback(doc, scene_path, payload):
if TerminalImport is None or not isinstance(payload, dict):
return None
if not isinstance(payload.get("devices"), list) or not payload.get("devices"):
return None
try:
# 保存/写回以当前 2d_to_3d.json 为端子快照,先同步 3D 工程端子,避免旧工程继续回写缺失或重复端子。
return TerminalImport.import_terminals_from_payload(payload, scene_path)
except Exception as exc:
_append_debug_log("write-back terminal sync failed: {0}".format(exc))
_append_debug_log(traceback.format_exc())
return None
def sync_terminals_from_current_payload(doc, scene_path="", payload=None):
scene_path = _scene_path_from_doc(doc, scene_path)
payload = _payload_for_writeback(scene_path, payload)
return _sync_terminals_for_writeback(doc, scene_path, payload)
def _format_timestamp():
return datetime.now().astimezone().isoformat(timespec="seconds")
@ -173,9 +241,59 @@ def _collect_instance_bindings(doc):
return bindings
def _stable_terminal_instance_id(project_uuid, device_instance_id, terminal_obj):
values = [
project_uuid,
device_instance_id,
getattr(terminal_obj, "QetElementUuid", "").strip(),
getattr(terminal_obj, "QetTerminalUuid", "").strip(),
getattr(terminal_obj, "QetTemplateSlotName", "").strip(),
getattr(terminal_obj, "Label", "").strip(),
getattr(terminal_obj, "Name", "").strip(),
]
seed = "qet-freecad-writeback-terminal|" + "|".join(values)
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
def _writeback_terminal_instance_id(project_uuid, terminal_obj, device_instance_id, used_ids):
terminal_instance_id = (
getattr(terminal_obj, "QetTerminalInstanceId", "").strip()
or getattr(terminal_obj, "QetInstanceId", "").strip()
or ""
)
if (
not terminal_instance_id
or terminal_instance_id == device_instance_id
or terminal_instance_id in used_ids
):
terminal_instance_id = _stable_terminal_instance_id(
project_uuid,
device_instance_id,
terminal_obj,
)
suffix = 1
while terminal_instance_id in used_ids:
terminal_instance_id = str(uuid.uuid5(
uuid.NAMESPACE_URL,
"{0}|{1}".format(terminal_instance_id, suffix),
))
suffix += 1
TerminalObjects.ensure_string_property(
terminal_obj,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
terminal_instance_id,
)
used_ids.add(terminal_instance_id)
return terminal_instance_id
def _collect_terminal_bindings(doc):
bindings = []
seen = set()
used_terminal_instance_ids = set()
project_uuid = _project_uuid_from_doc(doc)
for device_group in _iter_device_groups(doc):
instance_id = getattr(device_group, "QetInstanceId", "").strip()
for terminal_obj in _iter_terminal_objects(device_group):
@ -186,7 +304,12 @@ def _collect_terminal_bindings(doc):
or binding_mode == TerminalObjects.TERMINAL_BINDING_MODE_LOCAL
):
continue
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id
terminal_instance_id = _writeback_terminal_instance_id(
project_uuid,
terminal_obj,
instance_id,
used_terminal_instance_ids,
)
if not terminal_uuid or not terminal_instance_id:
continue
key = (terminal_uuid, terminal_instance_id)
@ -235,6 +358,9 @@ def write_back_document(doc=None, scene_path="", payload=None):
"Cannot determine the 3d_to_2d.json output path."
)
payload = _payload_for_writeback(scene_path, payload)
_sync_terminals_for_writeback(doc, scene_path, payload)
project_uuid = _project_uuid_from_doc(doc, payload)
if not project_uuid:
raise ExchangeWriteBackError(
@ -343,6 +469,19 @@ class _WriteBackObserver:
_is_exchange_document(doc),
)
)
if not _is_exchange_document(doc):
return
try:
sync_terminals_from_current_payload(doc, scene_path=name)
except Exception as exc:
_append_debug_log("write-back terminal sync before save failed: {0}".format(exc))
_append_debug_log(traceback.format_exc())
try:
App.Console.PrintError(
"[FreeCADExchange] terminal sync before save failed: {0}\n".format(exc)
)
except Exception:
pass
def slotFinishSaveDocument(self, doc, name):
_append_debug_log(

@ -24,8 +24,24 @@ except ImportError:
import DeviceImport
try:
import TerminalImport
except ImportError:
TerminalImport = None
try:
import TemplateInstantiation
except ImportError:
TemplateInstantiation = None
try:
import BatchAssembly
except ImportError:
BatchAssembly = None
COMMAND_NAME = "QET_Exchange_OpenPendingDevicePanel"
STATE_PAYLOAD = "_qet_exchange_payload"
class PendingDeviceAssemblyPanelError(RuntimeError):
@ -169,6 +185,227 @@ def _set_status(label, message, error=False):
pass
def _imported_model_objects(result):
objects = []
for obj in list((result or {}).get("imported_objects", []) or []):
if obj is not None and getattr(obj, "Name", ""):
objects.append(obj)
return objects
def focus_inserted_result(doc, result):
imported_objects = _imported_model_objects(result)
if not imported_objects:
raise PendingDeviceAssemblyPanelError(
"没有导入任何可显示模型对象,请检查该设备绑定的 3D 模型文件。"
)
device = (result or {}).get("device")
for obj in [device] + imported_objects:
view_object = getattr(obj, "ViewObject", None)
if view_object is not None:
try:
view_object.Visibility = True
except Exception:
pass
if Gui is not None and hasattr(Gui, "Selection"):
try:
Gui.Selection.clearSelection()
except Exception:
pass
for obj in imported_objects:
try:
Gui.Selection.addSelection(doc.Name, obj.Name)
except Exception:
pass
if Gui is not None and hasattr(Gui, "SendMsgToActiveView"):
try:
Gui.SendMsgToActiveView("ViewSelection")
except Exception:
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
def _sync_engineering_terminals_for_inserted_device(doc, result):
device = result.get("device") if isinstance(result, dict) else None
payload = getattr(App, STATE_PAYLOAD, None)
if TerminalImport is not None and isinstance(payload, dict):
project_uuid = (payload.get("project_uuid") or "").strip()
if project_uuid:
return TerminalImport.import_terminals_from_payload(payload, "")
if TemplateInstantiation is not None and device is not None:
return TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device)
return {
"imported_terminals": 0,
"created_terminals": 0,
"updated_terminals": 0,
"warnings": ["端子同步模块不可用,已跳过工程端子生成。"],
}
def insert_device_and_sync_terminals(doc, device, **insert_kwargs):
result = DeviceImport.insert_pending_device(doc, device, **insert_kwargs)
result["terminal_report"] = _sync_engineering_terminals_for_inserted_device(doc, result)
return result
def _terminal_report_count(report, *keys):
for key in keys:
try:
return int(report.get(key, 0) or 0)
except Exception:
continue
return 0
def _terminal_status_suffix(result):
report = result.get("terminal_report", {}) if isinstance(result, dict) else {}
created = _terminal_report_count(report, "imported_terminals", "created_terminals")
updated = _terminal_report_count(report, "updated_terminals")
skipped = sum(
_terminal_report_count(report, key)
for key in (
"skipped_missing_slot",
"skipped_devices_without_template_slots",
"skipped_unbound_slots",
"skipped_missing_device",
)
)
return ",工程端子新增 {0} 个,更新 {1} 个,跳过 {2}".format(
created,
updated,
skipped,
)
def _pending_batch_key(device):
if BatchAssembly is not None:
try:
strip_name, order = BatchAssembly._parse_strip_name_and_order(device)
if strip_name:
return strip_name, order
except Exception:
pass
text = (
getattr(device, "QetDisplayTag", "")
or getattr(device, "Label", "")
or getattr(device, "Name", "")
or ""
).strip()
prefix = ""
number = None
for index, char in enumerate(text):
if char.isdigit():
prefix = text[:index].strip(" :_-")
try:
number = int("".join(ch for ch in text[index:] if ch.isdigit()) or "0")
except Exception:
number = None
break
return prefix or text, number
def matching_pending_batch_devices(doc, seed_device):
seed_prefix, _seed_order = _pending_batch_key(seed_device)
seed_prefix_key = (seed_prefix or "").strip().lower()
if not seed_prefix_key:
return [seed_device]
matches = []
for item in DeviceImport.list_pending_devices(doc):
device = item.get("device")
if device is None:
continue
prefix, order = _pending_batch_key(device)
if (prefix or "").strip().lower() == seed_prefix_key:
matches.append((order if order is not None else 10**9, getattr(device, "Label", "") or getattr(device, "Name", ""), device))
matches.sort(key=lambda item: (item[0], item[1]))
return [device for _order, _label, device in matches] or [seed_device]
def _sync_engineering_terminals_after_batch(doc, devices):
payload = getattr(App, STATE_PAYLOAD, None)
if TerminalImport is not None and isinstance(payload, dict) and (payload.get("project_uuid") or "").strip():
return TerminalImport.import_terminals_from_payload(payload, "")
totals = {"imported_terminals": 0, "created_terminals": 0, "updated_terminals": 0, "warnings": []}
if TemplateInstantiation is None:
totals["warnings"].append("端子同步模块不可用,已跳过工程端子生成。")
return totals
for device in devices:
report = TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device)
for key in ("imported_terminals", "created_terminals", "updated_terminals"):
try:
totals[key] += int(report.get(key, 0) or 0)
except Exception:
pass
totals["warnings"].extend(list(report.get("warnings", []) or []))
return totals
def insert_matching_pending_batch_to_target(
doc,
seed_device,
target,
pitch_mm=5.2,
start_offset_mm=0.0,
mount_offset_mm=20.0,
):
if BatchAssembly is None:
raise PendingDeviceAssemblyPanelError("批量排布模块不可用。")
if target is None:
raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择导轨或安装目标。")
rail = BatchAssembly._ensure_rail(target)
devices = matching_pending_batch_devices(doc, seed_device)
batch_prefix, _seed_order = _pending_batch_key(seed_device)
base = BatchAssembly._base_point(rail)
axis = BatchAssembly._axis_vector(rail)
source_doc_cache = {}
results = []
for index, device in enumerate(devices):
point = BatchAssembly._point_at(
base,
axis,
float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0),
)
result = DeviceImport.insert_pending_device(
doc,
device,
source_doc_cache=source_doc_cache,
mount_target=rail,
mount_placement=BatchAssembly._placement_at(rail, point),
mount_offset_mm=mount_offset_mm,
)
results.append(result)
terminal_report = _sync_engineering_terminals_after_batch(doc, [result["device"] for result in results])
for result in results:
result["terminal_report"] = terminal_report
try:
doc.recompute()
except Exception:
pass
return {
"devices": [result["device"] for result in results],
"results": results,
"terminal_report": terminal_report,
"batch_prefix": batch_prefix,
"device_labels": [
getattr(result["device"], "QetDisplayTag", "")
or getattr(result["device"], "Label", "")
or getattr(result["device"], "Name", "")
for result in results
],
"inserted_count": len(results),
}
class PendingDeviceAssemblyTaskPanel:
def __init__(self):
if QtWidgets is None:
@ -185,6 +422,7 @@ class PendingDeviceAssemblyTaskPanel:
self.refresh_button = QtWidgets.QPushButton("刷新清单")
self.insert_button = QtWidgets.QPushButton("插入设备")
self.insert_to_target_button = QtWidgets.QPushButton("插入到选中目标")
self.batch_insert_to_target_button = QtWidgets.QPushButton("批量插入同组到选中目标")
layout.addWidget(self.refresh_button)
layout.addWidget(self.insert_button)
@ -195,11 +433,31 @@ class PendingDeviceAssemblyTaskPanel:
self.mount_offset_input.setDecimals(1)
self.mount_offset_input.setSingleStep(1.0)
self.mount_offset_input.setSuffix(" mm")
self.mount_offset_input.setValue(0.0)
self.mount_offset_input.setValue(20.0)
offset_row.addWidget(self.mount_offset_input)
layout.addLayout(offset_row)
batch_row = QtWidgets.QHBoxLayout()
batch_row.addWidget(QtWidgets.QLabel("批量间距"))
self.batch_pitch_input = QtWidgets.QDoubleSpinBox()
self.batch_pitch_input.setRange(0.1, 10000.0)
self.batch_pitch_input.setDecimals(1)
self.batch_pitch_input.setSingleStep(1.0)
self.batch_pitch_input.setSuffix(" mm")
self.batch_pitch_input.setValue(5.2)
batch_row.addWidget(self.batch_pitch_input)
batch_row.addWidget(QtWidgets.QLabel("起点偏移"))
self.batch_start_offset_input = QtWidgets.QDoubleSpinBox()
self.batch_start_offset_input.setRange(-10000.0, 10000.0)
self.batch_start_offset_input.setDecimals(1)
self.batch_start_offset_input.setSingleStep(1.0)
self.batch_start_offset_input.setSuffix(" mm")
self.batch_start_offset_input.setValue(0.0)
batch_row.addWidget(self.batch_start_offset_input)
layout.addLayout(batch_row)
layout.addWidget(self.insert_to_target_button)
layout.addWidget(self.batch_insert_to_target_button)
self.status_label = QtWidgets.QLabel("")
self.status_label.setWordWrap(True)
@ -208,6 +466,7 @@ class PendingDeviceAssemblyTaskPanel:
self.refresh_button.clicked.connect(self.refresh)
self.insert_button.clicked.connect(self.insert_selected_device)
self.insert_to_target_button.clicked.connect(self.insert_selected_device_to_target)
self.batch_insert_to_target_button.clicked.connect(self.insert_matching_batch_to_target)
self.refresh()
@ -237,35 +496,80 @@ class PendingDeviceAssemblyTaskPanel:
def insert_selected_device(self):
try:
result = DeviceImport.insert_pending_device(_document(), self._selected_device())
doc = _document()
result = insert_device_and_sync_terminals(doc, self._selected_device())
focus_inserted_result(doc, result)
self.refresh()
_set_status(
self.status_label,
"已插入设备:{0}".format(getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "")),
"已插入设备:{0},导入模型对象 {1}{2}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""),
len(_imported_model_objects(result)),
_terminal_status_suffix(result),
),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_selected_device_to_target(self):
try:
doc = _document()
device = self._selected_device()
context = selected_mount_context(exclude_device=device)
target = context.get("target")
if target is None:
raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择安装板、导轨、线槽或柜体安装面。")
result = DeviceImport.insert_pending_device(
_document(),
result = insert_device_and_sync_terminals(
doc,
device,
mount_target=target,
mount_placement=context.get("placement"),
mount_normal=context.get("normal"),
mount_offset_mm=self.mount_offset_input.value(),
)
focus_inserted_result(doc, result)
self.refresh()
_set_status(
self.status_label,
"已插入设备到选中目标:{0},导入模型对象 {1}{2}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""),
len(_imported_model_objects(result)),
_terminal_status_suffix(result),
),
)
except Exception as exc:
_set_status(self.status_label, str(exc), error=True)
def insert_matching_batch_to_target(self):
try:
doc = _document()
device = self._selected_device()
context = selected_mount_context(exclude_device=device)
target = context.get("target")
report = insert_matching_pending_batch_to_target(
doc,
device,
target,
pitch_mm=self.batch_pitch_input.value(),
start_offset_mm=self.batch_start_offset_input.value(),
mount_offset_mm=self.mount_offset_input.value(),
)
imported_count = sum(len(_imported_model_objects(result)) for result in report.get("results", []))
device_labels = list(report.get("device_labels", []) or [])
label_preview = "".join(str(label) for label in device_labels[:5] if str(label))
if len(device_labels) > 5:
label_preview += ""
if label_preview:
label_preview = "{0}".format(label_preview)
self.refresh()
_set_status(
self.status_label,
"已插入设备到选中目标:{0}".format(
getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "")
"已批量插入同组 {0} 待装配设备 {1}{2},导入模型对象 {3}{4}".format(
report.get("batch_prefix", "") or "设备",
len(report.get("devices", []) or []),
label_preview,
imported_count,
_terminal_status_suffix({"terminal_report": report.get("terminal_report", {})}),
),
)
except Exception as exc:

@ -140,7 +140,8 @@ DEFAULT_KIND_COST_FACTORS = {
ROUTE_CARRIER_KIND: 1.0,
ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0,
ROUTE_CARRIER_KIND_TERMINAL_ACCESS: 2.0,
ROUTE_CARRIER_KIND_ROUTING_RANGE: 40.0,
# RoutingRange 是安装板/柜内面域兜底;成本要足够高,避免线槽复用较多时反向抢主路径。
ROUTE_CARRIER_KIND_ROUTING_RANGE: 1000.0,
ROUTE_CARRIER_KIND_USER_PATH: 1.0,
}
ROUTE_CARRIER_VIEW_STYLES = {
@ -2468,6 +2469,10 @@ def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, projec
"created": [],
"duplicates": 0,
"stale_suggestions": 0,
"unconnected_terminal_access_bridge_targets": 0,
"unconnected_terminal_access_user_path_bridges": 0,
"unconnected_terminal_access_bridge_duplicates": 0,
"unconnected_terminal_access_bridge_pair_labels": [],
}
if doc is None or not isinstance(diagnostic, dict):
return report
@ -2517,6 +2522,41 @@ def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, projec
),
)
report["created"].append(bridge)
for item in diagnostic.get("unconnected_terminals", []) or []:
if not isinstance(item, dict):
continue
access_name = str(item.get("access_carrier", "") or "").strip()
nearest_name = str(item.get("nearest_network_carrier_name", "") or "").strip()
nearest_label = str(item.get("nearest_network_carrier_label", "") or "").strip()
if not access_name or not (nearest_name or nearest_label):
continue
report["suggestions"] += 1
report["unconnected_terminal_access_bridge_targets"] += 1
access_carrier = _document_object_by_name(doc, access_name)
nearest_carrier = _document_object_by_name(doc, nearest_name)
if nearest_carrier is None and nearest_label:
nearest_carrier = _document_object_by_label(doc, nearest_label)
if not is_route_carrier(access_carrier) or not is_route_carrier(nearest_carrier):
report["stale_suggestions"] += 1
continue
bridges = create_user_path_bridge_between_objects(
doc,
access_carrier,
nearest_carrier,
project_uuid=project_uuid,
bridge_kind="UnconnectedTerminalAccessBridge",
)
if bridges:
report["created"].extend(bridges)
report["unconnected_terminal_access_user_path_bridges"] += len(bridges)
for bridge in bridges:
pair_label = str(getattr(bridge, "QetRouteBridgePairLabel", "") or "").strip()
if pair_label:
report["unconnected_terminal_access_bridge_pair_labels"].append(pair_label)
else:
report["duplicates"] += 1
report["unconnected_terminal_access_bridge_duplicates"] += 1
return report
@ -2961,6 +3001,16 @@ def _document_object_by_name(doc, name):
return None
def _document_object_by_label(doc, label):
if doc is None or not label:
return None
label = str(label or "").strip()
for candidate in list(getattr(doc, "Objects", []) or []):
if str(getattr(candidate, "Label", "") or "").strip() == label:
return candidate
return None
def cleanup_invalid_source_carriers(doc):
"""Remove generated carriers whose FreeCAD source object is missing or invalid."""
if doc is None:
@ -3137,7 +3187,7 @@ def routing_source_summary(doc):
}
def prepare_layout_space_sources_from_document(doc, project_uuid=""):
def prepare_layout_space_sources_from_document(doc, project_uuid="", include_existing_network=True):
"""Normalize the current FreeCAD document as an EPLAN-style layout space.
This does not generate the routing path network. It marks source objects so
@ -3174,13 +3224,15 @@ def prepare_layout_space_sources_from_document(doc, project_uuid=""):
except Exception:
pass
return {
payload = {
"wire_duct_sources": len(wire_duct_sources),
"support_surface_sources": len(support_surface_sources),
"wiring_cut_out_sources": len(wiring_cut_out_sources),
"routable_terminals": len(_collect_routable_terminals(doc)),
"existing_network": network_summary(doc),
}
if include_existing_network:
payload["existing_network"] = network_summary(doc)
return payload
def create_wire_duct_carriers_from_document(
@ -3482,7 +3534,32 @@ def _terminal_local_route_issue(terminal):
continue
points = [_json_route_point(item) for item in point_items if item is not None]
valid_points = [point for point in points if point is not None]
if len(_normalized_route_points(valid_points)) >= 2:
normalized_points = _normalized_route_points(valid_points)
if len(normalized_points) >= 2:
try:
origin = _vector(TerminalObjects.terminal_origin(terminal))
global_points = [_terminal_local_point_to_global(terminal, point) for point in normalized_points]
bbox = _terminal_parent_device_bbox(terminal, origin)
except Exception:
bbox = None
global_points = []
if (
bbox is not None
and global_points
and _point_inside_bbox(global_points[-1], bbox, tolerance=DEFAULT_NODE_TOLERANCE)
):
invalid_samples.append(
{
"property_name": property_name,
"reason": "local_route_end_inside_device_bbox",
"message": "Local route endpoint is still inside the parent device bounding box.",
"raw_sample": raw[:160],
"valid_point_count": len(valid_points),
"local_route_end_point": _point_payload(global_points[-1]),
"endpoint_device_bbox": _bbox_payload(bbox, clearance=0.0),
}
)
continue
return None
invalid_samples.append(
{
@ -3796,7 +3873,7 @@ def terminal_access_diagnostics(terminal, exit_length=20.0, max_exit_length=None
if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE:
points.insert(0, origin)
points = _normalized_route_points(points)
if len(points) >= 2:
if len(points) >= 2 and not _terminal_local_route_end_inside_parent_device(terminal, points):
direction = _normalize(_subtract(points[1], points[0])) or App.Vector(0, 0, 1)
return {
"requested_exit_length_mm": max(float(exit_length or 0.0), 0.0),
@ -4033,13 +4110,27 @@ def terminal_access_path_points(terminal, exit_length=20.0, max_exit_length=None
if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE:
points.insert(0, origin)
normalized = _normalized_route_points(points)
if len(normalized) >= 2:
if len(normalized) >= 2 and not _terminal_local_route_end_inside_parent_device(terminal, normalized):
return normalized
return _normalized_route_points(
[origin, _terminal_device_aware_exit_point(terminal, exit_length, max_exit_length=max_exit_length)]
)
def _terminal_local_route_end_inside_parent_device(terminal, global_points):
try:
origin = _vector(TerminalObjects.terminal_origin(terminal))
bbox = _terminal_parent_device_bbox(terminal, origin)
except Exception:
bbox = None
normalized = _normalized_route_points(global_points)
return bool(
bbox is not None
and normalized
and _point_inside_bbox(normalized[-1], bbox, tolerance=DEFAULT_NODE_TOLERANCE)
)
def terminal_access_carrier_for_terminal(terminal):
doc = getattr(terminal, "Document", None)
carrier = _live_source_carrier(doc, terminal)
@ -4407,9 +4498,52 @@ def _terminal_access_target_candidate(network, exit_point, max_distance):
selected = dict(ranked[0])
selected["terminal_access_target_rule"] = "fallback_only"
selected["terminal_access_fallback_target"] = True
selected["terminal_access_max_distance_mm"] = float(max(float(max_distance or 0.0), 0.0))
nearest_main_path = _nearest_terminal_access_main_path_candidate(network, exit_point)
if nearest_main_path is not None:
selected.update(_terminal_access_main_path_summary(nearest_main_path, max_distance))
return selected
def _nearest_terminal_access_main_path_candidate(network, exit_point):
candidates = connection_point_candidates(
network,
exit_point,
limit=0,
max_distance=0.0,
)
main_path_candidates = [
candidate
for candidate in candidates
if _is_terminal_access_main_path_target(candidate.get("carrier"))
]
if not main_path_candidates:
return None
return min(
main_path_candidates,
key=lambda candidate: float(candidate.get("distance", 0.0) or 0.0),
)
def _terminal_access_main_path_summary(candidate, max_distance):
carrier = candidate.get("carrier")
distance = float(candidate.get("distance", 0.0) or 0.0)
max_distance_value = float(max(float(max_distance or 0.0), 0.0))
kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND
name = (getattr(carrier, "Name", "") or "").strip()
label = (getattr(carrier, "Label", "") or "").strip() or name
return {
"nearest_main_path_kind": kind,
"nearest_main_path_name": name,
"nearest_main_path_label": label,
"nearest_main_path_distance_mm": distance,
"nearest_main_path_point": _point_payload(_vector(candidate.get("point"))),
"nearest_main_path_over_max_distance": bool(
max_distance_value > 0.0 and distance > max_distance_value
),
}
def _set_terminal_access_target_metadata(carrier, candidate):
if carrier is None or not isinstance(candidate, dict):
return
@ -4452,12 +4586,52 @@ def _set_terminal_access_target_metadata(carrier, candidate):
"Whether the terminal access target is only a fallback carrier",
"1" if bool(candidate.get("terminal_access_fallback_target", False)) else "0",
)
TerminalObjects.ensure_string_property(
carrier,
"QetTerminalAccessNearestMainPathKind",
PROPERTY_GROUP,
"Nearest main path carrier kind when TerminalAccess falls back to a routing range",
str(candidate.get("nearest_main_path_kind", "") or ""),
)
TerminalObjects.ensure_string_property(
carrier,
"QetTerminalAccessNearestMainPathName",
PROPERTY_GROUP,
"Nearest main path carrier name when TerminalAccess falls back to a routing range",
str(candidate.get("nearest_main_path_name", "") or ""),
)
TerminalObjects.ensure_string_property(
carrier,
"QetTerminalAccessNearestMainPathLabel",
PROPERTY_GROUP,
"Nearest main path carrier label when TerminalAccess falls back to a routing range",
str(candidate.get("nearest_main_path_label", "") or ""),
)
TerminalObjects.ensure_string_property(
carrier,
"QetTerminalAccessNearestMainPathOverMaxDistance",
PROPERTY_GROUP,
"Whether the nearest main path exceeded the configured terminal access max distance",
"1" if bool(candidate.get("nearest_main_path_over_max_distance", False)) else "0",
)
_ensure_float_property(
carrier,
"QetTerminalAccessTargetDistanceMm",
"Distance from terminal local exit to selected access target",
float(candidate.get("distance", 0.0) or 0.0),
)
_ensure_float_property(
carrier,
"QetTerminalAccessNearestMainPathDistanceMm",
"Distance from terminal local exit to the nearest main path when fallback is selected",
float(candidate.get("nearest_main_path_distance_mm", 0.0) or 0.0),
)
_ensure_float_property(
carrier,
"QetTerminalAccessMaxDistanceMm",
"Configured maximum distance allowed for TerminalAccess",
float(candidate.get("terminal_access_max_distance_mm", 0.0) or 0.0),
)
_ensure_integer_property(
carrier,
"QetTerminalAccessTargetComponentPrimarySegments",
@ -4583,6 +4757,7 @@ def create_routing_path_network_from_document(
layout_space = prepare_layout_space_sources_from_document(
doc,
project_uuid=project_uuid,
include_existing_network=False,
)
selected_wire_ducts = []
selected_user_paths = []
@ -5014,29 +5189,40 @@ def nearest_node(network, point):
return best_key, best_distance
def nearest_point_on_network(network, point):
"""Return the closest point on any route-network edge.
def _route_carrier_summary(carrier):
if carrier is None:
return {
"carrier_kind": "",
"carrier_name": "",
"carrier_label": "",
}
name = (getattr(carrier, "Name", "") or "").strip()
label = (getattr(carrier, "Label", "") or "").strip() or name
kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip()
return {
"carrier_kind": kind,
"carrier_name": name,
"carrier_label": label,
}
The point may lie in the middle of a carrier segment. If a TerminalAccess
carrier ends there, the next graph build will split the crossed segment at
that point and create an EPLAN-like jump-in routing point.
"""
def _nearest_point_on_network_payload(network, point):
"""Return closest route-network point and the carrier owning that segment."""
if not isinstance(network, dict):
return None, None
return None
nodes = network.get("nodes", {}) or {}
edges = network.get("edges", {}) or {}
if not nodes or not edges:
return None, None
return None
target = _vector(point)
best_point = None
best_distance = None
best_payload = None
seen = set()
for key, neighbors in edges.items():
start = nodes.get(key)
if start is None:
continue
for next_key, _weight, _carrier in neighbors:
for next_key, _weight, carrier in neighbors:
pair = tuple(sorted((key, next_key)))
if pair in seen:
continue
@ -5046,12 +5232,39 @@ def nearest_point_on_network(network, point):
continue
candidate = _closest_point_on_segment(target, start, end)
distance = _distance(target, candidate)
if best_distance is None or distance < best_distance:
best_point = candidate
best_distance = distance
if best_point is not None:
return best_point, best_distance
return nearest_node(network, target)
if best_payload is None or distance < best_payload["distance"]:
payload = {
"point": candidate,
"distance": float(distance),
}
payload.update(_route_carrier_summary(carrier))
best_payload = payload
if best_payload is not None:
return best_payload
node_key, distance = nearest_node(network, target)
if node_key is None:
return None
return {
"point": nodes.get(node_key),
"distance": float(distance),
"carrier_kind": "",
"carrier_name": "",
"carrier_label": "",
}
def nearest_point_on_network(network, point):
"""Return the closest point on any route-network edge.
The point may lie in the middle of a carrier segment. If a TerminalAccess
carrier ends there, the next graph build will split the crossed segment at
that point and create an EPLAN-like jump-in routing point.
"""
payload = _nearest_point_on_network_payload(network, point)
if payload is None:
return None, None
return payload.get("point"), payload.get("distance")
def connection_point_candidates(network, point, limit=8, max_distance=0.0):
@ -5927,6 +6140,9 @@ def _terminal_for_access_carrier(carrier):
def _terminal_access_diagnostic_payload(carrier):
terminal = _terminal_for_access_carrier(carrier)
access_points = _normalized_route_points(_carrier_points(carrier))
endpoint_device_avoided = str(
getattr(carrier, "QetTerminalAccessAvoidedEndpointDevice", "") or ""
).strip() == "1"
payload = {
"access_carrier_name": getattr(carrier, "Name", "") or "",
"access_carrier_label": getattr(carrier, "Label", "") or "",
@ -5935,6 +6151,15 @@ def _terminal_access_diagnostic_payload(carrier):
"target_label": (getattr(carrier, "QetTerminalAccessTargetLabel", "") or "").strip(),
"target_rule": (getattr(carrier, "QetTerminalAccessTargetRule", "") or "").strip(),
"target_distance_mm": float(getattr(carrier, "QetTerminalAccessTargetDistanceMm", 0.0) or 0.0),
"terminal_access_max_distance_mm": float(getattr(carrier, "QetTerminalAccessMaxDistanceMm", 0.0) or 0.0),
"nearest_main_path_kind": (getattr(carrier, "QetTerminalAccessNearestMainPathKind", "") or "").strip(),
"nearest_main_path_name": (getattr(carrier, "QetTerminalAccessNearestMainPathName", "") or "").strip(),
"nearest_main_path_label": (getattr(carrier, "QetTerminalAccessNearestMainPathLabel", "") or "").strip(),
"nearest_main_path_distance_mm": float(getattr(carrier, "QetTerminalAccessNearestMainPathDistanceMm", 0.0) or 0.0),
"nearest_main_path_over_max_distance": str(
getattr(carrier, "QetTerminalAccessNearestMainPathOverMaxDistance", "") or ""
).strip() == "1",
"endpoint_device_avoided": endpoint_device_avoided,
"access_length_mm": float(_route_length(access_points)),
"access_points": [_point_payload(point) for point in access_points],
}
@ -5952,6 +6177,13 @@ def _terminal_access_diagnostic_payload(carrier):
"parent_device_element_uuid": terminal_payload.get("parent_device_element_uuid", ""),
}
)
if endpoint_device_avoided:
try:
bbox = _terminal_parent_device_bbox(terminal, _vector(TerminalObjects.terminal_origin(terminal)))
except Exception:
bbox = None
if bbox is not None:
payload["endpoint_device_bbox"] = _bbox_payload(bbox, clearance=0.0)
return payload
@ -6132,12 +6364,13 @@ def diagnose_routing_path_network(
terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE,
terminal_access_warning_distance=0.0,
adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE,
route_network=None,
):
"""Inspect the generated routing path network without routing wires."""
if doc is None:
raise RoutingNetworkError("No FreeCAD document is available.")
network = build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance)
network = route_network if isinstance(route_network, dict) else build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance)
components = _route_graph_components(network)
summary = _network_summary_from_graph(network)
isolated_components = _actionable_isolated_components(components)
@ -6185,7 +6418,9 @@ def diagnose_routing_path_network(
max_exit_length=terminal_exit_max_length,
)
exit_point = terminal_access_points[-1] if terminal_access_points else _terminal_exit_point(terminal, terminal_exit_length)
nearest_point, distance = nearest_point_on_network(network, exit_point)
nearest_payload = _nearest_point_on_network_payload(network, exit_point)
nearest_point = nearest_payload.get("point") if nearest_payload is not None else None
distance = nearest_payload.get("distance") if nearest_payload is not None else None
access_carrier = _live_source_carrier(doc, terminal)
access_live = access_carrier is not None and is_route_carrier(access_carrier)
too_far = nearest_point is None or (max_distance > 0.0 and float(distance or 0.0) > max_distance)
@ -6197,11 +6432,17 @@ def diagnose_routing_path_network(
"access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "",
"nearest_network_distance_mm": None if distance is None else float(distance),
"nearest_network_point": None if nearest_point is None else _point_payload(nearest_point),
"nearest_network_carrier_kind": "" if nearest_payload is None else nearest_payload.get("carrier_kind", ""),
"nearest_network_carrier_name": "" if nearest_payload is None else nearest_payload.get("carrier_name", ""),
"nearest_network_carrier_label": "" if nearest_payload is None else nearest_payload.get("carrier_label", ""),
"terminal_access_max_distance_mm": float(max_distance),
"terminal_exit_length_mm": float(max(float(terminal_exit_length or 0.0), 0.0)),
"terminal_exit_point": _point_payload(exit_point),
"code": "terminal_access_missing" if not access_live else "terminal_access_too_far",
}
)
# 未接入端子也保留端子出线折线,方便手测时判断是端子方向错、出线过短,还是主路径入口缺失。
payload.update(_terminal_access_geometry_payload(terminal_access_points))
unconnected_terminals.append(payload)
continue
@ -6219,6 +6460,23 @@ def diagnose_routing_path_network(
"code": "terminal_access_long",
}
)
if access_carrier is not None:
access_payload = _terminal_access_diagnostic_payload(access_carrier)
for key in (
"target_kind",
"target_name",
"target_label",
"target_rule",
"target_distance_mm",
"nearest_main_path_kind",
"nearest_main_path_name",
"nearest_main_path_label",
"nearest_main_path_distance_mm",
"nearest_main_path_over_max_distance",
"endpoint_device_avoided",
):
if key in access_payload:
payload[key] = access_payload.get(key)
payload.update(_terminal_access_geometry_payload(access_points))
long_terminal_accesses.append(payload)
@ -6530,6 +6788,20 @@ def _diagnostic_terminal_text(sample):
)
def _diagnostic_nearest_network_carrier_text(sample):
if not isinstance(sample, dict):
return ""
label = str(sample.get("nearest_network_carrier_label", "") or "").strip()
name = str(sample.get("nearest_network_carrier_name", "") or "").strip()
kind = str(sample.get("nearest_network_carrier_kind", "") or "").strip()
text = label or name
if not text:
return ""
if kind:
return "{0}({1})".format(text, kind)
return text
def _routing_path_network_diagnostic_message(diagnostic):
if not isinstance(diagnostic, dict):
return "布线路径网络检查失败:诊断结果无效。"
@ -6552,17 +6824,29 @@ def _routing_path_network_diagnostic_message(diagnostic):
unconnected = _diagnostic_items(diagnostic.get("unconnected_terminals", []) or [])
if unconnected:
sample = unconnected[0]
message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}".format(
nearest_carrier = _diagnostic_nearest_network_carrier_text(sample)
nearest_carrier_clause = ",最近路径 {0}".format(nearest_carrier) if nearest_carrier else ""
message += "\n端子未接入:{0},距离最近网络 {1}{2},当前端子接入最大距离 {3}".format(
_diagnostic_terminal_text(sample),
_diagnostic_distance_text(sample.get("nearest_network_distance_mm")),
nearest_carrier_clause,
_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(
target_label = str(sample.get("target_label", "") or sample.get("target_name", "") or "").strip()
target_kind = str(sample.get("target_kind", "") or "").strip()
target_clause = ""
if target_label or target_kind:
target_text = target_label or "未知目标"
if target_kind:
target_text = "{0}({1})".format(target_text, target_kind)
target_clause = ",目标 {0}".format(target_text)
message += "\n端子接入过长:{0},接入段 {1}{2}".format(
_diagnostic_terminal_text(sample),
_diagnostic_distance_text(sample.get("terminal_access_length_mm")),
target_clause,
)
capped_exits = _diagnostic_items(diagnostic.get("capped_terminal_exits", []) or [])
if capped_exits:
@ -6630,6 +6914,24 @@ def _routing_path_network_diagnostic_message(diagnostic):
)
else:
message += "\n线槽未接入端子主网络:{0}".format(carrier_text)
fallback_targets = _diagnostic_items(diagnostic.get("terminal_access_fallback_targets", []) or [])
if fallback_targets:
sample = fallback_targets[0]
target_text = sample.get("target_label") or sample.get("target_name") or "布线面"
nearest_main = sample.get("nearest_main_path_label") or sample.get("nearest_main_path_name") or ""
if nearest_main:
message += "\n端子接入退回布线面:{0} 接入到 {1};最近主路径 {2},距离 {3},端子接入最大距离 {4}".format(
_diagnostic_terminal_text(sample),
target_text,
nearest_main,
_diagnostic_distance_text(sample.get("nearest_main_path_distance_mm")),
_diagnostic_distance_text(sample.get("terminal_access_max_distance_mm")),
)
else:
message += "\n端子接入退回布线面:{0} 接入到 {1},附近没有可用线槽/UserPath/过线孔主路径。".format(
_diagnostic_terminal_text(sample),
target_text,
)
isolated = _diagnostic_items(diagnostic.get("isolated_components", []) or [])
if isolated:
sample = isolated[0]

@ -1,7 +1,8 @@
# FreeCADExchange terminal import helpers.
from collections import OrderedDict
from collections import Counter, OrderedDict
import json
import uuid
import FreeCAD as App
@ -40,7 +41,14 @@ def _normalize_terminal_entry(item, index):
device_instance_id = (item.get("device_instance_id") or "").strip()
element_uuid = (item.get("element_uuid") or "").strip()
terminal_display = (item.get("terminal_display") or "").strip()
terminal_display = (
item.get("terminal_display")
or item.get("terminal_label")
or item.get("slot_name_hint")
or item.get("slot_name")
or ""
).strip()
terminal_instance_id = (item.get("terminal_instance_id") or "").strip()
slot_name_hint = (
item.get("slot_name_hint")
or item.get("terminal_display")
@ -54,6 +62,7 @@ def _normalize_terminal_entry(item, index):
"terminal_uuid": terminal_uuid,
"device_instance_id": device_instance_id,
"element_uuid": element_uuid,
"terminal_instance_id": terminal_instance_id,
"terminal_display": terminal_display,
"slot_name_hint": slot_name_hint,
}
@ -152,21 +161,23 @@ def _device_embedded_terminal_entries(payload, existing_keys):
# QET 的正式端子可能直接挂在 devices[].terminals[] 下。
# 直接调用本模块时也要读取它,避免正式布线匹配退回 local:* 端子。
key = (element_uuid, terminal_uuid)
if key in seen:
continue
seen.add(key)
terminal_display = (
terminal.get("terminal_display")
or terminal.get("terminal_label")
or terminal.get("slot_name_hint")
or terminal.get("slot_name")
or ""
)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if key in seen:
continue
seen.add(key)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"device_instance_id": device_instance_id,
"terminal_instance_id": (terminal.get("terminal_instance_id") or "").strip(),
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -192,20 +203,21 @@ def _wire_endpoint_terminal_entries(payload, existing_keys):
if not terminal_uuid or not (element_uuid or device_instance_id):
continue
key = (element_uuid, terminal_uuid)
if key in seen:
continue
seen.add(key)
terminal_display = (
wire.get("{0}_terminal_display".format(side))
or wire.get("{0}_terminal_label".format(side))
or ""
)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if key in seen:
continue
seen.add(key)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": element_uuid,
"device_instance_id": device_instance_id,
"terminal_instance_id": "",
"terminal_display": terminal_display,
"slot_name_hint": terminal_display,
}
@ -275,6 +287,110 @@ def _terminal_existing_index(container):
return index
def _terminal_existing_by_instance(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_instance_id = getattr(obj, "QetTerminalInstanceId", "").strip()
if terminal_instance_id and terminal_instance_id not in index:
index[terminal_instance_id] = obj
return index
def _terminal_existing_by_element_uuid(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
element_uuid = getattr(obj, "QetElementUuid", "").strip()
key = (element_uuid, terminal_uuid)
if element_uuid and terminal_uuid and key not in index:
index[key] = obj
return index
def _terminal_context_key(element_uuid, terminal_uuid, terminal_display):
return (
(element_uuid or "").strip(),
(terminal_uuid or "").strip(),
(terminal_display or "").strip(),
)
def _terminal_entry_context_key(entry):
return _terminal_context_key(
entry.get("element_uuid", ""),
entry.get("terminal_uuid", ""),
entry.get("terminal_display", ""),
)
def _terminal_object_display(obj):
if "QetTerminalDisplay" in getattr(obj, "PropertiesList", []):
return getattr(obj, "QetTerminalDisplay", "").strip()
return (
getattr(obj, "Label", "").strip()
or getattr(obj, "QetTemplateSlotName", "").strip()
)
def _terminal_existing_by_context(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
element_uuid = getattr(obj, "QetElementUuid", "").strip()
terminal_display = _terminal_object_display(obj)
key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display)
if element_uuid and terminal_uuid and terminal_display and key not in index:
index[key] = obj
return index
def _terminal_instance_id_seed(project_uuid, entry):
values = [
project_uuid,
entry.get("device_instance_id", ""),
entry.get("element_uuid", ""),
entry.get("terminal_uuid", ""),
entry.get("terminal_display", ""),
entry.get("slot_name_hint", ""),
]
return "qet-freecad-terminal|" + "|".join(str(value or "").strip() for value in values)
def _stable_terminal_instance_id(project_uuid, entry):
return str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry)))
def _terminal_instance_counts(items):
values = []
for item in list(items or []):
if not isinstance(item, dict):
continue
value = str(item.get("terminal_instance_id", "") or "").strip()
if value:
values.append(value)
return Counter(values)
def _repair_terminal_instance_id(entry, project_uuid, raw_counts, used_ids):
raw_value = str(entry.get("terminal_instance_id", "") or "").strip()
must_repair = (
not raw_value
or raw_counts.get(raw_value, 0) > 1
or raw_value in used_ids
)
if must_repair:
# QET 当前 v2 快照里可能出现重复 terminal_uuid / terminal_instance_id。
# FreeCAD 侧短期修复必须避开导出顺序所以用设备实例、2D 元件、端子 UUID 和脚号生成稳定 ID。
raw_value = _stable_terminal_instance_id(project_uuid, entry)
suffix = 1
while raw_value in used_ids:
raw_value = str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry) + "|{0}".format(suffix)))
suffix += 1
entry["terminal_instance_id"] = raw_value
used_ids.add(raw_value)
return must_repair
def _terminal_existing_local_by_slot(container):
index = {}
for obj in TerminalObjects.collect_terminal_objects(container):
@ -428,6 +544,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
instance_id,
label=terminal_label,
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
@ -435,6 +553,45 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
return terminal_obj
def _remove_terminal_from_group(doc, terminal_group, terminal_obj):
try:
children = list(getattr(terminal_group, "Group", []) or [])
if terminal_obj in children:
children.remove(terminal_obj)
terminal_group.Group = children
except Exception:
try:
terminal_group.Group.remove(terminal_obj)
except Exception:
pass
try:
if doc.getObject(terminal_obj.Name) is not None:
doc.removeObject(terminal_obj.Name)
except Exception:
pass
def _terminal_object_context_key(terminal_obj):
return _terminal_context_key(
getattr(terminal_obj, "QetElementUuid", ""),
getattr(terminal_obj, "QetTerminalUuid", ""),
_terminal_object_display(terminal_obj),
)
def _remove_stale_qet_terminals(doc, terminal_group, expected_contexts, used_objects):
removed = 0
for terminal_obj in list(TerminalObjects.collect_terminal_objects(terminal_group)):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
if TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
# 当前 2d_to_3d.json 是端子快照;不在快照中的历史 QET 端子会干扰回写和布线匹配,需要清理。
if terminal_obj not in used_objects or _terminal_object_context_key(terminal_obj) not in expected_contexts:
_remove_terminal_from_group(doc, terminal_group, terminal_obj)
removed += 1
return removed
def import_terminals_from_payload(payload, scene_path=""):
_append_debug_log("TerminalImport.import_terminals_from_payload entered")
@ -456,17 +613,25 @@ def import_terminals_from_payload(payload, scene_path=""):
continue
element_uuid = (item.get("element_uuid") or "").strip()
terminal_uuid = (item.get("terminal_uuid") or "").strip()
terminal_display = (
item.get("terminal_display")
or item.get("terminal_label")
or item.get("slot_name_hint")
or item.get("slot_name")
or ""
).strip()
if element_uuid and terminal_uuid:
terminal_entry_keys.add((element_uuid, terminal_uuid))
terminal_entry_keys.add(_terminal_context_key(element_uuid, terminal_uuid, terminal_display))
embedded_entries = _device_embedded_terminal_entries(payload, terminal_entry_keys)
terminal_entries.extend(embedded_entries)
terminal_entry_keys.update(
(entry["element_uuid"], entry["terminal_uuid"])
_terminal_entry_context_key(entry)
for entry in embedded_entries
if entry.get("element_uuid") and entry.get("terminal_uuid")
)
synthesized_entries = _wire_endpoint_terminal_entries(payload, terminal_entry_keys)
terminal_entries.extend(synthesized_entries)
raw_terminal_instance_counts = _terminal_instance_counts(terminal_entries)
device_lookup = _payload_device_lookup(payload)
@ -487,10 +652,12 @@ def import_terminals_from_payload(payload, scene_path=""):
"skipped_missing_device": 0,
"skipped_invalid_entry": 0,
"skipped_unmatched_parent": 0,
"repaired_terminal_instance_ids": 0,
"warnings": [],
}
grouped = OrderedDict()
used_terminal_instance_ids = set()
for index, item in enumerate(terminal_entries):
report["total_terminals"] += 1
try:
@ -503,6 +670,13 @@ def import_terminals_from_payload(payload, scene_path=""):
if not _terminal_belongs_to_payload_devices(entry, device_lookup):
report["skipped_unmatched_parent"] += 1
continue
if _repair_terminal_instance_id(
entry,
project_uuid,
raw_terminal_instance_counts,
used_terminal_instance_ids,
):
report["repaired_terminal_instance_ids"] += 1
device_group = _locate_device_group(doc, entry)
if device_group is None:
@ -537,7 +711,12 @@ def import_terminals_from_payload(payload, scene_path=""):
terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
existing_by_uuid = _terminal_existing_index(terminal_group)
existing_by_instance = _terminal_existing_by_instance(terminal_group)
existing_by_context = _terminal_existing_by_context(terminal_group)
existing_by_element_uuid = _terminal_existing_by_element_uuid(terminal_group)
existing_local_by_slot = _terminal_existing_local_by_slot(terminal_group)
entry_uuid_counts = Counter(entry.get("terminal_uuid", "") for entry in entries)
expected_contexts = set(_terminal_entry_context_key(entry) for entry in entries)
used_uuids = set()
used_objects = set()
used_slot_names = set()
@ -587,18 +766,40 @@ def import_terminals_from_payload(payload, scene_path=""):
if slot_name:
used_slot_names.add(slot_name)
terminal_obj = existing_by_uuid.get(terminal_uuid)
terminal_obj = existing_by_instance.get(entry.get("terminal_instance_id", ""))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_by_context.get(_terminal_entry_context_key(entry))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_by_element_uuid.get((entry.get("element_uuid", ""), terminal_uuid))
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = (
existing_by_uuid.get(terminal_uuid)
if entry_uuid_counts.get(terminal_uuid, 0) == 1
else None
)
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is None:
terminal_obj = existing_local_by_slot.get(slot_name)
if terminal_obj in used_objects:
terminal_obj = None
if terminal_obj is not None:
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
@ -616,7 +817,7 @@ def import_terminals_from_payload(payload, scene_path=""):
slot,
terminal_group,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
device_instance_id,
)
report["imported_terminals"] += 1
@ -624,12 +825,14 @@ def import_terminals_from_payload(payload, scene_path=""):
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
entry.get("element_uuid", "") or device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
label=_terminal_entry_label(entry, slot, terminal_uuid),
slot_name=slot.get("name", ""),
terminal_instance_id=entry.get("terminal_instance_id", ""),
terminal_display=entry.get("terminal_display", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try:
@ -649,6 +852,13 @@ def import_terminals_from_payload(payload, scene_path=""):
_hide_object(source_obj)
report["reused_template_hints"] += 1
if entries:
report["removed_terminals"] += _remove_stale_qet_terminals(
doc,
terminal_group,
expected_contexts,
used_objects,
)
TerminalObjects.sort_group_children(terminal_group)
TerminalObjects.sort_group_children(root_group)

@ -548,6 +548,8 @@ def set_terminal_semantics(
instance_id,
label="",
slot_name="",
terminal_instance_id="",
terminal_display="",
):
ensure_string_property(
obj,
@ -589,6 +591,27 @@ def set_terminal_semantics(
"Parent instance UUID for this terminal",
instance_id,
)
# QetInstanceId 表示父设备实例QetTerminalInstanceId 才是 3D 端子对象自己的稳定实例 ID。
# 旧工程可能没有该字段,缺省时先兼容退回父设备实例,导入器会尽量写入真正端子实例 ID。
terminal_instance_id = (
(terminal_instance_id or "").strip()
or getattr(obj, "QetTerminalInstanceId", "").strip()
or instance_id
)
ensure_string_property(
obj,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
terminal_instance_id,
)
ensure_string_property(
obj,
"QetTerminalDisplay",
"QET Exchange",
"QET terminal display text / terminal number",
(terminal_display or "").strip(),
)
ensure_string_property(
obj,
"Role",

File diff suppressed because it is too large Load Diff

@ -948,6 +948,53 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual("Placed", device_group.QetAssemblyState)
self.assertEqual(["DeviceBody"], [obj.Name for obj in device_group.Group if obj.Name == "DeviceBody"])
def test_insert_pending_device_does_not_treat_existing_engineering_terminals_as_model_objects(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
model_path.write_text("fake step placeholder", encoding="utf-8")
_install_fake_freecad(None)
device_import, _ = _reload_modules()
doc = FakeDocument("QETScene")
root = device_import._ensure_root_group(doc, None, "project-1")
device_group, _created = device_import._ensure_device_group(
doc,
root,
"element-a",
"device-inst-1",
str(model_path),
"A6300-2",
0,
)
device_import._set_device_assembly_state(
device_group,
device_import.ASSEMBLY_STATE_PENDING,
)
terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_existing")
terminal.Role = "Terminal"
terminal.addProperty("App::PropertyString", "QetTerminalUuid", "QET Exchange", "")
terminal.QetTerminalUuid = "terminal-a"
terminal.addProperty("App::PropertyBool", "CanWire", "QET Exchange", "")
terminal.CanWire = True
device_group.addObject(terminal)
import_calls = []
def fake_import_model(doc_arg, group_arg, path_arg, **kwargs):
import_calls.append((doc_arg, group_arg, path_arg, kwargs))
body = doc_arg.addObject("Part::Feature", "DeviceBody")
group_arg.addObject(body)
return [body]
device_import._import_model_into_group = fake_import_model
result = device_import.insert_pending_device(doc, device_group)
self.assertEqual(1, len(import_calls))
self.assertFalse(result["already_placed"])
self.assertEqual(["DeviceBody"], [obj.Name for obj in result["imported_objects"]])
def test_insert_pending_device_can_place_whole_device_on_mount_target(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "device.step"
@ -1201,6 +1248,171 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertIn("jhd5.FCStd", rows[0]["display_text"])
self.assertIn("QET_Exchange_OpenPendingDevicePanel", registered)
def test_pending_device_panel_focuses_imported_objects_after_insert(self):
_install_fake_freecad(None)
gui = sys.modules["FreeCADGui"]
selection_calls = []
view_messages = []
gui.Selection = types.SimpleNamespace(
clearSelection=lambda: selection_calls.append(("clear",)),
addSelection=lambda doc_name, obj_name: selection_calls.append((doc_name, obj_name)),
)
gui.SendMsgToActiveView = lambda message: view_messages.append(message)
device_import, _ = _reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
device = doc.addObject("App::Part", "QETDevice_A6300_2")
body = doc.addObject("Part::Feature", "Solid")
body.ViewObject.Visibility = False
pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": [body]})
self.assertTrue(body.ViewObject.Visibility)
self.assertEqual([("clear",), ("QETScene", "Solid")], selection_calls)
self.assertEqual(["ViewSelection"], view_messages)
def test_pending_device_panel_rejects_insert_result_without_visible_model_objects(self):
_install_fake_freecad(None)
_reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
device = doc.addObject("App::Part", "QETDevice_A6300_2")
with self.assertRaisesRegex(
pending_panel.PendingDeviceAssemblyPanelError,
"没有导入任何可显示模型对象",
):
pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": []})
def test_pending_device_panel_syncs_engineering_terminals_after_insert(self):
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _ = _reload_modules()
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []}
device = doc.addObject("App::Part", "QETDevice_A6300_2")
body = doc.addObject("Part::Feature", "Solid")
calls = []
def fake_insert(insert_doc, insert_device, **kwargs):
calls.append(("insert", insert_doc, insert_device, kwargs))
return {"device": insert_device, "imported_objects": [body]}
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append(("terminals", payload, scene_path))
return {"imported_terminals": 1, "updated_terminals": 2, "warnings": []}
original_insert = device_import.insert_pending_device
original_terminal_import = pending_panel.TerminalImport
try:
device_import.insert_pending_device = fake_insert
pending_panel.TerminalImport = FakeTerminalImport
result = pending_panel.insert_device_and_sync_terminals(
doc,
device,
mount_offset_mm=20.0,
)
finally:
device_import.insert_pending_device = original_insert
pending_panel.TerminalImport = original_terminal_import
self.assertIs(device, result["device"])
self.assertEqual([body], result["imported_objects"])
self.assertEqual({"imported_terminals": 1, "updated_terminals": 2, "warnings": []}, result["terminal_report"])
self.assertEqual("insert", calls[0][0])
self.assertEqual("terminals", calls[1][0])
self.assertEqual(app._qet_exchange_payload, calls[1][1])
def test_pending_device_panel_batch_inserts_same_prefix_qet_devices_to_target(self):
_install_fake_freecad(None)
app = sys.modules["FreeCAD"]
device_import, _template_semantics = _reload_modules()
terminal_objects = importlib.import_module("TerminalObjects")
sys.modules.pop("PendingDeviceAssemblyPanel", None)
pending_panel = importlib.import_module("PendingDeviceAssemblyPanel")
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []}
root = device_import._ensure_root_group(doc, None, "project-1")
def pending_device(element_uuid, instance_id, display_tag):
device, _created = device_import._ensure_device_group(
doc,
root,
element_uuid,
instance_id,
r"D:\models\terminal.FCStd",
display_tag,
0,
)
device_import._set_device_assembly_state(
device,
device_import.ASSEMBLY_STATE_PENDING,
)
return device
ud2 = pending_device("element-ud2", "instance-ud2", "UD:2")
pending_device("element-id1", "instance-id1", "ID:1")
pending_device("element-ud1", "instance-ud1", "UD:1")
rail = doc.addObject("App::DocumentObjectGroup", "DINRail")
rail.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
calls = []
body = doc.addObject("Part::Feature", "ImportedBody")
def fake_insert(insert_doc, insert_device, **kwargs):
calls.append((getattr(insert_device, "QetDisplayTag", ""), kwargs))
return {"device": insert_device, "imported_objects": [body]}
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append(("terminals", payload, scene_path))
return {"imported_terminals": 2, "updated_terminals": 0, "warnings": []}
original_insert = device_import.insert_pending_device
original_terminal_import = pending_panel.TerminalImport
try:
device_import.insert_pending_device = fake_insert
pending_panel.TerminalImport = FakeTerminalImport
report = pending_panel.insert_matching_pending_batch_to_target(
doc,
ud2,
rail,
pitch_mm=5.2,
start_offset_mm=1.0,
mount_offset_mm=20.0,
)
finally:
device_import.insert_pending_device = original_insert
pending_panel.TerminalImport = original_terminal_import
self.assertEqual(["UD:1", "UD:2"], [call[0] for call in calls[:2]])
self.assertEqual("terminals", calls[2][0])
self.assertEqual(2, len(report["devices"]))
self.assertEqual("UD", report["batch_prefix"])
self.assertEqual(["UD:1", "UD:2"], report["device_labels"])
self.assertEqual(2, report["inserted_count"])
self.assertEqual({"imported_terminals": 2, "updated_terminals": 0, "warnings": []}, report["terminal_report"])
self.assertAlmostEqual(1.0, calls[0][1]["mount_placement"].Base.x - rail.Placement.Base.x)
self.assertAlmostEqual(6.2, calls[1][1]["mount_placement"].Base.x - rail.Placement.Base.x)
def test_import_devices_from_payload_reuses_fcstd_source_document_within_one_sync(self):
source = FakeDocument("TerminalSlice", r"D:\models\qet_terminal_slice.FCStd")
source.addObject("Part::Feature", "Body")

@ -192,6 +192,426 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource)
self.assertFalse(terminals[0].ViewObject.Visibility)
def test_import_repairs_duplicate_terminal_instance_ids(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "shared-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "shared-instance",
"terminal_display": "B1",
},
],
}
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
terminal_instance_ids = [terminal.QetTerminalInstanceId for terminal in terminals]
self.assertEqual(2, report["imported_terminals"])
self.assertEqual(2, report["repaired_terminal_instance_ids"])
self.assertEqual(2, len(set(terminal_instance_ids)))
self.assertNotIn("shared-instance", terminal_instance_ids)
self.assertEqual({"element-a", "element-b"}, {terminal.QetElementUuid for terminal in terminals})
def test_repaired_terminal_instance_ids_are_stable_when_payload_order_changes(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
def import_and_collect(entries):
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": entries,
}
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
return {
(terminal.QetElementUuid, terminal.Label): terminal.QetTerminalInstanceId
for terminal in terminal_objects.collect_terminal_objects(terminal_group)
}
first_order = [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "shared-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "shared-instance",
"terminal_display": "B1",
},
]
second_order = list(reversed(first_order))
self.assertEqual(
import_and_collect(first_order),
import_and_collect(second_order),
)
def test_import_does_not_treat_existing_terminal_object_as_parent_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
stale_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Stale")
terminal_group.addObject(stale_terminal)
terminal_objects.set_terminal_semantics(
stale_terminal,
"project-1",
"element-b",
"terminal-old",
"device-instance-a",
label="old",
slot_name="old",
terminal_instance_id="terminal-old-instance",
terminal_display="old",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "terminal-b",
"element_uuid": "element-b",
"terminal_instance_id": "terminal-instance-b",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(
{("element-b", "terminal-b", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_reimport_creates_new_terminal_when_existing_uuid_belongs_to_other_element(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
old_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Shared")
terminal_group.addObject(old_terminal)
terminal_objects.set_terminal_semantics(
old_terminal,
"project-1",
"element-a",
"shared-terminal",
"device-instance-a",
label="A1",
slot_name="A1",
terminal_instance_id="old-terminal-instance",
terminal_display="A1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-a",
"terminal_instance_id": "old-terminal-instance",
"terminal_display": "A1",
},
{
"terminal_uuid": "shared-terminal",
"element_uuid": "element-b",
"terminal_instance_id": "new-terminal-instance",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(1, report["updated_terminals"])
self.assertEqual(
{("element-a", "shared-terminal", "A1"), ("element-b", "shared-terminal", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_reimport_removes_stale_duplicate_qet_terminal_contexts(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"element-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
stale_duplicate = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleDuplicate")
terminal_group.addObject(stale_duplicate)
terminal_objects.set_terminal_semantics(
stale_duplicate,
"project-1",
"element-a",
"terminal-a",
"device-instance-a",
label="A1-old",
slot_name="A1",
terminal_instance_id="stale-terminal-instance",
terminal_display="A1",
)
stale_not_in_payload = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleMissing")
terminal_group.addObject(stale_not_in_payload)
terminal_objects.set_terminal_semantics(
stale_not_in_payload,
"project-1",
"element-c",
"terminal-c",
"device-instance-a",
label="C1",
slot_name="C1",
terminal_instance_id="stale-missing-instance",
terminal_display="C1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "device-instance-a",
"terminals": [
{
"terminal_uuid": "terminal-a",
"element_uuid": "element-a",
"terminal_instance_id": "terminal-instance-a",
"terminal_display": "A1",
},
{
"terminal_uuid": "terminal-b",
"element_uuid": "element-b",
"terminal_instance_id": "terminal-instance-b",
"terminal_display": "B1",
},
],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["removed_terminals"])
self.assertEqual(
{("element-a", "terminal-a", "A1"), ("element-b", "terminal-b", "B1")},
{
(
terminal.QetElementUuid,
terminal.QetTerminalUuid,
terminal.QetTerminalDisplay,
)
for terminal in terminals
},
)
def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
@ -264,6 +684,74 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual([local_terminal], terminals)
self.assertEqual("local:instance-a:P1", local_terminal.QetTerminalUuid)
def test_import_preserves_existing_qet_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="instance-a",
)
existing_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Existing")
terminal_group.addObject(existing_terminal)
terminal_objects.set_terminal_semantics(
existing_terminal,
"project-1",
"device-a",
"terminal-a",
"instance-a",
label="P1",
slot_name="P1",
terminal_instance_id="terminal-instance-a",
terminal_display="P1",
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [],
}
],
}
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(0, report["removed_terminals"])
self.assertEqual([existing_terminal], terminals)
self.assertEqual("terminal-a", existing_terminal.QetTerminalUuid)
def test_import_accepts_nested_device_terminals_without_top_level_terminals(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()

@ -560,6 +560,65 @@ class WiringTest(unittest.TestCase):
label="P1",
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
scene_path=str(Path(tmp_dir) / "scene.FCStd"),
payload={"project_uuid": "project-1"},
)
self.assertEqual(1, len(report["terminals"]))
self.assertEqual("terminal-a", report["terminals"][0]["terminal_uuid"])
self.assertEqual("instance-a", report["terminals"][0]["device_instance_id"])
self.assertNotEqual("instance-a", report["terminals"][0]["terminal_instance_id"])
def test_writeback_prefers_terminal_instance_id_property(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"device-instance-a",
)
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="device-instance-a",
)
terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_A")
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"device-a",
"terminal-a",
"device-instance-a",
label="A",
)
terminal_objects.ensure_string_property(
terminal,
"QetTerminalInstanceId",
"QET Exchange",
"Stable 3D terminal instance UUID",
"terminal-instance-a",
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
@ -570,8 +629,8 @@ class WiringTest(unittest.TestCase):
self.assertEqual(
[{
"terminal_uuid": "terminal-a",
"device_instance_id": "instance-a",
"terminal_instance_id": "instance-a",
"device_instance_id": "device-instance-a",
"terminal_instance_id": "terminal-instance-a",
}],
report["terminals"],
)
@ -642,6 +701,132 @@ class WiringTest(unittest.TestCase):
report["instances"],
)
def test_writeback_syncs_terminals_from_payload_before_collecting_bindings(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
def sync_from_payload(payload, _scene_path=""):
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid=payload["project_uuid"],
instance_id="instance-a",
)
terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_B")
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
payload["project_uuid"],
"device-b",
"terminal-b",
"instance-a",
label="B",
terminal_instance_id="terminal-instance-b",
terminal_display="B",
)
return {"imported_terminals": 1}
write_back.TerminalImport = types.SimpleNamespace(
import_terminals_from_payload=sync_from_payload
)
with tempfile.TemporaryDirectory() as tmp_dir:
report = write_back.write_back_document(
doc,
scene_path=str(Path(tmp_dir) / "scene.FCStd"),
payload={
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [
{
"element_uuid": "device-b",
"terminal_uuid": "terminal-b",
"terminal_display": "B",
}
],
}
],
},
)
self.assertEqual(
[
{"element_uuid": "device-a", "device_instance_id": "instance-a"},
{"element_uuid": "device-b", "device_instance_id": "instance-a"},
],
report["instances"],
)
def test_writeback_observer_syncs_terminals_before_document_save(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
calls = []
class FakeTerminalImport:
@staticmethod
def import_terminals_from_payload(payload, scene_path=""):
calls.append((payload, scene_path))
return {"imported_terminals": 1}
original_terminal_import = write_back.TerminalImport
try:
write_back.TerminalImport = FakeTerminalImport
with tempfile.TemporaryDirectory() as tmp_dir:
scene_path = Path(tmp_dir) / "scene.FCStd"
payload_path = Path(tmp_dir) / "2d_to_3d.json"
payload_path.write_text(
json.dumps(
{
"project_uuid": "project-1",
"devices": [
{
"device_instance_id": "instance-a",
"terminals": [
{
"element_uuid": "device-a",
"terminal_uuid": "terminal-a",
}
],
}
],
}
),
encoding="utf-8",
)
observer = write_back._WriteBackObserver()
observer.slotStartSaveDocument(doc, str(scene_path))
finally:
write_back.TerminalImport = original_terminal_import
self.assertEqual(1, len(calls))
self.assertEqual("project-1", calls[0][0]["project_uuid"])
self.assertEqual(str(scene_path), calls[0][1])
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save