feat: 支持QET端子排和断路器批量排布

dev
Zhaowenlong 3 weeks ago
parent 35218b85bc
commit daf16df9d6

@ -146,6 +146,8 @@
导轨也通常不需要端子 LCS。它是安装基准件。 导轨也通常不需要端子 LCS。它是安装基准件。
当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别导轨。名称中包含 `rail`、`din`、`导轨` 等关键词时,系统会自动补充导轨语义;`标记为导轨` 按钮主要用于纠错或兜底。
本仓库已有示例资产: 本仓库已有示例资产:
```text ```text
@ -166,7 +168,9 @@ data/examples/qet_cabinet_assets/qet_wire_duct.FCStd
data/examples/qet_cabinet_assets/qet_wire_duct.step data/examples/qet_cabinet_assets/qet_wire_duct.step
``` ```
线槽需要在工程里标记为“线槽”,这样布线连接或路径分析才能把它当作走线路径参考。 线槽需要在工程里具备“线槽”语义,这样布线连接或路径分析才能把它当作走线路径参考。
当前版本会优先按 QET 传入语义、对象名称、Label、模型路径自动识别线槽。名称中包含 `wire_duct`、`duct`、`trunking`、`线槽` 等关键词时,系统会自动补充线槽语义;`标记为线槽` 按钮主要用于纠错或兜底。
### 3.4 有接线点的设备 ### 3.4 有接线点的设备
@ -459,10 +463,19 @@ QET模板 -> 导入模板实例
### 9.2 摆放断路器 ### 9.2 摆放断路器
1. 导入小型断路器 FCStd 模板。 正式 QET 工程中,如果 QET 已经传入真实断路器设备,不要再重复批量生成断路器。推荐操作:
2. 移动到导轨前方。
3. 用 `Assembly` 对齐到导轨。 1. 从 QET 点击 `3D视图` 打开 FreeCAD确认树目录中已经有断路器设备。
4. 多个断路器并排时,使用固定间距复制。 2. 选中要安装断路器的导轨。
3. 切换到 `QET模板`
4. 打开 `3D手动布线`
5. 点击 `批量断路器`
6. 在 `QET断路器前缀` 中输入实际设备前缀,例如 `QF`
7. 输入断路器间距和起始偏移。
8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布。
9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留。
只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。
常见间距: 常见间距:
@ -474,7 +487,20 @@ QET模板 -> 导入模板实例
### 9.3 摆放接线端子 ### 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 5.2 mm
``` ```
操作建议 无 QET 数据的手工演示流程
1. 导入一个端子片。 1. 导入一个端子片。
2. 移动到导轨上。 2. 移动到导轨上。
3. 用 Draft 阵列或 Link 复制。 3. 用 Draft 阵列或 Link 复制。
4. X 方向间距设为 `5.2 mm` 4. X 方向间距设为 `5.2 mm`
5. 需要端子排编号时,后续在 QET 2D 侧维护端子 UUID 和端子名称 5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程
### 9.4 摆放电流互感器 ### 9.4 摆放电流互感器
@ -812,7 +838,7 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。
2. 常用设备都整理成 FCStd 模板。 2. 常用设备都整理成 FCStd 模板。
3. 有接线点的设备一定补模板端子。 3. 有接线点的设备一定补模板端子。
4. 导轨、线槽、机柜可作为纯几何资产。 4. 导轨、线槽、机柜可作为纯几何资产。
5. 端子排优先用单片端子复制,不要每次重建 5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例Draft 阵列只作为无 QET 数据时的手工演示方式
6. 每完成一段装配就保存一次 `scene.FCStd` 6. 每完成一段装配就保存一次 `scene.FCStd`
7. 布线前先生成工程端子。 7. 布线前先生成工程端子。
8. 生成布线连接前先建立布线路径网络。 8. 生成布线连接前先建立布线路径网络。

@ -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` - 2D 原理图中的设备、符号、端子、端子排和导线任务。
- 多个端子片实例,例如 `XT1_001`、`XT1_002`。这些实例同时写入标准 QET 设备语义,便于后续端子绑定和布线索引识别。 - 设备型号、端子号、端子排名称,例如 `UD`、`ID`。
- 每片端子对应的工程端子,例如 `XT1:1`、`XT1:2` - 设备与 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:<instance_id>:<terminal_no>`,避免伪造 QET terminal_uuid。
- 批量设备组写入 `QetProjectUuid`、`QetElementUuid`、`QetInstanceId` 和 `QetGroupKind=Device`,使它们能被现有端子导入/布线绑定逻辑找到。
- 当 QET 的 `2d_to_3d.json` 后续提供真实 `terminal_uuid + terminal_display` 时,布线/端子绑定逻辑按端子号匹配本地槽位,并把 `local:*` 提升为真实 QET `terminal_uuid`
- 树目录显示名使用 `设备名:端子号`,方便设计人员辨认。
- 批量对象额外写入本地属性:
- `QetBatchAssemblyKind` - `QetBatchAssemblyKind`
- `QetBatchAssemblyName` - `QetBatchAssemblyName`
- `QetBatchAssemblyMode = layout_existing`
- `QetBatchAssemblyOrder`
- `QetBatchAssemblyOffsetMm`
- `QetMountKind = rail`
- `QetMountHostName` - `QetMountHostName`
- `QetMountKind` - `QetMountHostKind`
- `QetBatchSourceModelPath`,仅导入本地模型文件时写入可见几何对象
这些属性保存在 FreeCAD 文档里,用于后续刷新、诊断和显示,不扩展第一版数据库绑定表。
## 导轨定位规则 ## 导轨定位规则
第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。放置公式: 第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。如果导轨带旋转,排列轴经过导轨 `Placement.Rotation` 转换。
放置公式:
```text ```text
第 N 个对象位置 = 导轨 Placement.Base + 轴向单位向量 * (起始偏移 + N * 间距) 第 N 个对象位置 = 导轨 Placement.Base + 导轨轴向单位向量 * (起始偏移 + N * 间距)
``` ```
如果导轨对象带有旋转,排列轴会经过导轨 `Placement.Rotation` 转换。第一版先保证当前工程和内置导轨的稳定演示。 当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版
## UI ## 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` 分组和端子片。 1. 从 QET 点击 `3D视图` 打开 FreeCAD
2. 在参数窗口中可调整端子排名称、数量、间距和起始偏移。 2. 树目录中已经存在 QET 导入的端子片或设备实例
3. 每个端子片都有一个工程端子Label 为 `XT1:n` 3. 选中导轨,点击 `批量端子排`。
4. 选中导轨后点击 `批量断路器`,生成 `QF1`、`QF2`、`QF3`。 4. 输入 `UD``ID`,确认后真实端子片沿导轨排布
5. 在参数窗口中可调整断路器前缀、数量、间距、起始偏移和端子号模板。 5. 排布后端子对象仍保留真实 `QetTerminalUuid`,不会变成 `local:*`
6. 每个断路器生成指定端子号。 6. 选中导轨,点击 `批量断路器`
7. 生成对象位于导轨方向上,间距正确。 7. 输入 `QF`,确认后真实断路器沿导轨排布
8. 端子默认隐藏,但可被手动/自动布线模块按端子对象收集。 8. 保存后重新打开 `scene.FCStd`,设备位置保持
9. 如果 QET 已传入同一设备实例和端子号,`local:*` 槽位能被提升为真实 `terminal_uuid`,从而参与导线任务匹配。 9. 后续 `3D手动布线``3D布线连接` 能继续通过 `terminal_uuid` 匹配导线任务
## 非目标 ## 非目标
- 不做完整 SolidWorks Electrical 设备库。 - 不做完整 SolidWorks Electrical / EPLAN 设备库。
- 不自动读取所有 2D 设备属性批量生成真实设备。 - 不在 FreeCAD 中重新创建 QET 已经传入的正式设备。
- 不伪造 QET terminal_uuid只有 QET 输入中存在真实端子 UUID 时才提升绑定。 - 不伪造 QET `terminal_uuid`
- 不做复杂 Assembly Joint。 - 不删除旧兜底生成函数,但普通工程主流程不依赖它
- 不做完整端子排电气跨接片、跳线、短接片规则。 - 不实现完整端子排电气跨接片、跳线、端挡和标记条规则。

@ -317,7 +317,7 @@ class AutoRoutingTaskPanel:
) )
) )
options_layout.addWidget(self.terminal_access_max_distance_spin) 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 = QtWidgets.QDoubleSpinBox()
self.terminal_exit_length_spin.setRange(0.0, 1000.0) self.terminal_exit_length_spin.setRange(0.0, 1000.0)
self.terminal_exit_length_spin.setDecimals(1) self.terminal_exit_length_spin.setDecimals(1)

@ -1,4 +1,5 @@
import math import math
import re
from pathlib import Path from pathlib import Path
import FreeCAD as App import FreeCAD as App
@ -15,6 +16,33 @@ class BatchAssemblyError(RuntimeError):
pass 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): def _project_uuid(doc):
try: try:
root = TerminalObjects.ensure_root_group(doc) root = TerminalObjects.ensure_root_group(doc)
@ -28,6 +56,198 @@ def _safe_label(text, fallback):
return value or 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): def _axis_vector(rail):
axis = (getattr(rail, "QetCarrierAxis", "") or "x").strip().lower() axis = (getattr(rail, "QetCarrierAxis", "") or "x").strip().lower()
if axis == "y": if axis == "y":
@ -76,6 +296,103 @@ def _placement_at(rail, point):
return App.Placement(point, rotation or App.Rotation()) 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): def _ensure_rail(rail):
if rail is None: if rail is None:
raise BatchAssemblyError("请先选择一根导轨。") raise BatchAssemblyError("请先选择一根导轨。")
@ -222,6 +539,67 @@ def _set_batch_properties(obj, kind, batch_name, host):
"Mount host object name", "Mount host object name",
getattr(host, "Name", "") or "", 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): def _set_qet_device_properties(obj, project_uuid, element_uuid, instance_id):
@ -367,7 +745,17 @@ def _create_terminal(
return 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 { return {
"kind": kind, "kind": kind,
"group": group, "group": group,
@ -375,9 +763,106 @@ def _batch_report(kind, group, devices, terminals):
"terminals": terminals, "terminals": terminals,
"created_devices": len(devices), "created_devices": len(devices),
"created_terminals": len(terminals), "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( def create_terminal_block(
doc, doc,
rail, rail,

@ -140,13 +140,31 @@ def _dialog_accepted_value():
return 1 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): def _prompt_terminal_block_options(parent=None):
if QtWidgets is None: if QtWidgets is None:
raise ManualWiringPanelError("当前 FreeCAD 未加载 Qt不能打开批量端子排参数窗口。") raise ManualWiringPanelError("当前 FreeCAD 未加载 Qt不能打开批量端子排参数窗口。")
dialog = QtWidgets.QDialog(parent) dialog = QtWidgets.QDialog(parent)
dialog.setWindowTitle("批量端子排") dialog.setWindowTitle("批量端子排")
layout = QtWidgets.QFormLayout(dialog) 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 = QtWidgets.QSpinBox()
count_input.setRange(1, 10000) count_input.setRange(1, 10000)
count_input.setValue(DEFAULT_BATCH_TERMINAL_BLOCK_COUNT) 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.setRange(-100000.0, 100000.0)
offset_input.setDecimals(2) offset_input.setDecimals(2)
offset_input.setValue(0.0) offset_input.setValue(0.0)
layout.addRow("端子排名称", name_input) layout.addRow("QET端子排名称/前缀", name_input)
layout.addRow("端子数量", count_input) layout.addRow("兜底端子数量", count_input)
layout.addRow("端子间距 mm", pitch_input) layout.addRow("端子间距 mm", pitch_input)
layout.addRow("起始偏移 mm", offset_input) layout.addRow("起始偏移 mm", offset_input)
buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) 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(): if _exec_dialog(dialog) != _dialog_accepted_value():
return None return None
return _batch_terminal_block_options( return _batch_terminal_block_options(
name_input.text(), _combo_or_line_text(name_input),
count_input.value(), count_input.value(),
pitch_input.value(), pitch_input.value(),
offset_input.value(), offset_input.value(),
@ -195,11 +213,11 @@ def _prompt_breaker_options(parent=None):
offset_input.setDecimals(2) offset_input.setDecimals(2)
offset_input.setValue(0.0) offset_input.setValue(0.0)
terminals_input = QtWidgets.QLineEdit(DEFAULT_BATCH_BREAKER_TERMINALS_TEXT) terminals_input = QtWidgets.QLineEdit(DEFAULT_BATCH_BREAKER_TERMINALS_TEXT)
layout.addRow("断路器前缀", name_input) layout.addRow("QET断路器前缀", name_input)
layout.addRow("断路器数量", count_input) layout.addRow("兜底断路器数量", count_input)
layout.addRow("断路器间距 mm", pitch_input) layout.addRow("断路器间距 mm", pitch_input)
layout.addRow("起始偏移 mm", offset_input) layout.addRow("起始偏移 mm", offset_input)
layout.addRow("端子号", terminals_input) layout.addRow("兜底端子号", terminals_input)
buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept) buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject) buttons.rejected.connect(dialog.reject)
@ -898,6 +916,8 @@ def _carrier_kind_from_object(obj):
if obj is not None: if obj is not None:
text_parts.append(getattr(obj, "Name", "") or "") text_parts.append(getattr(obj, "Name", "") or "")
text_parts.append(getattr(obj, "Label", "") 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() text = " ".join(text_parts).lower()
if "线槽" in text or "duct" in text or "trunking" in text: if "线槽" in text or "duct" in text or "trunking" in text:
return "wire_duct" return "wire_duct"
@ -908,17 +928,36 @@ def _carrier_kind_from_object(obj):
return "" 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): def _carrier_object_from_object(obj):
candidates = []
current = obj current = obj
if current is not None: visited = set()
candidates.append(current) while current is not None and id(current) not in visited:
candidates.extend(list(getattr(current, "InList", []) or [])) visited.add(id(current))
if (getattr(current, "QetCarrierKind", "") or "").strip():
for candidate in candidates: return current
carrier_kind = (getattr(candidate, "QetCarrierKind", "") or "").strip() parents = list(getattr(current, "InList", []) or [])
if carrier_kind: current = parents[0] if parents else None
return candidate
return None return None
@ -943,11 +982,25 @@ def _carrier_role_label(carrier_kind):
def _selected_carrier_objects(): def _selected_carrier_objects():
return [ selected = []
obj for obj in _selection():
for obj in _selection() if obj is not None and not TerminalObjects.is_terminal_object(obj):
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(): def _selected_rail_object():
@ -1505,8 +1558,18 @@ class ManualWiringController:
rail = _selected_rail_object() rail = _selected_rail_object()
if rail is None: if rail is None:
raise ManualWiringPanelError("请先选择一根已标记的导轨。") 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( return BatchAssembly.create_terminal_block(
_active_document(), doc,
rail, rail,
block_name=block_name, block_name=block_name,
count=count, count=count,
@ -1527,8 +1590,19 @@ class ManualWiringController:
rail = _selected_rail_object() rail = _selected_rail_object()
if rail is None: if rail is None:
raise ManualWiringPanelError("请先选择一根已标记的导轨。") 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( return BatchAssembly.create_breakers(
_active_document(), doc,
rail, rail,
base_name=base_name, base_name=base_name,
count=count, count=count,
@ -1848,7 +1922,7 @@ class ManualWiringTaskPanel:
layout.addWidget(self.use_task_button) layout.addWidget(self.use_task_button)
layout.addWidget(self.reload_tasks_button) layout.addWidget(self.reload_tasks_button)
exit_layout = QtWidgets.QHBoxLayout() exit_layout = QtWidgets.QHBoxLayout()
exit_layout.addWidget(QtWidgets.QLabel("端子出线长度")) exit_layout.addWidget(QtWidgets.QLabel("手动端子出线长度"))
exit_layout.addWidget(self.exit_length_input) exit_layout.addWidget(self.exit_length_input)
layout.addLayout(exit_layout) layout.addLayout(exit_layout)
carrier_length_layout = QtWidgets.QHBoxLayout() carrier_length_layout = QtWidgets.QHBoxLayout()
@ -2066,12 +2140,20 @@ class ManualWiringTaskPanel:
if options is None: if options is None:
return return
report = self.controller.create_terminal_block_from_selection(**options) report = self.controller.create_terminal_block_from_selection(**options)
self._set_status( if report.get("source") == "qet_existing":
"已批量生成端子排:设备 {0} 个,工程端子 {1} 个。".format( self._set_status(
report.get("created_devices", 0), "已排布 QET 端子排:设备 {0} 个,工程端子 {1} 个。".format(
report.get("created_terminals", 0), 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: except Exception as exc:
self._set_error(str(exc)) self._set_error(str(exc))
@ -2081,12 +2163,20 @@ class ManualWiringTaskPanel:
if options is None: if options is None:
return return
report = self.controller.create_breakers_from_selection(**options) report = self.controller.create_breakers_from_selection(**options)
self._set_status( if report.get("source") == "qet_existing":
"已批量生成小型断路器:设备 {0} 个,工程端子 {1} 个。".format( self._set_status(
report.get("created_devices", 0), "已排布 QET 断路器:设备 {0} 个,工程端子 {1} 个。".format(
report.get("created_terminals", 0), 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: except Exception as exc:
self._set_error(str(exc)) self._set_error(str(exc))

@ -124,6 +124,166 @@ def _reload_modules(*extra_names):
class BatchAssemblyTest(unittest.TestCase): 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): def test_create_terminal_block_places_slices_and_local_terminals_along_selected_rail(self):
_install_fake_freecad() _install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules() terminal_objects, batch_assembly = _reload_modules()

@ -613,6 +613,77 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(500.0, getattr(carrier, "QetCarrierLength", None)) self.assertEqual(500.0, getattr(carrier, "QetCarrierLength", None))
self.assertEqual(2.5, getattr(carrier, "QetCarrierScaleX", 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): def test_controller_aligns_second_selected_face_to_first_selected_face(self):
selection_state = _install_fake_freecad() selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules() terminal_objects, panel = _reload_modules()

Loading…
Cancel
Save