From daf16df9d6ea3999e3113821e8197e7c66022f50 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 3 Jun 2026 11:40:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81QET=E7=AB=AF=E5=AD=90?= =?UTF-8?q?=E6=8E=92=E5=92=8C=E6=96=AD=E8=B7=AF=E5=99=A8=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8E=92=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 机柜装配操作文档.md | 44 +- ...06-02-batch-din-device-placement-design.md | 181 +++++-- src/Mod/FreeCADExchange/AutoRoutingPanel.py | 2 +- src/Mod/FreeCADExchange/BatchAssembly.py | 487 +++++++++++++++++- src/Mod/FreeCADExchange/ManualWiringPanel.py | 158 ++++-- .../freecad_exchange_batch_assembly_test.py | 160 ++++++ ...eecad_exchange_manual_wiring_panel_test.py | 71 +++ 7 files changed, 1010 insertions(+), 93 deletions(-) diff --git a/docs/FreeCAD 机柜装配操作文档.md b/docs/FreeCAD 机柜装配操作文档.md index 1a6a638..00c30d9 100644 --- a/docs/FreeCAD 机柜装配操作文档.md +++ b/docs/FreeCAD 机柜装配操作文档.md @@ -146,6 +146,8 @@ 导轨也通常不需要端子 LCS。它是安装基准件。 +当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别导轨。名称中包含 `rail`、`din`、`导轨` 等关键词时,系统会自动补充导轨语义;`标记为导轨` 按钮主要用于纠错或兜底。 + 本仓库已有示例资产: ```text @@ -166,7 +168,9 @@ data/examples/qet_cabinet_assets/qet_wire_duct.FCStd data/examples/qet_cabinet_assets/qet_wire_duct.step ``` -线槽需要在工程里标记为“线槽”,这样布线连接或路径分析才能把它当作走线路径参考。 +线槽需要在工程里具备“线槽”语义,这样布线连接或路径分析才能把它当作走线路径参考。 + +当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别线槽。名称中包含 `wire_duct`、`duct`、`trunking`、`线槽` 等关键词时,系统会自动补充线槽语义;`标记为线槽` 按钮主要用于纠错或兜底。 ### 3.4 有接线点的设备 @@ -459,10 +463,19 @@ QET模板 -> 导入模板实例 ### 9.2 摆放断路器 -1. 导入小型断路器 FCStd 模板。 -2. 移动到导轨前方。 -3. 用 `Assembly` 对齐到导轨。 -4. 多个断路器并排时,使用固定间距复制。 +正式 QET 工程中,如果 QET 已经传入真实断路器设备,不要再重复批量生成断路器。推荐操作: + +1. 从 QET 点击 `3D视图` 打开 FreeCAD,确认树目录中已经有断路器设备。 +2. 选中要安装断路器的导轨。 +3. 切换到 `QET模板`。 +4. 打开 `3D手动布线`。 +5. 点击 `批量断路器`。 +6. 在 `QET断路器前缀` 中输入实际设备前缀,例如 `QF`。 +7. 输入断路器间距和起始偏移。 +8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布。 +9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留。 + +只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。 常见间距: @@ -474,7 +487,20 @@ QET模板 -> 导入模板实例 ### 9.3 摆放接线端子 -端子片可按固定间距复制。 +正式 QET 工程中,端子排通常已经由 QET 传入到 FreeCAD,例如树目录中出现 `UD:1`、`UD:2`、`ID:6` 这类端子片设备。此时不要再手动复制一批新端子片,应该排布 QET 已导入的真实端子片。 + +推荐操作: + +1. 从 QET 点击 `3D视图` 打开 FreeCAD。 +2. 确认树目录中已经有 `UD`、`ID` 等端子排相关设备。 +3. 选中要安装端子排的导轨。 +4. 切换到 `QET模板`。 +5. 打开 `3D手动布线`。 +6. 点击 `批量端子排`。 +7. 在 `QET端子排名称/前缀` 中输入 `UD` 或 `ID`。 +8. 输入端子片间距,例如 `5.2 mm`,以及起始偏移。 +9. 确认后,系统会把匹配的 QET 真实端子片沿导轨按顺序排布。 +10. 如果状态提示 `已排布 QET 端子排`,说明工程端子和 `terminal_uuid` 没有被替换成本地端子。 例如本仓库生成的端子片: @@ -488,13 +514,13 @@ data/examples/qet_terminal_block/qet_terminal_slice.FCStd 5.2 mm ``` -操作建议: +无 QET 数据的手工演示流程: 1. 导入一个端子片。 2. 移动到导轨上。 3. 用 Draft 阵列或 Link 复制。 4. X 方向间距设为 `5.2 mm`。 -5. 需要端子排编号时,后续在 QET 2D 侧维护端子 UUID 和端子名称。 +5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程。 ### 9.4 摆放电流互感器 @@ -812,7 +838,7 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。 2. 常用设备都整理成 FCStd 模板。 3. 有接线点的设备一定补模板端子。 4. 导轨、线槽、机柜可作为纯几何资产。 -5. 端子排优先用单片端子复制,不要每次重建。 +5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例;Draft 阵列只作为无 QET 数据时的手工演示方式。 6. 每完成一段装配就保存一次 `scene.FCStd`。 7. 布线前先生成工程端子。 8. 生成布线连接前先建立布线路径网络。 diff --git a/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md index 201bdd2..9e54fef 100644 --- a/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md +++ b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md @@ -2,88 +2,173 @@ ## 目标 -第一版只做当前项目可演示、可操作的批量装配能力:用户在 FreeCAD 中选中一根已标记的导轨后,可以批量插入端子片形成端子排,也可以批量插入小型断路器。系统按导轨方向等距放置对象,并为每个对象生成可布线工程端子。 +本功能用于 QET 与 FreeCAD 协同工程中的快速 3D 装配。正式工程里,QET 已经传入真实设备、真实端子和 3D 模型,FreeCAD 不再把 `批量端子排`、`批量断路器` 理解为重新生成一批假设备,而是把 QET 已导入的真实实例沿导轨批量排布。 -本功能不做完整设备库、不扩展数据库绑定表、不替代 QET 现有 2D 设备/符号关联。2D 仍负责设备型号、端子号和导线任务;3D 只负责模型、位姿、端子空间点和装配状态。 +第一版目标: -## 范围 +- 选择一根 DIN 导轨后,批量排布 QET 已导入的端子排实例,例如 `UD`、`ID`。 +- 选择一根 DIN 导轨后,批量排布 QET 已导入的小型断路器或同类设备,例如 `QF1`、`QF2`。 +- 保留 QET 身份字段,尤其是 `QetTerminalUuid`、`QetInstanceId`、`QetElementUuid`。 +- 不破坏现有工程端子、导线任务和后续布线匹配。 +- 旧的本地占位生成逻辑只作为没有 QET 数据时的演示兜底。 -### 端子排 +## 数据职责 -用户选择导轨后,输入端子排名称、端子数量、端子片宽度/间距和起始偏移。系统沿导轨方向生成: +QET 负责: -- 一个端子排分组,例如 `XT1` -- 多个端子片实例,例如 `XT1_001`、`XT1_002`。这些实例同时写入标准 QET 设备语义,便于后续端子绑定和布线索引识别。 -- 每片端子对应的工程端子,例如 `XT1:1`、`XT1:2` +- 2D 原理图中的设备、符号、端子、端子排和导线任务。 +- 设备型号、端子号、端子排名称,例如 `UD`、`ID`。 +- 设备与 3D 模型资产绑定。 +- 端子的真实 `terminal_uuid`。 -正式主流程中,端子片模型资源应来自 QET 传入的设备/资产绑定。后端保留 `model_path` 参数,用于 QET 自动传入模型路径或开发调试兜底;普通用户参数窗口不要求手动选择模型文件。未传入模型路径时回退为脚本生成的简化几何。关键是位置整齐、命名清楚、可被布线模块发现。 +FreeCAD 负责: -### 小型断路器 +- 真实 3D 设备实例的空间位姿。 +- 导轨、线槽、柜面等装配宿主。 +- 工程端子的 3D 坐标和出线方向。 +- 设备与导轨的批量排布状态。 +- 3D 布线路径和保存回写。 -用户选择导轨后,输入起始设备名、数量、单个宽度/间距和端子号模板。系统沿导轨方向生成: +第一版仍遵守 2D/3D 协同约束:3D 端子绑定唯一依据是 `terminal_uuid`,3D 位姿以 `scene.FCStd` 为准,不从数据库反推 3D 位姿。 -- 多个设备实例,例如 `QF1`、`QF2`。对象名称使用 `QETDevice_QF1` 这类标准前缀,树目录 Label 仍显示 `QF1`。 -- 每个设备的工程端子,例如 `QF1:1`、`QF1:2`、`QF1:3`、`QF1:4`、`QF1:5`、`QF1:6` +## 端子排批量排布 -如果 2D 已经提供真实设备名和端子号,后续导入/绑定逻辑优先使用 QET 的 `terminal_uuid`;本功能生成的是第一版本地 3D 辅助对象,用于快速摆放和演示。 +正式流程: -## 数据语义 +1. 用户在 FreeCAD 中选中一根已识别或已标记的导轨。 +2. 点击 `3D手动布线` 面板中的 `批量端子排`。 +3. 输入 QET 端子排名称或前缀,例如 `UD`、`ID`。 +4. 输入端子片间距和起始偏移。 +5. 系统扫描当前 `scene.FCStd` 中 QET 已导入的端子片设备。 +6. 匹配端子排名称,例如 `UD:1`、`UD-2`、`ID_006`。 +7. 按 QET 顺序字段或名称中的自然序号排序。 +8. 沿导轨轴向排布这些真实端子片。 +9. 写入轻量装配属性,不改变端子的 QET 绑定。 + +端子排匹配优先读取这些属性: + +- `QetTerminalStripName` +- `QetTerminalBlockName` +- `QetTerminalGroupName` +- `QetStripName` +- `QetParentTerminalBlockName` + +如果没有上述属性,则从对象 `Label` / `Name` 解析 `UD:1`、`ID-2` 这类名称。 + +端子排排序优先读取这些属性: + +- `QetTerminalStripIndex` +- `QetTerminalIndex` +- `QetTerminalSequence` +- `QetTerminalOrder` +- `QetTerminalNo` +- `QetTerminalDisplay` + +如果没有上述属性,则从对象名称中提取最后一个数字做自然排序。 + +## 小型断路器批量排布 + +正式流程: + +1. 用户在 FreeCAD 中选中一根导轨。 +2. 点击 `3D手动布线` 面板中的 `批量断路器`。 +3. 输入 QET 设备前缀,例如 `QF`。 +4. 输入设备间距和起始偏移。 +5. 系统扫描当前 `scene.FCStd` 中 QET 已导入的真实设备实例。 +6. 排除端子排端子片和旧的本地批量生成对象。 +7. 按设备 `Label`、`Name`、`QetInstanceId` 等字段匹配前缀。 +8. 按自然顺序排布,例如 `QF1`、`QF2`、`QF10`。 +9. 保留设备下的工程端子和 QET 绑定关系。 + +断路器端子号来自 QET 传入的真实端子数据。参数窗口中的“兜底端子号”只在当前工程没有匹配 QET 设备、需要演示生成占位对象时使用。 + +## 旧兜底逻辑 + +为了保留开发调试和无 QET 数据演示能力,旧接口仍保留: + +- `create_terminal_block(...)` +- `create_breakers(...)` + +但正式按钮调用顺序是: + +```text +先 layout_existing_terminal_block / layout_existing_devices +如果 updated_devices > 0,说明已排布 QET 真实对象 +如果没有匹配对象,才回退 create_terminal_block / create_breakers +``` + +兜底生成对象可能产生 `local:*` 端子,只能用于 3D 演示和开发测试,不作为正式 QET 布线匹配的主流程。 + +## 装配属性 + +排布真实 QET 对象时,系统只写入轻量属性: -- 不新增数据库字段。 -- 3D 对象保存在 FreeCAD 文档中。 -- 工程端子仍使用 `TerminalObjects.set_terminal_semantics(...)`。 -- 批量生成的本地槽位端子使用 `local::`,避免伪造 QET terminal_uuid。 -- 批量设备组写入 `QetProjectUuid`、`QetElementUuid`、`QetInstanceId` 和 `QetGroupKind=Device`,使它们能被现有端子导入/布线绑定逻辑找到。 -- 当 QET 的 `2d_to_3d.json` 后续提供真实 `terminal_uuid + terminal_display` 时,布线/端子绑定逻辑按端子号匹配本地槽位,并把 `local:*` 提升为真实 QET `terminal_uuid`。 -- 树目录显示名使用 `设备名:端子号`,方便设计人员辨认。 -- 批量对象额外写入本地属性: - `QetBatchAssemblyKind` - `QetBatchAssemblyName` +- `QetBatchAssemblyMode = layout_existing` +- `QetBatchAssemblyOrder` +- `QetBatchAssemblyOffsetMm` +- `QetMountKind = rail` - `QetMountHostName` -- `QetMountKind` -- `QetBatchSourceModelPath`,仅导入本地模型文件时写入可见几何对象 +- `QetMountHostKind` + +这些属性保存在 FreeCAD 文档里,用于后续刷新、诊断和显示,不扩展第一版数据库绑定表。 ## 导轨定位规则 -第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。放置公式: +第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。如果导轨带旋转,排列轴经过导轨 `Placement.Rotation` 转换。 + +放置公式: ```text -第 N 个对象位置 = 导轨 Placement.Base + 轴向单位向量 * (起始偏移 + N * 间距) +第 N 个对象位置 = 导轨 Placement.Base + 导轨轴向单位向量 * (起始偏移 + N * 间距) ``` -如果导轨对象带有旋转,排列轴会经过导轨 `Placement.Rotation` 转换。第一版先保证当前工程和内置导轨的稳定演示。 +当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版。 ## UI -挂在 `QET模板 -> 3D手动布线` 面板,新增两个按钮: +入口位于: + +```text +QET模板 -> 3D手动布线 +``` + +按钮: - `批量端子排` - `批量断路器` -点击按钮后弹出参数窗口,窗口内带默认参数: +参数窗口说明: + +- `QET端子排名称/前缀`:正式工程用于匹配 QET 端子排,例如 `UD`、`ID`。 +- `QET断路器前缀`:正式工程用于匹配 QET 已导入设备,例如 `QF`。 +- `端子间距 / 断路器间距`:沿导轨方向的排布间距。 +- `起始偏移`:从导轨基点开始的偏移。 +- `兜底数量 / 兜底端子号`:只有找不到匹配 QET 对象时才用于生成演示对象。 -- 端子排:`XT1`,10 片,5.2 mm 间距 -- 小型断路器:`QF`,3 个,18 mm 间距,端子号 `1,2,3,4,5,6` +执行成功后状态栏会区分: -端子号支持用空格、英文逗号、中文逗号、分号分隔;重复端子号会被拒绝,避免生成两个同名接线点。 -普通用户窗口不提供模型文件选择;模型文件由 QET 侧传入或由开发调试入口传入。不传入时使用脚本简化几何。 +- `已排布 QET 端子排` +- `已排布 QET 断路器` +- `未找到匹配的 QET ...,已兜底生成` ## 验收 -1. 选中导轨后点击 `批量端子排`,生成 `XT1` 分组和端子片。 -2. 在参数窗口中可调整端子排名称、数量、间距和起始偏移。 -3. 每个端子片都有一个工程端子,Label 为 `XT1:n`。 -4. 选中导轨后点击 `批量断路器`,生成 `QF1`、`QF2`、`QF3`。 -5. 在参数窗口中可调整断路器前缀、数量、间距、起始偏移和端子号模板。 -6. 每个断路器生成指定端子号。 -7. 生成对象位于导轨方向上,间距正确。 -8. 端子默认隐藏,但可被手动/自动布线模块按端子对象收集。 -9. 如果 QET 已传入同一设备实例和端子号,`local:*` 槽位能被提升为真实 `terminal_uuid`,从而参与导线任务匹配。 +1. 从 QET 点击 `3D视图` 打开 FreeCAD。 +2. 树目录中已经存在 QET 导入的端子片或设备实例。 +3. 选中导轨,点击 `批量端子排`。 +4. 输入 `UD` 或 `ID`,确认后真实端子片沿导轨排布。 +5. 排布后端子对象仍保留真实 `QetTerminalUuid`,不会变成 `local:*`。 +6. 选中导轨,点击 `批量断路器`。 +7. 输入 `QF`,确认后真实断路器沿导轨排布。 +8. 保存后重新打开 `scene.FCStd`,设备位置保持。 +9. 后续 `3D手动布线` 或 `3D布线连接` 能继续通过 `terminal_uuid` 匹配导线任务。 ## 非目标 -- 不做完整 SolidWorks Electrical 设备库。 -- 不自动读取所有 2D 设备属性批量生成真实设备。 -- 不伪造 QET terminal_uuid;只有 QET 输入中存在真实端子 UUID 时才提升绑定。 -- 不做复杂 Assembly Joint。 -- 不做完整端子排电气跨接片、跳线、短接片规则。 +- 不做完整 SolidWorks Electrical / EPLAN 设备库。 +- 不在 FreeCAD 中重新创建 QET 已经传入的正式设备。 +- 不伪造 QET `terminal_uuid`。 +- 不删除旧兜底生成函数,但普通工程主流程不依赖它。 +- 不实现完整端子排电气跨接片、跳线、端挡和标记条规则。 diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index f3c766c..abbc0dd 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -317,7 +317,7 @@ class AutoRoutingTaskPanel: ) ) options_layout.addWidget(self.terminal_access_max_distance_spin) - options_layout.addWidget(QtWidgets.QLabel("端子出线长度 mm")) + options_layout.addWidget(QtWidgets.QLabel("自动端子出线长度 mm")) self.terminal_exit_length_spin = QtWidgets.QDoubleSpinBox() self.terminal_exit_length_spin.setRange(0.0, 1000.0) self.terminal_exit_length_spin.setDecimals(1) diff --git a/src/Mod/FreeCADExchange/BatchAssembly.py b/src/Mod/FreeCADExchange/BatchAssembly.py index ecd97d4..2be0012 100644 --- a/src/Mod/FreeCADExchange/BatchAssembly.py +++ b/src/Mod/FreeCADExchange/BatchAssembly.py @@ -1,4 +1,5 @@ import math +import re from pathlib import Path import FreeCAD as App @@ -15,6 +16,33 @@ class BatchAssemblyError(RuntimeError): pass +TERMINAL_STRIP_NAME_PROPERTIES = ( + "QetTerminalStripName", + "QetTerminalBlockName", + "QetTerminalGroupName", + "QetStripName", + "QetParentTerminalBlockName", +) + +TERMINAL_STRIP_ORDER_PROPERTIES = ( + "QetTerminalStripIndex", + "QetTerminalIndex", + "QetTerminalSequence", + "QetTerminalOrder", + "QetTerminalNo", + "QetTerminalDisplay", +) + +DEVICE_PREFIX_PROPERTIES = ( + "QetDeviceTag", + "QetDeviceName", + "QetDisplayTag", + "QetSymbolLabel", + "QetInstanceId", + "QetElementUuid", +) + + def _project_uuid(doc): try: root = TerminalObjects.ensure_root_group(doc) @@ -28,6 +56,198 @@ def _safe_label(text, fallback): return value or fallback +def _text_values(obj, include_children=False): + values = [] + for attr_name in ("Label", "Name"): + value = (getattr(obj, attr_name, "") or "").strip() + if value: + values.append(value) + for prop_name in DEVICE_PREFIX_PROPERTIES + TERMINAL_STRIP_NAME_PROPERTIES: + value = (getattr(obj, prop_name, "") or "").strip() + if value: + values.append(value) + if include_children: + for child in list(getattr(obj, "Group", []) or []): + for attr_name in ("Label", "Name"): + value = (getattr(child, attr_name, "") or "").strip() + if value: + values.append(value) + return values + + +def _natural_sort_key(value): + text = str(value or "") + key = [] + for part in re.split(r"(\d+)", text): + if part.isdigit(): + key.append((0, int(part))) + else: + key.append((1, part.lower())) + return key + + +def _parse_strip_name_and_order(obj): + for prop_name in TERMINAL_STRIP_NAME_PROPERTIES: + strip_name = (getattr(obj, prop_name, "") or "").strip() + if not strip_name: + continue + order = _explicit_order(obj) + if order is None: + order = _order_from_texts(_text_values(obj)) + return strip_name, order + + for text in _text_values(obj): + # Examples from QET trees: UD:1, UD-2, ID_006. + match = re.match(r"^\s*([A-Za-z][A-Za-z0-9]{0,8})\s*[::_\-]\s*(\d+)\b", text) + if match: + return match.group(1), int(match.group(2)) + return "", None + + +def _explicit_order(obj): + for prop_name in TERMINAL_STRIP_ORDER_PROPERTIES: + value = (getattr(obj, prop_name, "") or "").strip() + if not value: + continue + match = re.search(r"\d+", value) + if match: + return int(match.group(0)) + return None + + +def _order_from_texts(texts): + for text in texts: + match = re.search(r"(\d+)(?!.*\d)", str(text or "")) + if match: + return int(match.group(1)) + return None + + +def _is_group_like(obj): + try: + return bool(obj and obj.isDerivedFrom("App::DocumentObjectGroup")) + except Exception: + return bool(getattr(obj, "Group", None) is not None) + + +def _qet_identity(obj): + instance_id = (getattr(obj, "QetInstanceId", "") or "").strip() + element_uuid = (getattr(obj, "QetElementUuid", "") or "").strip() + return instance_id, element_uuid + + +def _is_qet_device_object(obj): + if obj is None: + return False + name = getattr(obj, "Name", "") or "" + instance_id, element_uuid = _qet_identity(obj) + if name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX): + return True + return bool(instance_id or element_uuid) + + +def _contains_terminal_slice_geometry(obj): + text = " ".join(_text_values(obj, include_children=True)).lower() + return any( + token in text + for token in ( + "terminalslice", + "terminal_slice", + "terminal slice", + "get_terminal_slice", + "端子片", + "端子排", + ) + ) + + +def _contains_qet_terminal_group(obj): + for child in list(getattr(obj, "Group", []) or []): + if (getattr(child, "QetGroupKind", "") or "").strip() == TerminalObjects.TERMINAL_GROUP_KIND: + return True + if getattr(child, "Name", "").startswith(TerminalObjects.TERMINAL_GROUP_PREFIX): + return True + return False + + +def _is_terminal_strip_device(obj): + if not _is_qet_device_object(obj): + return False + strip_name, _order = _parse_strip_name_and_order(obj) + if not strip_name: + return False + if _contains_terminal_slice_geometry(obj): + return True + if _contains_qet_terminal_group(obj): + return True + return False + + +def _matches_prefix(obj, prefix): + prefix = (prefix or "").strip().lower() + if not prefix: + return True + for text in _text_values(obj): + if text.lower().startswith(prefix): + return True + return False + + +def _is_batch_generated(obj): + return bool((getattr(obj, "QetBatchAssemblyKind", "") or "").strip()) + + +def _existing_terminal_strip_devices(doc, strip_name=""): + wanted = (strip_name or "").strip().lower() + devices = [] + for obj in list(getattr(doc, "Objects", []) or []): + if not _is_terminal_strip_device(obj): + continue + current_strip, order = _parse_strip_name_and_order(obj) + if wanted and current_strip.lower() != wanted: + continue + devices.append((current_strip, order, obj)) + devices.sort( + key=lambda item: ( + item[0].lower(), + item[1] if item[1] is not None else 10**9, + _natural_sort_key(getattr(item[2], "Label", "") or getattr(item[2], "Name", "")), + ) + ) + return [obj for _strip, _order, obj in devices] + + +def available_terminal_strip_names(doc): + names = [] + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if not _is_terminal_strip_device(obj): + continue + strip_name, _order = _parse_strip_name_and_order(obj) + key = strip_name.lower() + if strip_name and key not in seen: + seen.add(key) + names.append(strip_name) + names.sort(key=_natural_sort_key) + return names + + +def _existing_devices_by_prefix(doc, prefix=""): + devices = [] + for obj in list(getattr(doc, "Objects", []) or []): + if not _is_qet_device_object(obj): + continue + if _is_terminal_strip_device(obj): + continue + if _is_batch_generated(obj): + continue + if not _matches_prefix(obj, prefix): + continue + devices.append(obj) + devices.sort(key=lambda obj: _natural_sort_key(getattr(obj, "Label", "") or getattr(obj, "Name", ""))) + return devices + + def _axis_vector(rail): axis = (getattr(rail, "QetCarrierAxis", "") or "x").strip().lower() if axis == "y": @@ -76,6 +296,103 @@ def _placement_at(rail, point): return App.Placement(point, rotation or App.Rotation()) +def _vector_copy(vector): + return App.Vector( + float(getattr(vector, "x", 0.0) or 0.0), + float(getattr(vector, "y", 0.0) or 0.0), + float(getattr(vector, "z", 0.0) or 0.0), + ) + + +def _vector_add(left, right): + return App.Vector( + float(getattr(left, "x", 0.0) or 0.0) + float(getattr(right, "x", 0.0) or 0.0), + float(getattr(left, "y", 0.0) or 0.0) + float(getattr(right, "y", 0.0) or 0.0), + float(getattr(left, "z", 0.0) or 0.0) + float(getattr(right, "z", 0.0) or 0.0), + ) + + +def _vector_sub(left, right): + return App.Vector( + float(getattr(left, "x", 0.0) or 0.0) - float(getattr(right, "x", 0.0) or 0.0), + float(getattr(left, "y", 0.0) or 0.0) - float(getattr(right, "y", 0.0) or 0.0), + float(getattr(left, "z", 0.0) or 0.0) - float(getattr(right, "z", 0.0) or 0.0), + ) + + +def _placement_base(obj): + placement = getattr(obj, "Placement", None) + base = getattr(placement, "Base", None) + if base is None: + return None + return _vector_copy(base) + + +def _placement_controls_children(obj): + try: + return bool(obj is not None and obj.isDerivedFrom("App::Part")) + except Exception: + return False + + +def _iter_transform_children(obj): + for child in list(getattr(obj, "Group", []) or []): + yield child + if not _placement_controls_children(child): + for nested in _iter_transform_children(child): + yield nested + + +def _object_anchor_point(obj): + if obj is None: + return App.Vector(0, 0, 0) + children = list(getattr(obj, "Group", []) or []) + if _placement_controls_children(obj) or not children: + return _placement_base(obj) or App.Vector(0, 0, 0) + + points = [] + for child in _iter_transform_children(obj): + base = _placement_base(child) + if base is not None: + points.append(base) + if not points: + return _placement_base(obj) or App.Vector(0, 0, 0) + + return App.Vector( + sum(point.x for point in points) / len(points), + sum(point.y for point in points) / len(points), + sum(point.z for point in points) / len(points), + ) + + +def _translate_placement(obj, delta): + placement = getattr(obj, "Placement", None) + base = getattr(placement, "Base", None) + if placement is None or base is None: + return False + new_base = _vector_add(base, delta) + try: + placement.Base = new_base + obj.Placement = placement + return True + except Exception: + try: + obj.Placement = App.Placement(new_base, getattr(placement, "Rotation", App.Rotation())) + return True + except Exception: + return False + + +def _translate_group_children(obj, delta): + moved = 0 + for child in list(getattr(obj, "Group", []) or []): + if _translate_placement(child, delta): + moved += 1 + if not _placement_controls_children(child): + moved += _translate_group_children(child, delta) + return moved + + def _ensure_rail(rail): if rail is None: raise BatchAssemblyError("请先选择一根导轨。") @@ -222,6 +539,67 @@ def _set_batch_properties(obj, kind, batch_name, host): "Mount host object name", getattr(host, "Name", "") or "", ) + TerminalObjects.ensure_string_property( + obj, + "QetMountHostKind", + "QET Mount", + "Mount host kind", + (getattr(host, "QetCarrierKind", "") or "").strip(), + ) + + +def _set_layout_properties(obj, kind, batch_name, host, order_index, offset_mm): + _set_batch_properties(obj, kind, batch_name, host) + TerminalObjects.ensure_string_property( + obj, + "QetBatchAssemblyMode", + "QET Batch Assembly", + "Batch assembly mode", + "layout_existing", + ) + TerminalObjects.ensure_string_property( + obj, + "QetBatchAssemblyOrder", + "QET Batch Assembly", + "Batch assembly order", + str(int(order_index)), + ) + TerminalObjects.ensure_string_property( + obj, + "QetBatchAssemblyOffsetMm", + "QET Batch Assembly", + "Batch assembly offset in millimeters", + "{0:.6f}".format(float(offset_mm or 0.0)), + ) + + +def _set_object_placement(obj, placement): + if obj is not None and getattr(obj, "Group", None) and not _placement_controls_children(obj): + current = _object_anchor_point(obj) + target = getattr(placement, "Base", App.Vector(0, 0, 0)) + delta = _vector_sub(target, current) + moved_children = _translate_group_children(obj, delta) + try: + obj.Placement = placement + except Exception: + pass + if moved_children: + return True + + try: + obj.Placement = placement + return True + except Exception: + try: + existing = getattr(obj, "Placement", None) + if existing is not None: + existing.Base = placement.Base + existing.Rotation = placement.Rotation + obj.Placement = existing + return True + except Exception: + pass + return False def _set_qet_device_properties(obj, project_uuid, element_uuid, instance_id): @@ -367,7 +745,17 @@ def _create_terminal( return terminal -def _batch_report(kind, group, devices, terminals): +def _terminal_objects_for_devices(devices): + terminals = [] + for device in devices or []: + try: + terminals.extend(TerminalObjects.collect_terminal_objects(device)) + except Exception: + pass + return terminals + + +def _batch_report(kind, group, devices, terminals, source="fallback_created"): return { "kind": kind, "group": group, @@ -375,9 +763,106 @@ def _batch_report(kind, group, devices, terminals): "terminals": terminals, "created_devices": len(devices), "created_terminals": len(terminals), + "updated_devices": 0, + "updated_terminals": 0, + "source": source, } +def _layout_existing_objects( + doc, + rail, + objects, + kind, + batch_name, + pitch_mm, + start_offset_mm, +): + rail = _ensure_rail(rail) + objects = [obj for obj in objects or [] if obj is not None] + if not objects: + return _batch_report(kind, rail, [], [], source="qet_existing") + + base = _base_point(rail) + axis = _axis_vector(rail) + updated = [] + for index, obj in enumerate(objects): + offset = float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0) + placement = _placement_at(rail, _point_at(base, axis, offset)) + if _set_object_placement(obj, placement): + _set_layout_properties(obj, kind, batch_name, rail, index + 1, offset) + updated.append(obj) + + try: + doc.recompute() + except Exception: + pass + + terminals = _terminal_objects_for_devices(updated) + return { + "kind": kind, + "group": rail, + "devices": updated, + "terminals": terminals, + "created_devices": 0, + "created_terminals": 0, + "updated_devices": len(updated), + "updated_terminals": len(terminals), + "source": "qet_existing", + } + + +def layout_existing_terminal_block( + doc, + rail, + block_name="", + pitch_mm=5.2, + start_offset_mm=0.0, +): + if doc is None: + raise BatchAssemblyError("请先打开 FreeCAD 工程。") + rail = _ensure_rail(rail) + devices = _existing_terminal_strip_devices(doc, block_name) + if not devices: + return _batch_report("terminal_block", rail, [], [], source="qet_existing") + batch_name = _safe_label(block_name, _parse_strip_name_and_order(devices[0])[0] or "QET端子排") + return _layout_existing_objects( + doc, + rail, + devices, + "terminal_block", + batch_name, + pitch_mm, + start_offset_mm, + ) + + +def layout_existing_devices( + doc, + rail, + prefix="QF", + pitch_mm=18.0, + start_offset_mm=0.0, + kind="device_batch", +): + if doc is None: + raise BatchAssemblyError("请先打开 FreeCAD 工程。") + rail = _ensure_rail(rail) + devices = _existing_devices_by_prefix(doc, prefix) + if not devices: + return _batch_report(kind, rail, [], [], source="qet_existing") + batch_name = _safe_label(prefix, "QET设备") + return _layout_existing_objects( + doc, + rail, + devices, + kind, + batch_name, + pitch_mm, + start_offset_mm, + ) + + def create_terminal_block( doc, rail, diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index 19c4716..fc5598e 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -140,13 +140,31 @@ def _dialog_accepted_value(): return 1 +def _combo_or_line_text(widget): + if hasattr(widget, "currentText"): + return widget.currentText() + if hasattr(widget, "text"): + return widget.text() + return "" + + def _prompt_terminal_block_options(parent=None): if QtWidgets is None: raise ManualWiringPanelError("当前 FreeCAD 未加载 Qt,不能打开批量端子排参数窗口。") dialog = QtWidgets.QDialog(parent) dialog.setWindowTitle("批量端子排") layout = QtWidgets.QFormLayout(dialog) - name_input = QtWidgets.QLineEdit(DEFAULT_BATCH_TERMINAL_BLOCK_NAME) + strip_names = [] + try: + strip_names = BatchAssembly.available_terminal_strip_names(_active_document()) + except Exception: + strip_names = [] + if strip_names and hasattr(QtWidgets, "QComboBox"): + name_input = QtWidgets.QComboBox() + name_input.setEditable(True) + name_input.addItems(strip_names) + else: + name_input = QtWidgets.QLineEdit(DEFAULT_BATCH_TERMINAL_BLOCK_NAME) count_input = QtWidgets.QSpinBox() count_input.setRange(1, 10000) count_input.setValue(DEFAULT_BATCH_TERMINAL_BLOCK_COUNT) @@ -158,8 +176,8 @@ def _prompt_terminal_block_options(parent=None): offset_input.setRange(-100000.0, 100000.0) offset_input.setDecimals(2) offset_input.setValue(0.0) - layout.addRow("端子排名称", name_input) - layout.addRow("端子数量", count_input) + layout.addRow("QET端子排名称/前缀", name_input) + layout.addRow("兜底端子数量", count_input) layout.addRow("端子间距 mm", pitch_input) layout.addRow("起始偏移 mm", offset_input) buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) @@ -169,7 +187,7 @@ def _prompt_terminal_block_options(parent=None): if _exec_dialog(dialog) != _dialog_accepted_value(): return None return _batch_terminal_block_options( - name_input.text(), + _combo_or_line_text(name_input), count_input.value(), pitch_input.value(), offset_input.value(), @@ -195,11 +213,11 @@ def _prompt_breaker_options(parent=None): offset_input.setDecimals(2) offset_input.setValue(0.0) terminals_input = QtWidgets.QLineEdit(DEFAULT_BATCH_BREAKER_TERMINALS_TEXT) - layout.addRow("断路器前缀", name_input) - layout.addRow("断路器数量", count_input) + layout.addRow("QET断路器前缀", name_input) + layout.addRow("兜底断路器数量", count_input) layout.addRow("断路器间距 mm", pitch_input) layout.addRow("起始偏移 mm", offset_input) - layout.addRow("端子号", terminals_input) + layout.addRow("兜底端子号", terminals_input) buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) @@ -898,6 +916,8 @@ def _carrier_kind_from_object(obj): if obj is not None: text_parts.append(getattr(obj, "Name", "") or "") text_parts.append(getattr(obj, "Label", "") or "") + text_parts.append(getattr(obj, "QetCarrierSourcePath", "") or "") + text_parts.append(getattr(obj, "QetResolvedModelPath", "") or "") text = " ".join(text_parts).lower() if "线槽" in text or "duct" in text or "trunking" in text: return "wire_duct" @@ -908,17 +928,36 @@ def _carrier_kind_from_object(obj): return "" +def _auto_mark_carrier_if_detected(doc, obj): + if obj is None: + return None + carrier = _carrier_object_from_object(obj) + if carrier is not None: + return carrier + + carrier_kind = _carrier_kind_from_object(obj) + if carrier_kind not in CARRIER_ROLE_LABELS: + return None + + _set_carrier_properties(obj, carrier_kind) + try: + carrier_group = WiringObjects.ensure_carrier_group(doc or _active_document()) + if obj not in getattr(carrier_group, "Group", []): + carrier_group.addObject(obj) + except Exception: + pass + return obj + + def _carrier_object_from_object(obj): - candidates = [] current = obj - if current is not None: - candidates.append(current) - candidates.extend(list(getattr(current, "InList", []) or [])) - - for candidate in candidates: - carrier_kind = (getattr(candidate, "QetCarrierKind", "") or "").strip() - if carrier_kind: - return candidate + visited = set() + while current is not None and id(current) not in visited: + visited.add(id(current)) + if (getattr(current, "QetCarrierKind", "") or "").strip(): + return current + parents = list(getattr(current, "InList", []) or []) + current = parents[0] if parents else None return None @@ -943,11 +982,25 @@ def _carrier_role_label(carrier_kind): def _selected_carrier_objects(): - return [ - obj - for obj in _selection() - if obj is not None and not TerminalObjects.is_terminal_object(obj) - ] + selected = [] + for obj in _selection(): + if obj is not None and not TerminalObjects.is_terminal_object(obj): + selected.append(obj) + for picked in _selection_ex(): + obj = getattr(picked, "Object", None) + if obj is not None and not TerminalObjects.is_terminal_object(obj): + selected.append(obj) + + carriers = [] + try: + doc = _active_document() + except Exception: + doc = None + for obj in selected: + carrier = _auto_mark_carrier_if_detected(doc, obj) or _carrier_object_from_object(obj) or obj + if carrier not in carriers: + carriers.append(carrier) + return carriers def _selected_rail_object(): @@ -1505,8 +1558,18 @@ class ManualWiringController: rail = _selected_rail_object() if rail is None: raise ManualWiringPanelError("请先选择一根已标记的导轨。") + doc = _active_document() + report = BatchAssembly.layout_existing_terminal_block( + doc, + rail, + block_name=block_name, + pitch_mm=pitch_mm, + start_offset_mm=start_offset_mm, + ) + if report.get("updated_devices", 0): + return report return BatchAssembly.create_terminal_block( - _active_document(), + doc, rail, block_name=block_name, count=count, @@ -1527,8 +1590,19 @@ class ManualWiringController: rail = _selected_rail_object() if rail is None: raise ManualWiringPanelError("请先选择一根已标记的导轨。") + doc = _active_document() + report = BatchAssembly.layout_existing_devices( + doc, + rail, + prefix=base_name, + pitch_mm=pitch_mm, + start_offset_mm=start_offset_mm, + kind="breaker_batch", + ) + if report.get("updated_devices", 0): + return report return BatchAssembly.create_breakers( - _active_document(), + doc, rail, base_name=base_name, count=count, @@ -1848,7 +1922,7 @@ class ManualWiringTaskPanel: layout.addWidget(self.use_task_button) layout.addWidget(self.reload_tasks_button) exit_layout = QtWidgets.QHBoxLayout() - exit_layout.addWidget(QtWidgets.QLabel("端子出线长度")) + exit_layout.addWidget(QtWidgets.QLabel("手动端子出线长度")) exit_layout.addWidget(self.exit_length_input) layout.addLayout(exit_layout) carrier_length_layout = QtWidgets.QHBoxLayout() @@ -2066,12 +2140,20 @@ class ManualWiringTaskPanel: if options is None: return report = self.controller.create_terminal_block_from_selection(**options) - self._set_status( - "已批量生成端子排:设备 {0} 个,工程端子 {1} 个。".format( - report.get("created_devices", 0), - report.get("created_terminals", 0), + if report.get("source") == "qet_existing": + self._set_status( + "已排布 QET 端子排:设备 {0} 个,工程端子 {1} 个。".format( + report.get("updated_devices", 0), + report.get("updated_terminals", 0), + ) + ) + else: + self._set_status( + "未找到匹配的 QET 端子排,已兜底生成:设备 {0} 个,工程端子 {1} 个。".format( + report.get("created_devices", 0), + report.get("created_terminals", 0), + ) ) - ) except Exception as exc: self._set_error(str(exc)) @@ -2081,12 +2163,20 @@ class ManualWiringTaskPanel: if options is None: return report = self.controller.create_breakers_from_selection(**options) - self._set_status( - "已批量生成小型断路器:设备 {0} 个,工程端子 {1} 个。".format( - report.get("created_devices", 0), - report.get("created_terminals", 0), + if report.get("source") == "qet_existing": + self._set_status( + "已排布 QET 断路器:设备 {0} 个,工程端子 {1} 个。".format( + report.get("updated_devices", 0), + report.get("updated_terminals", 0), + ) + ) + else: + self._set_status( + "未找到匹配的 QET 断路器,已兜底生成:设备 {0} 个,工程端子 {1} 个。".format( + report.get("created_devices", 0), + report.get("created_terminals", 0), + ) ) - ) except Exception as exc: self._set_error(str(exc)) diff --git a/tests/python/freecad_exchange_batch_assembly_test.py b/tests/python/freecad_exchange_batch_assembly_test.py index c5f8c07..123ab60 100644 --- a/tests/python/freecad_exchange_batch_assembly_test.py +++ b/tests/python/freecad_exchange_batch_assembly_test.py @@ -124,6 +124,166 @@ def _reload_modules(*extra_names): class BatchAssemblyTest(unittest.TestCase): + def _qet_device(self, doc, terminal_objects, label, instance_id=None, element_uuid=None): + token = terminal_objects.safe_token(label) + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_" + token) + device.Label = label + terminal_objects.ensure_string_property(device, "QetGroupKind", "QET Exchange", "", "Device") + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "", + element_uuid or label, + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "", + instance_id or label, + ) + return device + + def _terminal(self, doc, terminal_objects, device, terminal_uuid, label): + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id=device.QetInstanceId, + ) + terminal = terminal_objects.create_lcs_object( + doc, + "QETTerminal_" + terminal_objects.safe_token(terminal_uuid), + label=label, + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + device.QetElementUuid, + terminal_uuid, + device.QetInstanceId, + label=label, + ) + return terminal + + def test_layout_existing_terminal_block_places_qet_terminal_slices_without_local_rebind(self): + _install_fake_freecad() + terminal_objects, batch_assembly = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + rail = doc.addObject("App::DocumentObjectGroup", "DINRail") + rail.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation()) + terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") + terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") + + ud2 = self._qet_device(doc, terminal_objects, "UD:2", instance_id="ud-2", element_uuid="element-ud-2") + ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") + self._terminal(doc, terminal_objects, ud2, "terminal-ud-2", "UD:2") + self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") + + report = batch_assembly.layout_existing_terminal_block( + doc, + rail, + block_name="UD", + pitch_mm=5.2, + start_offset_mm=10.0, + ) + + self.assertEqual("qet_existing", report["source"]) + self.assertEqual(2, report["updated_devices"]) + self.assertEqual(0, report["created_devices"]) + self.assertEqual(["UD:1", "UD:2"], [device.Label for device in report["devices"]]) + self.assertEqual([110.0, 115.2], [device.Placement.Base.x for device in report["devices"]]) + self.assertEqual(["terminal-ud-1", "terminal-ud-2"], [terminal.QetTerminalUuid for terminal in report["terminals"]]) + self.assertFalse(any(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"])) + self.assertEqual("layout_existing", ud1.QetBatchAssemblyMode) + self.assertEqual("rail", ud1.QetMountHostKind) + + def test_layout_existing_terminal_block_moves_group_children_for_document_groups(self): + _install_fake_freecad() + terminal_objects, batch_assembly = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + rail = doc.addObject("App::DocumentObjectGroup", "DINRail") + rail.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) + terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") + terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") + ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") + body = doc.addObject("Part::Feature", "TerminalSlice_GreenBody") + body.Placement = app.Placement(app.Vector(10, 20, 30), app.Rotation()) + ud1.addObject(body) + self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") + + report = batch_assembly.layout_existing_terminal_block( + doc, + rail, + block_name="UD", + pitch_mm=5.2, + start_offset_mm=10.0, + ) + + self.assertEqual(1, report["updated_devices"]) + self.assertNotEqual(10.0, body.Placement.Base.x) + self.assertAlmostEqual(110.0, ud1.Placement.Base.x) + + def test_available_terminal_strip_names_comes_from_existing_qet_devices(self): + _install_fake_freecad() + terminal_objects, batch_assembly = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") + id2 = self._qet_device(doc, terminal_objects, "ID:2", instance_id="id-2", element_uuid="element-id-2") + self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") + self._terminal(doc, terminal_objects, id2, "terminal-id-2", "ID:2") + + self.assertEqual(["ID", "UD"], batch_assembly.available_terminal_strip_names(doc)) + + def test_layout_existing_devices_filters_qet_breakers_and_ignores_terminal_slices(self): + _install_fake_freecad() + terminal_objects, batch_assembly = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + rail = doc.addObject("App::DocumentObjectGroup", "DINRail") + rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") + terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") + + qf2 = self._qet_device(doc, terminal_objects, "QF2", instance_id="qf-2", element_uuid="element-qf-2") + qf1 = self._qet_device(doc, terminal_objects, "QF1", instance_id="qf-1", element_uuid="element-qf-1") + ta1 = self._qet_device(doc, terminal_objects, "TA1", instance_id="ta-1", element_uuid="element-ta-1") + ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") + self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") + qf0 = self._qet_device(doc, terminal_objects, "QF0", instance_id="qf-0", element_uuid="element-qf-0") + terminal_objects.ensure_string_property( + qf0, + "QetBatchAssemblyKind", + "QET Batch Assembly", + "", + "breaker_batch", + ) + + report = batch_assembly.layout_existing_devices( + doc, + rail, + prefix="QF", + pitch_mm=18.0, + start_offset_mm=5.0, + kind="breaker_batch", + ) + + self.assertEqual("qet_existing", report["source"]) + self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]]) + self.assertNotIn(ta1, report["devices"]) + self.assertNotIn(ud1, report["devices"]) + self.assertNotIn(qf0, report["devices"]) + self.assertEqual([5.0, 23.0], [device.Placement.Base.x for device in report["devices"]]) + self.assertEqual("layout_existing", qf1.QetBatchAssemblyMode) + def test_create_terminal_block_places_slices_and_local_terminals_along_selected_rail(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 1d104fc..5ec5884 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -613,6 +613,77 @@ class ManualWiringPanelTest(unittest.TestCase): self.assertEqual(500.0, getattr(carrier, "QetCarrierLength", None)) self.assertEqual(2.5, getattr(carrier, "QetCarrierScaleX", None)) + def test_controller_applies_length_when_selected_object_is_carrier_child(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + carrier = doc.addObject("App::DocumentObjectGroup", "WireDuctCarrier") + child = doc.addObject("Part::Feature", "WireDuctBody") + nested_child = doc.addObject("Part::Feature", "WireDuctBodyFaceOwner") + carrier.addObject(child) + child.addObject(nested_child) + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + carrier.addProperty("App::PropertyFloat", "QetCarrierBaseLength", "QET Wiring", "Base length") + carrier.QetCarrierBaseLength = 200.0 + selection_state["selection"] = [nested_child] + + updated = panel.ManualWiringController().apply_length_to_selected_carriers(400.0) + + self.assertEqual([carrier], updated) + self.assertEqual(400.0, getattr(carrier, "QetCarrierLength", None)) + self.assertEqual(2.0, getattr(carrier, "QetCarrierScaleX", None)) + + def test_controller_auto_marks_selected_wire_duct_by_name_before_length_change(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "qet_wire_duct_001") + duct.Label = "线槽001" + selection_state["selection"] = [duct] + + updated = panel.ManualWiringController().apply_length_to_selected_carriers(450.0) + + carrier_group = doc.getObject("QETWiring_02_Carriers") + self.assertEqual([duct], updated) + self.assertEqual("wire_duct", duct.QetCarrierKind) + self.assertEqual("线槽", duct.QetCarrierRoleLabel) + self.assertIn(duct, carrier_group.Group) + self.assertEqual(450.0, duct.QetCarrierLength) + + def test_controller_auto_detects_selected_din_rail_for_batch_placement(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + rail = doc.addObject("App::DocumentObjectGroup", "DINRail001") + rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + selection_state["selection"] = [rail] + + report = panel.ManualWiringController().create_breakers_from_selection( + base_name="QF", + count=1, + ) + + self.assertEqual(1, report["created_devices"]) + self.assertEqual("rail", rail.QetCarrierKind) + def test_controller_aligns_second_selected_face_to_first_selected_face(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules()