From baf323b0c1a4540245063aaae04e7bba579f77d5 Mon Sep 17 00:00:00 2001 From: zhanghao <2024138486@qq.com> Date: Tue, 19 May 2026 18:00:58 +0800 Subject: [PATCH] Add FreeCAD exchange integration --- .gitignore | 1 + docs/2D-3D交换协议.md | 403 +++++++++++++++ src/Mod/CMakeLists.txt | 2 + src/Mod/FreeCADExchange/CMakeLists.txt | 24 + src/Mod/FreeCADExchange/DeviceImport.py | 318 ++++++++++++ src/Mod/FreeCADExchange/ExchangeBootstrap.py | 488 ++++++++++++++++++ src/Mod/FreeCADExchange/Init.py | 1 + src/Mod/FreeCADExchange/InitGui.py | 34 ++ src/Mod/FreeCADExchange/__init__.py | 1 + .../deploy_freecad_runtime.ps1 | 250 +++++++++ 10 files changed, 1522 insertions(+) create mode 100644 docs/2D-3D交换协议.md create mode 100644 src/Mod/FreeCADExchange/CMakeLists.txt create mode 100644 src/Mod/FreeCADExchange/DeviceImport.py create mode 100644 src/Mod/FreeCADExchange/ExchangeBootstrap.py create mode 100644 src/Mod/FreeCADExchange/Init.py create mode 100644 src/Mod/FreeCADExchange/InitGui.py create mode 100644 src/Mod/FreeCADExchange/__init__.py create mode 100644 tools/vs_runtime_bootstrap/deploy_freecad_runtime.ps1 diff --git a/.gitignore b/.gitignore index bfdab1d..3755196 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ install_manifest.txt /Mod/ /ZERO_CHECK.dir/ /build/ +/bulid/ /build-*/ /cmake-build*/ /src/Tools/offlinedoc/localwiki/ diff --git a/docs/2D-3D交换协议.md b/docs/2D-3D交换协议.md new file mode 100644 index 0000000..be5d4e6 --- /dev/null +++ b/docs/2D-3D交换协议.md @@ -0,0 +1,403 @@ +# 2D / 3D 交换协议(第一版) + +本文档只描述 **QET 与 FreeCAD 之间的 JSON 交换格式**。 + +不负责描述: + +- 数据库建表 +- 数据库存储约束 +- 旧表兼容策略 + +这些内容统一放在: + +- [数据库设计.md](D:\project\LightWork3D\FreeCAD\docs\数据库设计.md) + +--- + +## 1. 协议目标 + +第一版协议只解决下面这条最小闭环: + +1. QET 能把当前项目中的设备实例和端子实例导出给 FreeCAD +2. FreeCAD 能根据导出结果创建或更新 3D 设备实例 +3. FreeCAD 能根据导出结果创建或更新 3D 端子对象 +4. FreeCAD 保存后,能把最小回写结果再交还给 QET + +当前版本明确采用: + +- **文件交换** +- **JSON 格式** + +当前版本明确不采用: + +- 实时 RPC +- HTTP API +- 直接双向数据库同步 + +--- + +## 2. 交换文件位置建议 + +建议所有交换文件都放到项目目录下: + +```text +/.qet_freecad/ + 2d_to_3d.json + 3d_to_2d.json + scene.FCStd + logs/ +``` + +含义: + +- `2d_to_3d.json`:QET 导出给 FreeCAD 的输入快照 +- `3d_to_2d.json`:FreeCAD 回写给 QET 的结果快照 +- `scene.FCStd`:该项目对应的 FreeCAD 3D 工程 + +--- + +## 3. 第一版 `2d_to_3d.json` 设计原则 + +### 3.1 最小主键集 + +第一版最小主键集只认: + +- `project_uuid` +- `element_uuid` +- `terminal_uuid` +- `instance_id` + +### 3.2 数据来源 + +`2d_to_3d.json` 不是数据库整表导出,而是: + +> 从 QET 当前项目状态中整理出的、面向 FreeCAD 使用的最小交换快照。 + +也就是说: + +- 它可以来自数据库 +- 也可以来自当前内存状态 +- 但最终输出是给 FreeCAD 消费的统一协议格式 + +### 3.3 协议可以比数据库稍微丰富 + +数据库设计要求尽量去冗余。 + +但 JSON 交换协议可以为了: + +- 降低 FreeCAD 读取复杂度 +- 提升可调试性 +- 避免 FreeCAD 再回查 QET 内部数据库 + +而适当带上一些“已解析数据”。 + +这不等于数据库必须保留这些字段。 + +--- + +## 4. 第一版 `2d_to_3d.json` 顶层结构 + +推荐结构: + +```json +{ + "schema_version": "1.0", + "project_uuid": "string", + "generated_at": "2026-05-18T10:30:00+08:00", + "source": { + "app": "QET", + "version": "string" + }, + "devices": [], + "terminals": [], + "device_models": [] +} +``` + +说明: + +- `schema_version`:协议版本,便于后续兼容升级 +- `project_uuid`:项目主键 +- `generated_at`:导出时间 +- `source`:导出来源信息 +- `devices`:设备实例绑定 +- `terminals`:端子实例绑定 +- `device_models`:设备 3D 模型解析结果 + +--- + +## 5. `devices` 结构 + +### 5.1 作用 + +`devices` 负责表达: + +> 一个 2D 设备实例,对应哪个 3D 设备实例。 + +### 5.2 第一版字段 + +```json +{ + "element_uuid": "string", + "instance_id": "string", + "display_tag": "string" +} +``` + +### 5.3 字段说明 + +| 字段 | 中文 | 必需 | 说明 | +| --- | --- | --- | --- | +| `element_uuid` | 2D设备实例UUID | 是 | QET 图纸中的设备实例主键 | +| `instance_id` | 3D实例ID | 是 | FreeCAD 侧设备实例主键 | +| `display_tag` | 2D设备实例标注 | 否 | JSON 显示辅助字段,优先使用 2D 中设备标注作为 FreeCAD 树标签;为空时再退回 `instance_id` / `element_uuid` | + +### 5.4 说明 + +- 如果第一次进入 3D 时还没有 `instance_id`,允许先导出空字符串或缺省值 +- FreeCAD 创建 3D 实例后,再在回写阶段补齐 +- `display_tag` 不进入第一版数据库最小字段集,它只存在于交换 JSON 中,用来让 3D 树视图与 2D 标注更容易对上 + +--- + +## 6. `terminals` 结构 + +### 6.1 作用 + +`terminals` 负责表达: + +> 一个 2D 端子实例,属于哪个 3D 设备实例。 + +### 6.2 第一版字段 + +```json +{ + "terminal_uuid": "string", + "instance_id": "string", + "element_uuid": "string" +} +``` + +### 6.3 字段说明 + +| 字段 | 中文 | 必需 | 说明 | +| --- | --- | --- | --- | +| `terminal_uuid` | 2D端子UUID | 是 | QET 端子实例主键 | +| `instance_id` | 3D实例ID | 是 | 该端子所属的 3D 设备实例 | +| `element_uuid` | 2D设备实例UUID | 否 | JSON 导入辅助字段,帮助 FreeCAD 在首次没有 `instance_id` 时仍能知道端子属于哪个设备 | + +### 6.4 为什么这里允许带 `element_uuid` + +注意: + +- `element_uuid` **不是**第一版端子绑定表的数据库字段扩张 +- 它只是交换 JSON 中的上下文辅助字段 + +原因: + +- 当某些设备第一次进入 3D、暂时还没有 `instance_id` 时 +- FreeCAD 仍需要知道该端子属于哪个 2D 设备实例 + +所以这里允许 JSON 比数据库稍微丰富一些。 + +### 6.5 为什么第一版不带更多字段 + +第一版先不强制包含: + +- `terminal_key` +- `connection_point_key` +- `symbol_terminal` +- `wire_label` +- `net_id` + +原因: + +- 这些字段当前都不是 FreeCAD 第一版创建端子对象的硬前提 +- 端子语义可以通过 `terminal_uuid` 回查 QET +- 先把绑定关系打通,比先堆字段更重要 + +--- + +## 7. `device_models` 结构 + +### 7.1 作用 + +`device_models` 不是绑定表本身,而是: + +> QET 在导出时,顺手把设备对应 3D 模型资源解析出来,减少 FreeCAD 再回查 QET 内部数据库的复杂度。 + +### 7.2 第二步设备导入推荐字段 + +```json +{ + "element_uuid": "string", + "device_id": 123, + "parts_3d": "string", + "resolved_model_path": "string" +} +``` + +### 7.3 字段说明 + +| 字段 | 中文 | 必需 | 说明 | +| --- | --- | --- | --- | +| `element_uuid` | 2D设备实例UUID | 是 | 与 `devices` 关联 | +| `device_id` | 设备类型ID | 否 | QET 设备主数据 ID | +| `parts_3d` | 3D模型资源URI | 否 | 原始资源引用,来自 `device_3d_asset.uri` 或 `device_attribute.parts_3d` | +| `resolved_model_path` | 已解析模型路径 | 是 | QET 已经解析好的本地模型文件路径,FreeCAD 第二步直接用它导入 STEP / FCStd | + +### 7.3 为什么 `resolved_model_path` 是第二步关键字段 + +第二步开始,FreeCAD 不再只做 JSON 校验,而要真正导入设备模型。 + +如果没有 `resolved_model_path`,FreeCAD 就必须自己理解和回查: + +1. `element_uuid` +2. `device_id` +3. `device_3d_asset` +4. `device_attribute.parts_3d` +5. 本地路径解析规则 + +这会让 FreeCAD 过度耦合 QET 内部数据库结构。 + +所以第二步推荐由 QET 在导出时直接给出: + +- `resolved_model_path` + +让 FreeCAD 只负责消费结果。 + +### 7.4 为什么这里允许同时带 `parts_3d` + +注意: + +- **数据库设计**里我们已经决定不在绑定表冗余保存 `asset_uri` +- 但**交换协议**里允许带 `parts_3d` + +原因是: + +- JSON 是导出快照 +- 不是绑定数据库本身 +- 这样 FreeCAD 读取时更简单 + +也就是说: + +> 数据库去冗余 +> JSON 可适度带已解析结果 + +--- + +## 8. 第一版 `2d_to_3d.json` 完整样例 + +```json +{ + "schema_version": "1.0", + "project_uuid": "proj-001", + "generated_at": "2026-05-18T10:30:00+08:00", + "source": { + "app": "QET", + "version": "1.0" + }, + "devices": [ + { + "element_uuid": "elem-1001", + "instance_id": "fc-inst-0001" + } + ], + "terminals": [ + { + "terminal_uuid": "term-2001", + "instance_id": "fc-inst-0001", + "element_uuid": "elem-1001" + }, + { + "terminal_uuid": "term-2002", + "instance_id": "fc-inst-0001", + "element_uuid": "elem-1001" + } + ], + "device_models": [ + { + "element_uuid": "elem-1001", + "device_id": 123, + "parts_3d": "models/mccb/model.step", + "resolved_model_path": "C:/Users/Admin/Documents/MingTuProject/models/mccb/model.step" + } + ] +} +``` + +--- + +## 9. 第一版 `3d_to_2d.json` 建议 + +第一版回写建议同样保持最小化。 + +推荐结构: + +```json +{ + "schema_version": "1.0", + "project_uuid": "string", + "generated_at": "2026-05-18T11:00:00+08:00", + "instances": [], + "terminals": [] +} +``` + +### 9.1 `instances` + +```json +{ + "element_uuid": "string", + "instance_id": "string" +} +``` + +### 9.2 `terminals` + +```json +{ + "terminal_uuid": "string", + "instance_id": "string" +} +``` + +### 9.3 说明 + +第一版不回写: + +- 3D 位姿 +- 装配层级 +- 几何路径 +- 线槽信息 + +这些仍以 FreeCAD 文档为准。 + +--- + +## 10. 第一版推荐交互流程 + +1. 用户在 QET 中点击 `3D视图` +2. QET 生成 `2d_to_3d.json` +3. QET 打开 FreeCAD,并打开 `scene.FCStd` +4. FreeCAD 读取 `2d_to_3d.json` +5. FreeCAD 创建或更新: + - 3D 设备实例 + - 3D 端子对象 +6. 用户在 FreeCAD 中完成装配和接线 +7. 用户保存 FreeCAD 工程 +8. FreeCAD 生成 `3d_to_2d.json` +9. QET 在后续时机读取 `3d_to_2d.json` + +--- + +## 11. 当前推荐结论 + +第一版协议建议明确分层: + +- **数据库设计**:尽量去冗余 +- **JSON 协议**:允许带少量已解析结果,方便 FreeCAD 使用 + +一句话总结: + +> 第一版先把 `2d_to_3d.json` 做成“面向 FreeCAD 的最小项目快照”,而不是数据库整表镜像。 diff --git a/src/Mod/CMakeLists.txt b/src/Mod/CMakeLists.txt index e07921e..b380824 100644 --- a/src/Mod/CMakeLists.txt +++ b/src/Mod/CMakeLists.txt @@ -22,6 +22,8 @@ if(BUILD_DRAFT) add_subdirectory(Draft) endif(BUILD_DRAFT) +add_subdirectory(FreeCADExchange) + if(BUILD_FEM) add_subdirectory(Fem) endif(BUILD_FEM) diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt new file mode 100644 index 0000000..e117dd3 --- /dev/null +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -0,0 +1,24 @@ +set(FreeCADExchange_Scripts + __init__.py + Init.py + InitGui.py + ExchangeBootstrap.py + DeviceImport.py +) + +add_custom_target(FreeCADExchangeScripts ALL + SOURCES ${FreeCADExchange_Scripts} +) + +fc_target_copy_resource(FreeCADExchangeScripts + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_BINARY_DIR}/Mod/FreeCADExchange + ${FreeCADExchange_Scripts} +) + +install( + FILES + ${FreeCADExchange_Scripts} + DESTINATION + Mod/FreeCADExchange +) diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py new file mode 100644 index 0000000..64f8028 --- /dev/null +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -0,0 +1,318 @@ +import os +from pathlib import Path + +import FreeCAD as App +import FreeCADGui as Gui +import ImportGui + + +ROOT_GROUP_NAME = "QETExchangeDevices" +ROOT_GROUP_LABEL = "QET Exchange Devices" +DEVICE_GROUP_PREFIX = "QETDevice_" + + +class DeviceImportError(RuntimeError): + pass + + +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 _native_path(value): + text = (value or "").strip() + if not text: + return "" + return os.path.normpath(os.path.expandvars(os.path.expanduser(text))) + + +def _existing_object_names(doc): + return {obj.Name for obj in doc.Objects} + + +def _new_objects_since(doc, before_names): + return [obj for obj in doc.Objects if obj.Name not in before_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_document(scene_path): + if App.ActiveDocument: + return App.ActiveDocument + + 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 _ensure_root_group(doc): + root = doc.getObject(ROOT_GROUP_NAME) + if root is None: + root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME) + root.Label = ROOT_GROUP_LABEL + return root + + +def _find_device_group(doc, element_uuid): + target_uuid = (element_uuid or "").strip() + if not target_uuid: + return None + + preferred_name = DEVICE_GROUP_PREFIX + _safe_token(target_uuid) + obj = doc.getObject(preferred_name) + if obj is not None: + return obj + + for candidate in doc.Objects: + if "QetElementUuid" in getattr(candidate, "PropertiesList", []): + if getattr(candidate, "QetElementUuid", "").strip() == target_uuid: + return candidate + return None + +def _device_label_text(display_tag, instance_id, element_uuid): + label = (display_tag or "").strip() + if label: + return label + + fallback = (instance_id or "").strip() or (element_uuid or "").strip() + if fallback: + return fallback + return "QET Device" + + +def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, display_tag): + device_group = _find_device_group(doc, element_uuid) + if device_group is None: + device_group = doc.addObject( + "App::DocumentObjectGroup", + DEVICE_GROUP_PREFIX + _safe_token(element_uuid), + ) + + if device_group not in getattr(root_group, "Group", []): + root_group.addObject(device_group) + + device_group.Label = _device_label_text(display_tag, instance_id, element_uuid) + _ensure_string_property( + device_group, + "QetElementUuid", + "QET Exchange", + "2D element UUID from QET", + element_uuid, + ) + _ensure_string_property( + device_group, + "QetInstanceId", + "QET Exchange", + "3D instance id from QET/FreeCAD exchange", + instance_id, + ) + _ensure_string_property( + device_group, + "QetResolvedModelPath", + "QET Exchange", + "Resolved local model path from QET exchange", + model_path, + ) + _ensure_string_property( + device_group, + "QetDisplayTag", + "QET Exchange", + "2D display tag from QET exchange", + display_tag, + ) + return device_group + + +def _remove_object_tree(doc, obj): + if obj is None: + return + + children = list(getattr(obj, "Group", []) or []) + for child in children: + _remove_object_tree(doc, child) + + if doc.getObject(obj.Name) is not None: + doc.removeObject(obj.Name) + + +def _clear_group_contents(doc, group): + for child in list(getattr(group, "Group", []) or []): + _remove_object_tree(doc, child) + + +def _supported_for_import(model_path): + suffix = Path(model_path).suffix.lower() + return suffix in { + ".step", + ".stp", + ".iges", + ".igs", + ".brep", + ".brp", + ".fcstd", + } + + +def _import_model_into_group(doc, device_group, model_path): + before_names = _existing_object_names(doc) + try: + ImportGui.insert(name=model_path, docName=doc.Name, merge=False, useLinkGroup=True) + 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: + if obj not in getattr(device_group, "Group", []): + device_group.addObject(obj) + return imported_objects + + +def _model_index(payload): + index = {} + for item in payload.get("device_models", []): + element_uuid = item.get("element_uuid", "").strip() + if element_uuid and element_uuid not in index: + index[element_uuid] = item + return index + + +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) + models_by_element = _model_index(payload) + + report = { + "document_name": doc.Name, + "scene_path": scene_path or "", + "total_devices": 0, + "imported_devices": 0, + "updated_devices": 0, + "imported_without_instance_id": 0, + "skipped_missing_model": 0, + "skipped_missing_file": 0, + "skipped_unsupported_format": 0, + "skipped_import_error": 0, + "warnings": [], + } + + for device in payload.get("devices", []): + report["total_devices"] += 1 + + element_uuid = device.get("element_uuid", "").strip() + instance_id = (device.get("instance_id") or "").strip() + display_tag = (device.get("display_tag") or "").strip() + model_info = models_by_element.get(element_uuid, {}) + resolved_model_path = _native_path(model_info.get("resolved_model_path", "")) + _append_debug_log( + "DeviceImport device element_uuid={0}, instance_id={1}, display_tag={2}, resolved_model_path={3}".format( + element_uuid, instance_id, display_tag, resolved_model_path + ) + ) + + if not resolved_model_path: + report["skipped_missing_model"] += 1 + report["warnings"].append( + "设备 {0} 缺少 resolved_model_path,已跳过。".format(element_uuid) + ) + continue + + if not os.path.isfile(resolved_model_path): + report["skipped_missing_file"] += 1 + report["warnings"].append( + "设备 {0} 的模型文件不存在:{1}".format(element_uuid, resolved_model_path) + ) + continue + + if not _supported_for_import(resolved_model_path): + report["skipped_unsupported_format"] += 1 + report["warnings"].append( + "设备 {0} 的模型格式暂不支持:{1}".format(element_uuid, resolved_model_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 + ) + _clear_group_contents(doc, device_group) + + try: + _append_debug_log( + "DeviceImport importing model for element_uuid={0}: {1}".format( + element_uuid, resolved_model_path + ) + ) + _import_model_into_group(doc, device_group, resolved_model_path) + _append_debug_log( + "DeviceImport import succeeded for element_uuid={0}".format(element_uuid) + ) + except Exception as exc: + report["skipped_import_error"] += 1 + report["warnings"].append( + "设备 {0} 导入失败:{1}".format(element_uuid, exc) + ) + _append_debug_log( + "DeviceImport import failed for element_uuid={0}: {1}".format( + element_uuid, exc + ) + ) + continue + + if existing_group is None: + report["imported_devices"] += 1 + else: + report["updated_devices"] += 1 + + if not instance_id: + report["imported_without_instance_id"] += 1 + + doc.recompute() + try: + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + + _append_debug_log( + "DeviceImport finished: imported={0}, updated={1}, skipped_missing_model={2}, skipped_missing_file={3}, skipped_import_error={4}".format( + report["imported_devices"], + report["updated_devices"], + report["skipped_missing_model"], + report["skipped_missing_file"], + report["skipped_import_error"], + ) + ) + return report diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py new file mode 100644 index 0000000..92c3de7 --- /dev/null +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -0,0 +1,488 @@ +import json +import traceback +import os +from pathlib import Path + +import FreeCAD as App +import FreeCADGui as Gui +import DeviceImport + +try: + from PySide6 import QtCore, QtWidgets +except ImportError: + try: + from PySide2 import QtCore, QtWidgets + except ImportError: + from PySide import QtCore + from PySide import QtGui as QtWidgets + + +ENV_JSON_PATH = "QET_2D_TO_3D_JSON" +ENV_SCENE_PATH = "QET_FREECAD_SCENE_FILE" +STATE_FLAG = "_qet_exchange_bootstrapped" +STATE_PAYLOAD = "_qet_exchange_payload" +STATE_SUMMARY = "_qet_exchange_summary" +STATE_IMPORT_REPORT = "_qet_exchange_import_report" +STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled" +IMPORT_READY_DELAY_MS = 1500 +IMPORT_READY_RETRY_DELAY_MS = 1000 +IMPORT_READY_MAX_RETRIES = 10 + + +class ExchangeValidationError(RuntimeError): + pass + + +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 _get_main_window(): + try: + return Gui.getMainWindow() + except Exception: + return None + + +def _show_info(title, message): + QtWidgets.QMessageBox.information(_get_main_window(), title, message) + + +def _show_error(title, message): + QtWidgets.QMessageBox.critical(_get_main_window(), title, message) + + +def _is_gui_ready(): + main_window = _get_main_window() + if main_window is None: + return False + try: + return bool(main_window.isVisible()) + except Exception: + return False + + +def _require_string(payload, field_name): + value = payload.get(field_name) + if not isinstance(value, str) or not value.strip(): + raise ExchangeValidationError( + "Field '{0}' must be a non-empty string.".format(field_name) + ) + return value.strip() + + +def _normalize_instance_id(item): + value = item.get("instance_id", "") + if value is None: + return "" + if not isinstance(value, str): + raise ExchangeValidationError( + "Field 'instance_id' must be a string when present." + ) + return value.strip() + + +def _normalize_devices(payload): + devices = payload.get("devices", []) + if not isinstance(devices, list): + raise ExchangeValidationError("Field 'devices' must be a list.") + + normalized = [] + for index, item in enumerate(devices): + if not isinstance(item, dict): + raise ExchangeValidationError( + "Device entry #{0} must be an object.".format(index) + ) + element_uuid = _require_string(item, "element_uuid") + display_tag = item.get("display_tag", "") + if display_tag and not isinstance(display_tag, str): + raise ExchangeValidationError( + "Field 'display_tag' in device entry #{0} must be a string.".format( + index + ) + ) + normalized.append( + { + "element_uuid": element_uuid, + "instance_id": _normalize_instance_id(item), + "display_tag": display_tag.strip() if isinstance(display_tag, str) else "", + } + ) + return normalized + + +def _normalize_terminals(payload): + terminals = payload.get("terminals", []) + if not isinstance(terminals, list): + raise ExchangeValidationError("Field 'terminals' must be a list.") + + normalized = [] + for index, item in enumerate(terminals): + if not isinstance(item, dict): + raise ExchangeValidationError( + "Terminal entry #{0} must be an object.".format(index) + ) + terminal_uuid = _require_string(item, "terminal_uuid") + element_uuid = item.get("element_uuid", "") + if element_uuid and not isinstance(element_uuid, str): + raise ExchangeValidationError( + "Field 'element_uuid' in terminal entry #{0} must be a string.".format( + index + ) + ) + normalized.append( + { + "terminal_uuid": terminal_uuid, + "instance_id": _normalize_instance_id(item), + "element_uuid": element_uuid.strip() if isinstance(element_uuid, str) else "", + } + ) + return normalized + + +def _normalize_device_models(payload): + models = payload.get("device_models", []) + if not isinstance(models, list): + raise ExchangeValidationError("Field 'device_models' must be a list.") + + normalized = [] + for index, item in enumerate(models): + if not isinstance(item, dict): + raise ExchangeValidationError( + "Device model entry #{0} must be an object.".format(index) + ) + element_uuid = _require_string(item, "element_uuid") + parts_3d = item.get("parts_3d", "") + if parts_3d and not isinstance(parts_3d, str): + raise ExchangeValidationError( + "Field 'parts_3d' in device model entry #{0} must be a string.".format( + index + ) + ) + resolved_model_path = item.get("resolved_model_path", "") + if resolved_model_path and not isinstance(resolved_model_path, str): + raise ExchangeValidationError( + "Field 'resolved_model_path' in device model entry #{0} must be a string.".format( + index + ) + ) + + device_id = item.get("device_id") + if device_id is not None and not isinstance(device_id, int): + raise ExchangeValidationError( + "Field 'device_id' in device model entry #{0} must be an integer.".format( + index + ) + ) + + normalized.append( + { + "element_uuid": element_uuid, + "device_id": device_id, + "parts_3d": parts_3d.strip() if isinstance(parts_3d, str) else "", + "resolved_model_path": ( + resolved_model_path.strip() + if isinstance(resolved_model_path, str) + else "" + ), + } + ) + return normalized + + +def load_exchange_payload(json_path): + try: + raw_text = Path(json_path).read_text(encoding="utf-8") + except OSError as exc: + raise ExchangeValidationError( + "Failed to read exchange file:\n{0}".format(exc) + ) from exc + + try: + payload = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise ExchangeValidationError( + "Exchange JSON is invalid:\n{0}".format(exc) + ) from exc + + if not isinstance(payload, dict): + raise ExchangeValidationError("Exchange JSON root must be an object.") + + project_uuid = _require_string(payload, "project_uuid") + schema_version = payload.get("schema_version", "1.0") + if not isinstance(schema_version, str) or not schema_version.strip(): + raise ExchangeValidationError("Field 'schema_version' must be a string.") + + normalized = { + "schema_version": schema_version.strip(), + "project_uuid": project_uuid, + "generated_at": payload.get("generated_at", ""), + "source": payload.get("source", {}), + "devices": _normalize_devices(payload), + "terminals": _normalize_terminals(payload), + "device_models": _normalize_device_models(payload), + } + return normalized + + +def _build_summary(payload, json_path): + devices = payload["devices"] + terminals = payload["terminals"] + device_models = payload["device_models"] + 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"] + ) + with_model_paths = sum( + 1 for item in device_models if item["resolved_model_path"] or item["parts_3d"] + ) + + return { + "json_path": json_path, + "project_uuid": payload["project_uuid"], + "device_count": len(devices), + "terminal_count": len(terminals), + "device_model_count": len(device_models), + "device_models_with_parts": with_model_paths, + "missing_device_instances": missing_device_instances, + "missing_terminal_instances": missing_terminal_instances, + "scene_path": os.environ.get(ENV_SCENE_PATH, "").strip(), + } + + +def _summary_message(summary, import_report=None): + lines = [ + "QET exchange file loaded successfully.", + "", + "Project UUID: {0}".format(summary["project_uuid"]), + "Exchange file: {0}".format(summary["json_path"]), + "Devices: {0}".format(summary["device_count"]), + "Terminals: {0}".format(summary["terminal_count"]), + "Device models: {0}".format(summary["device_model_count"]), + "Resolved model paths: {0}".format(summary["device_models_with_parts"]), + ] + + if summary["missing_device_instances"]: + lines.append( + "Devices without instance_id yet: {0}".format( + summary["missing_device_instances"] + ) + ) + if summary["missing_terminal_instances"]: + lines.append( + "Terminals without instance_id yet: {0}".format( + summary["missing_terminal_instances"] + ) + ) + if summary["scene_path"]: + lines.append("Scene file: {0}".format(summary["scene_path"])) + + if import_report: + lines.extend( + [ + "", + "3D device import summary:", + "Target document: {0}".format(import_report["document_name"]), + "Imported devices: {0}".format(import_report["imported_devices"]), + "Updated devices: {0}".format(import_report["updated_devices"]), + ] + ) + if import_report["imported_without_instance_id"]: + lines.append( + "Imported without instance_id yet: {0}".format( + import_report["imported_without_instance_id"] + ) + ) + if import_report["skipped_missing_model"]: + lines.append( + "Skipped without resolved model path: {0}".format( + import_report["skipped_missing_model"] + ) + ) + if import_report["skipped_missing_file"]: + lines.append( + "Skipped missing model file: {0}".format( + import_report["skipped_missing_file"] + ) + ) + if import_report["skipped_unsupported_format"]: + lines.append( + "Skipped unsupported model format: {0}".format( + import_report["skipped_unsupported_format"] + ) + ) + if import_report["skipped_import_error"]: + lines.append( + "Skipped after import errors: {0}".format( + import_report["skipped_import_error"] + ) + ) + warnings = import_report.get("warnings", []) + if warnings: + lines.append("") + lines.append("Warnings:") + lines.extend("- {0}".format(item) for item in warnings[:10]) + if len(warnings) > 10: + lines.append("- ... ({0} more)".format(len(warnings) - 10)) + + lines.append("") + lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.") + lines.append("3D terminal creation is not running yet.") + return "\n".join(lines) + + +def _run_scheduled_device_import(attempt=0): + _append_debug_log( + "scheduled device import invoked: attempt={0}".format(attempt) + ) + + if not _is_gui_ready(): + if attempt < IMPORT_READY_MAX_RETRIES: + _append_debug_log( + "scheduled device import postponed: gui not ready, retrying" + ) + QtCore.QTimer.singleShot( + IMPORT_READY_RETRY_DELAY_MS, + lambda: _run_scheduled_device_import(attempt + 1), + ) + return + + _append_debug_log("scheduled device import aborted: gui never became ready") + _show_error( + "QET Exchange", + "FreeCAD main window did not finish initializing before device import.", + ) + return + + payload = getattr(App, STATE_PAYLOAD, None) + summary = getattr(App, STATE_SUMMARY, None) + if not isinstance(payload, dict) or not isinstance(summary, dict): + _append_debug_log( + "scheduled device import aborted: cached payload/summary missing" + ) + return + + scene_path = os.environ.get(ENV_SCENE_PATH, "").strip() + _append_debug_log( + "scheduled device import starting with scene_path={0}".format(scene_path) + ) + try: + import_report = DeviceImport.import_devices_from_payload(payload, scene_path) + except DeviceImport.DeviceImportError as exc: + _append_debug_log("device import failed: {0}".format(exc)) + _show_error("QET Exchange", str(exc)) + App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc)) + return + except Exception as exc: + _append_debug_log("unexpected device import exception: {0}".format(exc)) + _append_debug_log(traceback.format_exc()) + _show_error("QET Exchange", "Failed to import 3D devices:\n{0}".format(exc)) + App.Console.PrintError( + "[FreeCADExchange] Failed to import devices: {0}\n".format(exc) + ) + return + + setattr(App, STATE_IMPORT_REPORT, import_report) + _append_debug_log( + "device import summary: imported={0}, updated={1}, skipped_missing_model={2}, skipped_missing_file={3}, skipped_import_error={4}".format( + import_report["imported_devices"], + import_report["updated_devices"], + import_report["skipped_missing_model"], + import_report["skipped_missing_file"], + import_report["skipped_import_error"], + ) + ) + + App.Console.PrintMessage( + "[FreeCADExchange] Loaded exchange payload from {0}\n".format( + summary["json_path"] + ) + ) + App.Console.PrintMessage( + "[FreeCADExchange] Devices: {0}, Terminals: {1}, Device models: {2}\n".format( + summary["device_count"], + summary["terminal_count"], + summary["device_model_count"], + ) + ) + App.Console.PrintMessage( + "[FreeCADExchange] Imported devices: {0}, updated: {1}, skipped without model: {2}\n".format( + import_report["imported_devices"], + import_report["updated_devices"], + import_report["skipped_missing_model"], + ) + ) + + _show_info("QET Exchange", _summary_message(summary, import_report)) + _append_debug_log("summary dialog shown") + + +def bootstrap_if_requested(): + _append_debug_log("bootstrap_if_requested entered") + if getattr(App, STATE_FLAG, False): + _append_debug_log("bootstrap_if_requested skipped: already bootstrapped") + return + + json_path = os.environ.get(ENV_JSON_PATH, "").strip() + _append_debug_log("ENV QET_2D_TO_3D_JSON={0}".format(json_path)) + _append_debug_log("ENV QET_FREECAD_SCENE_FILE={0}".format(os.environ.get(ENV_SCENE_PATH, "").strip())) + if not json_path: + _append_debug_log("bootstrap_if_requested skipped: env missing") + return + + setattr(App, STATE_FLAG, True) + _append_debug_log("STATE_FLAG set") + + if not os.path.isfile(json_path): + _append_debug_log("exchange file missing: {0}".format(json_path)) + _show_error( + "QET Exchange", + "Environment variable {0} points to a missing file:\n{1}".format( + ENV_JSON_PATH, json_path + ), + ) + return + + try: + payload = load_exchange_payload(json_path) + except ExchangeValidationError as exc: + _append_debug_log("payload validation failed: {0}".format(exc)) + _show_error("QET Exchange", str(exc)) + App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc)) + return + except Exception as exc: + _append_debug_log("unexpected payload exception: {0}".format(exc)) + _append_debug_log(traceback.format_exc()) + raise + + 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"] + ) + ) + setattr(App, STATE_PAYLOAD, payload) + setattr(App, STATE_SUMMARY, summary) + if not getattr(App, STATE_IMPORT_SCHEDULED, False): + setattr(App, STATE_IMPORT_SCHEDULED, True) + _append_debug_log( + "device import scheduled after startup delay: {0} ms".format( + IMPORT_READY_DELAY_MS + ) + ) + QtCore.QTimer.singleShot( + IMPORT_READY_DELAY_MS, lambda: _run_scheduled_device_import(0) + ) diff --git a/src/Mod/FreeCADExchange/Init.py b/src/Mod/FreeCADExchange/Init.py new file mode 100644 index 0000000..fc83498 --- /dev/null +++ b/src/Mod/FreeCADExchange/Init.py @@ -0,0 +1 @@ +# FreeCADExchange init module. diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py new file mode 100644 index 0000000..1c82d5f --- /dev/null +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -0,0 +1,34 @@ +# FreeCADExchange gui init module. + +import os +from pathlib import Path + +try: + from PySide6 import QtCore +except ImportError: + try: + from PySide2 import QtCore + except ImportError: + from PySide import QtCore + +import ExchangeBootstrap + + +def _append_init_log(message): + try: + local_app_data = os.environ.get("LOCALAPPDATA", "").strip() + if local_app_data: + log_path = os.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log") + else: + log_path = os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log") + 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 + + +_append_init_log("InitGui imported") + + +QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) diff --git a/src/Mod/FreeCADExchange/__init__.py b/src/Mod/FreeCADExchange/__init__.py new file mode 100644 index 0000000..a3c5654 --- /dev/null +++ b/src/Mod/FreeCADExchange/__init__.py @@ -0,0 +1 @@ +# FreeCADExchange Python module. diff --git a/tools/vs_runtime_bootstrap/deploy_freecad_runtime.ps1 b/tools/vs_runtime_bootstrap/deploy_freecad_runtime.ps1 new file mode 100644 index 0000000..65c6aaa --- /dev/null +++ b/tools/vs_runtime_bootstrap/deploy_freecad_runtime.ps1 @@ -0,0 +1,250 @@ +param( + [string]$RunRoot = "", + [string]$LibPackRoot = "", + [string]$OcctRoot = "", + [string]$RuntimeRoot = "", + [switch]$SkipRuntimeJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-NormalizedPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + return [System.IO.Path]::GetFullPath($Path) +} + +function Resolve-ConfiguredPath { + param( + [string]$ConfiguredPath, + [string[]]$EnvironmentVariableNames + ) + + if (-not [string]::IsNullOrWhiteSpace($ConfiguredPath)) { + return Resolve-NormalizedPath -Path $ConfiguredPath + } + + foreach ($variableName in $EnvironmentVariableNames) { + $value = [Environment]::GetEnvironmentVariable($variableName) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return Resolve-NormalizedPath -Path $value + } + } + + return "" +} + +function Ensure-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType Directory -Path $Path | Out-Null + } +} + +function Copy-MatchingFiles { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDir, + [Parameter(Mandatory = $true)] + [string[]]$Patterns, + [Parameter(Mandatory = $true)] + [string]$DestinationDir + ) + + if (-not (Test-Path -LiteralPath $SourceDir)) { + return + } + + foreach ($pattern in $Patterns) { + Get-ChildItem -LiteralPath $SourceDir -Filter $pattern -File -ErrorAction SilentlyContinue | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $DestinationDir $_.Name) -Force + } + } +} + +function Copy-PluginDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDir, + [Parameter(Mandatory = $true)] + [string]$DestinationDir + ) + + if (-not (Test-Path -LiteralPath $SourceDir)) { + return + } + + Ensure-Directory -Path $DestinationDir + Get-ChildItem -LiteralPath $SourceDir -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $DestinationDir -Recurse -Force + } +} + +function Resolve-RuntimeRoot { + param([string]$ConfiguredRoot) + + if (-not [string]::IsNullOrWhiteSpace($ConfiguredRoot)) { + return Resolve-NormalizedPath -Path $ConfiguredRoot + } + + $localAppData = [Environment]::GetEnvironmentVariable("LOCALAPPDATA") + if ([string]::IsNullOrWhiteSpace($localAppData)) { + throw "LOCALAPPDATA is not available." + } + + return Resolve-NormalizedPath -Path (Join-Path $localAppData "QETDeps") +} + +function Read-ExistingRuntimeJson { + param([string]$RuntimeConfigPath) + + if (-not (Test-Path -LiteralPath $RuntimeConfigPath)) { + return $null + } + + return Get-Content -LiteralPath $RuntimeConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Resolve-OcctRoot { + param( + [string]$ConfiguredRoot, + $ExistingRuntime + ) + + $resolvedConfiguredRoot = Resolve-ConfiguredPath -ConfiguredPath $ConfiguredRoot -EnvironmentVariableNames @( + "FREECAD_OCCT_ROOT", + "QET_OCCT_ROOT" + ) + if (-not [string]::IsNullOrWhiteSpace($resolvedConfiguredRoot)) { + return $resolvedConfiguredRoot + } + + if ($null -ne $ExistingRuntime -and $ExistingRuntime.PSObject.Properties.Name -contains "occt_root") { + $existingOcctRoot = [string]$ExistingRuntime.occt_root + if (-not [string]::IsNullOrWhiteSpace($existingOcctRoot) -and (Test-Path -LiteralPath $existingOcctRoot)) { + return Resolve-NormalizedPath -Path $existingOcctRoot + } + } + + return "" +} + +$resolvedRunRoot = Resolve-ConfiguredPath -ConfiguredPath $RunRoot -EnvironmentVariableNames @( + "FREECAD_RUN_ROOT", + "QET_FREECAD_RUN_ROOT" +) +if ([string]::IsNullOrWhiteSpace($resolvedRunRoot)) { + throw "RunRoot is required. Pass -RunRoot or set FREECAD_RUN_ROOT / QET_FREECAD_RUN_ROOT." +} + +$resolvedLibPackRoot = Resolve-ConfiguredPath -ConfiguredPath $LibPackRoot -EnvironmentVariableNames @( + "FREECAD_LIBPACK_ROOT", + "QET_FREECAD_LIBPACK_ROOT" +) +if ([string]::IsNullOrWhiteSpace($resolvedLibPackRoot)) { + throw "LibPackRoot is required. Pass -LibPackRoot or set FREECAD_LIBPACK_ROOT / QET_FREECAD_LIBPACK_ROOT." +} + +$runBinDir = Join-Path $resolvedRunRoot "bin" + +if (-not (Test-Path -LiteralPath $runBinDir)) { + throw "Run directory bin folder was not found: $runBinDir" +} + +if (-not (Test-Path -LiteralPath $resolvedLibPackRoot)) { + throw "LibPack root was not found: $resolvedLibPackRoot" +} + +$copySpecs = @( + @{ Source = (Join-Path $resolvedLibPackRoot "bin"); Patterns = @("*.dll") }, + @{ Source = (Join-Path $resolvedLibPackRoot "lib"); Patterns = @("*.dll") }, + @{ Source = (Join-Path $resolvedLibPackRoot "bin"); Patterns = @("python.exe", "pythonw.exe", "py.exe", "python*.zip") }, + @{ Source = (Join-Path $resolvedLibPackRoot "bin\Lib\site-packages\shiboken6"); Patterns = @("*.dll", "*.pyd") }, + @{ Source = (Join-Path $resolvedLibPackRoot "bin\Lib\site-packages\PySide6"); Patterns = @("*.dll", "*.pyd") } +) + +foreach ($copySpec in $copySpecs) { + Copy-MatchingFiles -SourceDir $copySpec.Source -Patterns $copySpec.Patterns -DestinationDir $runBinDir +} + +$pluginRoot = Join-Path $resolvedLibPackRoot "bin\Lib\site-packages\PySide6\plugins" +$pluginDirs = @( + "platforms", + "imageformats", + "iconengines", + "platformthemes", + "styles" +) + +foreach ($pluginDir in $pluginDirs) { + Copy-PluginDirectory ` + -SourceDir (Join-Path $pluginRoot $pluginDir) ` + -DestinationDir (Join-Path $runBinDir $pluginDir) +} + +Copy-PluginDirectory ` + -SourceDir (Join-Path $resolvedLibPackRoot "bin\Lib") ` + -DestinationDir (Join-Path $runBinDir "Lib") + +Copy-PluginDirectory ` + -SourceDir (Join-Path $resolvedLibPackRoot "bin\DLLs") ` + -DestinationDir (Join-Path $runBinDir "DLLs") + +if (-not $SkipRuntimeJson) { + $resolvedRuntimeRoot = Resolve-RuntimeRoot -ConfiguredRoot $RuntimeRoot + Ensure-Directory -Path $resolvedRuntimeRoot + + $runtimeConfigPath = Join-Path $resolvedRuntimeRoot "runtime.json" + $diagnosticLogPath = Join-Path $resolvedRuntimeRoot "bootstrap.log" + $existingRuntime = Read-ExistingRuntimeJson -RuntimeConfigPath $runtimeConfigPath + $resolvedOcctRoot = Resolve-OcctRoot -ConfiguredRoot $OcctRoot -ExistingRuntime $existingRuntime + + $resolvedFreeCadPython = "" + $pythonCandidate = Join-Path $runBinDir "python.exe" + if (Test-Path -LiteralPath $pythonCandidate) { + $resolvedFreeCadPython = $pythonCandidate + } + + $resolvedFreeCadCmd = "" + $cmdCandidate = Join-Path $runBinDir "FreeCADCmd.exe" + if (Test-Path -LiteralPath $cmdCandidate) { + $resolvedFreeCadCmd = $cmdCandidate + } + + $qet3dPython = if (-not [string]::IsNullOrWhiteSpace($resolvedFreeCadPython)) { + $resolvedFreeCadPython + } else { + $resolvedFreeCadCmd + } + + $runtimeObject = [ordered]@{ + schema_version = 1 + runtime_root = $resolvedRuntimeRoot + runtime_config = $runtimeConfigPath + diagnostic_log = $diagnosticLogPath + occt_root = $resolvedOcctRoot + freecad_root = $resolvedRunRoot + freecad_lib = $runBinDir + freecad_python = $resolvedFreeCadPython + freecad_cmd = $resolvedFreeCadCmd + qet_3d_python = $qet3dPython + path_prefix = @($runBinDir) + } + + ($runtimeObject | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $runtimeConfigPath -Encoding UTF8 +} + +Write-Host "FreeCAD runtime deployment completed." +Write-Host (" Run root: {0}" -f $resolvedRunRoot) +Write-Host (" LibPack root: {0}" -f $resolvedLibPackRoot) +if (-not $SkipRuntimeJson) { + Write-Host (" runtime.json: {0}" -f (Join-Path (Resolve-RuntimeRoot -ConfiguredRoot $RuntimeRoot) "runtime.json")) +}