feat(freecad): improve qet assembly placement workflow

dev
Zhaowenlong 3 weeks ago
parent 5dbd39747b
commit b0c662bd11

@ -381,8 +381,10 @@ Z = 1200 mm
1. FreeCAD 原生 `Assembly` 工作台可以做平面对齐、距离、同轴/共线等配合关系。
2. FreeCADExchange 目前主要保存对象最终 `Placement`,并提供轻量的 `贴合到选中面` 辅助。
3. `贴合到选中面` 是一次性位姿调整,不是持久 Assembly 约束求解器。
4. 当前不会自动保存“设备装在哪根导轨上”“导轨固定在哪块安装板上”这类完整宿主关系。
5. 自动布线读取 `scene.FCStd` 里的最终几何位置,不从装配约束反推位置。
4. 执行 `贴合到选中面` 后,被移动对象会记录一组轻量宿主元数据:`QetMountMode`、`QetMountKind`、`QetMountHostName`、`QetMountHostKind`、`QetMountHostSubElement`、`QetMountContactSubElement`。这些信息保存在 FreeCAD 文档对象属性中,不写入第一版数据库。
5. `3D手动布线` 面板提供 `刷新宿主装配`。宿主对象平移后,已记录宿主关系的导轨、线槽或设备会按保存的局部偏移跟随更新。
6. 当前 `刷新宿主装配` 只处理平移联动,不处理宿主旋转、导轨槽位约束或复杂 Assembly 求解。
7. 自动布线读取 `scene.FCStd` 里的最终几何位置,不从装配约束反推位置。
推荐建模习惯:

@ -24,10 +24,22 @@ CAD 用户在 FreeCAD 中摆放导轨、线槽和设备时,需要让两个接
## 交互
1. 在 3D 视图中选择机柜、导轨或线槽上的目标面。
2. 按住 Ctrl再选择要移动对象上的接触面。
3. 点击 `贴合到选中面`
4. 如果方向不理想,用户可以先用 FreeCAD `变换` 粗调姿态,再重新执行贴合。
推荐流程:
1. 在 3D 视图中选择机柜、导轨或线槽上的目标安装面。
2. 点击 `设为贴合目标面`
3. 选择要移动对象上的接触面。
4. 点击 `贴合到选中面`
5. 对同一个目标面连续摆放多个设备时,只重复第 3、4 步。
兼容流程:
1. 同时选择两个面,第一个是目标面,第二个是要移动对象的接触面。
2. 点击 `贴合到选中面`
如果用户只选中了一个对象而没有选中具体面,系统会尝试使用该对象面积最大的平面作为贴合面。这是为了降低机柜板、导轨、线槽这类规则模型的选面难度;复杂设备仍建议精确选择真实安装面。
如果方向不理想,用户可以先用 FreeCAD 旋转视图、隐藏遮挡物或透明化对象来选面。不要为了选面而旋转模型本体。
第一版只接受两个面。多选多个设备、多个面时,系统不能唯一判断哪个对象应该移动、哪个面是目标、是否要同时满足多个约束,因此会直接提示错误。多面贴合属于完整 Assembly 约束求解范围,不放进这个轻量按钮。
@ -39,9 +51,14 @@ CAD 用户在 FreeCAD 中摆放导轨、线槽和设备时,需要让两个接
- 多于两个面:提示只能选择两个面。
- 第二个选择对象没有可移动 `Placement`:提示对象不能移动。
- 无法读取面中心或法向:提示请选择有效模型面。
- 已设置目标面后又选中多个移动面:提示只选择一个接触面。
## 测试
- 选择两个面后,移动对象应只沿目标面法向平移,消除法向间距。
- 多选三个或更多面时应报错。
- 已设置目标面后,只选择一个接触面即可贴合。
- 选择内部子零件面时,应移动 QET 设备或载体根对象,而不是只移动内部 Shape。
- 贴合后应重新选中被移动的根对象,保证后续 FreeCAD `变换` 从新坐标开始。
- 只选对象时,可用最大平面作为辅助贴合面。
- 导入类操作或贴合操作后,`App.ActiveDocument` 仍应是当前 QET 工程。

@ -0,0 +1,89 @@
# 批量端子排与小型断路器装配设计
## 目标
第一版只做当前项目可演示、可操作的批量装配能力:用户在 FreeCAD 中选中一根已标记的导轨后,可以批量插入端子片形成端子排,也可以批量插入小型断路器。系统按导轨方向等距放置对象,并为每个对象生成可布线工程端子。
本功能不做完整设备库、不扩展数据库绑定表、不替代 QET 现有 2D 设备/符号关联。2D 仍负责设备型号、端子号和导线任务3D 只负责模型、位姿、端子空间点和装配状态。
## 范围
### 端子排
用户选择导轨后,输入端子排名称、端子数量、端子片宽度/间距和起始偏移。系统沿导轨方向生成:
- 一个端子排分组,例如 `XT1`
- 多个端子片实例,例如 `XT1_001`、`XT1_002`。这些实例同时写入标准 QET 设备语义,便于后续端子绑定和布线索引识别。
- 每片端子对应的工程端子,例如 `XT1:1`、`XT1:2`
正式主流程中,端子片模型资源应来自 QET 传入的设备/资产绑定。后端保留 `model_path` 参数,用于 QET 自动传入模型路径或开发调试兜底;普通用户参数窗口不要求手动选择模型文件。未传入模型路径时回退为脚本生成的简化几何。关键是位置整齐、命名清楚、可被布线模块发现。
### 小型断路器
用户选择导轨后,输入起始设备名、数量、单个宽度/间距和端子号模板。系统沿导轨方向生成:
- 多个设备实例,例如 `QF1`、`QF2`。对象名称使用 `QETDevice_QF1` 这类标准前缀,树目录 Label 仍显示 `QF1`
- 每个设备的工程端子,例如 `QF1:1`、`QF1:2`、`QF1:3`、`QF1:4`、`QF1:5`、`QF1:6`
如果 2D 已经提供真实设备名和端子号,后续导入/绑定逻辑优先使用 QET 的 `terminal_uuid`;本功能生成的是第一版本地 3D 辅助对象,用于快速摆放和演示。
## 数据语义
- 不新增数据库字段。
- 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`
- `QetMountHostName`
- `QetMountKind`
- `QetBatchSourceModelPath`,仅导入本地模型文件时写入可见几何对象
## 导轨定位规则
第一版使用导轨对象的 `QetCarrierAxis` 作为排列轴,默认 `x`。放置公式:
```text
第 N 个对象位置 = 导轨 Placement.Base + 轴向单位向量 * (起始偏移 + N * 间距)
```
如果导轨对象带有旋转,排列轴会经过导轨 `Placement.Rotation` 转换。第一版先保证当前工程和内置导轨的稳定演示。
## UI
挂在 `QET模板 -> 3D手动布线` 面板,新增两个按钮:
- `批量端子排`
- `批量断路器`
点击按钮后弹出参数窗口,窗口内带默认参数:
- 端子排:`XT1`10 片5.2 mm 间距
- 小型断路器:`QF`3 个18 mm 间距,端子号 `1,2,3,4,5,6`
端子号支持用空格、英文逗号、中文逗号、分号分隔;重复端子号会被拒绝,避免生成两个同名接线点。
普通用户窗口不提供模型文件选择;模型文件由 QET 侧传入或由开发调试入口传入。不传入时使用脚本简化几何。
## 验收
1. 选中导轨后点击 `批量端子排`,生成 `XT1` 分组和端子片。
2. 在参数窗口中可调整端子排名称、数量、间距和起始偏移。
3. 每个端子片都有一个工程端子Label 为 `XT1:n`
4. 选中导轨后点击 `批量断路器`,生成 `QF1`、`QF2`、`QF3`。
5. 在参数窗口中可调整断路器前缀、数量、间距、起始偏移和端子号模板。
6. 每个断路器生成指定端子号。
7. 生成对象位于导轨方向上,间距正确。
8. 端子默认隐藏,但可被手动/自动布线模块按端子对象收集。
9. 如果 QET 已传入同一设备实例和端子号,`local:*` 槽位能被提升为真实 `terminal_uuid`,从而参与导线任务匹配。
## 非目标
- 不做完整 SolidWorks Electrical 设备库。
- 不自动读取所有 2D 设备属性批量生成真实设备。
- 不伪造 QET terminal_uuid只有 QET 输入中存在真实端子 UUID 时才提升绑定。
- 不做复杂 Assembly Joint。
- 不做完整端子排电气跨接片、跳线、短接片规则。

@ -0,0 +1,515 @@
import math
from pathlib import Path
import FreeCAD as App
import TerminalObjects
try:
import ImportGui
except Exception:
ImportGui = None
class BatchAssemblyError(RuntimeError):
pass
def _project_uuid(doc):
try:
root = TerminalObjects.ensure_root_group(doc)
return (getattr(root, "QetProjectUuid", "") or "").strip()
except Exception:
return ""
def _safe_label(text, fallback):
value = str(text or "").strip()
return value or fallback
def _axis_vector(rail):
axis = (getattr(rail, "QetCarrierAxis", "") or "x").strip().lower()
if axis == "y":
vector = App.Vector(0, 1, 0)
elif axis == "z":
vector = App.Vector(0, 0, 1)
else:
vector = App.Vector(1, 0, 0)
try:
placement = getattr(rail, "Placement", None)
rotation = getattr(placement, "Rotation", None)
if rotation is not None and hasattr(rotation, "multVec"):
vector = rotation.multVec(vector)
except Exception:
pass
length = math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
if length <= 1e-9:
return App.Vector(1, 0, 0)
return App.Vector(vector.x / length, vector.y / length, vector.z / length)
def _base_point(rail):
placement = getattr(rail, "Placement", None)
base = getattr(placement, "Base", None)
if base is None:
return App.Vector(0, 0, 0)
return App.Vector(base.x, base.y, base.z)
def _point_at(base, axis, offset):
return App.Vector(
base.x + axis.x * float(offset or 0.0),
base.y + axis.y * float(offset or 0.0),
base.z + axis.z * float(offset or 0.0),
)
def _placement_at(rail, point):
rotation = None
try:
rotation = getattr(getattr(rail, "Placement", None), "Rotation", None)
except Exception:
rotation = None
return App.Placement(point, rotation or App.Rotation())
def _ensure_rail(rail):
if rail is None:
raise BatchAssemblyError("请先选择一根导轨。")
kind = (getattr(rail, "QetCarrierKind", "") or "").strip()
if kind and kind != "rail":
raise BatchAssemblyError("所选对象不是导轨。")
return rail
def _ensure_batch_root(doc, project_uuid=""):
group = doc.getObject("QETBatchAssembly")
if group is None:
group = doc.addObject("App::DocumentObjectGroup", "QETBatchAssembly")
group.Label = "QET Batch Assembly"
if project_uuid:
TerminalObjects.ensure_string_property(
group,
"QetProjectUuid",
"QET Batch Assembly",
"Project UUID",
project_uuid,
)
return group
def _unique_object_name(doc, base_name):
name = TerminalObjects.safe_token(base_name) or "QETObject"
if doc.getObject(name) is None:
return name
suffix = 1
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(name, suffix)
def _existing_object_names(doc):
return {getattr(obj, "Name", "") for obj in list(getattr(doc, "Objects", []) or [])}
def _new_objects_since(doc, before_names):
return [
obj
for obj in list(getattr(doc, "Objects", []) or [])
if getattr(obj, "Name", "") not in before_names
]
def _top_level_objects(objects):
object_set = set(objects or [])
result = []
for obj in objects or []:
parents = set(getattr(obj, "InList", []) or [])
if parents.intersection(object_set):
continue
result.append(obj)
return result
def _supported_model_path(path):
suffix = Path(path or "").suffix.lower()
return suffix in {".fcstd", ".step", ".stp"}
def _set_source_model_path(obj, model_path):
TerminalObjects.ensure_string_property(
obj,
"QetBatchSourceModelPath",
"QET Batch Assembly",
"Batch assembly imported model path",
model_path,
)
def _import_fcstd_objects(doc, path):
if not hasattr(App, "openDocument") or not hasattr(doc, "copyObject"):
return []
source_doc = None
try:
source_doc = App.openDocument(path, hidden=True, temporary=True)
copied = []
for source_obj in _top_level_objects(list(getattr(source_doc, "Objects", []) or [])):
copied.append(doc.copyObject(source_obj, True))
return copied
finally:
if source_doc is not None and hasattr(App, "closeDocument"):
try:
App.closeDocument(source_doc.Name)
except Exception:
pass
def _import_step_objects(doc, path):
if ImportGui is None:
return []
before = _existing_object_names(doc)
try:
ImportGui.insert(name=path, docName=doc.Name, merge=False, useLinkGroup=True)
except TypeError:
ImportGui.insert(path, doc.Name)
return _top_level_objects(_new_objects_since(doc, before))
def _import_model_objects(doc, model_path):
path = str(model_path or "").strip()
if not path:
return []
if not _supported_model_path(path):
raise BatchAssemblyError("请选择 STEP/STP/FCStd 模型文件。")
if not Path(path).is_file():
raise BatchAssemblyError("模型文件不存在:{0}".format(path))
suffix = Path(path).suffix.lower()
if suffix == ".fcstd":
return _import_fcstd_objects(doc, path)
return _import_step_objects(doc, path)
def _set_batch_properties(obj, kind, batch_name, host):
TerminalObjects.ensure_string_property(
obj,
"QetBatchAssemblyKind",
"QET Batch Assembly",
"Batch assembly kind",
kind,
)
TerminalObjects.ensure_string_property(
obj,
"QetBatchAssemblyName",
"QET Batch Assembly",
"Batch assembly name",
batch_name,
)
TerminalObjects.ensure_string_property(
obj,
"QetMountKind",
"QET Mount",
"Mount kind",
"rail",
)
TerminalObjects.ensure_string_property(
obj,
"QetMountHostName",
"QET Mount",
"Mount host object name",
getattr(host, "Name", "") or "",
)
def _set_qet_device_properties(obj, project_uuid, element_uuid, instance_id):
TerminalObjects.ensure_string_property(
obj,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
"Device",
)
TerminalObjects.ensure_string_property(
obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
TerminalObjects.ensure_string_property(
obj,
"QetElementUuid",
"QET Exchange",
"Parent element UUID from QET exchange",
element_uuid,
)
TerminalObjects.ensure_string_property(
obj,
"QetInstanceId",
"QET Exchange",
"Parent instance id from QET exchange",
instance_id,
)
def _create_device_group(
doc,
parent,
label,
placement,
kind,
batch_name,
host,
project_uuid,
element_uuid,
instance_id,
):
group = doc.addObject(
"App::DocumentObjectGroup",
_unique_object_name(doc, "QETDevice_{0}".format(label)),
)
group.Label = label
try:
group.Placement = placement
except Exception:
pass
parent.addObject(group)
_set_batch_properties(group, kind, batch_name, host)
_set_qet_device_properties(group, project_uuid, element_uuid, instance_id)
return group
def _create_visual_placeholder(doc, device_group, label, placement, kind):
try:
body = doc.addObject("Part::Feature", "QETBatchBody_{0}".format(TerminalObjects.safe_token(label)))
body.Label = "{0} 模型".format(label)
body.Placement = placement
try:
import Part
if kind == "breaker":
body.Shape = Part.makeBox(18.0, 72.0, 80.0)
else:
body.Shape = Part.makeBox(5.2, 40.0, 45.0)
except Exception:
pass
try:
if kind == "breaker":
body.ViewObject.ShapeColor = (0.78, 0.78, 0.72)
else:
body.ViewObject.ShapeColor = (0.35, 0.75, 0.35)
except Exception:
pass
device_group.addObject(body)
_set_batch_properties(body, kind, getattr(device_group, "Label", "") or label, device_group)
return body
except Exception:
return None
def _create_visual_model(doc, device_group, label, placement, kind, model_path=""):
imported = _import_model_objects(doc, model_path)
if imported:
for index, obj in enumerate(imported):
try:
obj.Placement = placement
except Exception:
pass
if index == 0:
obj.Label = "{0} 模型".format(label)
device_group.addObject(obj)
_set_batch_properties(obj, kind, getattr(device_group, "Label", "") or label, device_group)
_set_source_model_path(obj, model_path)
return imported[0]
return _create_visual_placeholder(doc, device_group, label, placement, kind)
def _create_terminal(
doc,
device_group,
project_uuid,
element_uuid,
instance_id,
terminal_no,
offset_index=0,
label_prefix="",
):
owner_label = _safe_label(label_prefix, getattr(device_group, "Label", "") or instance_id)
label = "{0}:{1}".format(owner_label, terminal_no)
base = getattr(getattr(device_group, "Placement", None), "Base", App.Vector())
terminal_point = App.Vector(base.x, base.y + float(offset_index) * 2.0, base.z)
terminal = TerminalObjects.create_lcs_object(
doc,
"QETTerminal_{0}_{1}".format(TerminalObjects.safe_token(instance_id), TerminalObjects.safe_token(terminal_no)),
placement=App.Placement(terminal_point, App.Rotation()),
label=label,
)
terminal_group = TerminalObjects.ensure_terminal_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=instance_id,
)
terminal_group.addObject(terminal)
TerminalObjects.set_terminal_semantics(
terminal,
project_uuid,
element_uuid,
"local:{0}:{1}".format(instance_id, terminal_no),
instance_id,
label=label,
slot_name=str(terminal_no),
)
TerminalObjects.hide_engineering_terminal(terminal)
return terminal
def _batch_report(kind, group, devices, terminals):
return {
"kind": kind,
"group": group,
"devices": devices,
"terminals": terminals,
"created_devices": len(devices),
"created_terminals": len(terminals),
}
def create_terminal_block(
doc,
rail,
block_name="XT1",
count=10,
pitch_mm=5.2,
start_offset_mm=0.0,
model_path="",
):
if doc is None:
raise BatchAssemblyError("请先打开 FreeCAD 工程。")
rail = _ensure_rail(rail)
count = int(count or 0)
if count <= 0:
raise BatchAssemblyError("端子数量必须大于 0。")
project_uuid = _project_uuid(doc)
batch_name = _safe_label(block_name, "XT1")
root = _ensure_batch_root(doc, project_uuid)
group = doc.addObject("App::DocumentObjectGroup", TerminalObjects.safe_token(batch_name))
group.Label = batch_name
root.addObject(group)
_set_batch_properties(group, "terminal_block", batch_name, rail)
base = _base_point(rail)
axis = _axis_vector(rail)
devices = []
terminals = []
for index in range(count):
terminal_no = str(index + 1)
point = _point_at(base, axis, float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0))
device_label = "{0}_{1:03d}".format(batch_name, index + 1)
device = _create_device_group(
doc,
group,
device_label,
_placement_at(rail, point),
"terminal_slice",
batch_name,
rail,
project_uuid,
device_label,
device_label,
)
_create_visual_model(doc, device, device_label, _placement_at(rail, point), "terminal_slice", model_path)
instance_id = device_label
element_uuid = batch_name
terminal = _create_terminal(
doc,
device,
project_uuid,
element_uuid,
instance_id,
terminal_no,
label_prefix=batch_name,
)
devices.append(device)
terminals.append(terminal)
try:
doc.recompute()
except Exception:
pass
return _batch_report("terminal_block", group, devices, terminals)
def create_breakers(
doc,
rail,
base_name="QF",
count=3,
pitch_mm=18.0,
start_offset_mm=0.0,
terminal_numbers=("1", "2", "3", "4", "5", "6"),
model_path="",
):
if doc is None:
raise BatchAssemblyError("请先打开 FreeCAD 工程。")
rail = _ensure_rail(rail)
count = int(count or 0)
if count <= 0:
raise BatchAssemblyError("断路器数量必须大于 0。")
terminal_numbers = [str(item).strip() for item in terminal_numbers or () if str(item).strip()]
if not terminal_numbers:
raise BatchAssemblyError("至少需要一个端子号。")
project_uuid = _project_uuid(doc)
batch_name = _safe_label(base_name, "QF")
root = _ensure_batch_root(doc, project_uuid)
group = doc.addObject("App::DocumentObjectGroup", "QETBatch_{0}".format(TerminalObjects.safe_token(batch_name)))
group.Label = "{0} 批量断路器".format(batch_name)
root.addObject(group)
_set_batch_properties(group, "breaker_batch", batch_name, rail)
base = _base_point(rail)
axis = _axis_vector(rail)
devices = []
terminals = []
for index in range(count):
device_label = "{0}{1}".format(batch_name, index + 1)
point = _point_at(base, axis, float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0))
device = _create_device_group(
doc,
group,
device_label,
_placement_at(rail, point),
"breaker",
batch_name,
rail,
project_uuid,
device_label,
device_label,
)
_create_visual_model(doc, device, device_label, _placement_at(rail, point), "breaker", model_path)
instance_id = device_label
element_uuid = device_label
for terminal_index, terminal_no in enumerate(terminal_numbers):
terminals.append(
_create_terminal(
doc,
device,
project_uuid,
element_uuid,
instance_id,
terminal_no,
offset_index=terminal_index,
)
)
devices.append(device)
try:
doc.recompute()
except Exception:
pass
return _batch_report("breaker_batch", group, devices, terminals)

@ -21,6 +21,7 @@ set(FreeCADExchange_Scripts
ExchangeWriteBack.py
ManualWiring.py
ManualWiringPanel.py
BatchAssembly.py
)
set(FreeCADExchange_CabinetAssetDir

@ -2,6 +2,7 @@
import json
import math
import re
from pathlib import Path
import FreeCAD as App
@ -25,6 +26,7 @@ except ImportError:
import ManualWiring
import TerminalObjects
import WiringObjects
import BatchAssembly
try:
import ExchangeWriteBack
@ -40,6 +42,13 @@ except Exception:
COMMAND_NAME = "QET_Exchange_OpenManualWiringPanel"
DEFAULT_TERMINAL_EXIT_LENGTH = 20.0
DEFAULT_CARRIER_BASE_LENGTH = 200.0
DEFAULT_BATCH_TERMINAL_BLOCK_NAME = "XT1"
DEFAULT_BATCH_TERMINAL_BLOCK_COUNT = 10
DEFAULT_BATCH_TERMINAL_BLOCK_PITCH = 5.2
DEFAULT_BATCH_BREAKER_BASE_NAME = "QF"
DEFAULT_BATCH_BREAKER_COUNT = 3
DEFAULT_BATCH_BREAKER_PITCH = 18.0
DEFAULT_BATCH_BREAKER_TERMINALS_TEXT = "1,2,3,4,5,6"
CARRIER_ROLE_LABELS = {
"wire_duct": "线槽",
"cabinet": "柜面",
@ -55,6 +64,157 @@ class ManualWiringPanelError(RuntimeError):
pass
def _positive_int(value, field_label):
try:
result = int(value)
except Exception as exc:
raise ManualWiringPanelError("{0}必须是整数。".format(field_label)) from exc
if result <= 0:
raise ManualWiringPanelError("{0}必须大于 0。".format(field_label))
return result
def _float_value(value, field_label):
try:
return float(value)
except Exception as exc:
raise ManualWiringPanelError("{0}必须是数字。".format(field_label)) from exc
def _positive_float(value, field_label):
result = _float_value(value, field_label)
if result <= 0:
raise ManualWiringPanelError("{0}必须大于 0。".format(field_label))
return result
def _batch_name(value, fallback, field_label):
text = (str(value or "").strip() or fallback).strip()
if not text:
raise ManualWiringPanelError("{0}不能为空。".format(field_label))
return text
def _parse_terminal_numbers_text(value):
parts = [item.strip() for item in re.split(r"[\s,;]+", str(value or "")) if item.strip()]
if not parts:
raise ManualWiringPanelError("端子号不能为空。")
seen = set()
for item in parts:
key = item.lower()
if key in seen:
raise ManualWiringPanelError("端子号不能重复:{0}".format(item))
seen.add(key)
return tuple(parts)
def _batch_terminal_block_options(block_name, count, pitch_mm, start_offset_mm):
return {
"block_name": _batch_name(block_name, DEFAULT_BATCH_TERMINAL_BLOCK_NAME, "端子排名称"),
"count": _positive_int(count, "端子数量"),
"pitch_mm": _positive_float(pitch_mm, "端子间距"),
"start_offset_mm": _float_value(start_offset_mm, "起始偏移"),
}
def _batch_breaker_options(base_name, count, pitch_mm, start_offset_mm, terminal_numbers_text):
return {
"base_name": _batch_name(base_name, DEFAULT_BATCH_BREAKER_BASE_NAME, "断路器前缀"),
"count": _positive_int(count, "断路器数量"),
"pitch_mm": _positive_float(pitch_mm, "断路器间距"),
"start_offset_mm": _float_value(start_offset_mm, "起始偏移"),
"terminal_numbers": _parse_terminal_numbers_text(terminal_numbers_text),
}
def _exec_dialog(dialog):
if hasattr(dialog, "exec"):
return dialog.exec()
return dialog.exec_()
def _dialog_accepted_value():
try:
return QtWidgets.QDialog.Accepted
except Exception:
return 1
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)
count_input = QtWidgets.QSpinBox()
count_input.setRange(1, 10000)
count_input.setValue(DEFAULT_BATCH_TERMINAL_BLOCK_COUNT)
pitch_input = QtWidgets.QDoubleSpinBox()
pitch_input.setRange(0.01, 10000.0)
pitch_input.setDecimals(2)
pitch_input.setValue(DEFAULT_BATCH_TERMINAL_BLOCK_PITCH)
offset_input = QtWidgets.QDoubleSpinBox()
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("端子间距 mm", pitch_input)
layout.addRow("起始偏移 mm", offset_input)
buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addRow(buttons)
if _exec_dialog(dialog) != _dialog_accepted_value():
return None
return _batch_terminal_block_options(
name_input.text(),
count_input.value(),
pitch_input.value(),
offset_input.value(),
)
def _prompt_breaker_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_BREAKER_BASE_NAME)
count_input = QtWidgets.QSpinBox()
count_input.setRange(1, 10000)
count_input.setValue(DEFAULT_BATCH_BREAKER_COUNT)
pitch_input = QtWidgets.QDoubleSpinBox()
pitch_input.setRange(0.01, 10000.0)
pitch_input.setDecimals(2)
pitch_input.setValue(DEFAULT_BATCH_BREAKER_PITCH)
offset_input = QtWidgets.QDoubleSpinBox()
offset_input.setRange(-100000.0, 100000.0)
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("断路器间距 mm", pitch_input)
layout.addRow("起始偏移 mm", offset_input)
layout.addRow("端子号", terminals_input)
buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addRow(buttons)
if _exec_dialog(dialog) != _dialog_accepted_value():
return None
return _batch_breaker_options(
name_input.text(),
count_input.value(),
pitch_input.value(),
offset_input.value(),
terminals_input.text(),
)
def _console_message(message):
try:
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message))
@ -245,9 +405,43 @@ def _selected_contact_face_refs():
}
)
break
if refs:
return refs
for obj in _selection():
ref = _largest_object_face_ref(obj)
if ref is not None:
refs.append(ref)
return refs
def _largest_object_face_ref(obj):
if obj is None:
return None
shape = getattr(obj, "Shape", None)
faces = list(getattr(shape, "Faces", []) or [])
candidates = []
for face in faces:
if (getattr(face, "ShapeType", "") or "").strip().lower() != "face":
continue
point = _face_anchor_point(None, face)
normal = _face_normal(face)
if point is None or normal is None:
continue
candidates.append((float(getattr(face, "Area", 0.0) or 0.0), face, point, normal))
if not candidates:
return None
_area, face, point, normal = max(candidates, key=lambda item: item[0])
return {
"object": obj,
"face": face,
"point": point,
"normal": normal,
"subelement_name": "LargestFace",
}
def _has_placement(obj):
return getattr(obj, "Placement", None) is not None
@ -350,6 +544,187 @@ def _translate_object(obj, translation):
return new_base
def _placement_base(obj):
placement = getattr(obj, "Placement", None)
return getattr(placement, "Base", None)
def _set_placement_base(obj, base):
placement = getattr(obj, "Placement", None)
if placement is None or base is None:
return False
try:
placement.Base = base
obj.Placement = placement
return True
except Exception:
try:
obj.Placement = App.Placement(base, getattr(placement, "Rotation", App.Rotation()))
return True
except Exception:
return False
def _vector_payload(vector):
return {
"x": float(getattr(vector, "x", 0.0) or 0.0),
"y": float(getattr(vector, "y", 0.0) or 0.0),
"z": float(getattr(vector, "z", 0.0) or 0.0),
}
def _vector_from_payload(payload):
if not isinstance(payload, dict):
return None
try:
return _vector(
float(payload.get("x", 0.0) or 0.0),
float(payload.get("y", 0.0) or 0.0),
float(payload.get("z", 0.0) or 0.0),
)
except Exception:
return None
def _vector_from_json_property(obj, prop_name):
try:
payload = json.loads((getattr(obj, prop_name, "") or "").strip() or "{}")
except Exception:
return None
return _vector_from_payload(payload)
def _set_vector_json_property(obj, prop_name, vector, description):
if vector is None:
return
TerminalObjects.ensure_string_property(
obj,
prop_name,
"QET Assembly",
description,
json.dumps(_vector_payload(vector), ensure_ascii=False),
)
def _mount_kind(obj):
carrier_kind = (getattr(obj, "QetCarrierKind", "") or "").strip()
if carrier_kind:
return carrier_kind
if (getattr(obj, "QetInstanceId", "") or "").strip():
return "device"
try:
if TerminalObjects.is_terminal_object(obj):
return "terminal"
except Exception:
pass
text = "{0} {1}".format(getattr(obj, "Name", "") or "", getattr(obj, "Label", "") or "").lower()
if "wireduct" in text or "wire_duct" in text or "线槽" in text:
return "wire_duct"
if "rail" in text or "din" in text or "导轨" in text:
return "rail"
if "cabinet" in text or "panel" in text or "" in text or "安装板" in text:
return "cabinet"
return "object"
def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
if moving_obj is None or not isinstance(target_ref, dict) or not isinstance(moving_ref, dict):
return
target_obj = target_ref.get("object")
if target_obj is None:
return
target_name = getattr(target_obj, "Name", "") or ""
target_label = getattr(target_obj, "Label", "") or target_name
target_base = _placement_base(target_obj)
moving_base = _placement_base(moving_obj)
values = {
"QetMountMode": "face_contact",
"QetMountKind": _mount_kind(moving_obj),
"QetMountHostName": target_name,
"QetMountHostLabel": target_label,
"QetMountHostKind": _mount_kind(target_obj),
"QetMountHostSubElement": target_ref.get("subelement_name", "") or "",
"QetMountContactSubElement": moving_ref.get("subelement_name", "") or "",
}
for prop_name, value in values.items():
TerminalObjects.ensure_string_property(
moving_obj,
prop_name,
"QET Assembly",
"QET cabinet assembly mount metadata",
value,
)
if target_base is not None:
_set_vector_json_property(
moving_obj,
"QetMountHostBaseJson",
target_base,
"QET cabinet assembly host base at bind time",
)
if target_base is not None and moving_base is not None:
_set_vector_json_property(
moving_obj,
"QetMountLocalBaseJson",
_vector_sub(moving_base, target_base),
"QET cabinet assembly local base offset from host",
)
def refresh_mount_hosted_objects(doc):
if doc is None:
raise ManualWiringPanelError("请先打开 QET 3D 工程文档。")
updated = []
for obj in list(getattr(doc, "Objects", []) or []):
if (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact":
continue
host_name = (getattr(obj, "QetMountHostName", "") or "").strip()
if not host_name:
continue
try:
host = doc.getObject(host_name)
except Exception:
host = None
if host is None:
continue
host_base = _placement_base(host)
local_base = _vector_from_json_property(obj, "QetMountLocalBaseJson")
if host_base is None or local_base is None:
continue
target_base = _vector_add(host_base, local_base)
current_base = _placement_base(obj)
changed = current_base is None or not _vectors_close(current_base, target_base)
if _set_placement_base(obj, target_base):
_set_vector_json_property(
obj,
"QetMountHostBaseJson",
host_base,
"QET cabinet assembly host base at last refresh",
)
if changed:
updated.append(obj)
if updated:
try:
doc.recompute()
except Exception:
pass
return updated
def _select_object_for_followup_transform(obj):
if Gui is None or obj is None:
return
selection = getattr(Gui, "Selection", None)
if selection is None:
return
try:
if hasattr(selection, "clearSelection"):
selection.clearSelection()
if hasattr(selection, "addSelection"):
selection.addSelection(obj)
except Exception:
pass
def _selected_point():
for picked in _selection_ex():
picked_points = list(getattr(picked, "PickedPoints", []) or [])
@ -575,6 +950,13 @@ def _selected_carrier_objects():
]
def _selected_rail_object():
for obj in _selected_carrier_objects():
if (getattr(obj, "QetCarrierKind", "") or "").strip() == "rail":
return obj
return None
def _existing_object_names(doc):
return {getattr(obj, "Name", "") for obj in list(getattr(doc, "Objects", []) or [])}
@ -949,6 +1331,7 @@ class ManualWiringController:
def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH):
self.terminal_exit_length = float(terminal_exit_length or 0.0)
self.current_task = None
self.contact_target_ref = None
self.start_terminal = None
self.waypoints = []
self.preview_objects = []
@ -1055,15 +1438,28 @@ class ManualWiringController:
pass
return updated
def set_contact_target_from_selection(self):
refs = _selected_contact_face_refs()
if len(refs) != 1:
raise ManualWiringPanelError("请只选择一个目标贴合面。")
self.contact_target_ref = refs[0]
return self.contact_target_ref
def align_selected_contact_faces(self):
refs = _selected_contact_face_refs()
if len(refs) < 2:
raise ManualWiringPanelError("请先选择目标面,再按 Ctrl 选择要移动对象的接触面。")
if len(refs) > 2:
raise ManualWiringPanelError("只能选择两个面:第一个是目标面,第二个是要移动对象的接触面。")
if self.contact_target_ref is not None:
if len(refs) != 1:
raise ManualWiringPanelError("已设置目标面时,请只选择一个要移动对象的接触面。")
target = self.contact_target_ref
moving = refs[0]
else:
if len(refs) < 2:
raise ManualWiringPanelError("请先选择目标面,再按 Ctrl 选择要移动对象的接触面。")
if len(refs) > 2:
raise ManualWiringPanelError("只能选择两个面:第一个是目标面,第二个是要移动对象的接触面。")
target = refs[0]
moving = refs[1]
target = refs[0]
moving = refs[1]
moving_object = _contact_transform_object(moving["object"])
if moving_object is None:
raise ManualWiringPanelError("没有找到可移动的对象。")
@ -1078,11 +1474,13 @@ class ManualWiringController:
if translation is None:
raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。")
_translate_object(moving_object, translation)
_set_face_contact_mount_metadata(moving_object, target, moving)
try:
_active_document().recompute()
except Exception:
pass
_activate_document(_active_document())
_select_object_for_followup_transform(moving_object)
return {
"target_object": target["object"],
"moving_object": moving_object,
@ -1093,6 +1491,53 @@ class ManualWiringController:
"rotated": rotated,
}
def refresh_mount_hosts(self):
return refresh_mount_hosted_objects(_active_document())
def create_terminal_block_from_selection(
self,
block_name="XT1",
count=10,
pitch_mm=5.2,
start_offset_mm=0.0,
model_path="",
):
rail = _selected_rail_object()
if rail is None:
raise ManualWiringPanelError("请先选择一根已标记的导轨。")
return BatchAssembly.create_terminal_block(
_active_document(),
rail,
block_name=block_name,
count=count,
pitch_mm=pitch_mm,
start_offset_mm=start_offset_mm,
model_path=model_path,
)
def create_breakers_from_selection(
self,
base_name="QF",
count=3,
pitch_mm=18.0,
start_offset_mm=0.0,
terminal_numbers=("1", "2", "3", "4", "5", "6"),
model_path="",
):
rail = _selected_rail_object()
if rail is None:
raise ManualWiringPanelError("请先选择一根已标记的导轨。")
return BatchAssembly.create_breakers(
_active_document(),
rail,
base_name=base_name,
count=count,
pitch_mm=pitch_mm,
start_offset_mm=start_offset_mm,
terminal_numbers=terminal_numbers,
model_path=model_path,
)
def _clear_preview_objects(self):
doc = getattr(App, "ActiveDocument", None)
if doc is None:
@ -1383,7 +1828,11 @@ class ManualWiringTaskPanel:
self.mark_duct_button = QtWidgets.QPushButton("标记为线槽")
self.mark_cabinet_button = QtWidgets.QPushButton("标记为柜面")
self.mark_rail_button = QtWidgets.QPushButton("标记为导轨")
self.batch_terminal_block_button = QtWidgets.QPushButton("批量端子排")
self.batch_breaker_button = QtWidgets.QPushButton("批量断路器")
self.set_contact_target_button = QtWidgets.QPushButton("设为贴合目标面")
self.align_faces_button = QtWidgets.QPushButton("贴合到选中面")
self.refresh_mount_hosts_button = QtWidgets.QPushButton("刷新宿主装配")
self.waypoint_button = QtWidgets.QPushButton("添加折点")
self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点")
self.end_button = QtWidgets.QPushButton("设为终点并生成")
@ -1416,7 +1865,13 @@ class ManualWiringTaskPanel:
carrier_layout.addWidget(self.mark_cabinet_button)
carrier_layout.addWidget(self.mark_rail_button)
layout.addLayout(carrier_layout)
batch_layout = QtWidgets.QHBoxLayout()
batch_layout.addWidget(self.batch_terminal_block_button)
batch_layout.addWidget(self.batch_breaker_button)
layout.addLayout(batch_layout)
layout.addWidget(self.set_contact_target_button)
layout.addWidget(self.align_faces_button)
layout.addWidget(self.refresh_mount_hosts_button)
layout.addWidget(self.start_button)
layout.addWidget(self.waypoint_button)
layout.addWidget(self.delete_waypoint_button)
@ -1448,7 +1903,11 @@ class ManualWiringTaskPanel:
self.mark_duct_button.clicked.connect(self.mark_wire_duct)
self.mark_cabinet_button.clicked.connect(self.mark_cabinet)
self.mark_rail_button.clicked.connect(self.mark_rail)
self.batch_terminal_block_button.clicked.connect(self.create_terminal_block)
self.batch_breaker_button.clicked.connect(self.create_breakers)
self.set_contact_target_button.clicked.connect(self.set_contact_target_face)
self.align_faces_button.clicked.connect(self.align_selected_contact_faces)
self.refresh_mount_hosts_button.clicked.connect(self.refresh_mount_hosts)
self.start_button.clicked.connect(self.set_start)
self.waypoint_button.clicked.connect(self.add_waypoint)
self.delete_waypoint_button.clicked.connect(self.delete_last_waypoint)
@ -1601,6 +2060,36 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def create_terminal_block(self):
try:
options = _prompt_terminal_block_options(self.form)
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),
)
)
except Exception as exc:
self._set_error(str(exc))
def create_breakers(self):
try:
options = _prompt_breaker_options(self.form)
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),
)
)
except Exception as exc:
self._set_error(str(exc))
def align_selected_contact_faces(self):
try:
result = self.controller.align_selected_contact_faces()
@ -1609,6 +2098,21 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def set_contact_target_face(self):
try:
target = self.controller.set_contact_target_from_selection()
label = getattr(target.get("object"), "Label", "") or getattr(target.get("object"), "Name", "")
self._set_status("已设置贴合目标面:{0}".format(label or "选中面"))
except Exception as exc:
self._set_error(str(exc))
def refresh_mount_hosts(self):
try:
updated = self.controller.refresh_mount_hosts()
self._set_status("已刷新宿主装配:{0} 个对象。".format(len(updated)))
except Exception as exc:
self._set_error(str(exc))
def set_start(self):
try:
terminal = self.controller.set_start_from_selection()

@ -207,7 +207,7 @@ def ensure_engineering_terminals_for_device(doc, device_group):
slot_name=slot_name,
)
try:
terminal_obj.ViewObject.Visibility = True
TerminalObjects.hide_engineering_terminal(terminal_obj)
terminal_obj.ViewObject.ShapeColor = (0.0, 0.75, 1.0)
except Exception:
pass

@ -274,6 +274,17 @@ def is_template_terminal_object(obj):
return is_terminal_hint_object(obj) and not is_terminal_object(obj)
def hide_engineering_terminal(obj):
try:
view_object = getattr(obj, "ViewObject", None)
if view_object is not None and hasattr(view_object, "Visibility"):
view_object.Visibility = False
return True
except Exception:
pass
return False
def hide_template_terminal_hints(container):
hidden = 0
if container is None:

@ -0,0 +1,260 @@
import importlib
import sys
import tempfile
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
if str(MODULE_DIR) not in sys.path:
sys.path.insert(0, str(MODULE_DIR))
def _install_fake_freecad():
class Vector:
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
class Rotation:
def multVec(self, vector):
return vector
class Placement:
def __init__(self, base=None, rotation=None):
self.Base = base or Vector()
self.Rotation = rotation or Rotation()
fake_freecad = types.ModuleType("FreeCAD")
fake_freecad.Vector = Vector
fake_freecad.Rotation = Rotation
fake_freecad.Placement = Placement
fake_freecad.ActiveDocument = None
fake_freecad.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
sys.modules["FreeCAD"] = fake_freecad
fake_importgui = types.ModuleType("ImportGui")
def insert(name, docName=None, merge=False, useLinkGroup=True):
doc = fake_freecad.ActiveDocument
obj = doc.addObject("Part::Feature", "ImportedBatchModel")
obj.ImportedPath = name
return obj
fake_importgui.insert = insert
sys.modules["ImportGui"] = fake_importgui
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
class FakeViewObject:
def __init__(self):
self.Visibility = True
class FakeObject:
def __init__(self, name, type_id):
self.Name = name
self.Label = name
self.TypeId = type_id
self.PropertiesList = []
self.Group = []
self.InList = []
self.ViewObject = FakeViewObject()
self.Placement = sys.modules["FreeCAD"].Placement()
self.Shape = None
def isDerivedFrom(self, type_name):
if self.TypeId == type_name:
return True
if type_name == "App::DocumentObjectGroup":
return self.TypeId == "App::DocumentObjectGroup"
if type_name == "App::LocalCoordinateSystem":
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
return False
def addProperty(self, _prop_type, prop_name, _group_name, _description):
if prop_name not in self.PropertiesList:
self.PropertiesList.append(prop_name)
def addObject(self, child):
if child not in self.Group:
self.Group.append(child)
if self not in child.InList:
child.InList.append(self)
class FakeDocument:
def __init__(self):
self.Objects = []
self.Name = "QETScene"
self.recompute_count = 0
def addObject(self, type_name, name):
obj = FakeObject(name, type_name)
self.Objects.append(obj)
return obj
def getObject(self, name):
for obj in self.Objects:
if obj.Name == name:
return obj
return None
def recompute(self):
self.recompute_count += 1
def _reload_modules(*extra_names):
for name in ["RoutingNetwork", "WiringObjects", "TemplateSemantics", "TerminalObjects", "BatchAssembly"] + list(extra_names):
sys.modules.pop(name, None)
terminal_objects = importlib.import_module("TerminalObjects")
batch_assembly = importlib.import_module("BatchAssembly")
return terminal_objects, batch_assembly
class BatchAssemblyTest(unittest.TestCase):
def test_create_terminal_block_places_slices_and_local_terminals_along_selected_rail(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")
report = batch_assembly.create_terminal_block(
doc,
rail,
block_name="XT1",
count=3,
pitch_mm=5.2,
start_offset_mm=10.0,
)
self.assertEqual(3, report["created_devices"])
self.assertEqual(3, report["created_terminals"])
self.assertEqual("XT1", report["group"].Label)
placements = [item.Placement.Base.x for item in report["devices"]]
self.assertEqual([110.0, 115.2, 120.4], placements)
terminal_labels = [terminal.Label for terminal in report["terminals"]]
self.assertEqual(["XT1:1", "XT1:2", "XT1:3"], terminal_labels)
self.assertTrue(all(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"]))
self.assertTrue(all(not terminal.ViewObject.Visibility for terminal in report["terminals"]))
def test_create_breakers_generates_numbered_devices_and_terminal_labels(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")
report = batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=2,
pitch_mm=18.0,
start_offset_mm=0.0,
terminal_numbers=("1", "2", "3", "4", "5", "6"),
)
self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]])
self.assertEqual(12, report["created_terminals"])
self.assertTrue(all(device.Name.startswith("QETDevice_") for device in report["devices"]))
self.assertEqual(["QF1", "QF2"], [device.QetInstanceId for device in report["devices"]])
self.assertEqual(["QF1", "QF2"], [device.QetElementUuid for device in report["devices"]])
labels = [terminal.Label for terminal in report["terminals"]]
self.assertEqual(["QF1:1", "QF1:2", "QF1:3", "QF1:4", "QF1:5", "QF1:6"], labels[:6])
self.assertEqual(["QF2:1", "QF2:2", "QF2:3", "QF2:4", "QF2:5", "QF2:6"], labels[6:])
self.assertEqual([0.0, 18.0], [device.Placement.Base.x for device in report["devices"]])
def test_created_breaker_local_slots_can_be_promoted_to_qet_terminal_uuid(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules("AutoRouting")
auto_routing = importlib.import_module("AutoRouting")
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")
batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=1,
terminal_numbers=("1", "2"),
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_uuid": "wire-1",
"start_instance_id": "QF1",
"start_terminal_uuid": "terminal-qf1-1",
"start_terminal_display": "1",
"end_instance_id": "QF1",
"end_terminal_uuid": "terminal-qf1-2",
"end_terminal_display": "2",
}
],
}
report = auto_routing.bind_wire_task_terminals_from_payload(doc, payload)
self.assertEqual(2, report["bound"])
indexed = auto_routing.index_terminals(doc)
self.assertIn("terminal-qf1-1", indexed)
self.assertIn("terminal-qf1-2", indexed)
self.assertFalse(any(key.startswith("local:QF1") for key in indexed))
def test_create_breakers_can_import_model_template_instead_of_placeholder_box(self):
_install_fake_freecad()
terminal_objects, batch_assembly = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
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")
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "breaker.step"
model_path.write_text("fake step", encoding="utf-8")
report = batch_assembly.create_breakers(
doc,
rail,
base_name="QF",
count=1,
model_path=str(model_path),
)
imported_children = [child for child in report["devices"][0].Group if getattr(child, "ImportedPath", "")]
self.assertEqual(1, len(imported_children))
self.assertEqual(str(model_path), imported_children[0].QetBatchSourceModelPath)
self.assertEqual("QF1 模型", imported_children[0].Label)
if __name__ == "__main__":
unittest.main()

@ -61,9 +61,17 @@ def _install_fake_freecad():
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
def clear_selection():
selection_state["selection"] = []
def add_selection(obj):
selection_state["selection"] = [obj]
fake_freecadgui.Selection = types.SimpleNamespace(
getSelection=lambda: list(selection_state["selection"]),
getSelectionEx=lambda: list(selection_state["selection_ex"]),
clearSelection=clear_selection,
addSelection=add_selection,
)
fake_freecadgui.Control = types.SimpleNamespace(
activeDialog=lambda: False,
@ -190,6 +198,7 @@ def _reload_modules():
"ManualWiring",
"TemplateAuthoring",
"ExchangeWriteBack",
"BatchAssembly",
"ManualWiringPanel",
]:
sys.modules.pop(name, None)
@ -222,6 +231,122 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(str(asset), resolved)
def test_controller_batch_creates_terminal_block_from_selected_rail(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", "DINRail")
rail.Placement = app.Placement(app.Vector(50, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail")
terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x")
selection_state["selection"] = [rail]
report = panel.ManualWiringController().create_terminal_block_from_selection(
block_name="XT1",
count=2,
pitch_mm=5.2,
)
self.assertEqual(2, report["created_devices"])
self.assertEqual(["XT1:1", "XT1:2"], [terminal.Label for terminal in report["terminals"]])
def test_controller_batch_breaker_default_creates_three_pole_terminal_numbers(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", "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")
selection_state["selection"] = [rail]
report = panel.ManualWiringController().create_breakers_from_selection(
base_name="QF",
count=1,
)
self.assertEqual(1, report["created_devices"])
self.assertEqual(["QF1:1", "QF1:2", "QF1:3", "QF1:4", "QF1:5", "QF1:6"], [terminal.Label for terminal in report["terminals"]])
def test_controller_batch_accepts_empty_model_path_from_dialog_options(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", "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")
selection_state["selection"] = [rail]
report = panel.ManualWiringController().create_breakers_from_selection(
base_name="QF",
count=1,
model_path="",
)
self.assertEqual(1, report["created_devices"])
def test_batch_terminal_block_options_validate_user_fields(self):
_install_fake_freecad()
_terminal_objects, panel = _reload_modules()
options = panel._batch_terminal_block_options(
block_name=" XT2 ",
count=12,
pitch_mm=6.2,
start_offset_mm=-5,
)
self.assertEqual(
{
"block_name": "XT2",
"count": 12,
"pitch_mm": 6.2,
"start_offset_mm": -5.0,
},
options,
)
def test_batch_breaker_options_parse_terminal_number_text(self):
_install_fake_freecad()
_terminal_objects, panel = _reload_modules()
options = panel._batch_breaker_options(
base_name=" QF ",
count=2,
pitch_mm=18,
start_offset_mm=0,
terminal_numbers_text="1,23 4;56",
)
self.assertEqual(("1", "2", "3", "4", "5", "6"), options["terminal_numbers"])
self.assertNotIn("model_path", options)
def test_batch_breaker_options_reject_duplicate_terminal_numbers(self):
_install_fake_freecad()
_terminal_objects, panel = _reload_modules()
with self.assertRaises(panel.ManualWiringPanelError):
panel._batch_breaker_options(
base_name="QF",
count=1,
pitch_mm=18,
start_offset_mm=0,
terminal_numbers_text="1,2,1",
)
def test_controller_rejects_local_terminal_as_manual_wiring_start(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -540,6 +665,269 @@ class ManualWiringPanelTest(unittest.TestCase):
)
self.assertEqual("normal", result["translation_mode"])
def test_controller_records_face_contact_mount_host_after_alignment(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")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
terminal_objects.ensure_string_property(
cabinet,
"QetCarrierKind",
"QET Wiring",
"3D wiring carrier kind",
"cabinet",
)
terminal_objects.ensure_string_property(
rail,
"QetCarrierKind",
"QET Wiring",
"3D wiring carrier kind",
"rail",
)
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(100, 20, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(5, 6, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=rail,
),
]
result = panel.ManualWiringController().align_selected_contact_faces()
self.assertIs(cabinet, result["target_object"])
self.assertEqual("face_contact", rail.QetMountMode)
self.assertEqual("CabinetPanel", rail.QetMountHostName)
self.assertEqual("CabinetPanel", rail.QetMountHostLabel)
self.assertEqual("cabinet", rail.QetMountHostKind)
self.assertEqual("rail", rail.QetMountKind)
self.assertEqual("Face1", rail.QetMountHostSubElement)
self.assertEqual("Face2", rail.QetMountContactSubElement)
self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, json.loads(rail.QetMountHostBaseJson))
self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountLocalBaseJson))
def test_refresh_mount_hosted_objects_moves_child_by_host_delta(self):
_install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
cabinet.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation())
rail.Placement = app.Placement(app.Vector(120, 0, 5), app.Rotation())
terminal_objects.ensure_string_property(
rail,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
terminal_objects.ensure_string_property(
rail,
"QetMountHostName",
"QET Assembly",
"QET cabinet assembly mount metadata",
"CabinetPanel",
)
terminal_objects.ensure_string_property(
rail,
"QetMountLocalBaseJson",
"QET Assembly",
"QET cabinet assembly local base offset",
json.dumps({"x": 20.0, "y": 0.0, "z": 5.0}, ensure_ascii=False),
)
cabinet.Placement = app.Placement(app.Vector(130, 0, 0), app.Rotation())
updated = panel.refresh_mount_hosted_objects(doc)
self.assertEqual([rail], updated)
self.assertEqual((150.0, 0.0, 5.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
self.assertEqual({"x": 130.0, "y": 0.0, "z": 0.0}, json.loads(rail.QetMountHostBaseJson))
def test_controller_refreshes_mount_hosted_objects_from_active_document(self):
_install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
cabinet.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation())
rail.Placement = app.Placement(app.Vector(15, 0, 0), app.Rotation())
terminal_objects.ensure_string_property(
rail,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
terminal_objects.ensure_string_property(
rail,
"QetMountHostName",
"QET Assembly",
"QET cabinet assembly mount metadata",
"CabinetPanel",
)
terminal_objects.ensure_string_property(
rail,
"QetMountLocalBaseJson",
"QET Assembly",
"QET cabinet assembly local base offset",
json.dumps({"x": 5.0, "y": 0.0, "z": 0.0}, ensure_ascii=False),
)
cabinet.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation())
updated = panel.ManualWiringController().refresh_mount_hosts()
self.assertEqual([rail], updated)
self.assertEqual((25.0, 0.0, 0.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
def test_controller_uses_stored_target_face_for_single_moving_face_alignment(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")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
controller = panel.ManualWiringController()
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(100, 20, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
)
]
controller.set_contact_target_from_selection()
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(5, 6, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=rail,
)
]
result = controller.align_selected_contact_faces()
self.assertIs(rail, result["moving_object"])
self.assertEqual((0.0, 0.0, 1.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
def test_controller_uses_largest_object_face_when_no_subface_is_selected(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")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
small_face = types.SimpleNamespace(
ShapeType="Face",
Area=10.0,
CenterOfMass=app.Vector(0, 0, 5),
normalAt=lambda u, v: app.Vector(0, 1, 0),
)
large_face = types.SimpleNamespace(
ShapeType="Face",
Area=1000.0,
CenterOfMass=app.Vector(0, 0, 0),
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
cabinet.Shape = types.SimpleNamespace(Faces=[small_face, large_face])
selection_state["selection"] = [cabinet]
selection_state["selection_ex"] = []
target = panel.ManualWiringController().set_contact_target_from_selection()
self.assertIs(large_face, target["face"])
self.assertEqual((0.0, 0.0, 0.0), (target["point"].x, target["point"].y, target["point"].z))
def test_controller_moves_qet_device_root_when_selected_face_belongs_to_child_shape(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
root = terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a")
child = doc.addObject("Part::Feature", "DeviceSolid")
device.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
child.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
root.addObject(device)
device.addObject(child)
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=child,
),
]
result = panel.ManualWiringController().align_selected_contact_faces()
self.assertIs(device, result["moving_object"])
self.assertEqual((0.0, 0.0, 1.0), (device.Placement.Base.x, device.Placement.Base.y, device.Placement.Base.z))
self.assertEqual((0.0, 0.0, 0.0), (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z))
self.assertEqual([device], selection_state["selection"])
def test_controller_requires_two_faces_for_contact_alignment(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()

@ -211,6 +211,7 @@ class TemplateInstantiationTest(unittest.TestCase):
self.assertEqual(1, len(terminals))
self.assertEqual("terminal-p1", terminals[0].QetTerminalUuid)
self.assertEqual("qet", terminals[0].QetTerminalBindingMode)
self.assertFalse(terminals[0].ViewObject.Visibility)
self.assertFalse(p1.ViewObject.Visibility)
def test_device_without_template_slots_reports_no_created_terminals(self):

Loading…
Cancel
Save