jiangyini 7 days ago
commit 646209ba15

@ -101,13 +101,14 @@
```json ```json
{ {
"schema_version": "1.0", "schema_version": "1.1",
"project_uuid": "string", "project_uuid": "string",
"generated_at": "2026-05-18T10:30:00+08:00", "generated_at": "2026-05-18T10:30:00+08:00",
"source": { "source": {
"app": "QET", "app": "QET",
"version": "string" "version": "string"
}, },
"cabinet": {},
"devices": [], "devices": [],
"terminals": [], "terminals": [],
"device_models": [] "device_models": []
@ -120,21 +121,71 @@
- `project_uuid`:项目主键 - `project_uuid`:项目主键
- `generated_at`:导出时间 - `generated_at`:导出时间
- `source`:导出来源信息 - `source`:导出来源信息
- `cabinet`:当前图纸属性中绑定的机柜信息
- `devices`:设备实例绑定 - `devices`:设备实例绑定
- `terminals`:端子实例绑定 - `terminals`:端子实例绑定
- `device_models`:设备 3D 模型解析结果 - `device_models`:设备 3D 模型解析结果
--- ---
## 5. `devices` 结构 ## 5. `cabinet` 结构
### 5.1 作用 ### 5.1 作用
当前版本仍然以 **图纸** 作为交换单位。
但图纸属性本身已经绑定了一个机柜,因此 `2d_to_3d.json` 顶层会额外带上:
> 当前图纸绑定的机柜信息
这样 FreeCAD 在保持“按图纸导入设备/端子”的同时,也能知道:
- 这批设备属于哪个机柜
- 这个机柜当前绑定了哪个 3D 相对路径
### 5.2 推荐字段
```json
{
"location_id": 12,
"label": "C",
"name": "电容柜",
"display_text": "C - 电容柜",
"associated_fileset": "电容柜文件集",
"three_d_relative_path": "3D/Cabinets/C.FCStd",
"resolved_scene_path": "C:/Users/Admin/Documents/MingTuProject/xxx/3D/Cabinets/C.FCStd"
}
```
### 5.3 字段说明
| 字段 | 中文 | 必需 | 说明 |
| --- | --- | --- | --- |
| `location_id` | 机柜位置ID | 否 | 当前图纸属性绑定的机柜位置 ID |
| `label` | 机柜标注 | 否 | 用于 3D 侧快速识别机柜 |
| `name` | 机柜名称 | 否 | 机柜名称 |
| `display_text` | 机柜显示文本 | 否 | 等价于 QET 图纸属性界面中看到的机柜显示文本 |
| `associated_fileset` | 关联文件集 | 否 | 当前机柜位置绑定的文件集信息 |
| `three_d_relative_path` | 3D 相对路径 | 否 | 相对于工程目录保存的 3D 机柜路径 |
| `resolved_scene_path` | 已解析机柜场景路径 | 否 | QET 已解析出的本地场景文件路径,便于 FreeCAD 直接使用 |
### 5.4 说明
- `cabinet` 是一个 **图纸级上下文对象**
- 它不是设备绑定表或端子绑定表的扩张
- 它只描述“当前图纸绑定了哪个机柜,以及机柜当前对应哪个 3D 文件”
---
## 6. `devices` 结构
### 6.1 作用
`devices` 负责表达: `devices` 负责表达:
> 一个 2D 设备实例,对应哪个 3D 设备实例。 > 一个 2D 设备实例,对应哪个 3D 设备实例。
### 5.2 第一版字段 ### 6.2 第一版字段
```json ```json
{ {
@ -144,7 +195,7 @@
} }
``` ```
### 5.3 字段说明 ### 6.3 字段说明
| 字段 | 中文 | 必需 | 说明 | | 字段 | 中文 | 必需 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -152,7 +203,7 @@
| `instance_id` | 3D实例ID | 是 | FreeCAD 侧设备实例主键 | | `instance_id` | 3D实例ID | 是 | FreeCAD 侧设备实例主键 |
| `display_tag` | 2D设备实例标注 | 否 | JSON 显示辅助字段,优先使用 2D 中设备标注作为 FreeCAD 树标签;为空时再退回 `instance_id` / `element_uuid` | | `display_tag` | 2D设备实例标注 | 否 | JSON 显示辅助字段,优先使用 2D 中设备标注作为 FreeCAD 树标签;为空时再退回 `instance_id` / `element_uuid` |
### 5.4 说明 ### 6.4 说明
- 如果第一次进入 3D 时还没有 `instance_id`,允许先导出空字符串或缺省值 - 如果第一次进入 3D 时还没有 `instance_id`,允许先导出空字符串或缺省值
- FreeCAD 创建 3D 实例后,再在回写阶段补齐 - FreeCAD 创建 3D 实例后,再在回写阶段补齐
@ -160,15 +211,15 @@
--- ---
## 6. `terminals` 结构 ## 7. `terminals` 结构
### 6.1 作用 ### 7.1 作用
`terminals` 负责表达: `terminals` 负责表达:
> 一个 2D 端子实例,属于哪个 3D 设备实例。 > 一个 2D 端子实例,属于哪个 3D 设备实例。
### 6.2 第一版字段 ### 7.2 第一版字段
```json ```json
{ {
@ -178,7 +229,7 @@
} }
``` ```
### 6.3 字段说明 ### 7.3 字段说明
| 字段 | 中文 | 必需 | 说明 | | 字段 | 中文 | 必需 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -186,7 +237,7 @@
| `instance_id` | 3D实例ID | 是 | 该端子所属的 3D 设备实例 | | `instance_id` | 3D实例ID | 是 | 该端子所属的 3D 设备实例 |
| `element_uuid` | 2D设备实例UUID | 否 | JSON 导入辅助字段,帮助 FreeCAD 在首次没有 `instance_id` 时仍能知道端子属于哪个设备 | | `element_uuid` | 2D设备实例UUID | 否 | JSON 导入辅助字段,帮助 FreeCAD 在首次没有 `instance_id` 时仍能知道端子属于哪个设备 |
### 6.4 为什么这里允许带 `element_uuid` ### 7.4 为什么这里允许带 `element_uuid`
注意: 注意:
@ -200,7 +251,7 @@
所以这里允许 JSON 比数据库稍微丰富一些。 所以这里允许 JSON 比数据库稍微丰富一些。
### 6.5 为什么第一版不带更多字段 ### 7.5 为什么第一版不带更多字段
第一版先不强制包含: 第一版先不强制包含:
@ -218,15 +269,15 @@
--- ---
## 7. `device_models` 结构 ## 8. `device_models` 结构
### 7.1 作用 ### 8.1 作用
`device_models` 不是绑定表本身,而是: `device_models` 不是绑定表本身,而是:
> QET 在导出时,顺手把设备对应 3D 模型资源解析出来,减少 FreeCAD 再回查 QET 内部数据库的复杂度。 > QET 在导出时,顺手把设备对应 3D 模型资源解析出来,减少 FreeCAD 再回查 QET 内部数据库的复杂度。
### 7.2 第二步设备导入推荐字段 ### 8.2 第二步设备导入推荐字段
```json ```json
{ {
@ -237,7 +288,7 @@
} }
``` ```
### 7.3 字段说明 ### 8.3 字段说明
| 字段 | 中文 | 必需 | 说明 | | 字段 | 中文 | 必需 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -246,7 +297,7 @@
| `parts_3d` | 3D模型资源URI | 否 | 原始资源引用,来自 `device_3d_asset.uri``device_attribute.parts_3d` | | `parts_3d` | 3D模型资源URI | 否 | 原始资源引用,来自 `device_3d_asset.uri``device_attribute.parts_3d` |
| `resolved_model_path` | 已解析模型路径 | 是 | QET 已经解析好的本地模型文件路径FreeCAD 第二步直接用它导入 STEP / FCStd | | `resolved_model_path` | 已解析模型路径 | 是 | QET 已经解析好的本地模型文件路径FreeCAD 第二步直接用它导入 STEP / FCStd |
### 7.3 为什么 `resolved_model_path` 是第二步关键字段 ### 8.4 为什么 `resolved_model_path` 是第二步关键字段
第二步开始FreeCAD 不再只做 JSON 校验,而要真正导入设备模型。 第二步开始FreeCAD 不再只做 JSON 校验,而要真正导入设备模型。

@ -4,6 +4,7 @@ set(FreeCADExchange_Scripts
InitGui.py InitGui.py
ExchangeBootstrap.py ExchangeBootstrap.py
DeviceImport.py DeviceImport.py
DevicePreview.py
) )
add_custom_target(FreeCADExchangeScripts ALL add_custom_target(FreeCADExchangeScripts ALL

@ -4,10 +4,12 @@ from pathlib import Path
import FreeCAD as App import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import ImportGui import ImportGui
import DevicePreview
ROOT_GROUP_NAME = "QETExchangeDevices" ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices" ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
DEVICE_GROUP_PREFIX = "QETDevice_" DEVICE_GROUP_PREFIX = "QETDevice_"
@ -61,29 +63,130 @@ def _new_objects_since(doc, before_names):
return [obj for obj in doc.Objects if obj.Name not in before_names] return [obj for obj in doc.Objects if obj.Name not in before_names]
def _top_level_imported_objects(imported_objects):
imported_by_name = {obj.Name: obj for obj in imported_objects}
child_names = set()
for obj in imported_objects:
for parent in list(getattr(obj, "InList", []) or []):
if getattr(parent, "Name", None) in imported_by_name:
child_names.add(obj.Name)
for child in list(getattr(obj, "Group", []) or []):
if getattr(child, "Name", None) in imported_by_name:
child_names.add(child.Name)
return [obj for obj in imported_objects if obj.Name not in child_names]
def _ensure_string_property(obj, prop_name, group_name, description, value): def _ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []): if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description) obj.addProperty("App::PropertyString", prop_name, group_name, description)
setattr(obj, prop_name, value or "") setattr(obj, prop_name, value or "")
def _ensure_bool_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyBool", prop_name, group_name, description)
setattr(obj, prop_name, bool(value))
def _ensure_document(scene_path): def _ensure_document(scene_path):
if App.ActiveDocument: preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene"
return App.ActiveDocument existing_doc = DevicePreview.find_main_exchange_document(preferred_name)
if existing_doc is not None:
return existing_doc
return App.newDocument(preferred_name)
doc_name = Path(scene_path).stem if scene_path else "QETScene" def _cabinet_label_text(cabinet):
doc_name = _safe_token(doc_name)[:48] or "QETScene" if not isinstance(cabinet, dict):
return App.newDocument(doc_name) return "QET Cabinet"
label = (cabinet.get("display_text") or "").strip()
if label:
return label
def _ensure_root_group(doc): label = (cabinet.get("label") or "").strip()
if label:
return label
label = (cabinet.get("name") or "").strip()
if label:
return label
return "QET Cabinet"
def _ensure_root_group(doc, cabinet=None):
root = doc.getObject(ROOT_GROUP_NAME) root = doc.getObject(ROOT_GROUP_NAME)
if root is None: if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME) root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
root.Label = ROOT_GROUP_LABEL if isinstance(cabinet, dict):
root.Label = _cabinet_label_text(cabinet)
else:
root.Label = ROOT_GROUP_LABEL
_ensure_string_property(
root,
"QetCabinetLabel",
"QET Exchange",
"Cabinet label from QET exchange",
cabinet.get("label", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetName",
"QET Exchange",
"Cabinet name from QET exchange",
cabinet.get("name", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetDisplayText",
"QET Exchange",
"Cabinet display text from QET exchange",
cabinet.get("display_text", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetFileSet",
"QET Exchange",
"Associated fileset from QET exchange",
cabinet.get("associated_fileset", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetRelativePath",
"QET Exchange",
"Relative 3D cabinet path from QET exchange",
cabinet.get("three_d_relative_path", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
cabinet.get("resolved_scene_path", "") if isinstance(cabinet, dict) else "",
)
_ensure_string_property(
root,
"QetCabinetLocationId",
"QET Exchange",
"Cabinet location id from QET exchange",
str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "",
)
return root return root
def _ensure_cabinet_model_group(doc, root_group):
group = doc.getObject(CABINET_MODEL_GROUP_NAME)
if group is None:
group = doc.addObject("App::DocumentObjectGroup", CABINET_MODEL_GROUP_NAME)
group.Label = "3D机柜"
if group not in getattr(root_group, "Group", []):
root_group.addObject(group)
return group
def _find_device_group(doc, element_uuid): def _find_device_group(doc, element_uuid):
target_uuid = (element_uuid or "").strip() target_uuid = (element_uuid or "").strip()
if not target_uuid: if not target_uuid:
@ -111,13 +214,19 @@ def _device_label_text(display_tag, instance_id, element_uuid):
return "QET Device" return "QET Device"
def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, display_tag): def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, display_tag, layout_index):
created_now = False
device_group = _find_device_group(doc, element_uuid) device_group = _find_device_group(doc, element_uuid)
if device_group is not None and getattr(device_group, "TypeId", "") != "App::Part":
_remove_object_tree(doc, device_group)
device_group = None
if device_group is None: if device_group is None:
device_group = doc.addObject( device_group = doc.addObject(
"App::DocumentObjectGroup", "App::Part",
DEVICE_GROUP_PREFIX + _safe_token(element_uuid), DEVICE_GROUP_PREFIX + _safe_token(element_uuid),
) )
created_now = True
if device_group not in getattr(root_group, "Group", []): if device_group not in getattr(root_group, "Group", []):
root_group.addObject(device_group) root_group.addObject(device_group)
@ -151,7 +260,16 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path,
"2D display tag from QET exchange", "2D display tag from QET exchange",
display_tag, display_tag,
) )
return device_group _ensure_bool_property(
device_group,
"QetAutoPlaced",
"QET Exchange",
"Whether the device has been placed by the QET auto layout.",
created_now,
)
if created_now:
device_group.Placement = App.Placement()
return device_group, created_now
def _remove_object_tree(doc, obj): def _remove_object_tree(doc, obj):
@ -184,20 +302,26 @@ def _supported_for_import(model_path):
} }
def _import_model_into_group(doc, device_group, model_path): def _import_model_into_group(doc, device_group, model_path, merge=False, use_link_group=True):
before_names = _existing_object_names(doc) before_names = _existing_object_names(doc)
try: try:
ImportGui.insert(name=model_path, docName=doc.Name, merge=False, useLinkGroup=True) ImportGui.insert(
name=model_path,
docName=doc.Name,
merge=bool(merge),
useLinkGroup=bool(use_link_group),
)
except Exception: except Exception:
for obj in _new_objects_since(doc, before_names): for obj in _new_objects_since(doc, before_names):
_remove_object_tree(doc, obj) _remove_object_tree(doc, obj)
raise raise
imported_objects = _new_objects_since(doc, before_names) imported_objects = _new_objects_since(doc, before_names)
for obj in imported_objects: top_level_objects = _top_level_imported_objects(imported_objects)
for obj in top_level_objects:
if obj not in getattr(device_group, "Group", []): if obj not in getattr(device_group, "Group", []):
device_group.addObject(obj) device_group.addObject(obj)
return imported_objects return top_level_objects
def _model_index(payload): def _model_index(payload):
@ -209,10 +333,71 @@ def _model_index(payload):
return index return index
def _import_cabinet_model(doc, root_group, cabinet, report):
if not isinstance(cabinet, dict):
return
resolved_scene_path = _native_path(cabinet.get("resolved_scene_path", ""))
_append_debug_log(
"DeviceImport cabinet resolved_scene_path={0}".format(resolved_scene_path)
)
if not resolved_scene_path:
report["cabinet_skipped_missing_model"] += 1
return
if not os.path.isfile(resolved_scene_path):
report["cabinet_skipped_missing_file"] += 1
report["warnings"].append(
"机柜 3D 文件不存在:{0}".format(resolved_scene_path)
)
return
if not _supported_for_import(resolved_scene_path):
report["cabinet_skipped_unsupported_format"] += 1
report["warnings"].append(
"机柜 3D 文件格式暂不支持:{0}".format(resolved_scene_path)
)
return
cabinet_group = _ensure_cabinet_model_group(doc, root_group)
_clear_group_contents(doc, cabinet_group)
_ensure_string_property(
cabinet_group,
"QetCabinetResolvedScenePath",
"QET Exchange",
"Resolved local cabinet scene path from QET exchange",
resolved_scene_path,
)
try:
_append_debug_log(
"DeviceImport importing cabinet model: {0}".format(
resolved_scene_path
)
)
_import_model_into_group(
doc,
cabinet_group,
resolved_scene_path,
merge=False,
use_link_group=True,
)
report["cabinet_imported"] += 1
_append_debug_log("DeviceImport cabinet import succeeded")
except Exception as exc:
report["cabinet_skipped_import_error"] += 1
report["warnings"].append(
"机柜 3D 导入失败:{0}".format(exc)
)
_append_debug_log(
"DeviceImport cabinet import failed: {0}".format(exc)
)
def import_devices_from_payload(payload, scene_path=""): def import_devices_from_payload(payload, scene_path=""):
_append_debug_log("DeviceImport.import_devices_from_payload entered") _append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path) doc = _ensure_document(scene_path)
root_group = _ensure_root_group(doc) cabinet = payload.get("cabinet")
root_group = _ensure_root_group(doc, cabinet)
models_by_element = _model_index(payload) models_by_element = _model_index(payload)
report = { report = {
@ -226,10 +411,17 @@ def import_devices_from_payload(payload, scene_path=""):
"skipped_missing_file": 0, "skipped_missing_file": 0,
"skipped_unsupported_format": 0, "skipped_unsupported_format": 0,
"skipped_import_error": 0, "skipped_import_error": 0,
"cabinet_imported": 0,
"cabinet_skipped_missing_model": 0,
"cabinet_skipped_missing_file": 0,
"cabinet_skipped_unsupported_format": 0,
"cabinet_skipped_import_error": 0,
"warnings": [], "warnings": [],
} }
for device in payload.get("devices", []): _import_cabinet_model(doc, root_group, cabinet, report)
for index, device in enumerate(payload.get("devices", [])):
report["total_devices"] += 1 report["total_devices"] += 1
element_uuid = device.get("element_uuid", "").strip() element_uuid = device.get("element_uuid", "").strip()
@ -265,8 +457,14 @@ def import_devices_from_payload(payload, scene_path=""):
continue continue
existing_group = _find_device_group(doc, element_uuid) existing_group = _find_device_group(doc, element_uuid)
device_group = _ensure_device_group( device_group, created_now = _ensure_device_group(
doc, root_group, element_uuid, instance_id, resolved_model_path, display_tag doc,
root_group,
element_uuid,
instance_id,
resolved_model_path,
display_tag,
index,
) )
_clear_group_contents(doc, device_group) _clear_group_contents(doc, device_group)
@ -292,7 +490,7 @@ def import_devices_from_payload(payload, scene_path=""):
) )
continue continue
if existing_group is None: if created_now or existing_group is None:
report["imported_devices"] += 1 report["imported_devices"] += 1
else: else:
report["updated_devices"] += 1 report["updated_devices"] += 1
@ -307,7 +505,8 @@ def import_devices_from_payload(payload, scene_path=""):
pass pass
_append_debug_log( _append_debug_log(
"DeviceImport finished: imported={0}, updated={1}, skipped_missing_model={2}, skipped_missing_file={3}, skipped_import_error={4}".format( "DeviceImport finished: cabinet_imported={0}, imported={1}, updated={2}, skipped_missing_model={3}, skipped_missing_file={4}, skipped_import_error={5}".format(
report["cabinet_imported"],
report["imported_devices"], report["imported_devices"],
report["updated_devices"], report["updated_devices"],
report["skipped_missing_model"], report["skipped_missing_model"],

@ -0,0 +1,241 @@
import os
from pathlib import Path
import FreeCAD as App
import FreeCADGui as Gui
DEVICE_GROUP_PREFIX = "QETDevice_"
PREVIEW_DOC_PREFIX = "QETPreview_"
ROOT_GROUP_NAME = "QETExchangeDevices"
def _debug_log_path():
local_app_data = os.environ.get("LOCALAPPDATA", "").strip()
if local_app_data:
return os.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log")
return os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log")
def _append_debug_log(message):
try:
log_path = _debug_log_path()
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as handle:
handle.write(message + "\n")
except Exception:
pass
def _safe_token(value):
text = (value or "").strip()
if not text:
return "unknown"
chars = []
for ch in text:
if ch.isalnum():
chars.append(ch)
else:
chars.append("_")
return "".join(chars)
def is_preview_document_name(doc_name):
return str(doc_name or "").startswith(PREVIEW_DOC_PREFIX)
def _get_document_if_exists(doc_name):
try:
return App.getDocument(doc_name)
except Exception:
return None
def find_main_exchange_document(preferred_name=""):
preferred_name = (preferred_name or "").strip()
if preferred_name:
preferred_doc = _get_document_if_exists(preferred_name)
if preferred_doc is not None and not is_preview_document_name(preferred_doc.Name):
return preferred_doc
active_doc = App.ActiveDocument
if (
active_doc is not None
and not is_preview_document_name(active_doc.Name)
and active_doc.getObject(ROOT_GROUP_NAME) is not None
):
return active_doc
for candidate in App.listDocuments().values():
if (
candidate is not None
and not is_preview_document_name(candidate.Name)
and candidate.getObject(ROOT_GROUP_NAME) is not None
):
return candidate
if active_doc is not None and not is_preview_document_name(active_doc.Name):
return active_doc
return None
def is_qet_device_object(obj):
if obj is None:
return False
if getattr(obj, "TypeId", "") != "App::Part":
return False
if not getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return False
return "QetElementUuid" in getattr(obj, "PropertiesList", [])
def find_parent_qet_device_object(obj):
"""
从当前选中对象向上查找所属的 QETDevice_xxx 设备组
作用
1. 双击 QETDevice_xxx 本身可以预览
2. 双击 QETDevice_xxx 下面的子实体也可以自动找到父设备并预览
"""
if obj is None:
_append_debug_log("DevicePreview resolve parent: selected object is None")
return None
if is_qet_device_object(obj):
_append_debug_log(
"DevicePreview resolve parent: selected object is direct device {0}".format(
getattr(obj, "Name", "")
)
)
return obj
visited = set()
stack = list(getattr(obj, "InList", []) or [])
while stack:
parent = stack.pop()
if parent is None:
continue
parent_name = getattr(parent, "Name", "")
if parent_name in visited:
continue
visited.add(parent_name)
if is_qet_device_object(parent):
_append_debug_log(
"DevicePreview resolve parent: found parent device {0} from child {1}".format(
getattr(parent, "Name", ""),
getattr(obj, "Name", ""),
)
)
return parent
stack.extend(list(getattr(parent, "InList", []) or []))
_append_debug_log(
"DevicePreview resolve parent: no device parent found for {0}".format(
getattr(obj, "Name", "")
)
)
return None
def _preview_document_name(device_group):
display_tag = getattr(device_group, "QetDisplayTag", "")
element_uuid = getattr(device_group, "QetElementUuid", "")
display_token = _safe_token(display_tag)[:24]
uuid_token = _safe_token(element_uuid)[:8] or _safe_token(device_group.Name)[-8:]
if display_token:
return "{0}{1}_{2}".format(PREVIEW_DOC_PREFIX, display_token, uuid_token)
return "{0}{1}".format(PREVIEW_DOC_PREFIX, uuid_token)
def _clear_document_objects(doc):
while getattr(doc, "Objects", []):
doc.removeObject(doc.Objects[-1].Name)
def _activate_document(doc):
App.setActiveDocument(doc.Name)
App.ActiveDocument = doc
try:
Gui.ActiveDocument = Gui.getDocument(doc.Name)
except Exception:
pass
def _fit_active_view():
try:
active_view = Gui.activeDocument().activeView()
active_view.viewIsometric()
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
def _set_visibility_recursive(obj, visible):
if obj is None:
return
try:
view_object = getattr(obj, "ViewObject", None)
if view_object is not None and hasattr(view_object, "Visibility"):
view_object.Visibility = bool(visible)
except Exception:
pass
for child in list(getattr(obj, "Group", []) or []):
_set_visibility_recursive(child, visible)
def _open_device_preview(device_group):
doc_name = _preview_document_name(device_group)
_append_debug_log(
"DevicePreview opening preview doc={0} for device={1}".format(
doc_name,
getattr(device_group, "Name", ""),
)
)
preview_doc = _get_document_if_exists(doc_name)
if preview_doc is None:
preview_doc = App.newDocument(doc_name)
_append_debug_log("DevicePreview created new preview document")
else:
_append_debug_log("DevicePreview reusing existing preview document")
_clear_document_objects(preview_doc)
copied_device = preview_doc.copyObject(device_group, True)
copied_device.Label = device_group.Label
if hasattr(copied_device, "Placement"):
copied_device.Placement = App.Placement()
_set_visibility_recursive(copied_device, True)
preview_doc.recompute()
_activate_document(preview_doc)
_fit_active_view()
_append_debug_log(
"DevicePreview preview ready: doc={0}, copied_name={1}, copied_label={2}".format(
preview_doc.Name,
getattr(copied_device, "Name", ""),
getattr(copied_device, "Label", ""),
)
)
return preview_doc
def open_preview_for_device_object(obj):
if not is_qet_device_object(obj):
return None
if is_preview_document_name(getattr(getattr(obj, "Document", None), "Name", "")):
return None
_append_debug_log(
"DevicePreview open requested element_uuid={0}, label={1}".format(
getattr(obj, "QetElementUuid", ""),
getattr(obj, "Label", ""),
)
)
return _open_device_preview(obj)

@ -6,6 +6,7 @@ from pathlib import Path
import FreeCAD as App import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import DeviceImport import DeviceImport
import DevicePreview
try: try:
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
@ -24,6 +25,10 @@ STATE_PAYLOAD = "_qet_exchange_payload"
STATE_SUMMARY = "_qet_exchange_summary" STATE_SUMMARY = "_qet_exchange_summary"
STATE_IMPORT_REPORT = "_qet_exchange_import_report" STATE_IMPORT_REPORT = "_qet_exchange_import_report"
STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled" STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled"
STATE_TREE_FILTER = "_qet_exchange_tree_filter"
STATE_TREE_SIGNAL_CONNECTIONS = "_qet_exchange_tree_signal_connections"
TREE_FILTER_MARKER = "_qet_exchange_tree_filter_installed"
TREE_SIGNAL_MARKER = "_qet_exchange_tree_signal_installed"
IMPORT_READY_DELAY_MS = 1500 IMPORT_READY_DELAY_MS = 1500
IMPORT_READY_RETRY_DELAY_MS = 1000 IMPORT_READY_RETRY_DELAY_MS = 1000
IMPORT_READY_MAX_RETRIES = 10 IMPORT_READY_MAX_RETRIES = 10
@ -50,6 +55,16 @@ def _append_debug_log(message):
pass pass
def _reset_debug_log():
try:
log_path = _debug_log_path()
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "w", encoding="utf-8") as handle:
handle.write("[QET Exchange] new debug session\n")
except Exception:
pass
def _get_main_window(): def _get_main_window():
try: try:
return Gui.getMainWindow() return Gui.getMainWindow()
@ -65,6 +80,185 @@ def _show_error(title, message):
QtWidgets.QMessageBox.critical(_get_main_window(), title, message) QtWidgets.QMessageBox.critical(_get_main_window(), title, message)
def _qt_class_name(widget):
try:
return widget.metaObject().className()
except Exception:
return ""
def _has_tree_widget_parent(widget):
current = widget
while current is not None:
class_name = _qt_class_name(current)
if "TreeWidget" in class_name or class_name.endswith("QTreeWidget") or class_name.endswith("QTreeView"):
return True
current = current.parent()
return False
class _DeviceTreeDoubleClickFilter(QtCore.QObject):
def eventFilter(self, watched, event):
try:
if event.type() != QtCore.QEvent.MouseButtonDblClick:
return False
_append_debug_log(
"tree double click captured: watched_class={0}".format(
_qt_class_name(watched)
)
)
QtCore.QTimer.singleShot(0, _open_selected_device_preview_if_needed)
except Exception as exc:
_append_debug_log(
"tree double click filter failed: {0}".format(exc)
)
return False
class _DeviceTreeSignalBridge(QtCore.QObject):
def on_item_double_clicked(self, *args):
_append_debug_log("tree double click signal received: itemDoubleClicked")
QtCore.QTimer.singleShot(0, _open_selected_device_preview_if_needed)
def on_index_double_clicked(self, *args):
_append_debug_log("tree double click signal received: doubleClicked")
QtCore.QTimer.singleShot(0, _open_selected_device_preview_if_needed)
def _install_tree_double_click_filter():
main_window = _get_main_window()
if main_window is None:
return
if getattr(Gui, STATE_TREE_FILTER, None) is not None:
tree_filter = getattr(Gui, STATE_TREE_FILTER)
else:
tree_filter = _DeviceTreeDoubleClickFilter(main_window)
setattr(Gui, STATE_TREE_FILTER, tree_filter)
if getattr(Gui, STATE_TREE_SIGNAL_CONNECTIONS, None) is not None:
signal_bridge = getattr(Gui, STATE_TREE_SIGNAL_CONNECTIONS)
else:
signal_bridge = _DeviceTreeSignalBridge(main_window)
setattr(Gui, STATE_TREE_SIGNAL_CONNECTIONS, signal_bridge)
installed_count = 0
signal_count = 0
for widget in main_window.findChildren(QtWidgets.QWidget):
class_name = _qt_class_name(widget)
if "TreeWidget" not in class_name and not class_name.endswith("QTreeWidget") and not class_name.endswith("QTreeView"):
continue
targets = [widget]
viewport = getattr(widget, "viewport", lambda: None)()
if viewport is not None:
targets.append(viewport)
for target in targets:
if target is None:
continue
if bool(target.property(TREE_FILTER_MARKER)):
continue
target.installEventFilter(tree_filter)
target.setProperty(TREE_FILTER_MARKER, True)
installed_count += 1
_append_debug_log(
"tree double click filter attached: class={0}".format(
_qt_class_name(target)
)
)
if not bool(widget.property(TREE_SIGNAL_MARKER)):
connected = False
item_double_clicked = getattr(widget, "itemDoubleClicked", None)
if item_double_clicked is not None:
try:
item_double_clicked.connect(signal_bridge.on_item_double_clicked)
connected = True
signal_count += 1
_append_debug_log(
"tree double click signal connected: class={0}, signal=itemDoubleClicked".format(
class_name
)
)
except Exception as exc:
_append_debug_log(
"tree double click signal connect failed: class={0}, signal=itemDoubleClicked, error={1}".format(
class_name, exc
)
)
view_double_clicked = getattr(widget, "doubleClicked", None)
if view_double_clicked is not None:
try:
view_double_clicked.connect(signal_bridge.on_index_double_clicked)
connected = True
signal_count += 1
_append_debug_log(
"tree double click signal connected: class={0}, signal=doubleClicked".format(
class_name
)
)
except Exception as exc:
_append_debug_log(
"tree double click signal connect failed: class={0}, signal=doubleClicked, error={1}".format(
class_name, exc
)
)
if connected:
widget.setProperty(TREE_SIGNAL_MARKER, True)
_append_debug_log(
"tree double click filter install pass complete: attached={0}, signal_connections={1}".format(
installed_count,
signal_count,
)
)
def _open_selected_device_preview_if_needed():
try:
selection = Gui.Selection.getSelection()
_append_debug_log(
"tree double click selection count={0}".format(len(selection))
)
if len(selection) != 1:
return
obj = selection[0]
_append_debug_log(
"tree double click selected object: name={0}, label={1}, type={2}, doc={3}".format(
getattr(obj, "Name", ""),
getattr(obj, "Label", ""),
getattr(obj, "TypeId", ""),
getattr(getattr(obj, "Document", None), "Name", ""),
)
)
device_obj = DevicePreview.find_parent_qet_device_object(obj)
_append_debug_log(
"tree double click resolved device object: name={0}, label={1}, doc={2}".format(
getattr(device_obj, "Name", "") if device_obj else "",
getattr(device_obj, "Label", "") if device_obj else "",
getattr(getattr(device_obj, "Document", None), "Name", "") if device_obj else "",
)
)
if device_obj is None:
return
if DevicePreview.is_preview_document_name(
getattr(getattr(device_obj, "Document", None), "Name", "")
):
_append_debug_log("tree double click ignored inside preview document")
return
DevicePreview.open_preview_for_device_object(device_obj)
_append_debug_log("tree double click preview open requested")
except Exception as exc:
_append_debug_log(
"open selected device preview failed: {0}".format(exc)
)
def _is_gui_ready(): def _is_gui_ready():
main_window = _get_main_window() main_window = _get_main_window()
if main_window is None: if main_window is None:
@ -203,6 +397,43 @@ def _normalize_device_models(payload):
return normalized return normalized
def _normalize_cabinet(payload):
cabinet = payload.get("cabinet")
if cabinet is None:
return None
if not isinstance(cabinet, dict):
raise ExchangeValidationError("Field 'cabinet' must be an object when present.")
location_id = cabinet.get("location_id")
if location_id is not None and not isinstance(location_id, int):
raise ExchangeValidationError("Field 'location_id' in cabinet must be an integer.")
normalized = {
"location_id": location_id,
"label": "",
"name": "",
"display_text": "",
"associated_fileset": "",
"three_d_relative_path": "",
"resolved_scene_path": "",
}
for field_name in (
"label",
"name",
"display_text",
"associated_fileset",
"three_d_relative_path",
"resolved_scene_path",
):
value = cabinet.get(field_name, "")
if value and not isinstance(value, str):
raise ExchangeValidationError(
"Field '{0}' in cabinet must be a string.".format(field_name)
)
normalized[field_name] = value.strip() if isinstance(value, str) else ""
return normalized
def load_exchange_payload(json_path): def load_exchange_payload(json_path):
try: try:
raw_text = Path(json_path).read_text(encoding="utf-8") raw_text = Path(json_path).read_text(encoding="utf-8")
@ -231,6 +462,7 @@ def load_exchange_payload(json_path):
"project_uuid": project_uuid, "project_uuid": project_uuid,
"generated_at": payload.get("generated_at", ""), "generated_at": payload.get("generated_at", ""),
"source": payload.get("source", {}), "source": payload.get("source", {}),
"cabinet": _normalize_cabinet(payload),
"devices": _normalize_devices(payload), "devices": _normalize_devices(payload),
"terminals": _normalize_terminals(payload), "terminals": _normalize_terminals(payload),
"device_models": _normalize_device_models(payload), "device_models": _normalize_device_models(payload),
@ -242,6 +474,7 @@ def _build_summary(payload, json_path):
devices = payload["devices"] devices = payload["devices"]
terminals = payload["terminals"] terminals = payload["terminals"]
device_models = payload["device_models"] device_models = payload["device_models"]
cabinet = payload.get("cabinet")
missing_device_instances = sum(1 for item in devices if not item["instance_id"]) missing_device_instances = sum(1 for item in devices if not item["instance_id"])
missing_terminal_instances = sum( missing_terminal_instances = sum(
1 for item in terminals if not item["instance_id"] 1 for item in terminals if not item["instance_id"]
@ -259,6 +492,7 @@ def _build_summary(payload, json_path):
"device_models_with_parts": with_model_paths, "device_models_with_parts": with_model_paths,
"missing_device_instances": missing_device_instances, "missing_device_instances": missing_device_instances,
"missing_terminal_instances": missing_terminal_instances, "missing_terminal_instances": missing_terminal_instances,
"cabinet": cabinet,
"scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(), "scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(),
} }
@ -275,6 +509,26 @@ def _summary_message(summary, import_report=None):
"Resolved model paths: {0}".format(summary["device_models_with_parts"]), "Resolved model paths: {0}".format(summary["device_models_with_parts"]),
] ]
cabinet = summary.get("cabinet")
if isinstance(cabinet, dict):
cabinet_name = cabinet.get("display_text") or cabinet.get("label") or cabinet.get("name")
if cabinet_name:
lines.append("Cabinet: {0}".format(cabinet_name))
if cabinet.get("associated_fileset"):
lines.append("Cabinet fileset: {0}".format(cabinet["associated_fileset"]))
if cabinet.get("three_d_relative_path"):
lines.append(
"Cabinet 3D relative path: {0}".format(
cabinet["three_d_relative_path"]
)
)
if cabinet.get("resolved_scene_path"):
lines.append(
"Cabinet resolved scene path: {0}".format(
cabinet["resolved_scene_path"]
)
)
if summary["missing_device_instances"]: if summary["missing_device_instances"]:
lines.append( lines.append(
"Devices without instance_id yet: {0}".format( "Devices without instance_id yet: {0}".format(
@ -296,6 +550,7 @@ def _summary_message(summary, import_report=None):
"", "",
"3D device import summary:", "3D device import summary:",
"Target document: {0}".format(import_report["document_name"]), "Target document: {0}".format(import_report["document_name"]),
"Imported cabinet models: {0}".format(import_report["cabinet_imported"]),
"Imported devices: {0}".format(import_report["imported_devices"]), "Imported devices: {0}".format(import_report["imported_devices"]),
"Updated devices: {0}".format(import_report["updated_devices"]), "Updated devices: {0}".format(import_report["updated_devices"]),
] ]
@ -431,7 +686,10 @@ def _run_scheduled_device_import(attempt=0):
def bootstrap_if_requested(): def bootstrap_if_requested():
if not getattr(App, STATE_FLAG, False):
_reset_debug_log()
_append_debug_log("bootstrap_if_requested entered") _append_debug_log("bootstrap_if_requested entered")
_install_tree_double_click_filter()
if getattr(App, STATE_FLAG, False): if getattr(App, STATE_FLAG, False):
_append_debug_log("bootstrap_if_requested skipped: already bootstrapped") _append_debug_log("bootstrap_if_requested skipped: already bootstrapped")
return return
@ -470,8 +728,11 @@ def bootstrap_if_requested():
summary = _build_summary(payload, json_path) summary = _build_summary(payload, json_path)
_append_debug_log( _append_debug_log(
"payload loaded: devices={0}, terminals={1}, models={2}".format( "payload loaded: devices={0}, terminals={1}, models={2}, cabinet={3}".format(
summary["device_count"], summary["terminal_count"], summary["device_model_count"] summary["device_count"],
summary["terminal_count"],
summary["device_model_count"],
"yes" if summary.get("cabinet") else "no",
) )
) )
setattr(App, STATE_PAYLOAD, payload) setattr(App, STATE_PAYLOAD, payload)

Loading…
Cancel
Save