diff --git a/docs/2D-3D交换协议.md b/docs/2D-3D交换协议.md index be5d4e6..b0d1474 100644 --- a/docs/2D-3D交换协议.md +++ b/docs/2D-3D交换协议.md @@ -101,13 +101,14 @@ ```json { - "schema_version": "1.0", + "schema_version": "1.1", "project_uuid": "string", "generated_at": "2026-05-18T10:30:00+08:00", "source": { "app": "QET", "version": "string" }, + "cabinet": {}, "devices": [], "terminals": [], "device_models": [] @@ -120,21 +121,71 @@ - `project_uuid`:项目主键 - `generated_at`:导出时间 - `source`:导出来源信息 +- `cabinet`:当前图纸属性中绑定的机柜信息 - `devices`:设备实例绑定 - `terminals`:端子实例绑定 - `device_models`:设备 3D 模型解析结果 --- -## 5. `devices` 结构 +## 5. `cabinet` 结构 ### 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` 负责表达: > 一个 2D 设备实例,对应哪个 3D 设备实例。 -### 5.2 第一版字段 +### 6.2 第一版字段 ```json { @@ -144,7 +195,7 @@ } ``` -### 5.3 字段说明 +### 6.3 字段说明 | 字段 | 中文 | 必需 | 说明 | | --- | --- | --- | --- | @@ -152,7 +203,7 @@ | `instance_id` | 3D实例ID | 是 | FreeCAD 侧设备实例主键 | | `display_tag` | 2D设备实例标注 | 否 | JSON 显示辅助字段,优先使用 2D 中设备标注作为 FreeCAD 树标签;为空时再退回 `instance_id` / `element_uuid` | -### 5.4 说明 +### 6.4 说明 - 如果第一次进入 3D 时还没有 `instance_id`,允许先导出空字符串或缺省值 - FreeCAD 创建 3D 实例后,再在回写阶段补齐 @@ -160,15 +211,15 @@ --- -## 6. `terminals` 结构 +## 7. `terminals` 结构 -### 6.1 作用 +### 7.1 作用 `terminals` 负责表达: > 一个 2D 端子实例,属于哪个 3D 设备实例。 -### 6.2 第一版字段 +### 7.2 第一版字段 ```json { @@ -178,7 +229,7 @@ } ``` -### 6.3 字段说明 +### 7.3 字段说明 | 字段 | 中文 | 必需 | 说明 | | --- | --- | --- | --- | @@ -186,7 +237,7 @@ | `instance_id` | 3D实例ID | 是 | 该端子所属的 3D 设备实例 | | `element_uuid` | 2D设备实例UUID | 否 | JSON 导入辅助字段,帮助 FreeCAD 在首次没有 `instance_id` 时仍能知道端子属于哪个设备 | -### 6.4 为什么这里允许带 `element_uuid` +### 7.4 为什么这里允许带 `element_uuid` 注意: @@ -200,7 +251,7 @@ 所以这里允许 JSON 比数据库稍微丰富一些。 -### 6.5 为什么第一版不带更多字段 +### 7.5 为什么第一版不带更多字段 第一版先不强制包含: @@ -218,15 +269,15 @@ --- -## 7. `device_models` 结构 +## 8. `device_models` 结构 -### 7.1 作用 +### 8.1 作用 `device_models` 不是绑定表本身,而是: > QET 在导出时,顺手把设备对应 3D 模型资源解析出来,减少 FreeCAD 再回查 QET 内部数据库的复杂度。 -### 7.2 第二步设备导入推荐字段 +### 8.2 第二步设备导入推荐字段 ```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` | | `resolved_model_path` | 已解析模型路径 | 是 | QET 已经解析好的本地模型文件路径,FreeCAD 第二步直接用它导入 STEP / FCStd | -### 7.3 为什么 `resolved_model_path` 是第二步关键字段 +### 8.4 为什么 `resolved_model_path` 是第二步关键字段 第二步开始,FreeCAD 不再只做 JSON 校验,而要真正导入设备模型。 diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index e117dd3..2000ba1 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -4,6 +4,7 @@ set(FreeCADExchange_Scripts InitGui.py ExchangeBootstrap.py DeviceImport.py + DevicePreview.py ) add_custom_target(FreeCADExchangeScripts ALL diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index 64f8028..2239df7 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -4,10 +4,12 @@ from pathlib import Path import FreeCAD as App import FreeCADGui as Gui import ImportGui +import DevicePreview ROOT_GROUP_NAME = "QETExchangeDevices" ROOT_GROUP_LABEL = "QET Exchange Devices" +CABINET_MODEL_GROUP_NAME = "QETCabinetModel" 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] +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): if prop_name not in getattr(obj, "PropertiesList", []): obj.addProperty("App::PropertyString", prop_name, group_name, description) 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): - if App.ActiveDocument: - return App.ActiveDocument + preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene" + 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" - doc_name = _safe_token(doc_name)[:48] or "QETScene" - return App.newDocument(doc_name) +def _cabinet_label_text(cabinet): + if not isinstance(cabinet, dict): + 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) if root is None: 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 +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): target_uuid = (element_uuid or "").strip() if not target_uuid: @@ -111,13 +214,19 @@ def _device_label_text(display_tag, instance_id, element_uuid): 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) + 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: device_group = doc.addObject( - "App::DocumentObjectGroup", + "App::Part", DEVICE_GROUP_PREFIX + _safe_token(element_uuid), ) + created_now = True if device_group not in getattr(root_group, "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", 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): @@ -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) 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: for obj in _new_objects_since(doc, before_names): _remove_object_tree(doc, obj) raise 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", []): device_group.addObject(obj) - return imported_objects + return top_level_objects def _model_index(payload): @@ -209,10 +333,71 @@ def _model_index(payload): 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=""): _append_debug_log("DeviceImport.import_devices_from_payload entered") 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) report = { @@ -226,10 +411,17 @@ def import_devices_from_payload(payload, scene_path=""): "skipped_missing_file": 0, "skipped_unsupported_format": 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": [], } - 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 element_uuid = device.get("element_uuid", "").strip() @@ -265,8 +457,14 @@ def import_devices_from_payload(payload, scene_path=""): continue existing_group = _find_device_group(doc, element_uuid) - device_group = _ensure_device_group( - doc, root_group, element_uuid, instance_id, resolved_model_path, display_tag + device_group, created_now = _ensure_device_group( + doc, + root_group, + element_uuid, + instance_id, + resolved_model_path, + display_tag, + index, ) _clear_group_contents(doc, device_group) @@ -292,7 +490,7 @@ def import_devices_from_payload(payload, scene_path=""): ) continue - if existing_group is None: + if created_now or existing_group is None: report["imported_devices"] += 1 else: report["updated_devices"] += 1 @@ -307,7 +505,8 @@ def import_devices_from_payload(payload, scene_path=""): pass _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["updated_devices"], report["skipped_missing_model"], diff --git a/src/Mod/FreeCADExchange/DevicePreview.py b/src/Mod/FreeCADExchange/DevicePreview.py new file mode 100644 index 0000000..8bb1b5e --- /dev/null +++ b/src/Mod/FreeCADExchange/DevicePreview.py @@ -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) diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index 92c3de7..2df476e 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -6,6 +6,7 @@ from pathlib import Path import FreeCAD as App import FreeCADGui as Gui import DeviceImport +import DevicePreview try: from PySide6 import QtCore, QtWidgets @@ -24,6 +25,10 @@ STATE_PAYLOAD = "_qet_exchange_payload" STATE_SUMMARY = "_qet_exchange_summary" STATE_IMPORT_REPORT = "_qet_exchange_import_report" 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_RETRY_DELAY_MS = 1000 IMPORT_READY_MAX_RETRIES = 10 @@ -50,6 +55,16 @@ def _append_debug_log(message): 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(): try: return Gui.getMainWindow() @@ -65,6 +80,185 @@ def _show_error(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(): main_window = _get_main_window() if main_window is None: @@ -203,6 +397,43 @@ def _normalize_device_models(payload): 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): try: raw_text = Path(json_path).read_text(encoding="utf-8") @@ -231,6 +462,7 @@ def load_exchange_payload(json_path): "project_uuid": project_uuid, "generated_at": payload.get("generated_at", ""), "source": payload.get("source", {}), + "cabinet": _normalize_cabinet(payload), "devices": _normalize_devices(payload), "terminals": _normalize_terminals(payload), "device_models": _normalize_device_models(payload), @@ -242,6 +474,7 @@ def _build_summary(payload, json_path): devices = payload["devices"] terminals = payload["terminals"] device_models = payload["device_models"] + cabinet = payload.get("cabinet") missing_device_instances = sum(1 for item in devices if not item["instance_id"]) missing_terminal_instances = sum( 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, "missing_device_instances": missing_device_instances, "missing_terminal_instances": missing_terminal_instances, + "cabinet": cabinet, "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"]), ] + 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"]: lines.append( "Devices without instance_id yet: {0}".format( @@ -296,6 +550,7 @@ def _summary_message(summary, import_report=None): "", "3D device import summary:", "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"]), "Updated devices: {0}".format(import_report["updated_devices"]), ] @@ -431,7 +686,10 @@ def _run_scheduled_device_import(attempt=0): def bootstrap_if_requested(): + if not getattr(App, STATE_FLAG, False): + _reset_debug_log() _append_debug_log("bootstrap_if_requested entered") + _install_tree_double_click_filter() if getattr(App, STATE_FLAG, False): _append_debug_log("bootstrap_if_requested skipped: already bootstrapped") return @@ -470,8 +728,11 @@ def bootstrap_if_requested(): summary = _build_summary(payload, json_path) _append_debug_log( - "payload loaded: devices={0}, terminals={1}, models={2}".format( - summary["device_count"], summary["terminal_count"], summary["device_model_count"] + "payload loaded: devices={0}, terminals={1}, models={2}, cabinet={3}".format( + summary["device_count"], + summary["terminal_count"], + summary["device_model_count"], + "yes" if summary.get("cabinet") else "no", ) ) setattr(App, STATE_PAYLOAD, payload)