feat: 增加FreeCAD面贴合装配辅助

dev
Zhaowenlong 3 weeks ago
parent 0a753ffbd6
commit 10401497f3

@ -0,0 +1,47 @@
# 面贴合装配辅助设计
## 背景
CAD 用户在 FreeCAD 中摆放导轨、线槽和设备时需要让两个接触面刚好贴合避免穿模或悬空。FreeCAD 原生 `变换` 可以移动旋转对象,但不会自动判断面接触;`Assembly` 工作台可以做装配约束,但对当前 QET 演示流程偏重。
## 目标
`QET模板 -> 3D手动布线` 面板增加一个轻量装配辅助按钮:`贴合到选中面`。
用户先选择目标承载面,再选择要移动对象的接触面,点击按钮后:
- 沿第一个目标面的法向移动第二个对象,使第二个选择面落到目标面的同一平面上。
- 尽量让第二个选择面的法向与第一个选择面的反向对齐。
- 保持第二个对象原来的切向位置,不把对象横向拉到目标面的拾取点。
- 操作完成后恢复当前 QET 工程为活动文档。
- 不写数据库,不改 2D/3D 绑定表,不影响导线任务。
## 适用场景
- 导轨背面贴合机柜安装板。
- 线槽背面或底面贴合机柜安装板。
- 设备背面或卡扣接触面贴合导轨安装面。
## 交互
1. 在 3D 视图中选择机柜、导轨或线槽上的目标面。
2. 按住 Ctrl再选择要移动对象上的接触面。
3. 点击 `贴合到选中面`
4. 如果方向不理想,用户可以先用 FreeCAD `变换` 粗调姿态,再重新执行贴合。
第一版只接受两个面。多选多个设备、多个面时,系统不能唯一判断哪个对象应该移动、哪个面是目标、是否要同时满足多个约束,因此会直接提示错误。多面贴合属于完整 Assembly 约束求解范围,不放进这个轻量按钮。
第一版不做多约束求解,不自动识别“哪个面是背面”,也不保存永久装配约束。它只执行一次几何位姿调整。
## 错误处理
- 少于两个面:提示用户先选目标面,再选移动对象接触面。
- 多于两个面:提示只能选择两个面。
- 第二个选择对象没有可移动 `Placement`:提示对象不能移动。
- 无法读取面中心或法向:提示请选择有效模型面。
## 测试
- 选择两个面后,移动对象应只沿目标面法向平移,消除法向间距。
- 多选三个或更多面时应报错。
- 导入类操作或贴合操作后,`App.ActiveDocument` 仍应是当前 QET 工程。

@ -23,6 +23,19 @@ set(FreeCADExchange_Scripts
ManualWiringPanel.py
)
set(FreeCADExchange_CabinetAssetDir
${CMAKE_CURRENT_SOURCE_DIR}/../../../data/examples/qet_cabinet_assets
)
set(FreeCADExchange_CabinetAssets
${FreeCADExchange_CabinetAssetDir}/README.md
${FreeCADExchange_CabinetAssetDir}/qet_cabinet_assets_report.json
${FreeCADExchange_CabinetAssetDir}/qet_din_rail.FCStd
${FreeCADExchange_CabinetAssetDir}/qet_din_rail.step
${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.FCStd
${FreeCADExchange_CabinetAssetDir}/qet_wire_duct.step
)
add_custom_target(FreeCADExchangeScripts ALL
SOURCES ${FreeCADExchange_Scripts}
)
@ -39,3 +52,10 @@ install(
DESTINATION
Mod/FreeCADExchange
)
install(
FILES
${FreeCADExchange_CabinetAssets}
DESTINATION
data/examples/qet_cabinet_assets
)

@ -791,25 +791,28 @@ def _import_model_into_group(doc, device_group, model_path, merge=False, use_lin
before_names = _existing_object_names(doc)
try:
ImportGui.insert(
name=model_path,
docName=doc.Name,
merge=bool(merge),
useLinkGroup=bool(use_link_group),
)
except Exception:
for obj in _new_objects_since(doc, before_names):
_remove_object_tree(doc, obj)
raise
imported_objects = _new_objects_since(doc, before_names)
top_level_objects = _top_level_imported_objects(imported_objects)
for obj in top_level_objects:
if obj not in getattr(device_group, "Group", []):
device_group.addObject(obj)
TemplateSemantics.clear_stored_template_slot_hints(device_group)
TerminalObjects.hide_template_terminal_hints(device_group)
return top_level_objects
try:
ImportGui.insert(
name=model_path,
docName=doc.Name,
merge=bool(merge),
useLinkGroup=bool(use_link_group),
)
except Exception:
for obj in _new_objects_since(doc, before_names):
_remove_object_tree(doc, obj)
raise
imported_objects = _new_objects_since(doc, before_names)
top_level_objects = _top_level_imported_objects(imported_objects)
for obj in top_level_objects:
if obj not in getattr(device_group, "Group", []):
device_group.addObject(obj)
TemplateSemantics.clear_stored_template_slot_hints(device_group)
TerminalObjects.hide_template_terminal_hints(device_group)
return top_level_objects
finally:
_activate_document(doc)
def _open_fcstd_source_document(model_path):

@ -1,6 +1,7 @@
# FreeCADExchange GUI panel for guided manual 3D wiring.
import json
import math
from pathlib import Path
import FreeCAD as App
@ -68,15 +69,32 @@ def _console_error(message):
pass
def _repo_root():
return Path(__file__).resolve().parents[3]
def _builtin_carrier_asset_path(carrier_kind):
file_name = BUILTIN_CARRIER_ASSETS.get((carrier_kind or "").strip())
if not file_name:
return ""
return str(_repo_root() / "data" / "examples" / "qet_cabinet_assets" / file_name)
module_path = Path(__file__).resolve()
candidate_roots = []
for index in (3, 2):
try:
candidate_roots.append(module_path.parents[index])
except IndexError:
pass
try:
app_home = App.ConfigGet("AppHomePath")
if app_home:
candidate_roots.append(Path(app_home))
except Exception:
pass
for root in candidate_roots:
candidate = root / "data" / "examples" / "qet_cabinet_assets" / file_name
if candidate.is_file():
return str(candidate)
if candidate_roots:
return str(candidate_roots[0] / "data" / "examples" / "qet_cabinet_assets" / file_name)
return str(module_path.parent / file_name)
def _supported_carrier_asset(path):
@ -91,6 +109,29 @@ def _active_document():
return doc
def _activate_document(doc):
if doc is None:
return
setter = getattr(App, "setActiveDocument", None)
if callable(setter):
try:
setter(doc.Name)
except Exception:
pass
try:
App.ActiveDocument = doc
except Exception:
pass
try:
if Gui is not None:
Gui.ActiveDocument = Gui.getDocument(doc.Name)
except Exception:
pass
def _selection():
if Gui is None:
return []
@ -120,6 +161,195 @@ def _shape_center(shape):
)
def _vector(x=0.0, y=0.0, z=0.0):
return App.Vector(float(x), float(y), float(z))
def _vector_add(left, right):
return _vector(left.x + right.x, left.y + right.y, left.z + right.z)
def _vector_sub(left, right):
return _vector(left.x - right.x, left.y - right.y, left.z - right.z)
def _vector_scale(vector, factor):
return _vector(vector.x * float(factor), vector.y * float(factor), vector.z * float(factor))
def _vector_length(vector):
return math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
def _normalize_vector(vector):
if vector is None:
return None
length = _vector_length(vector)
if length <= 1e-9:
return None
return _vector_scale(vector, 1.0 / length)
def _vector_dot(left, right):
return left.x * right.x + left.y * right.y + left.z * right.z
def _vectors_close(left, right, tolerance=1e-6):
return _vector_length(_vector_sub(left, right)) <= tolerance
def _face_anchor_point(picked, sub_object):
picked_points = list(getattr(picked, "PickedPoints", []) or [])
if picked_points:
return picked_points[0]
center = getattr(sub_object, "CenterOfMass", None)
if center is not None:
return center
return _shape_center(sub_object)
def _face_normal(sub_object):
if not hasattr(sub_object, "normalAt"):
return None
try:
return _normalize_vector(sub_object.normalAt(0.5, 0.5))
except Exception:
try:
return _normalize_vector(sub_object.normalAt(0.5))
except Exception:
return None
def _selected_contact_face_refs():
refs = []
for picked in _selection_ex():
obj = getattr(picked, "Object", None)
if obj is None:
continue
subelement_names = list(getattr(picked, "SubElementNames", []) or [])
for index, sub_object in enumerate(list(getattr(picked, "SubObjects", []) or [])):
shape_type = (getattr(sub_object, "ShapeType", "") or "").strip().lower()
if shape_type != "face":
continue
point = _face_anchor_point(picked, sub_object)
normal = _face_normal(sub_object)
if point is None or normal is None:
continue
refs.append(
{
"object": obj,
"face": sub_object,
"point": point,
"normal": normal,
"subelement_name": subelement_names[index] if index < len(subelement_names) else "",
}
)
break
return refs
def _has_placement(obj):
return getattr(obj, "Placement", None) is not None
def _contact_transform_object(obj):
carrier = _carrier_object_from_object(obj)
if carrier is not None and _has_placement(carrier):
return carrier
best = obj if _has_placement(obj) else None
current = obj
visited = set()
while current is not None and id(current) not in visited:
visited.add(id(current))
parents = list(getattr(current, "InList", []) or [])
parent = parents[0] if parents else None
if parent is None:
break
if _has_placement(parent):
name = getattr(parent, "Name", "") or ""
if (
name.startswith("QETDevice_")
or (getattr(parent, "QetInstanceId", "") or "").strip()
or (getattr(parent, "QetCarrierKind", "") or "").strip()
):
best = parent
current = parent
return best
def _rotation_for_face_contact(moving_normal, target_normal):
desired = _vector_scale(target_normal, -1.0)
moving = _normalize_vector(moving_normal)
desired = _normalize_vector(desired)
if moving is None or desired is None or _vectors_close(moving, desired):
return None
try:
return App.Rotation(moving, desired)
except Exception:
return None
def _normal_contact_translation(target_point, target_normal, moving_point):
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)
def _rotate_object_about_point(obj, rotation, pivot):
if rotation is None:
return False
placement = getattr(obj, "Placement", None)
if placement is None or not hasattr(rotation, "multVec"):
return False
base = getattr(placement, "Base", None)
if base is None:
return False
try:
rotated_base = _vector_add(pivot, rotation.multVec(_vector_sub(base, pivot)))
except Exception:
return False
old_rotation = getattr(placement, "Rotation", None)
new_rotation = old_rotation
try:
if hasattr(rotation, "multiply"):
new_rotation = rotation.multiply(old_rotation)
except Exception:
new_rotation = old_rotation
try:
obj.Placement = App.Placement(rotated_base, new_rotation)
return True
except Exception:
try:
placement.Base = rotated_base
placement.Rotation = new_rotation
obj.Placement = placement
return True
except Exception:
return False
def _translate_object(obj, translation):
placement = getattr(obj, "Placement", None)
base = getattr(placement, "Base", None)
if placement is None or base is None:
raise ManualWiringPanelError("所选对象没有可移动的 Placement不能执行贴合。")
new_base = _vector_add(base, translation)
try:
placement.Base = new_base
obj.Placement = placement
except Exception:
obj.Placement = App.Placement(new_base, getattr(placement, "Rotation", App.Rotation()))
return new_base
def _selected_point():
for picked in _selection_ex():
picked_points = list(getattr(picked, "PickedPoints", []) or [])
@ -377,32 +607,36 @@ def _import_carrier_objects_from_path(doc, path):
if not _supported_carrier_asset(path):
raise ManualWiringPanelError("请选择 STEP/STP/FCStd 线槽或导轨模型。")
suffix = Path(path).suffix.lower()
if suffix == ".fcstd":
source_doc = None
try:
source_doc = _open_fcstd_source(path)
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:
try:
App.closeDocument(source_doc.Name)
except Exception:
pass
if ImportGui is None:
raise ManualWiringPanelError("当前 FreeCAD 无法导入 STEP/STP 文件。")
before = _existing_object_names(doc)
ImportGui.insert(
name=path,
docName=doc.Name,
merge=False,
useLinkGroup=True,
)
return _top_level_objects(_new_objects_since(doc, before))
try:
suffix = Path(path).suffix.lower()
if suffix == ".fcstd":
source_doc = None
try:
source_doc = _open_fcstd_source(path)
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:
try:
App.closeDocument(source_doc.Name)
except Exception:
pass
if ImportGui is None:
raise ManualWiringPanelError("当前 FreeCAD 无法导入 STEP/STP 文件。")
before = _existing_object_names(doc)
ImportGui.insert(
name=path,
docName=doc.Name,
merge=False,
useLinkGroup=True,
)
return _top_level_objects(_new_objects_since(doc, before))
finally:
_activate_document(doc)
def _ensure_float_property(obj, prop_name, value, description):
@ -776,30 +1010,33 @@ class ManualWiringController:
raise ManualWiringPanelError("找不到内置线槽/导轨资产。")
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid)
imported = list((importer or _import_carrier_objects_from_path)(doc, path) or [])
if not imported:
raise ManualWiringPanelError("没有从模型文件导入任何对象。")
role_label = _carrier_role_label(carrier_kind)
container_name = "QETCarrier_{0}".format(TerminalObjects.safe_token(carrier_kind))
container = doc.addObject("App::DocumentObjectGroup", container_name)
container.Label = "QET {0}".format(role_label or carrier_kind)
carrier_group.addObject(container)
for obj in imported:
if obj not in getattr(container, "Group", []):
container.addObject(obj)
_set_carrier_properties(container, carrier_kind, source_path=path)
for obj in imported:
_set_carrier_properties(obj, carrier_kind, source_path=path)
_apply_carrier_length(container, length_mm)
try:
doc.recompute()
except Exception:
pass
return container
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid)
imported = list((importer or _import_carrier_objects_from_path)(doc, path) or [])
if not imported:
raise ManualWiringPanelError("没有从模型文件导入任何对象。")
role_label = _carrier_role_label(carrier_kind)
container_name = "QETCarrier_{0}".format(TerminalObjects.safe_token(carrier_kind))
container = doc.addObject("App::DocumentObjectGroup", container_name)
container.Label = "QET {0}".format(role_label or carrier_kind)
carrier_group.addObject(container)
for obj in imported:
if obj not in getattr(container, "Group", []):
container.addObject(obj)
_set_carrier_properties(container, carrier_kind, source_path=path)
for obj in imported:
_set_carrier_properties(obj, carrier_kind, source_path=path)
_apply_carrier_length(container, length_mm)
try:
doc.recompute()
except Exception:
pass
return container
finally:
_activate_document(doc)
def apply_length_to_selected_carriers(self, length_mm):
selected = _selected_carrier_objects()
@ -818,6 +1055,44 @@ class ManualWiringController:
pass
return updated
def align_selected_contact_faces(self):
refs = _selected_contact_face_refs()
if len(refs) < 2:
raise ManualWiringPanelError("请先选择目标面,再按 Ctrl 选择要移动对象的接触面。")
if len(refs) > 2:
raise ManualWiringPanelError("只能选择两个面:第一个是目标面,第二个是要移动对象的接触面。")
target = refs[0]
moving = refs[1]
moving_object = _contact_transform_object(moving["object"])
if moving_object is None:
raise ManualWiringPanelError("没有找到可移动的对象。")
rotation = _rotation_for_face_contact(moving["normal"], target["normal"])
rotated = _rotate_object_about_point(moving_object, rotation, moving["point"])
translation = _normal_contact_translation(
target["point"],
target["normal"],
moving["point"],
)
if translation is None:
raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。")
_translate_object(moving_object, translation)
try:
_active_document().recompute()
except Exception:
pass
_activate_document(_active_document())
return {
"target_object": target["object"],
"moving_object": moving_object,
"target_point": target["point"],
"moving_point": moving["point"],
"translation": translation,
"translation_mode": "normal",
"rotated": rotated,
}
def _clear_preview_objects(self):
doc = getattr(App, "ActiveDocument", None)
if doc is None:
@ -1108,6 +1383,7 @@ class ManualWiringTaskPanel:
self.mark_duct_button = QtWidgets.QPushButton("标记为线槽")
self.mark_cabinet_button = QtWidgets.QPushButton("标记为柜面")
self.mark_rail_button = QtWidgets.QPushButton("标记为导轨")
self.align_faces_button = QtWidgets.QPushButton("贴合到选中面")
self.waypoint_button = QtWidgets.QPushButton("添加折点")
self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点")
self.end_button = QtWidgets.QPushButton("设为终点并生成")
@ -1140,6 +1416,7 @@ class ManualWiringTaskPanel:
carrier_layout.addWidget(self.mark_cabinet_button)
carrier_layout.addWidget(self.mark_rail_button)
layout.addLayout(carrier_layout)
layout.addWidget(self.align_faces_button)
layout.addWidget(self.start_button)
layout.addWidget(self.waypoint_button)
layout.addWidget(self.delete_waypoint_button)
@ -1171,6 +1448,7 @@ 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.align_faces_button.clicked.connect(self.align_selected_contact_faces)
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)
@ -1323,6 +1601,14 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def align_selected_contact_faces(self):
try:
result = self.controller.align_selected_contact_faces()
moving_label = getattr(result.get("moving_object"), "Label", "") or getattr(result.get("moving_object"), "Name", "")
self._set_status("已贴合对象:{0}".format(moving_label or "选中对象"))
except Exception as exc:
self._set_error(str(exc))
def set_start(self):
try:
terminal = self.controller.set_start_from_selection()

@ -486,6 +486,32 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual("", device_group.QetTemplateSlotsJson)
self.assertEqual([], template_semantics.collect_terminal_hints(device_group))
def test_non_fcstd_import_restores_target_document_after_insert_changes_active_document(self):
source = FakeDocument("Source", r"D:\models\breaker.FCStd")
_install_fake_freecad(source)
app = sys.modules["FreeCAD"]
doc = FakeDocument("QETScene")
app.ActiveDocument = doc
device_group = doc.addObject("App::Part", "QETDevice_breaker")
device_import, _ = _reload_modules()
def insert_step_body(name, docName, merge, useLinkGroup):
doc.addObject("Part::Feature", "StepBody")
app.ActiveDocument = None
device_import.ImportGui.insert = insert_step_body
device_import._import_model_into_group(
doc,
device_group,
r"D:\models\breaker.step",
)
self.assertIs(doc, app.ActiveDocument)
self.assertIn("QETScene", app.set_active_document_calls)
def test_fcstd_import_detaches_removed_template_lcs_from_parent_group(self):
source = FakeDocument("Source", r"D:\models\breaker.FCStd")
_install_fake_freecad(source)

@ -1,6 +1,7 @@
import importlib
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
@ -198,6 +199,29 @@ def _reload_modules():
class ManualWiringPanelTest(unittest.TestCase):
def test_builtin_carrier_asset_path_supports_installed_freecad_layout(self):
_selection_state = _install_fake_freecad()
_terminal_objects, panel = _reload_modules()
with tempfile.TemporaryDirectory() as temp_dir:
app_home = Path(temp_dir) / "run-FreeCAD"
module_dir = app_home / "Mod" / "FreeCADExchange"
asset_dir = app_home / "data" / "examples" / "qet_cabinet_assets"
module_dir.mkdir(parents=True)
asset_dir.mkdir(parents=True)
asset = asset_dir / "qet_din_rail.FCStd"
asset.write_text("fake rail", encoding="utf-8")
original_file = panel.__file__
try:
panel.__file__ = str(module_dir / "ManualWiringPanel.py")
resolved = panel._builtin_carrier_asset_path("rail")
finally:
panel.__file__ = original_file
self.assertEqual(str(asset), resolved)
def test_controller_rejects_local_terminal_as_manual_wiring_start(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -415,6 +439,29 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertIn(carrier, carrier_group.Group)
self.assertEqual(3.0, getattr(carrier, "QetCarrierScaleX", None))
def test_controller_restores_active_document_after_carrier_import_changes_it(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")
def importer(doc, path):
obj = doc.addObject("Part::Feature", "ImportedRail")
app.ActiveDocument = None
return [obj]
panel.ManualWiringController().import_carrier_asset(
r"D:\assets\rail.FCStd",
"rail",
length_mm=300.0,
importer=importer,
)
self.assertIs(doc, app.ActiveDocument)
def test_controller_applies_length_to_selected_carrier(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -441,6 +488,123 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(500.0, getattr(carrier, "QetCarrierLength", None))
self.assertEqual(2.5, getattr(carrier, "QetCarrierScaleX", None))
def test_controller_aligns_second_selected_face_to_first_selected_face(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().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),
)
self.assertEqual(
(-0.0, -0.0, -9.0),
(
result["translation"].x,
result["translation"].y,
result["translation"].z,
),
)
self.assertEqual("normal", result["translation_mode"])
def test_controller_requires_two_faces_for_contact_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")
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=[face],
SubElementNames=["Face1"],
Object=cabinet,
)
]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "目标面"):
panel.ManualWiringController().align_selected_contact_faces()
def test_controller_rejects_more_than_two_faces_for_contact_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")
breaker = doc.addObject("Part::Feature", "Breaker")
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=[face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 10)],
SubObjects=[face],
SubElementNames=["Face2"],
Object=rail,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 20)],
SubObjects=[face],
SubElementNames=["Face3"],
Object=breaker,
),
]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "只能选择两个面"):
panel.ManualWiringController().align_selected_contact_faces()
def test_controller_deletes_last_waypoint_and_preview_point(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()

Loading…
Cancel
Save