feat(freecad): 优化3D装配贴合与树目录排序

dev
Zhaowenlong 3 weeks ago
parent 199412b2c8
commit 9d31edff70

@ -142,6 +142,144 @@ FreeCAD 导入工程端子时按下面顺序读取:
当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版。
## 装配视频复盘
本节作为后续 3D 装配优化的对比基准。装配相关需求、问题复盘和验收差异优先沉淀到本文档,再拆分为具体实现计划。
### 用户装配视频提炼
用户视频前半段体现的目标流程:
1. 先按真实设备和实物安装关系确认 3D 模型是否匹配。
2. 对设备补充可复用的装配脚点、连接点或接线点。
3. 设备脚点制作完成后,保存为可复用 `.FCStd` 模块。
4. 后续工程中再次插入该设备时,自动带出脚点、端子和装配语义。
5. 装配时可使用 FreeCAD 原生 `切换透明度`、`显示/隐藏所选`,便于选中柜板、导轨、线槽和设备背面。
6. 按步骤导入设备并完成贴合,避免只靠人工拖拽。
当前 FreeCAD 二开需要重点解决的问题:
- 面不容易选中,尤其是柜内导轨、线槽、设备背面被遮挡时。
- 旋转模型后再贴合,容易出现一部分贴合、一部分穿模或悬空。
- 贴合时如果只移动可视子对象,父对象 `Placement` 没同步,后续使用 `变换` 会回到旧位置。
- 多选多个设备面参与贴合不合理,约束语义不清,会导致算法不知道哪个面是移动面。
- 线槽、导轨贴合后仍需要能二次修改长度,并保持与柜板的贴合关系。
- FreeCAD 任务面板和原生 `变换` 任务框会冲突,普通用户需要一键关闭当前面板并进入原生变换。
### 甲方视频参考能力
甲方视频中可参考的装配体验:
- 柜体、导轨、线槽、安装板可透明显示,便于从柜内选择目标面;透明化优先复用 FreeCAD 原生右键菜单能力。
- 对象树、属性面板和三维操纵器联动,用户能明确看到当前选中的对象和坐标。
- 装配过程使用面、边、点作为参考,而不是单纯输入绝对坐标。
- 设备沿导轨或安装板成组排列,位置规则清晰,适合端子排、断路器、继电器等电气元件。
- 贴合后仍能继续微调距离、方向和局部偏移。
- 电气装配关注柜板、导轨、线槽、设备安装面,不需要第一阶段实现完整机械 CAD 装配约束。
## 后续装配优化方向
后续装配能力优先向 SolidWorks Electrical / EPLAN 的电气柜装配体验靠拢,但第一阶段只做电气常用能力,不做完整机械装配工作台。
### 1. 面贴合可靠性
目标:
- 目标面和移动面只允许一对一贴合。
- 如果用户已点击 `设为贴合目标面`,后续只能再选一个移动面。
- 如果用户一次选择两个面,按选择顺序解释为:第一个目标面,第二个移动面。
- 如果选择超过两个面,直接提示重新选择,不执行贴合。
- 贴合时同时更新父级可移动对象的 `Placement`,避免可视位置和对象坐标脱节。
贴合计算原则:
```text
移动面法向 -> 目标面反向法向
移动面参考点 -> 目标面参考点所在平面
最终位姿写入可移动父对象 Placement
```
### 2. 旋转模型后的贴合
目标:
- 用户为了选面临时旋转设备后,贴合仍能根据真实面法向计算旋转和位移。
- 不再只做单轴平移。
- 贴合完成后设备安装面应整体与目标面共面,不允许局部穿模。
- 用户可设置 `贴合间距`0 mm 表示完全贴合,正值表示沿目标面法向预留距离。
- 已贴合对象保存 `QetMountHostNormalJson``QetMountOffsetMm`,后续选择对象后可点击 `应用贴合间距` 做二次调节。
- 如果模型法向与现场直觉相反,选择已贴合对象后点击 `反转贴合方向`,再应用贴合间距。
验收:
- 电流互感器、小型断路器、端子片旋转后,仍可贴到导轨或柜板。
- 贴合后使用 FreeCAD 原生 `变换`,对象从当前贴合位置继续移动,不跳回旧位置。
- 在 `3D手动布线` 面板中选择对象后点击 `关闭面板并变换`,系统先关闭当前任务面板,再调用 FreeCAD 原生 `Std_TransformManip`
### 3. 导轨、线槽、柜板宿主语义
装配宿主分为:
- `cabinet`:柜板、安装板、门板等。
- `rail`DIN 导轨。
- `wire_duct`:线槽。
- `device`:已经装配好的设备,可作为局部参考。
宿主对象应保存:
- `QetCarrierKind`
- `QetCarrierAxis`
- `QetCarrierBaseLength`
- `QetMountMode`
- `QetMountHostName`
- `QetMountHostKind`
- `QetMountContactSubElement`
- `QetMountHostSubElement`
- `QetMountLocalBaseJson`
- `QetMountHostBaseJson`
这些属性用于保存重开、刷新宿主装配和后续自动布线。
### 4. 长度二次调节
导轨和线槽长度调整规则:
- 导入时可设置初始长度。
- 贴合到柜板后仍可修改长度。
- 修改长度时保持宿主贴合面不变。
- 长度变化应优先沿 `QetCarrierAxis` 扩展。
- 如果对象是导入的 FCStd/STEP 组合体,优先修改带 `QetCarrierBaseLength` 的父级载体对象,不应误选内部子零件。
### 5. 设备模板化与复用
设备模板应包含:
- 真实几何模型。
- 安装接触面或装配脚点。
- 工程端子 LCS。
- 端子出线方向。
- 可选局部出线路径。
保存为 `.FCStd`QET 再次导入同型号设备时,应复用这些模板语义。正式导线匹配仍以 QET 传入的 `terminal_uuid` 为准,不使用 `local:*` 作为正式端子身份。
### 6. 电气装配优先级
优先实现:
1. 导轨贴柜板。
2. 线槽贴柜板。
3. 端子排沿导轨排列。
4. 小型断路器沿导轨排列。
5. 电流互感器、继电器等设备贴导轨或柜板。
6. 贴合后的长度调节和刷新宿主装配。
暂不优先实现:
- 完整机械装配 Joint。
- 螺钉、孔、螺纹的精确机械配合。
- 复杂运动学约束。
- 完整 SW Mechanical 级别的 Mate 系统。
## UI
入口位于:
@ -154,6 +292,12 @@ QET模板 -> 3D手动布线
- `批量端子排`
- `批量断路器`
- `设为贴合目标面`
- `贴合到选中面`
- `应用贴合间距`
- `反转贴合方向`
- `刷新宿主装配`
- `贴合间距`
参数窗口说明:

@ -1109,6 +1109,7 @@ def import_devices_from_payload(payload, scene_path=""):
if not instance_id:
report["imported_without_instance_id"] += 1
TerminalObjects.sort_group_children(root_group)
doc.recompute()
try:
Gui.SendMsgToActiveView("ViewFit")

@ -409,7 +409,12 @@ def _selected_contact_face_refs():
shape_type = (getattr(sub_object, "ShapeType", "") or "").strip().lower()
if shape_type != "face":
continue
point = _face_anchor_point(picked, sub_object)
point = None
picked_points = list(getattr(picked, "PickedPoints", []) or [])
if index < len(picked_points):
point = picked_points[index]
if point is None:
point = _face_anchor_point(picked if not picked_points else None, sub_object)
normal = _face_normal(sub_object)
if point is None or normal is None:
continue
@ -422,7 +427,6 @@ def _selected_contact_face_refs():
"subelement_name": subelement_names[index] if index < len(subelement_names) else "",
}
)
break
if refs:
return refs
@ -464,6 +468,13 @@ def _has_placement(obj):
return getattr(obj, "Placement", None) is not None
def _is_app_part_object(obj):
try:
return bool(obj is not None and obj.isDerivedFrom("App::Part"))
except Exception:
return (getattr(obj, "TypeId", "") or "") == "App::Part"
def _contact_transform_object(obj):
carrier = _carrier_object_from_object(obj)
if carrier is not None and _has_placement(carrier):
@ -484,6 +495,7 @@ def _contact_transform_object(obj):
name.startswith("QETDevice_")
or (getattr(parent, "QetInstanceId", "") or "").strip()
or (getattr(parent, "QetCarrierKind", "") or "").strip()
or _is_app_part_object(parent)
):
best = parent
current = parent
@ -502,12 +514,12 @@ def _rotation_for_face_contact(moving_normal, target_normal):
return None
def _normal_contact_translation(target_point, target_normal, moving_point):
def _normal_contact_translation(target_point, target_normal, moving_point, offset_mm=0.0):
normal = _normalize_vector(target_normal)
if normal is None:
return None
signed_distance = _vector_dot(_vector_sub(moving_point, target_point), normal)
return _vector_scale(normal, -signed_distance)
return _vector_scale(normal, float(offset_mm or 0.0) - signed_distance)
def _rotate_object_about_point(obj, rotation, pivot):
@ -645,7 +657,7 @@ def _mount_kind(obj):
return "object"
def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref, offset_mm=0.0):
if moving_obj is None or not isinstance(target_ref, dict) or not isinstance(moving_ref, dict):
return
target_obj = target_ref.get("object")
@ -672,6 +684,12 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
"QET cabinet assembly mount metadata",
value,
)
_ensure_float_property(
moving_obj,
"QetMountOffsetMm",
float(offset_mm or 0.0),
"QET cabinet assembly contact offset in target normal direction",
)
if target_base is not None:
_set_vector_json_property(
moving_obj,
@ -679,6 +697,14 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
target_base,
"QET cabinet assembly host base at bind time",
)
host_normal = _normalize_vector(target_ref.get("normal"))
if host_normal is not None:
_set_vector_json_property(
moving_obj,
"QetMountHostNormalJson",
host_normal,
"QET cabinet assembly host face normal at bind time",
)
if target_base is not None and moving_base is not None:
_set_vector_json_property(
moving_obj,
@ -1381,8 +1407,9 @@ def _abort_transaction(doc, opened):
class ManualWiringController:
def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH):
def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH, contact_offset_mm=0.0):
self.terminal_exit_length = float(terminal_exit_length or 0.0)
self.contact_offset_mm = float(contact_offset_mm or 0.0)
self.current_task = None
self.contact_target_ref = None
self.start_terminal = None
@ -1395,6 +1422,10 @@ class ManualWiringController:
self.terminal_exit_length = max(float(value or 0.0), 0.0)
return self.terminal_exit_length
def set_contact_offset(self, value):
self.contact_offset_mm = float(value or 0.0)
return self.contact_offset_mm
def mark_selected_carriers(self, carrier_kind):
carrier_kind = (carrier_kind or "").strip()
if carrier_kind not in CARRIER_ROLE_LABELS:
@ -1475,6 +1506,7 @@ class ManualWiringController:
_activate_document(doc)
def apply_length_to_selected_carriers(self, length_mm):
doc = _active_document()
selected = _selected_carrier_objects()
if not selected:
raise ManualWiringPanelError("请先选择线槽或导轨对象。")
@ -1485,8 +1517,83 @@ class ManualWiringController:
updated.append(_apply_carrier_length(carrier, length_mm))
if not updated:
raise ManualWiringPanelError("所选对象不是已标记的线槽或导轨。")
refresh_mount_hosted_objects(doc)
try:
_active_document().recompute()
doc.recompute()
except Exception:
pass
return updated
def apply_contact_offset_to_selected_mounts(self, offset_mm):
doc = _active_document()
selected = _selection()
if not selected:
raise ManualWiringPanelError("请先选择已贴合的对象。")
new_offset = float(offset_mm or 0.0)
updated = []
for selected_obj in selected:
obj = _contact_transform_object(selected_obj)
if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact":
continue
host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson"))
if host_normal is None:
raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再调节间距。")
old_offset = float(getattr(obj, "QetMountOffsetMm", 0.0) or 0.0)
delta = _vector_scale(host_normal, new_offset - old_offset)
_translate_object(obj, delta)
_ensure_float_property(
obj,
"QetMountOffsetMm",
new_offset,
"QET cabinet assembly contact offset in target normal direction",
)
host_name = (getattr(obj, "QetMountHostName", "") or "").strip()
host = doc.getObject(host_name) if host_name and hasattr(doc, "getObject") else None
host_base = _placement_base(host)
obj_base = _placement_base(obj)
if host_base is not None and obj_base is not None:
_set_vector_json_property(
obj,
"QetMountLocalBaseJson",
_vector_sub(obj_base, host_base),
"QET cabinet assembly local base offset from host",
)
updated.append(obj)
if not updated:
raise ManualWiringPanelError("所选对象不是已贴合的装配对象。")
self.contact_offset_mm = new_offset
try:
doc.recompute()
except Exception:
pass
return updated
def reverse_contact_normal_for_selected_mounts(self):
doc = _active_document()
selected = _selection()
if not selected:
raise ManualWiringPanelError("请先选择已贴合的对象。")
updated = []
for selected_obj in selected:
obj = _contact_transform_object(selected_obj)
if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact":
continue
host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson"))
if host_normal is None:
raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再反转方向。")
_set_vector_json_property(
obj,
"QetMountHostNormalJson",
_vector_scale(host_normal, -1.0),
"QET cabinet assembly host face normal at bind time",
)
updated.append(obj)
if not updated:
raise ManualWiringPanelError("所选对象不是已贴合的装配对象。")
try:
doc.recompute()
except Exception:
pass
return updated
@ -1523,11 +1630,12 @@ class ManualWiringController:
target["point"],
target["normal"],
moving["point"],
self.contact_offset_mm,
)
if translation is None:
raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。")
_translate_object(moving_object, translation)
_set_face_contact_mount_metadata(moving_object, target, moving)
_set_face_contact_mount_metadata(moving_object, target, moving, self.contact_offset_mm)
try:
_active_document().recompute()
except Exception:
@ -1541,6 +1649,7 @@ class ManualWiringController:
"moving_point": moving["point"],
"translation": translation,
"translation_mode": "normal",
"contact_offset_mm": self.contact_offset_mm,
"rotated": rotated,
}
@ -1770,6 +1879,23 @@ class ManualWiringController:
return True
raise ManualWiringPanelError("当前 FreeCAD 文档不支持撤销。")
def launch_native_transform_for_selection(self):
_active_document()
if not _selection():
raise ManualWiringPanelError("请选择要变换的对象。")
if Gui is None or not hasattr(Gui, "runCommand"):
raise ManualWiringPanelError("当前 FreeCAD 界面不支持原生变换命令。")
# FreeCAD 原生变换也是任务面板;先关闭本面板,避免 TaskDialog 互相占用。
control = getattr(Gui, "Control", None)
if control is not None and hasattr(control, "closeDialog"):
try:
control.closeDialog()
except Exception:
pass
Gui.runCommand("Std_TransformManip")
return True
def set_end_from_selection_and_generate(self):
doc = _active_document()
if self.start_terminal is None:
@ -1859,10 +1985,11 @@ class ManualWiringController:
)
if len(self.waypoints) > 3:
waypoint_text += "..."
return "任务:{0};起点:{1};出线:{2:.1f} mm折点:{3} 个;最近导线:{4};折点明细:{5}".format(
return "任务:{0};起点:{1};出线:{2:.1f} mm贴合间距:{3:.1f} mm折点{4} 个;最近导线:{5};折点明细:{6}".format(
task_text,
start_text,
self.terminal_exit_length,
self.contact_offset_mm,
len(self.waypoints),
wire_text,
waypoint_text,
@ -1889,6 +2016,12 @@ class ManualWiringTaskPanel:
self.exit_length_input.setSingleStep(5.0)
self.exit_length_input.setSuffix(" mm")
self.exit_length_input.setValue(self.controller.terminal_exit_length)
self.contact_offset_input = QtWidgets.QDoubleSpinBox()
self.contact_offset_input.setRange(-1000.0, 1000.0)
self.contact_offset_input.setDecimals(1)
self.contact_offset_input.setSingleStep(1.0)
self.contact_offset_input.setSuffix(" mm")
self.contact_offset_input.setValue(self.controller.contact_offset_mm)
self.carrier_length_input = QtWidgets.QDoubleSpinBox()
self.carrier_length_input.setRange(1.0, 10000.0)
self.carrier_length_input.setDecimals(1)
@ -1906,7 +2039,10 @@ class ManualWiringTaskPanel:
self.batch_breaker_button = QtWidgets.QPushButton("批量断路器")
self.set_contact_target_button = QtWidgets.QPushButton("设为贴合目标面")
self.align_faces_button = QtWidgets.QPushButton("贴合到选中面")
self.apply_contact_offset_button = QtWidgets.QPushButton("应用贴合间距")
self.reverse_contact_normal_button = QtWidgets.QPushButton("反转贴合方向")
self.refresh_mount_hosts_button = QtWidgets.QPushButton("刷新宿主装配")
self.native_transform_button = QtWidgets.QPushButton("关闭面板并变换")
self.waypoint_button = QtWidgets.QPushButton("添加折点")
self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点")
self.end_button = QtWidgets.QPushButton("设为终点并生成")
@ -1925,6 +2061,10 @@ class ManualWiringTaskPanel:
exit_layout.addWidget(QtWidgets.QLabel("手动端子出线长度"))
exit_layout.addWidget(self.exit_length_input)
layout.addLayout(exit_layout)
contact_offset_layout = QtWidgets.QHBoxLayout()
contact_offset_layout.addWidget(QtWidgets.QLabel("贴合间距"))
contact_offset_layout.addWidget(self.contact_offset_input)
layout.addLayout(contact_offset_layout)
carrier_length_layout = QtWidgets.QHBoxLayout()
carrier_length_layout.addWidget(QtWidgets.QLabel("载体长度"))
carrier_length_layout.addWidget(self.carrier_length_input)
@ -1945,7 +2085,10 @@ class ManualWiringTaskPanel:
layout.addLayout(batch_layout)
layout.addWidget(self.set_contact_target_button)
layout.addWidget(self.align_faces_button)
layout.addWidget(self.apply_contact_offset_button)
layout.addWidget(self.reverse_contact_normal_button)
layout.addWidget(self.refresh_mount_hosts_button)
layout.addWidget(self.native_transform_button)
layout.addWidget(self.start_button)
layout.addWidget(self.waypoint_button)
layout.addWidget(self.delete_waypoint_button)
@ -1971,6 +2114,7 @@ class ManualWiringTaskPanel:
self.use_task_button.clicked.connect(self.use_selected_task)
self.reload_tasks_button.clicked.connect(self._refresh_task_list)
self.exit_length_input.valueChanged.connect(self.set_exit_length)
self.contact_offset_input.valueChanged.connect(self.set_contact_offset)
self.import_duct_button.clicked.connect(self.import_wire_duct)
self.import_rail_button.clicked.connect(self.import_din_rail)
self.apply_carrier_length_button.clicked.connect(self.apply_carrier_length)
@ -1981,7 +2125,10 @@ class ManualWiringTaskPanel:
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.apply_contact_offset_button.clicked.connect(self.apply_contact_offset)
self.reverse_contact_normal_button.clicked.connect(self.reverse_contact_normal)
self.refresh_mount_hosts_button.clicked.connect(self.refresh_mount_hosts)
self.native_transform_button.clicked.connect(self.launch_native_transform)
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)
@ -2057,6 +2204,13 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def set_contact_offset(self, value):
try:
self.controller.set_contact_offset(value)
self._set_status(self.controller.state_text())
except Exception as exc:
self._set_error(str(exc))
def _select_carrier_asset_path(self, carrier_kind):
default_path = _builtin_carrier_asset_path(carrier_kind)
default_dir = str(Path(default_path).parent) if default_path else ""
@ -2188,6 +2342,27 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def apply_contact_offset(self):
try:
updated = self.controller.apply_contact_offset_to_selected_mounts(
self.contact_offset_input.value()
)
self._set_status(
"已对 {0} 个贴合对象应用间距 {1:.1f} mm。".format(
len(updated),
self.contact_offset_input.value(),
)
)
except Exception as exc:
self._set_error(str(exc))
def reverse_contact_normal(self):
try:
updated = self.controller.reverse_contact_normal_for_selected_mounts()
self._set_status("已反转 {0} 个贴合对象的间距方向。".format(len(updated)))
except Exception as exc:
self._set_error(str(exc))
def set_contact_target_face(self):
try:
target = self.controller.set_contact_target_from_selection()
@ -2203,6 +2378,12 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def launch_native_transform(self):
try:
self.controller.launch_native_transform_for_selection()
except Exception as exc:
self._set_error(str(exc))
def set_start(self):
try:
terminal = self.controller.set_start_from_selection()

@ -629,6 +629,9 @@ def import_terminals_from_payload(payload, scene_path=""):
_hide_object(source_obj)
report["reused_template_hints"] += 1
TerminalObjects.sort_group_children(terminal_group)
TerminalObjects.sort_group_children(root_group)
doc.recompute()
if Gui is not None:
try:

@ -3,6 +3,7 @@
import json
import math
import os
import re
from pathlib import Path
import FreeCAD as App
@ -45,6 +46,45 @@ def is_local_terminal_uuid(value):
return (value or "").strip().lower().startswith("local:")
def natural_sort_key(value):
text = str(value or "").strip().casefold()
parts = re.split(r"(\d+)", text)
key = []
for part in parts:
if not part:
continue
if part.isdigit():
key.append((0, int(part)))
else:
key.append((1, part))
return tuple(key)
def object_display_sort_key(obj):
label = (getattr(obj, "Label", "") or "").strip()
name = (getattr(obj, "Name", "") or "").strip()
return (natural_sort_key(label or name), natural_sort_key(name))
def sort_group_children(group):
children = list(getattr(group, "Group", []) or [])
if len(children) < 2:
return children
sorted_children = sorted(
enumerate(children),
key=lambda item: (object_display_sort_key(item[1]), item[0]),
)
ordered = [child for _index, child in sorted_children]
try:
group.Group = ordered
except Exception:
try:
group.Group[:] = ordered
except Exception:
return children
return ordered
def ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description)

@ -57,9 +57,15 @@ def _install_fake_freecad():
)
sys.modules["FreeCAD"] = fake_freecad
selection_state = {"selection": [], "selection_ex": []}
selection_state = {
"selection": [],
"selection_ex": [],
"commands": [],
"control_events": [],
}
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.runCommand = lambda command: selection_state["commands"].append(command)
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
def clear_selection():
selection_state["selection"] = []
@ -76,7 +82,7 @@ def _install_fake_freecad():
fake_freecadgui.Control = types.SimpleNamespace(
activeDialog=lambda: False,
showDialog=lambda panel: panel,
closeDialog=lambda: None,
closeDialog=lambda: selection_state["control_events"].append("closeDialog"),
)
sys.modules["FreeCADGui"] = fake_freecadgui
@ -643,6 +649,58 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(400.0, getattr(carrier, "QetCarrierLength", None))
self.assertEqual(2.0, getattr(carrier, "QetCarrierScaleX", None))
def test_controller_refreshes_face_contact_mount_when_changing_carrier_length(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")
carrier = doc.addObject("App::DocumentObjectGroup", "WireDuctCarrier")
cabinet.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation())
carrier.Placement = app.Placement(app.Vector(120, 0, 5), app.Rotation())
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
terminal_objects.ensure_string_property(
carrier,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
terminal_objects.ensure_string_property(
carrier,
"QetMountHostName",
"QET Assembly",
"QET cabinet assembly mount metadata",
"CabinetPanel",
)
terminal_objects.ensure_string_property(
carrier,
"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())
selection_state["selection"] = [carrier]
updated = panel.ManualWiringController().apply_length_to_selected_carriers(500.0)
self.assertEqual([carrier], updated)
self.assertEqual(500.0, carrier.QetCarrierLength)
self.assertEqual((150.0, 0.0, 5.0), (carrier.Placement.Base.x, carrier.Placement.Base.y, carrier.Placement.Base.z))
self.assertEqual({"x": 130.0, "y": 0.0, "z": 0.0}, json.loads(carrier.QetMountHostBaseJson))
def test_controller_auto_marks_selected_wire_duct_by_name_before_length_change(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -797,6 +855,160 @@ class ManualWiringPanelTest(unittest.TestCase):
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))
self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountHostNormalJson))
def test_controller_aligns_contact_faces_with_configured_normal_offset(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),
)
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(contact_offset_mm=2.0).align_selected_contact_faces()
self.assertIs(rail, result["moving_object"])
self.assertEqual((0.0, 0.0, 3.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
self.assertEqual((-0.0, -0.0, -7.0), (result["translation"].x, result["translation"].y, result["translation"].z))
self.assertEqual(2.0, result["contact_offset_mm"])
self.assertEqual(2.0, rail.QetMountOffsetMm)
def test_controller_applies_contact_offset_to_selected_mounted_object(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),
)
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,
),
]
controller = panel.ManualWiringController()
controller.align_selected_contact_faces()
selection_state["selection"] = [rail]
updated = controller.apply_contact_offset_to_selected_mounts(5.0)
self.assertEqual([rail], updated)
self.assertEqual((0.0, 0.0, 6.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
self.assertEqual(5.0, rail.QetMountOffsetMm)
self.assertEqual({"x": 0.0, "y": 0.0, "z": 6.0}, json.loads(rail.QetMountLocalBaseJson))
def test_controller_reverses_contact_normal_for_selected_mounted_object(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),
)
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,
),
]
controller = panel.ManualWiringController()
controller.align_selected_contact_faces()
selection_state["selection"] = [rail]
reversed_objects = controller.reverse_contact_normal_for_selected_mounts()
updated = controller.apply_contact_offset_to_selected_mounts(5.0)
self.assertEqual([rail], reversed_objects)
self.assertEqual([rail], updated)
self.assertEqual({"x": -0.0, "y": -0.0, "z": -1.0}, json.loads(rail.QetMountHostNormalJson))
self.assertEqual((0.0, 0.0, -4.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
def test_controller_requires_saved_contact_normal_before_applying_offset(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("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 1), app.Rotation())
terminal_objects.ensure_string_property(
rail,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
selection_state["selection"] = [rail]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "贴合法向"):
panel.ManualWiringController().apply_contact_offset_to_selected_mounts(3.0)
def test_refresh_mount_hosted_objects_moves_child_by_host_delta(self):
_install_fake_freecad()
@ -999,6 +1211,97 @@ class ManualWiringPanelTest(unittest.TestCase):
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_rejects_multiple_moving_faces_after_target_face_is_set(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")
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face_a = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
moving_face_b = 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(0, 0, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
)
]
controller.set_contact_target_from_selection()
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 9), app.Vector(0, 1, 9)],
SubObjects=[moving_face_a, moving_face_b],
SubElementNames=["Face2", "Face3"],
Object=rail,
)
]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "只选择一个"):
controller.align_selected_contact_faces()
def test_controller_moves_plain_app_part_parent_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
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
assembly = doc.addObject("App::Part", "ImportedFcstdDevice")
child = doc.addObject("Part::Feature", "ImportedFcstdBody")
assembly.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
child.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
assembly.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(assembly, result["moving_object"])
self.assertEqual((0.0, 0.0, 1.0), (assembly.Placement.Base.x, assembly.Placement.Base.y, assembly.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([assembly], selection_state["selection"])
def test_controller_requires_two_faces_for_contact_alignment(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -1558,6 +1861,39 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(("undo", ""), doc.transactions[-1])
def test_controller_closes_panel_and_launches_native_transform(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")
device = doc.addObject("Part::Feature", "Device")
selection_state["selection"] = [device]
result = panel.ManualWiringController().launch_native_transform_for_selection()
self.assertTrue(result)
self.assertEqual(["closeDialog"], selection_state["control_events"])
self.assertEqual(["Std_TransformManip"], selection_state["commands"])
def test_controller_requires_selection_before_native_transform(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")
selection_state["selection"] = []
with self.assertRaisesRegex(panel.ManualWiringPanelError, "请选择要变换的对象"):
panel.ManualWiringController().launch_native_transform_for_selection()
self.assertEqual([], selection_state["control_events"])
self.assertEqual([], selection_state["commands"])
if __name__ == "__main__":
unittest.main()

@ -42,6 +42,7 @@ def _install_fake_freecad():
class FakeObject:
def __init__(self, name, type_id="App::DocumentObjectGroup"):
self.Name = name
self.Label = name
self.TypeId = type_id
self.Group = []
self.InList = []
@ -131,5 +132,24 @@ class TemplateTerminalVisibilityTest(unittest.TestCase):
self.assertTrue(engineering_terminal.ViewObject.Visibility)
class GroupSortingTest(unittest.TestCase):
def test_sort_group_children_uses_case_insensitive_natural_label_order(self):
_install_fake_freecad()
terminal_objects = _reload_module()
root = FakeObject("QETExchangeDevices")
for label in ["ID:10", "TAa", "id:2", "TAb", "C - 电容柜001", "ID:7"]:
child = FakeObject("QETDevice_" + label)
child.Label = label
root.addObject(child)
sorted_children = terminal_objects.sort_group_children(root)
self.assertEqual(
["C - 电容柜001", "id:2", "ID:7", "ID:10", "TAa", "TAb"],
[child.Label for child in sorted_children],
)
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save