From b0c662bd11ff51d4b7be20640b9750a6bd371f0d Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Tue, 2 Jun 2026 16:15:28 +0800 Subject: [PATCH] feat(freecad): improve qet assembly placement workflow --- docs/FreeCAD 机柜装配操作文档.md | 6 +- .../2026-05-29-face-contact-snap-design.md | 25 +- ...06-02-batch-din-device-placement-design.md | 89 +++ src/Mod/FreeCADExchange/BatchAssembly.py | 515 +++++++++++++++++ src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/ManualWiringPanel.py | 516 +++++++++++++++++- .../FreeCADExchange/TemplateInstantiation.py | 2 +- src/Mod/FreeCADExchange/TerminalObjects.py | 11 + .../freecad_exchange_batch_assembly_test.py | 260 +++++++++ ...eecad_exchange_manual_wiring_panel_test.py | 388 +++++++++++++ ...ad_exchange_template_instantiation_test.py | 1 + 11 files changed, 1801 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md create mode 100644 src/Mod/FreeCADExchange/BatchAssembly.py create mode 100644 tests/python/freecad_exchange_batch_assembly_test.py diff --git a/docs/FreeCAD 机柜装配操作文档.md b/docs/FreeCAD 机柜装配操作文档.md index afa1043..1a6a638 100644 --- a/docs/FreeCAD 机柜装配操作文档.md +++ b/docs/FreeCAD 机柜装配操作文档.md @@ -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` 里的最终几何位置,不从装配约束反推位置。 推荐建模习惯: diff --git a/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md b/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md index 19bbbd3..a1b0bdf 100644 --- a/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md +++ b/docs/superpowers/specs/2026-05-29-face-contact-snap-design.md @@ -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 工程。 diff --git a/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md new file mode 100644 index 0000000..201bdd2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-batch-din-device-placement-design.md @@ -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::`,避免伪造 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。 +- 不做完整端子排电气跨接片、跳线、短接片规则。 diff --git a/src/Mod/FreeCADExchange/BatchAssembly.py b/src/Mod/FreeCADExchange/BatchAssembly.py new file mode 100644 index 0000000..ecd97d4 --- /dev/null +++ b/src/Mod/FreeCADExchange/BatchAssembly.py @@ -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) diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index f1520fa..62207d8 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -21,6 +21,7 @@ set(FreeCADExchange_Scripts ExchangeWriteBack.py ManualWiring.py ManualWiringPanel.py + BatchAssembly.py ) set(FreeCADExchange_CabinetAssetDir diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index 3da71e3..19c4716 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -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() diff --git a/src/Mod/FreeCADExchange/TemplateInstantiation.py b/src/Mod/FreeCADExchange/TemplateInstantiation.py index 1f9a59d..1252b61 100644 --- a/src/Mod/FreeCADExchange/TemplateInstantiation.py +++ b/src/Mod/FreeCADExchange/TemplateInstantiation.py @@ -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 diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py index 6aba212..f30d5f5 100644 --- a/src/Mod/FreeCADExchange/TerminalObjects.py +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -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: diff --git a/tests/python/freecad_exchange_batch_assembly_test.py b/tests/python/freecad_exchange_batch_assembly_test.py new file mode 100644 index 0000000..c5f8c07 --- /dev/null +++ b/tests/python/freecad_exchange_batch_assembly_test.py @@ -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() diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 3c1e003..1d104fc 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -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,2,3 4;5;6", + ) + + 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() diff --git a/tests/python/freecad_exchange_template_instantiation_test.py b/tests/python/freecad_exchange_template_instantiation_test.py index fa78025..1ed0231 100644 --- a/tests/python/freecad_exchange_template_instantiation_test.py +++ b/tests/python/freecad_exchange_template_instantiation_test.py @@ -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):