Add FreeCAD exchange integration

csm
zhanghao 1 week ago
parent 45fdeba1d1
commit baf323b0c1

1
.gitignore vendored

@ -48,6 +48,7 @@ install_manifest.txt
/Mod/
/ZERO_CHECK.dir/
/build/
/bulid/
/build-*/
/cmake-build*/
/src/Tools/offlinedoc/localwiki/

@ -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
<ProjectRoot>/.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 的最小项目快照”,而不是数据库整表镜像。

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

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

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

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

@ -0,0 +1 @@
# FreeCADExchange init module.

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

@ -0,0 +1 @@
# FreeCADExchange Python module.

@ -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"))
}
Loading…
Cancel
Save