fix(freecad): 完善QET端子批量装配绑定

dev
Zhaowenlong 3 weeks ago
parent ec0f105c92
commit cedd38df40

@ -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`,设备位置保持。

@ -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)

@ -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,

@ -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)

@ -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()

@ -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)

Loading…
Cancel
Save