From cedd38df40c131d6d418a0385b581f84b8af7edd Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 3 Jun 2026 16:51:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(freecad):=20=E5=AE=8C=E5=96=84QET=E7=AB=AF?= =?UTF-8?q?=E5=AD=90=E6=89=B9=E9=87=8F=E8=A3=85=E9=85=8D=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-02-batch-din-device-placement-design.md | 18 ++++- src/Mod/FreeCADExchange/BatchAssembly.py | 20 ++++- src/Mod/FreeCADExchange/TerminalImport.py | 58 +++++++++++++ .../freecad_exchange_batch_assembly_test.py | 13 ++- ...nge_terminal_import_template_slots_test.py | 81 +++++++++++++++++++ .../freecad_exchange_wiring_import_test.py | 12 +-- 6 files changed, 191 insertions(+), 11 deletions(-) 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 index 9e54fef..d094f53 100644 --- 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 @@ -21,6 +21,8 @@ QET 负责: - 设备与 3D 模型资产绑定。 - 端子的真实 `terminal_uuid`。 +当前交换 JSON 中,正式端子优先来自 `devices[].terminals[]`。顶层 `terminals[]` 可以为空,不能因此判断 QET 没有传端子。导线任务的 `start_terminal_uuid / end_terminal_uuid` 也使用同一套真实端子 UUID。 + FreeCAD 负责: - 真实 3D 设备实例的空间位姿。 @@ -31,6 +33,18 @@ FreeCAD 负责: 第一版仍遵守 2D/3D 协同约束:3D 端子绑定唯一依据是 `terminal_uuid`,3D 位姿以 `scene.FCStd` 为准,不从数据库反推 3D 位姿。 +## 端子导入顺序 + +FreeCAD 导入工程端子时按下面顺序读取: + +```text +1. 顶层 terminals[] +2. devices[].terminals[] +3. wires[] 中的起点/终点端子,仅作为缺失端子的兜底补齐 +``` + +如果 `devices[].terminals[]` 已经包含某个导线端点,`wires[]` 不会重复生成同一个端子。正式工程中生成的工程端子必须保留 QET 传入的 `terminal_uuid`,包括 `element_uuid:terminal_uuid` 这种复合字符串,不允许转换为 `local:*`。 + ## 端子排批量排布 正式流程: @@ -80,6 +94,8 @@ FreeCAD 负责: 8. 按自然顺序排布,例如 `QF1`、`QF2`、`QF10`。 9. 保留设备下的工程端子和 QET 绑定关系。 +断路器筛选只处理真实设备对象,不处理设备下的工程端子对象或 `QET Terminals` 分组。工程端子和端子分组也会携带 `QetInstanceId / QetElementUuid`,不能只按这些字段判断为设备,否则 `QF1:1` 这类端子会被误当成断路器一起排布。 + 断路器端子号来自 QET 传入的真实端子数据。参数窗口中的“兜底端子号”只在当前工程没有匹配 QET 设备、需要演示生成占位对象时使用。 ## 旧兜底逻辑 @@ -159,7 +175,7 @@ QET模板 -> 3D手动布线 2. 树目录中已经存在 QET 导入的端子片或设备实例。 3. 选中导轨,点击 `批量端子排`。 4. 输入 `UD` 或 `ID`,确认后真实端子片沿导轨排布。 -5. 排布后端子对象仍保留真实 `QetTerminalUuid`,不会变成 `local:*`。 +5. 排布后端子对象仍保留真实 `QetTerminalUuid`,包括 `element_uuid:terminal_uuid` 这种 QET -> FreeCAD 交换身份,不会变成 `local:*`。 6. 选中导轨,点击 `批量断路器`。 7. 输入 `QF`,确认后真实断路器沿导轨排布。 8. 保存后重新打开 `scene.FCStd`,设备位置保持。 diff --git a/src/Mod/FreeCADExchange/BatchAssembly.py b/src/Mod/FreeCADExchange/BatchAssembly.py index 2be0012..0f26efc 100644 --- a/src/Mod/FreeCADExchange/BatchAssembly.py +++ b/src/Mod/FreeCADExchange/BatchAssembly.py @@ -75,6 +75,15 @@ def _text_values(obj, include_children=False): return values +def _label_name_values(obj): + values = [] + for attr_name in ("Label", "Name"): + value = (getattr(obj, attr_name, "") or "").strip() + if value: + values.append(value) + return values + + def _natural_sort_key(value): text = str(value or "") key = [] @@ -96,7 +105,7 @@ def _parse_strip_name_and_order(obj): order = _order_from_texts(_text_values(obj)) return strip_name, order - for text in _text_values(obj): + for text in _label_name_values(obj): # Examples from QET trees: UD:1, UD-2, ID_006. match = re.match(r"^\s*([A-Za-z][A-Za-z0-9]{0,8})\s*[::_\-]\s*(\d+)\b", text) if match: @@ -139,10 +148,19 @@ def _qet_identity(obj): def _is_qet_device_object(obj): if obj is None: return False + if TerminalObjects.is_terminal_object(obj): + return False + group_kind = (getattr(obj, "QetGroupKind", "") or "").strip() + if group_kind in {TerminalObjects.TERMINAL_GROUP_KIND, TerminalObjects.WIRE_GROUP_KIND}: + return False name = getattr(obj, "Name", "") or "" + if name.startswith(TerminalObjects.TERMINAL_GROUP_PREFIX) or name.startswith(TerminalObjects.WIRE_GROUP_PREFIX): + return False instance_id, element_uuid = _qet_identity(obj) if name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX): return True + if group_kind == "Device": + return True return bool(instance_id or element_uuid) diff --git a/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py index 88bed6e..8b922a2 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -93,6 +93,56 @@ def _payload_device_instance_by_element(payload): return result +def _device_embedded_terminal_entries(payload, existing_keys): + devices = payload.get("devices", []) or [] + if not isinstance(devices, list): + return [] + + seen = set(existing_keys or set()) + entries = [] + for device in devices: + if not isinstance(device, dict): + continue + + device_element_uuid = (device.get("element_uuid") or "").strip() + device_instance_id = (device.get("instance_id") or "").strip() + device_terminals = device.get("terminals", []) or [] + if not isinstance(device_terminals, list): + continue + + for terminal in device_terminals: + if not isinstance(terminal, dict): + continue + terminal_uuid = (terminal.get("terminal_uuid") or "").strip() + element_uuid = (terminal.get("element_uuid") or "").strip() or device_element_uuid + instance_id = (terminal.get("instance_id") or "").strip() or device_instance_id + if not terminal_uuid or not (element_uuid or instance_id): + continue + + # QET 的正式端子可能直接挂在 devices[].terminals[] 下。 + # 直接调用本模块时也要读取它,避免正式布线匹配退回 local:* 端子。 + key = (element_uuid, terminal_uuid) + if key in seen: + continue + seen.add(key) + terminal_display = ( + terminal.get("terminal_display") + or terminal.get("terminal_label") + or terminal.get("slot_name") + or "" + ) + entries.append( + { + "terminal_uuid": terminal_uuid, + "element_uuid": element_uuid, + "instance_id": instance_id, + "terminal_display": terminal_display, + "slot_name_hint": terminal_display, + } + ) + return entries + + def _wire_endpoint_terminal_entries(payload, existing_keys): wires = payload.get("wires", []) or [] if not isinstance(wires, list): @@ -382,6 +432,13 @@ def import_terminals_from_payload(payload, scene_path=""): terminal_uuid = (item.get("terminal_uuid") or "").strip() if element_uuid and terminal_uuid: terminal_entry_keys.add((element_uuid, terminal_uuid)) + embedded_entries = _device_embedded_terminal_entries(payload, terminal_entry_keys) + terminal_entries.extend(embedded_entries) + terminal_entry_keys.update( + (entry["element_uuid"], entry["terminal_uuid"]) + for entry in embedded_entries + if entry.get("element_uuid") and entry.get("terminal_uuid") + ) synthesized_entries = _wire_endpoint_terminal_entries(payload, terminal_entry_keys) terminal_entries.extend(synthesized_entries) @@ -398,6 +455,7 @@ def import_terminals_from_payload(payload, scene_path=""): "reused_template_hints": 0, "matched_by_slot_hint": 0, "generated_fallback_slots": 0, + "device_embedded_terminals": len(embedded_entries), "synthesized_wire_endpoint_terminals": len(synthesized_entries), "skipped_missing_slot": 0, "skipped_missing_device": 0, diff --git a/tests/python/freecad_exchange_batch_assembly_test.py b/tests/python/freecad_exchange_batch_assembly_test.py index 123ab60..46ddfaa 100644 --- a/tests/python/freecad_exchange_batch_assembly_test.py +++ b/tests/python/freecad_exchange_batch_assembly_test.py @@ -181,8 +181,8 @@ class BatchAssemblyTest(unittest.TestCase): ud2 = self._qet_device(doc, terminal_objects, "UD:2", instance_id="ud-2", element_uuid="element-ud-2") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") - self._terminal(doc, terminal_objects, ud2, "terminal-ud-2", "UD:2") - self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") + self._terminal(doc, terminal_objects, ud2, "element-ud-2:terminal-template-1", "UD:2") + self._terminal(doc, terminal_objects, ud1, "element-ud-1:terminal-template-1", "UD:1") report = batch_assembly.layout_existing_terminal_block( doc, @@ -197,7 +197,10 @@ class BatchAssemblyTest(unittest.TestCase): self.assertEqual(0, report["created_devices"]) self.assertEqual(["UD:1", "UD:2"], [device.Label for device in report["devices"]]) self.assertEqual([110.0, 115.2], [device.Placement.Base.x for device in report["devices"]]) - self.assertEqual(["terminal-ud-1", "terminal-ud-2"], [terminal.QetTerminalUuid for terminal in report["terminals"]]) + self.assertEqual( + ["element-ud-1:terminal-template-1", "element-ud-2:terminal-template-1"], + [terminal.QetTerminalUuid for terminal in report["terminals"]], + ) self.assertFalse(any(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"])) self.assertEqual("layout_existing", ud1.QetBatchAssemblyMode) self.assertEqual("rail", ud1.QetMountHostKind) @@ -255,6 +258,8 @@ class BatchAssemblyTest(unittest.TestCase): qf2 = self._qet_device(doc, terminal_objects, "QF2", instance_id="qf-2", element_uuid="element-qf-2") qf1 = self._qet_device(doc, terminal_objects, "QF1", instance_id="qf-1", element_uuid="element-qf-1") + qf2_terminal = self._terminal(doc, terminal_objects, qf2, "terminal-qf-2-1", "QF2:1") + qf1_terminal = self._terminal(doc, terminal_objects, qf1, "terminal-qf-1-1", "QF1:1") ta1 = self._qet_device(doc, terminal_objects, "TA1", instance_id="ta-1", element_uuid="element-ta-1") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") @@ -281,6 +286,8 @@ class BatchAssemblyTest(unittest.TestCase): self.assertNotIn(ta1, report["devices"]) self.assertNotIn(ud1, report["devices"]) self.assertNotIn(qf0, report["devices"]) + self.assertNotIn(qf1_terminal, report["devices"]) + self.assertNotIn(qf2_terminal, report["devices"]) self.assertEqual([5.0, 23.0], [device.Placement.Base.x for device in report["devices"]]) self.assertEqual("layout_existing", qf1.QetBatchAssemblyMode) diff --git a/tests/python/freecad_exchange_terminal_import_template_slots_test.py b/tests/python/freecad_exchange_terminal_import_template_slots_test.py index d6e09b1..0b7438e 100644 --- a/tests/python/freecad_exchange_terminal_import_template_slots_test.py +++ b/tests/python/freecad_exchange_terminal_import_template_slots_test.py @@ -350,6 +350,87 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual("terminal-b", end_terminals[0].QetTerminalUuid) self.assertEqual("device-b", end_terminals[0].QetElementUuid) + def test_import_reads_qet_terminals_embedded_in_devices(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "element_uuid": "device-a", + "instance_id": "instance-a", + "terminals": [ + { + "element_uuid": "device-a", + "instance_id": "instance-a", + "terminal_uuid": "device-a:terminal-p1", + "terminal_display": "P1", + } + ], + } + ], + "terminals": [], + "wires": [ + { + "wire_id": "wire-1", + "start_element_uuid": "device-a", + "start_terminal_uuid": "device-a:terminal-p1", + "start_instance_id": "instance-a", + "start_terminal_display": "P1", + "end_element_uuid": "device-a", + "end_terminal_uuid": "device-a:terminal-p1", + "end_instance_id": "instance-a", + "end_terminal_display": "P1", + } + ], + } + ) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["imported_terminals"]) + self.assertEqual(1, report["device_embedded_terminals"]) + self.assertEqual(0, report["synthesized_wire_endpoint_terminals"]) + self.assertEqual(1, len(terminals)) + self.assertEqual("device-a:terminal-p1", terminals[0].QetTerminalUuid) + self.assertEqual("device-a", terminals[0].QetElementUuid) + self.assertEqual("instance-a", terminals[0].QetInstanceId) + self.assertEqual("P1", terminals[0].Label) + self.assertFalse(terminals[0].QetTerminalUuid.startswith("local:")) + def test_import_prefers_terminal_element_uuid_over_conflicting_instance_id(self): _install_fake_freecad() terminal_import, terminal_objects, device_import = _reload_modules() diff --git a/tests/python/freecad_exchange_wiring_import_test.py b/tests/python/freecad_exchange_wiring_import_test.py index d63ac4c..3b1980c 100644 --- a/tests/python/freecad_exchange_wiring_import_test.py +++ b/tests/python/freecad_exchange_wiring_import_test.py @@ -124,11 +124,11 @@ class WiringImportTest(unittest.TestCase): "group_uuid": "group-1", "start_element_uuid": "device-a", "start_instance_id": "instance-a", - "start_terminal_uuid": "terminal-a", + "start_terminal_uuid": "device-a:terminal-a", "start_terminal_display": "A1", "end_element_uuid": "device-b", "end_instance_id": "instance-b", - "end_terminal_uuid": "terminal-b", + "end_terminal_uuid": "device-b:terminal-b", "end_terminal_display": "B1", } ], @@ -164,9 +164,9 @@ class WiringImportTest(unittest.TestCase): "wire_mark": "W001", "wire_mark_is_manual": True, "start_element_uuid": "device-a", - "start_terminal_uuid": "terminal-a", + "start_terminal_uuid": "device-a:terminal-a", "end_element_uuid": "device-b", - "end_terminal_uuid": "terminal-b", + "end_terminal_uuid": "device-b:terminal-b", "start_terminal_display": "A1", "end_terminal_display": "B1", "conductor_uuids": ["conductor-1"], @@ -187,8 +187,8 @@ class WiringImportTest(unittest.TestCase): self.assertEqual("group-1", task.QetGroupUuid) self.assertEqual("W001", task.QetWireMark) self.assertTrue(task.QetWireMarkIsManual) - self.assertEqual("terminal-a", task.QetStartTerminalUuid) - self.assertEqual("terminal-b", task.QetEndTerminalUuid) + self.assertEqual("device-a:terminal-a", task.QetStartTerminalUuid) + self.assertEqual("device-b:terminal-b", task.QetEndTerminalUuid) self.assertEqual("device-a", task.QetStartElementUuid) self.assertEqual("device-b", task.QetEndElementUuid) self.assertEqual("A1", task.QetStartTerminalDisplay)