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

dev
Zhaowenlong 3 weeks ago
parent 35218b85bc
commit daf16df9d6

@ -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. 生成布线连接前先建立布线路径网络。

@ -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:<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`
- `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`
- 不删除旧兜底生成函数,但普通工程主流程不依赖它
- 不实现完整端子排电气跨接片、跳线、端挡和标记条规则。

@ -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)

@ -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,

@ -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))

@ -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()

@ -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()

Loading…
Cancel
Save