feature/端子显示连线保存回写-zwl-0520

dev
Zhaowenlong 7 days ago
parent 79d39fcf2a
commit 73ce289d98

@ -0,0 +1,461 @@
# FreeCAD 端子显示、手动连线、保存回写开发文档
## 1. 当前任务边界
本文档只面向当前由一个人负责的 FreeCAD 3D 工作台能力:
- 端子显示
- 端子手动连线
- FreeCAD 文档保存
- 最小 3D -> 2D 回写
当前不做:
- 自动布线
- `D:\code\zwl\sources\ThreeD` 旧 3D 引擎扩展
- 复杂 UI 改造
- FreeCAD 核心 C/C++ 源码改造
- 3D 位姿写入数据库
- 新增或依赖旧 3D 场景表
第一版 3D 真相源是 FreeCAD 文档,即 `scene.FCStd`。QET/zwl 只负责 2D 数据、交换 JSON、启动 FreeCAD、后续读取最小回写结果。
## 2. 已有代码与文档现状
### 2.1 LightWork3D / FreeCAD 侧
已有模块:
- `src/Mod/FreeCADExchange/ExchangeBootstrap.py`
- `src/Mod/FreeCADExchange/DeviceImport.py`
- `src/Mod/FreeCADExchange/InitGui.py`
当前已经做到:
- 从环境变量 `QET_2D_TO_3D_JSON` 读取 `2d_to_3d.json`
- 校验 `project_uuid / devices / terminals / device_models`
- 根据 `resolved_model_path` 导入 STEP / IGES / FCStd 等本地模型
- 将导入的设备放入 `QETExchangeDevices` 根组
当前还没有真正完成:
- 根据 `terminals` 创建设备端子对象
- 端子对象的 LCS / 标记属性
- 端子之间的手动连线
- `3d_to_2d.json` 回写
### 2.2 zwl / QET 侧
已有模块:
- `sources/FreeCAD/FreeCADExchangeExportService.cpp`
- `sources/FreeCAD/FreeCADLaunchService.cpp`
当前已经做到:
- 导出 `<ProjectRoot>/.qet_freecad/2d_to_3d.json`
- 返回 `<ProjectRoot>/.qet_freecad/scene.FCStd`
- 启动 FreeCAD并设置
- `QET_2D_TO_3D_JSON`
- `QET_FREECAD_SCENE_FILE`
第一版数据库约束:
- 只允许依赖 `project_2d3d_symbol_binding`
- 只允许依赖 `project_2d3d_terminal_binding`
- 设备绑定只依赖 `project_uuid / element_uuid / instance_id`
- 端子绑定只依赖 `project_uuid / terminal_uuid / instance_id`
- 第一版端子绑定唯一依据是 `terminal_uuid`
### 2.3 代码落地约束
当前第一版实现优先放在:
```text
D:\LightWork3D\src\Mod\FreeCADExchange
```
实现形式优先使用 Python 文件,例如:
```text
src/Mod/FreeCADExchange/DeviceImport.py
src/Mod/FreeCADExchange/TerminalImport.py
src/Mod/FreeCADExchange/ManualWiring.py
src/Mod/FreeCADExchange/ExchangeWriteBack.py
```
第一版尽量只改 FreeCAD 插件层:
- 可以新增 `FreeCADExchange` 下的 `.py` 文件。
- 可以修改 `FreeCADExchange\ExchangeBootstrap.py` 做流程调度。
- 可以修改 `FreeCADExchange\InitGui.py` 注册简单命令。
- 可以修改 `FreeCADExchange\CMakeLists.txt`,把新增 `.py` 加入安装/复制列表。
第一版尽量不改:
- FreeCAD 核心 C/C++ 源码。
- `D:\code\zwl\sources\ThreeD` 旧 3D 模块。
- 数据库旧 3D 场景表。
这样做的好处是:你的端子显示、手动连线、保存回写可以先作为 FreeCAD 工作台插件跑通;后续 csm 改 UI、qdj 做自动布线时,可以直接调用这些 Python 层能力,不需要他们理解或修改 FreeCAD 核心源码。
## 3. 本地设备模板是否可以使用
可以,而且第一版建议优先使用本地模板。
QET 导出的 `device_models[].resolved_model_path` 本来就是给 FreeCAD 使用的本地模型路径。FreeCADExchange 当前支持导入:
- `.step`
- `.stp`
- `.iges`
- `.igs`
- `.brep`
- `.brp`
- `.fcstd`
因此本地 STEP 文件可以直接作为第一批模板资源。
但需要注意:
> 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。
所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版可以采用两种方式:
1. 优先方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。
2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。
sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。
sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotation`,让模板端子不只是“有位置”,还可以“有方向”。
FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。
## 4. 为什么要先落地设备模板
这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。
原因:
- 端子必须依附在设备实例上,不能只是空间中的孤立点。
- 端子连线需要稳定的起点、终点和出线方向。
- STEP 普通顶点不稳定,不适合作为端子主对象。
- 如果没有模板层,后续每种设备都要手工猜端子位置。
- 自动布线后续会依赖当前手动布线沉淀出的端子对象和路径对象。
因此第一版只需要选 1 到 2 个设备样板,例如断路器、端子排或继电器,把模板约定跑通。
## 5. 第一版对象模型
### 5.1 设备实例组
每个 2D 设备实例在 FreeCAD 中对应一个设备组。
建议对象:
```text
QETExchangeDevices
QETDevice_<element_uuid>
imported model objects
QETTerminals
QETWires
```
设备组至少保存属性:
- `QetProjectUuid`
- `QetElementUuid`
- `QetInstanceId`
- `QetResolvedModelPath`
`QetInstanceId` 如果输入为空,由 FreeCAD 生成,并在保存回写时写入 `3d_to_2d.json`
### 5.2 端子对象
端子使用 FreeCAD LCS 表示。推荐对象为:
```text
Datum CoordinateSystem + Role="Terminal"
```
端子对象至少保存属性:
- `QetProjectUuid`
- `QetElementUuid`
- `QetTerminalUuid`
- `QetInstanceId`
- `Role = "Terminal"`
- `CanWire = true`
端子判断规则:
1. 对象必须有 `Role`
2. `Role == "Terminal"`
3. 对象必须有 `QetTerminalUuid`
4. `CanWire == true`
第一版禁止把 `terminal_key``connection_point_key` 当作绑定主键。模板内部可以有槽位名,但最终业务识别只认 `terminal_uuid`
### 5.3 手动连线对象
手动连线可以先用 FreeCAD 中的折线对象表示,例如 Draft Wire 或 Part 曲线。
连线对象至少保存属性:
- `QetProjectUuid`
- `QetStartTerminalUuid`
- `QetEndTerminalUuid`
- `QetStartInstanceId`
- `QetEndInstanceId`
- `RouteType = "Manual"`
连线对象建议挂在对应设备实例下的 `QETWires_<element_uuid>` 组里,优先跟随起点端子所属设备。
第一版连线路径保存在 `scene.FCStd`。是否把路径几何回写给 QET后续单独扩协议不在当前最小数据库绑定范围内。
## 6. 推荐文件结构
`src/Mod/FreeCADExchange` 下逐步拆分模块:
```text
FreeCADExchange/
ExchangeBootstrap.py # 启动入口,读取 JSON调度导入流程
DeviceImport.py # 设备导入,已存在
TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位
TerminalImport.py # 新增:根据 terminals 创建/更新端子对象
TerminalObjects.py # 新增:端子对象属性、查找、校验工具
ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性
ExchangeWriteBack.py # 新增:生成 3d_to_2d.json
InitGui.py # 后续由 UI 改造统一接入命令
CMakeLists.txt # 新增 .py 后必须同步加入 FreeCADExchange_Scripts
```
如果暂时不做 UI可以先暴露 Python 函数和简单命令,保证功能链路可测。
推荐第一版调用链:
```text
ExchangeBootstrap.py
-> DeviceImport.import_devices_from_payload(...)
-> TerminalImport.import_terminals_from_payload(...)
-> ExchangeWriteBack.write_back_if_needed(...) 或由保存命令触发
ManualWiring.py
-> 只允许选择 TerminalObjects.is_terminal_object(...) 认可的端子对象
-> 创建 RouteType="Manual" 的连线对象
-> 保存到 scene.FCStd
```
每新增一个 Python 模块,都要加入 `FreeCADExchange\CMakeLists.txt``FreeCADExchange_Scripts`,否则 Visual Studio 构建/INSTALL 后运行目录里不会出现该脚本。
## 7. 开发步骤
### 阶段 A本地模板导入基线
目标:
- 本地 STEP / FCStd 可以稳定导入。
- 设备组有 `QetElementUuid / QetInstanceId`
- 重新打开同一个项目时,不重复创建同一设备。
改动点:
- 保持 `DeviceImport.py` 现有逻辑。
- 补齐设备组上的 `QetProjectUuid`
- 如果 `instance_id` 为空,生成稳定的 FreeCAD 实例 ID。
验收:
- 使用本地 STEP 文件导入设备。
- FreeCAD 树中可看到设备组。
- 关闭并重新打开,不产生重复设备组。
### 阶段 B端子显示
目标:
- 根据 `2d_to_3d.json``terminals` 为设备创建端子对象。
- 每个端子都能在 FreeCAD 场景中被看到、被选中。
实现建议:
- 新增 `TerminalImport.py`
- 按 `element_uuid``instance_id` 找到设备组。
- 为每个 `terminal_uuid` 创建 LCS。
- LCS 放入设备组下的 `QETTerminals` 子组。
端子位置策略:
1. 如果 FCStd 模板已有 `Role="Terminal"` 的 LCS则优先复用模板 LCS。
2. 如果有 sidecar JSON则按 sidecar 坐标创建。
3. 如果只有 STEP则先按设备包围盒生成临时端子排列用于打通流程。
临时端子排列只用于第一版验证,不作为长期物理端子定义。
验收:
- 控制台输出端子创建数量。
- 每个端子对象带 `QetTerminalUuid`
- 选择端子时能确认它属于哪个设备实例。
### 阶段 C端子手动连线
目标:
- 只能从端子连到端子。
- 普通几何点、边、面不能直接作为布线端点。
实现建议:
- 新增 `ManualWiring.py`
- 提供函数:
- `is_terminal_object(obj)`
- `create_manual_wire(start_terminal, end_terminal, waypoints)`
- `list_manual_wires(doc)`
- 第一版路径点可以先使用:
- 起点 LCS 原点
- 用户手动点选的中间点
- 终点 LCS 原点
如果暂时没有完整 UI可先做最小命令
1. 用户选择两个端子对象。
2. 执行命令生成一条直线或折线。
3. 连线对象保存起止端子 UUID。
验收:
- 未选择端子时拒绝创建连线。
- 选择同一个端子作为起终点时拒绝创建。
- 创建成功后,连线对象带起点和终点 UUID。
### 阶段 D保存与回写
目标:
- FreeCAD 保存 `scene.FCStd`
- 生成 `.qet_freecad/3d_to_2d.json`
实现建议:
- 新增 `ExchangeWriteBack.py`
- 从文档中扫描:
- 设备组
- 端子对象
- 手动连线对象
- 第一版正式回写结构遵守现有协议最小集:
```json
{
"schema_version": "1.0",
"project_uuid": "string",
"generated_at": "2026-05-20T10:00:00+08:00",
"instances": [
{
"element_uuid": "string",
"instance_id": "string"
}
],
"terminals": [
{
"terminal_uuid": "string",
"instance_id": "string"
}
]
}
```
连线路径第一版保存在 `.FCStd`。如需 QET 读取线对象,建议后续扩展协议字段,不直接写入现有两张绑定表。
验收:
- 保存后存在 `3d_to_2d.json`
- JSON 中每个设备有 `element_uuid / instance_id`
- JSON 中每个端子有 `terminal_uuid / instance_id`
- 不写入 `project_3d_scene_instance`、`project_3d_space_object`、`project_2d3d_link`。
## 8. 是否能达到图二效果
可以达到同方向效果,但需要分层理解。
图二效果包含:
- 本地模型导入
- 柜体和导轨等场景模型
- 设备装配
- FreeCAD 视图展示
- 模型树层级
本文档当前只保证:
- 本地模板能进入 FreeCAD
- 端子能显示
- 端子能手动连线
- FreeCAD 能保存并回写最小绑定
如果本地模板里包含机柜、导轨和设备,并且导入逻辑按组组织,就可以逐步做出图二那类 FreeCAD 场景。复杂 UI、自动排布、自动布线不属于当前个人任务第一阶段。
## 9. 首批模板建议
第一批不要铺太多设备。建议只选:
1. 机柜或安装板:提供场景参考。
2. DIN 导轨:提供设备摆放参考。
3. 一个断路器或继电器:验证设备端子。
4. 一个端子排:验证多端子排列和连线。
每个模板至少准备:
- 原始模型文件:`.STEP` 或 `.FCStd`
- 可选 sidecar端子槽位坐标
- 模板说明:原点、朝向、尺寸单位、端子数量
常用设备建议优先补齐 `sidecar JSON``FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。
## 10. 单人开发优先级
建议按下面顺序推进:
1. 不改 UI先把 Python 功能函数跑通。
2. 不碰自动布线,先把端子对象和手动线对象持久化。
3. 不扩旧数据库表,只生成 `3d_to_2d.json`
4. 不追求所有设备,先做一个样板设备跑通闭环。
5. 不让 STEP 顶点直接参与布线,只认带属性的端子 LCS。
## 11. 最小验收场景
准备一个测试项目:
- 2 个设备
- 每个设备至少 2 个端子
- 每个设备绑定一个本地 STEP 或 FCStd 模型
验收步骤:
1. 在 QET 点击打开 FreeCAD。
2. QET 生成 `2d_to_3d.json`
3. FreeCAD 导入设备模型。
4. FreeCAD 为端子创建 LCS。
5. 用户选择两个端子创建手动连线。
6. 保存 FreeCAD 文档。
7. 生成 `3d_to_2d.json`
8. 关闭 FreeCAD 再打开,设备、端子、连线仍存在。
达到以上结果,就说明“端子显示 + 端子连线 + 保存回写”第一版闭环成立。
## 12. 开发结果记录要求
每次开发完成后,都要在本文档末尾追加一条开发记录,至少包含:
- 时间
- 本次实现了什么
- 验证结果
- 未完成或待跟进事项
建议格式:
```text
- 2026-05-20完成端子对象创建与显示逻辑支持 FCStd LCS / sidecar JSON / bbox fallback已验证 FreeCAD 可导入并生成端子对象。待补常用设备真实端子坐标。
- 2026-05-20补上 sidecar `rotation` 解析与端子 Placement 应用,端子模板现在可以同时带位置和方向;已用单元测试验证解析和放置逻辑。
- 2026-05-20补上 FCStd 模板 LCS 朝向保留,模板端子现在可以从源对象直接继承 Placement 方向;已用单元测试验证。
- 2026-05-20补上手动连线对象归属到设备 `QETWires_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。
```

@ -5,6 +5,11 @@ set(FreeCADExchange_Scripts
ExchangeBootstrap.py ExchangeBootstrap.py
DeviceImport.py DeviceImport.py
DevicePreview.py DevicePreview.py
TerminalObjects.py
TemplateSemantics.py
TerminalImport.py
ExchangeWriteBack.py
ManualWiring.py
) )
add_custom_target(FreeCADExchangeScripts ALL add_custom_target(FreeCADExchangeScripts ALL

@ -1,5 +1,6 @@
import os import os
from pathlib import Path from pathlib import Path
import uuid
import FreeCAD as App import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
@ -11,6 +12,10 @@ ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices" ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel" CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
DEVICE_GROUP_PREFIX = "QETDevice_" DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
GROUP_KIND_TERMINALS = "Terminals"
GROUP_KIND_WIRES = "Wires"
class DeviceImportError(RuntimeError): class DeviceImportError(RuntimeError):
@ -88,6 +93,60 @@ def _ensure_bool_property(obj, prop_name, group_name, description, value):
setattr(obj, prop_name, bool(value)) setattr(obj, prop_name, bool(value))
def _ensure_child_group(doc, parent_group, element_uuid, instance_id, name_prefix, label, group_kind, project_uuid=""):
target_uuid = (element_uuid or "").strip()
preferred_name = name_prefix + _safe_token(target_uuid)
group = doc.getObject(preferred_name)
if group is None:
for candidate in getattr(parent_group, "Group", []) or []:
if getattr(candidate, "QetGroupKind", "").strip() != group_kind:
continue
if target_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_uuid:
continue
group = candidate
break
if group is None:
group = doc.addObject("App::DocumentObjectGroup", preferred_name)
if group not in getattr(parent_group, "Group", []):
parent_group.addObject(group)
group.Label = label
project_uuid = (project_uuid or "").strip() or getattr(group, "QetProjectUuid", "").strip()
element_uuid = (element_uuid or "").strip() or getattr(group, "QetElementUuid", "").strip()
instance_id = (instance_id or "").strip() or getattr(group, "QetInstanceId", "").strip()
_ensure_string_property(
group,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
group_kind,
)
_ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
_ensure_string_property(
group,
"QetElementUuid",
"QET Exchange",
"Parent element UUID from QET exchange",
element_uuid,
)
_ensure_string_property(
group,
"QetInstanceId",
"QET Exchange",
"Parent instance id from QET exchange",
instance_id,
)
return group
def _ensure_document(scene_path): def _ensure_document(scene_path):
preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene" 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) existing_doc = DevicePreview.find_main_exchange_document(preferred_name)
@ -116,7 +175,7 @@ def _cabinet_label_text(cabinet):
return "QET Cabinet" return "QET Cabinet"
def _ensure_root_group(doc, cabinet=None): def _ensure_root_group(doc, cabinet=None, project_uuid=""):
root = doc.getObject(ROOT_GROUP_NAME) root = doc.getObject(ROOT_GROUP_NAME)
if root is None: if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME) root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
@ -174,6 +233,14 @@ def _ensure_root_group(doc, cabinet=None):
"Cabinet location id from QET exchange", "Cabinet location id from QET exchange",
str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "", str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "",
) )
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
_ensure_string_property(
root,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
return root return root
@ -269,6 +336,33 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path,
) )
if created_now: if created_now:
device_group.Placement = App.Placement() device_group.Placement = App.Placement()
_ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
TERMINAL_GROUP_PREFIX,
"QET Terminals",
GROUP_KIND_TERMINALS,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
WIRE_GROUP_PREFIX,
"QET Wires",
GROUP_KIND_WIRES,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
return device_group, created_now return device_group, created_now
@ -286,9 +380,19 @@ def _remove_object_tree(doc, obj):
def _clear_group_contents(doc, group): def _clear_group_contents(doc, group):
for child in list(getattr(group, "Group", []) or []): for child in list(getattr(group, "Group", []) or []):
child_name = getattr(child, "Name", "")
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
continue
if getattr(child, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}:
continue
_remove_object_tree(doc, child) _remove_object_tree(doc, child)
def _generate_instance_id(project_uuid, element_uuid):
seed = "QET:{0}:{1}".format((project_uuid or "").strip(), (element_uuid or "").strip())
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
def _supported_for_import(model_path): def _supported_for_import(model_path):
suffix = Path(model_path).suffix.lower() suffix = Path(model_path).suffix.lower()
return suffix in { return suffix in {
@ -397,7 +501,8 @@ def import_devices_from_payload(payload, scene_path=""):
_append_debug_log("DeviceImport.import_devices_from_payload entered") _append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path) doc = _ensure_document(scene_path)
cabinet = payload.get("cabinet") cabinet = payload.get("cabinet")
root_group = _ensure_root_group(doc, cabinet) project_uuid = (payload.get("project_uuid") or "").strip()
root_group = _ensure_root_group(doc, cabinet, project_uuid)
models_by_element = _model_index(payload) models_by_element = _model_index(payload)
report = { report = {
@ -457,6 +562,13 @@ def import_devices_from_payload(payload, scene_path=""):
continue continue
existing_group = _find_device_group(doc, element_uuid) existing_group = _find_device_group(doc, element_uuid)
if not instance_id:
existing_instance_id = ""
if existing_group is not None:
existing_instance_id = getattr(existing_group, "QetInstanceId", "").strip()
instance_id = existing_instance_id or _generate_instance_id(project_uuid, element_uuid)
report.setdefault("generated_instance_ids", 0)
report["generated_instance_ids"] += 1
device_group, created_now = _ensure_device_group( device_group, created_now = _ensure_device_group(
doc, doc,
root_group, root_group,

@ -7,6 +7,8 @@ import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import DeviceImport import DeviceImport
import DevicePreview import DevicePreview
import ExchangeWriteBack
import TerminalImport
try: try:
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
@ -24,6 +26,8 @@ STATE_FLAG = "_qet_exchange_bootstrapped"
STATE_PAYLOAD = "_qet_exchange_payload" STATE_PAYLOAD = "_qet_exchange_payload"
STATE_SUMMARY = "_qet_exchange_summary" STATE_SUMMARY = "_qet_exchange_summary"
STATE_IMPORT_REPORT = "_qet_exchange_import_report" STATE_IMPORT_REPORT = "_qet_exchange_import_report"
STATE_TERMINAL_IMPORT_REPORT = "_qet_exchange_terminal_import_report"
STATE_WRITEBACK_REPORT = "_qet_exchange_writeback_report"
STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled" STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled"
STATE_TREE_FILTER = "_qet_exchange_tree_filter" STATE_TREE_FILTER = "_qet_exchange_tree_filter"
STATE_TREE_SIGNAL_CONNECTIONS = "_qet_exchange_tree_signal_connections" STATE_TREE_SIGNAL_CONNECTIONS = "_qet_exchange_tree_signal_connections"
@ -497,7 +501,7 @@ def _build_summary(payload, json_path):
} }
def _summary_message(summary, import_report=None): def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None):
lines = [ lines = [
"QET exchange file loaded successfully.", "QET exchange file loaded successfully.",
"", "",
@ -593,9 +597,38 @@ def _summary_message(summary, import_report=None):
if len(warnings) > 10: if len(warnings) > 10:
lines.append("- ... ({0} more)".format(len(warnings) - 10)) lines.append("- ... ({0} more)".format(len(warnings) - 10))
if terminal_report:
lines.extend(
[
"",
"3D terminal import summary:",
"Document: {0}".format(terminal_report["document_name"]),
"Imported terminals: {0}".format(terminal_report["imported_terminals"]),
"Updated terminals: {0}".format(terminal_report["updated_terminals"]),
"Removed stale terminals: {0}".format(terminal_report["removed_terminals"]),
]
)
warnings = terminal_report.get("warnings", [])
if warnings:
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))
if writeback_report:
lines.extend(
[
"",
"3D write-back:",
"Output file: {0}".format(writeback_report["output_path"]),
"Instances written: {0}".format(len(writeback_report["instances"])),
"Terminals written: {0}".format(len(writeback_report["terminals"])),
]
)
lines.append("") lines.append("")
lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.") 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.") lines.append("3D terminal import and write-back are enabled.")
return "\n".join(lines) return "\n".join(lines)
@ -641,6 +674,7 @@ def _run_scheduled_device_import(attempt=0):
_show_error("QET Exchange", str(exc)) _show_error("QET Exchange", str(exc))
App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc)) App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc))
return return
except Exception as exc: except Exception as exc:
_append_debug_log("unexpected device import exception: {0}".format(exc)) _append_debug_log("unexpected device import exception: {0}".format(exc))
_append_debug_log(traceback.format_exc()) _append_debug_log(traceback.format_exc())
@ -680,8 +714,54 @@ def _run_scheduled_device_import(attempt=0):
import_report["skipped_missing_model"], import_report["skipped_missing_model"],
) )
) )
if import_report.get("generated_instance_ids"):
App.Console.PrintMessage(
"[FreeCADExchange] Generated device instance IDs: {0}\n".format(
import_report["generated_instance_ids"]
)
)
try:
terminal_report = TerminalImport.import_terminals_from_payload(payload, scene_path)
except TerminalImport.TerminalImportError as exc:
_append_debug_log("terminal 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 terminal import exception: {0}".format(exc))
_append_debug_log(traceback.format_exc())
_show_error("QET Exchange", "Failed to import 3D terminals:\n{0}".format(exc))
App.Console.PrintError(
"[FreeCADExchange] Failed to import terminals: {0}\n".format(exc)
)
return
setattr(App, STATE_TERMINAL_IMPORT_REPORT, terminal_report)
try:
writeback_report = ExchangeWriteBack.write_back_document(
App.ActiveDocument, scene_path=scene_path, payload=payload
)
except Exception as exc:
_append_debug_log("write-back failed after import: {0}".format(exc))
_append_debug_log(traceback.format_exc())
writeback_report = None
else:
setattr(App, STATE_WRITEBACK_REPORT, writeback_report)
_show_info("QET Exchange", _summary_message(summary, import_report)) App.Console.PrintMessage(
"[FreeCADExchange] Imported terminals: {0}, updated: {1}, removed: {2}\n".format(
terminal_report["imported_terminals"],
terminal_report["updated_terminals"],
terminal_report["removed_terminals"],
)
)
_show_info(
"QET Exchange",
_summary_message(summary, import_report, terminal_report, writeback_report),
)
_append_debug_log("summary dialog shown") _append_debug_log("summary dialog shown")

@ -0,0 +1,323 @@
# FreeCADExchange write-back helpers.
import json
import os
from datetime import datetime
from pathlib import Path
import FreeCAD as App
import DeviceImport
import TerminalObjects as TerminalObjects
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
STATE_WRITEBACK_OBSERVER = "_qet_exchange_writeback_observer"
class ExchangeWriteBackError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _project_uuid_from_payload(payload):
if isinstance(payload, dict):
value = (payload.get("project_uuid") or "").strip()
if value:
return value
return ""
def _root_group(doc):
try:
return doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
except Exception:
return None
def _is_device_group(obj):
if obj is None:
return False
try:
if not obj.Name.startswith(DeviceImport.DEVICE_GROUP_PREFIX):
return False
return "QetElementUuid" in getattr(obj, "PropertiesList", [])
except Exception:
return False
def _iter_device_groups(doc):
root = _root_group(doc)
if root is not None:
for child in list(getattr(root, "Group", []) or []):
if _is_device_group(child):
yield child
return
for obj in doc.Objects:
if _is_device_group(obj):
yield obj
def _iter_terminal_objects(device_group):
terminal_container = TerminalObjects.find_child_group_by_kind(
device_group,
TerminalObjects.TERMINAL_GROUP_KIND,
)
if terminal_container is None:
return []
return TerminalObjects.collect_terminal_objects(terminal_container)
def _scene_path_from_doc(doc, scene_path=""):
candidate = (scene_path or "").strip()
if candidate:
return candidate
env_scene = os.environ.get("QET_FREECAD_SCENE_FILE", "").strip()
if env_scene:
return env_scene
file_name = getattr(doc, "FileName", "").strip()
if file_name:
return file_name
return ""
def _output_path_for_scene(scene_path):
scene_path = (scene_path or "").strip()
if not scene_path:
return ""
path = Path(scene_path)
if path.suffix.lower() == ".fcstd":
return str(path.with_name("3d_to_2d.json"))
if path.is_dir():
return str(path / "3d_to_2d.json")
if path.name.lower().endswith(".fcstd"):
return str(path.with_name("3d_to_2d.json"))
return str(path.parent / "3d_to_2d.json")
def _format_timestamp():
return datetime.now().astimezone().isoformat(timespec="seconds")
def _collect_instance_bindings(doc):
bindings = []
seen = set()
for device_group in _iter_device_groups(doc):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
instance_id = getattr(device_group, "QetInstanceId", "").strip()
if not element_uuid or not instance_id:
continue
key = (element_uuid, instance_id)
if key in seen:
continue
seen.add(key)
bindings.append(
{
"element_uuid": element_uuid,
"instance_id": instance_id,
}
)
return bindings
def _collect_terminal_bindings(doc):
bindings = []
seen = set()
for device_group in _iter_device_groups(doc):
instance_id = getattr(device_group, "QetInstanceId", "").strip()
for terminal_obj in _iter_terminal_objects(device_group):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id
if not terminal_uuid or not terminal_instance_id:
continue
key = (terminal_uuid, terminal_instance_id)
if key in seen:
continue
seen.add(key)
bindings.append(
{
"terminal_uuid": terminal_uuid,
"instance_id": terminal_instance_id,
}
)
return bindings
def _project_uuid_from_doc(doc, payload=None):
root = _root_group(doc)
if root is not None:
project_uuid = getattr(root, "QetProjectUuid", "").strip()
if project_uuid:
return project_uuid
return _project_uuid_from_payload(payload)
def write_back_document(doc=None, scene_path="", payload=None):
if doc is None:
doc = App.ActiveDocument
if doc is None:
raise ExchangeWriteBackError("No active FreeCAD document is available.")
scene_path = _scene_path_from_doc(doc, scene_path)
output_path = _output_path_for_scene(scene_path)
if not output_path:
raise ExchangeWriteBackError(
"Cannot determine the 3d_to_2d.json output path."
)
project_uuid = _project_uuid_from_doc(doc, payload)
if not project_uuid:
raise ExchangeWriteBackError(
"Cannot determine project_uuid for write-back."
)
report = {
"schema_version": "1.0",
"project_uuid": project_uuid,
"generated_at": _format_timestamp(),
"instances": _collect_instance_bindings(doc),
"terminals": _collect_terminal_bindings(doc),
"output_path": output_path,
}
output_dir = str(Path(output_path).parent)
os.makedirs(output_dir, exist_ok=True)
Path(output_path).write_text(
json.dumps(
{
"schema_version": report["schema_version"],
"project_uuid": report["project_uuid"],
"generated_at": report["generated_at"],
"instances": report["instances"],
"terminals": report["terminals"],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
_append_debug_log(
"write_back_document completed: instances={0}, terminals={1}, path={2}".format(
len(report["instances"]),
len(report["terminals"]),
output_path,
)
)
try:
App.Console.PrintMessage(
"[FreeCADExchange] Wrote 3d_to_2d.json to {0}\n".format(output_path)
)
except Exception:
pass
return report
def _is_exchange_document(doc):
if doc is None:
return False
if _root_group(doc) is not None:
return True
for obj in doc.Objects:
if _is_device_group(obj):
return True
return False
class _WriteBackObserver:
def slotFinishSaveDocument(self, doc, name):
if not _is_exchange_document(doc):
return
try:
write_back_document(doc, scene_path=name)
except Exception as exc:
_append_debug_log("write-back after save failed: {0}".format(exc))
try:
App.Console.PrintError(
"[FreeCADExchange] write-back after save failed: {0}\n".format(exc)
)
except Exception:
pass
def ensure_document_observer_installed():
if getattr(App, STATE_WRITEBACK_OBSERVER, None) is not None:
return getattr(App, STATE_WRITEBACK_OBSERVER)
observer = _WriteBackObserver()
try:
App.addDocumentObserver(observer)
except Exception as exc:
_append_debug_log("failed to add write-back observer: {0}".format(exc))
return None
setattr(App, STATE_WRITEBACK_OBSERVER, observer)
return observer
class CommandWriteBack:
def GetResources(self):
return {
"MenuText": "Write Back 3D Binding",
"ToolTip": "Generate 3d_to_2d.json from the current FreeCAD document",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
try:
report = write_back_document(App.ActiveDocument)
try:
App.Console.PrintMessage(
"[FreeCADExchange] Write-back completed: {0} instances, {1} terminals\n".format(
len(report["instances"]),
len(report["terminals"]),
)
)
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError(
"[FreeCADExchange] Write-back failed: {0}\n".format(exc)
)
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None:
return
try:
Gui.addCommand("QET_Exchange_WriteBack", CommandWriteBack())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register write-back command: {0}".format(exc))
register_commands()
ensure_document_observer_installed()

@ -12,6 +12,8 @@ except ImportError:
from PySide import QtCore from PySide import QtCore
import ExchangeBootstrap import ExchangeBootstrap
import ExchangeWriteBack
import ManualWiring
def _append_init_log(message): def _append_init_log(message):
@ -30,5 +32,20 @@ def _append_init_log(message):
_append_init_log("InitGui imported") _append_init_log("InitGui imported")
try:
ExchangeWriteBack.ensure_document_observer_installed()
except Exception:
pass
try:
ExchangeWriteBack.register_commands()
except Exception:
pass
try:
ManualWiring.register_commands()
except Exception:
pass
QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested)

@ -0,0 +1,236 @@
# FreeCADExchange manual wiring helpers.
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
import DeviceImport
import TerminalObjects as TerminalObjects
class ManualWiringError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _terminal_points(start_terminal, end_terminal, waypoints=None):
points = [TerminalObjects.terminal_origin(start_terminal)]
for point in waypoints or []:
if isinstance(point, App.Vector):
points.append(point)
elif isinstance(point, (list, tuple)) and len(point) >= 3:
points.append(App.Vector(float(point[0]), float(point[1]), float(point[2])))
points.append(TerminalObjects.terminal_origin(end_terminal))
return points
def _wire_object_name(start_terminal, end_terminal):
start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", ""))
end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", ""))
return "QETWire_{0}_{1}".format(start_uuid, end_uuid)
def _set_wire_properties(obj, project_uuid, start_terminal, end_terminal):
TerminalObjects.ensure_string_property(
obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID for this wire",
project_uuid,
)
TerminalObjects.ensure_string_property(
obj,
"QetStartTerminalUuid",
"QET Exchange",
"Start terminal UUID",
getattr(start_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndTerminalUuid",
"QET Exchange",
"End terminal UUID",
getattr(end_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetStartInstanceId",
"QET Exchange",
"Start device instance ID",
getattr(start_terminal, "QetInstanceId", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndInstanceId",
"QET Exchange",
"End device instance ID",
getattr(end_terminal, "QetInstanceId", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"RouteType",
"QET Exchange",
"Wire route type",
"Manual",
)
def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None):
for terminal in (start_terminal, end_terminal):
element_uuid = getattr(terminal, "QetElementUuid", "").strip()
if not element_uuid:
continue
device_group = TerminalObjects.find_device_group(doc, element_uuid)
if device_group is None:
continue
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
return TerminalObjects.ensure_wire_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=device_instance_id,
)
return fallback_group
def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent_group=None):
if not TerminalObjects.is_terminal_object(start_terminal):
raise ManualWiringError("The start selection is not a valid terminal.")
if not TerminalObjects.is_terminal_object(end_terminal):
raise ManualWiringError("The end selection is not a valid terminal.")
if start_terminal == end_terminal:
raise ManualWiringError("The start and end terminal must be different.")
project_uuid = (
getattr(start_terminal, "QetProjectUuid", "").strip()
or getattr(end_terminal, "QetProjectUuid", "").strip()
or getattr(DeviceImport._ensure_root_group(doc), "QetProjectUuid", "").strip()
)
if not project_uuid:
raise ManualWiringError("A project UUID is required to create a wire.")
wire_base_name = TerminalObjects.safe_token(
_wire_object_name(start_terminal, end_terminal)
)
wire_name = wire_base_name
suffix = 1
while doc.getObject(wire_name) is not None:
wire_name = "{0}_{1}".format(wire_base_name, suffix)
suffix += 1
wire_obj = doc.addObject("Part::Feature", wire_name)
wire_obj.Label = "QET Manual Wire"
points = _terminal_points(start_terminal, end_terminal, waypoints=waypoints)
if len(points) < 2:
raise ManualWiringError("A wire requires at least two points.")
import Part
wire_obj.Shape = Part.makePolygon(points)
_set_wire_properties(
wire_obj,
project_uuid,
start_terminal,
end_terminal,
)
if parent_group is None:
try:
parent_group = _wire_parent_group(
doc,
project_uuid,
start_terminal,
end_terminal,
fallback_group=DeviceImport._ensure_root_group(doc, project_uuid),
)
except Exception:
parent_group = None
if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []):
parent_group.addObject(wire_obj)
try:
wire_obj.ViewObject.LineWidth = 2.0
except Exception:
pass
try:
wire_obj.ViewObject.LineColor = (0.0, 0.6, 1.0)
except Exception:
pass
doc.recompute()
return wire_obj
class CommandCreateManualWire:
def GetResources(self):
return {
"MenuText": "Create Manual Wire",
"ToolTip": "Create a manual wire between two selected terminals",
}
def IsActive(self):
return App.ActiveDocument is not None and Gui is not None
def Activated(self):
if Gui is None:
return
selection = [
obj
for obj in Gui.Selection.getSelection()
if TerminalObjects.is_terminal_object(obj)
]
if len(selection) != 2:
try:
App.Console.PrintWarning(
"Select exactly two valid terminals before creating a wire.\n"
)
except Exception:
pass
return
try:
create_manual_wire(App.ActiveDocument, selection[0], selection[1])
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError(
"[FreeCADExchange] manual wire creation failed: {0}\n".format(exc)
)
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None:
return
try:
Gui.addCommand("QET_Exchange_CreateManualWire", CommandCreateManualWire())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register manual wiring command: {0}".format(exc))
register_commands()

@ -0,0 +1,363 @@
# FreeCADExchange template semantics helpers.
import json
from pathlib import Path
import FreeCAD as App
import TerminalObjects as TerminalObjects
def _sidecar_candidates(model_path):
native = TerminalObjects.native_path(model_path)
if not native:
return []
path = Path(native)
base = path.stem
parent = path.parent
return [
parent / (base + ".terminals.json"),
parent / (base + ".qet_terminals.json"),
parent / (base + ".terminal_slots.json"),
parent / (base + ".qet_template.json"),
]
def _load_json_file(path):
try:
raw_text = path.read_text(encoding="utf-8")
except OSError:
return None
try:
payload = json.loads(raw_text)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
return payload
def _vector_from_value(value):
if isinstance(value, App.Vector):
return value
if isinstance(value, dict):
if {"x", "y", "z"}.issubset(value.keys()):
return App.Vector(
float(value["x"]),
float(value["y"]),
float(value["z"]),
)
if isinstance(value, (list, tuple)) and len(value) >= 3:
return App.Vector(float(value[0]), float(value[1]), float(value[2]))
return None
def _rotation_from_value(value):
if not isinstance(value, dict):
return None
axis = _vector_from_value(value.get("axis"))
angle = value.get("angle")
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _rotation_from_object(source_object):
if source_object is None:
return None
rotation = None
placement = getattr(source_object, "Placement", None)
if placement is not None:
rotation = getattr(placement, "Rotation", None)
if rotation is None:
rotation = getattr(source_object, "Rotation", None)
if rotation is None:
return None
axis = getattr(rotation, "Axis", None)
if axis is None:
axis = getattr(rotation, "axis", None)
angle = getattr(rotation, "Angle", None)
if angle is None:
angle = getattr(rotation, "angle", None)
axis = _vector_from_value(axis)
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _slot_from_payload(item, source, index, source_object=None):
if not isinstance(item, dict):
return None
name = (item.get("name") or item.get("slot_name") or item.get("label") or "").strip()
if not name:
name = "SLOT_{0}".format(index + 1)
label = (item.get("label") or item.get("display_tag") or name).strip()
base = None
placement = item.get("placement")
if isinstance(placement, dict):
base = _vector_from_value(placement.get("base"))
if base is None:
base = _vector_from_value(item.get("base"))
if base is None:
base = _vector_from_value(item.get("position"))
if base is None:
base = _vector_from_value(item.get("origin"))
if base is None:
base = _vector_from_value(item.get("point"))
if base is None:
x = item.get("x")
y = item.get("y")
z = item.get("z")
if x is not None and y is not None and z is not None:
base = App.Vector(float(x), float(y), float(z))
if base is None:
return None
rotation = None
placement_rotation = None
placement = item.get("placement")
if isinstance(placement, dict):
placement_rotation = _rotation_from_value(placement.get("rotation"))
rotation = _rotation_from_value(item.get("rotation"))
if rotation is None:
rotation = placement_rotation
if rotation is None:
rotation = _rotation_from_object(source_object)
slot = {
"name": name,
"label": label,
"base": base,
"source": source,
"source_object": source_object,
}
if rotation is not None:
slot["rotation"] = rotation
return slot
def _is_child_group(obj, group_kind):
try:
return (
obj is not None
and obj.isDerivedFrom("App::DocumentObjectGroup")
and getattr(obj, "QetGroupKind", "").strip() == group_kind
)
except Exception:
return False
def collect_terminal_hints(container):
hints = []
if container is None:
return hints
for child in list(getattr(container, "Group", []) or []):
if TerminalObjects.is_terminal_hint_object(child) and not TerminalObjects.is_terminal_object(child):
hint = _slot_from_payload(
{
"name": getattr(child, "Name", ""),
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
"base": [
getattr(child.Placement.Base, "x", 0.0),
getattr(child.Placement.Base, "y", 0.0),
getattr(child.Placement.Base, "z", 0.0),
],
},
"model",
len(hints),
source_object=child,
)
if hint is not None:
hints.append(hint)
continue
if _is_child_group(child, TerminalObjects.TERMINAL_GROUP_KIND):
continue
if _is_child_group(child, TerminalObjects.WIRE_GROUP_KIND):
continue
if hasattr(child, "Group"):
hints.extend(collect_terminal_hints(child))
hints.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return hints
def collect_model_bounding_box(container):
boxes = []
def walk(obj):
if obj is None:
return
if TerminalObjects.is_terminal_hint_object(obj):
return
if _is_child_group(obj, TerminalObjects.TERMINAL_GROUP_KIND):
return
if _is_child_group(obj, TerminalObjects.WIRE_GROUP_KIND):
return
shape = getattr(obj, "Shape", None)
if shape is not None:
try:
if not shape.isNull():
boxes.append(shape.BoundBox)
except Exception:
pass
for child in list(getattr(obj, "Group", []) or []):
walk(child)
walk(container)
if not boxes:
return None
x_min = min(box.XMin for box in boxes)
y_min = min(box.YMin for box in boxes)
z_min = min(box.ZMin for box in boxes)
x_max = max(box.XMax for box in boxes)
y_max = max(box.YMax for box in boxes)
z_max = max(box.ZMax for box in boxes)
return {
"x_min": x_min,
"y_min": y_min,
"z_min": z_min,
"x_max": x_max,
"y_max": y_max,
"z_max": z_max,
"x_span": x_max - x_min,
"y_span": y_max - y_min,
"z_span": z_max - z_min,
"x_center": (x_min + x_max) * 0.5,
"y_center": (y_min + y_max) * 0.5,
"z_center": (z_min + z_max) * 0.5,
}
def _fallback_slot_base(bbox, index, count):
if bbox is None:
step = 8.0
return App.Vector(20.0, float(index) * step, 0.0)
span_x = max(bbox["x_span"], 1.0)
span_y = max(bbox["y_span"], 1.0)
span_z = max(bbox["z_span"], 1.0)
offset = max(8.0, max(span_x, span_y, span_z) * 0.15)
if count <= 1:
y_pos = bbox["y_center"]
z_pos = bbox["z_center"]
else:
if span_y >= span_z:
step = span_y / float(count + 1)
y_pos = bbox["y_min"] + step * float(index + 1)
z_pos = bbox["z_center"]
else:
step = span_z / float(count + 1)
y_pos = bbox["y_center"]
z_pos = bbox["z_min"] + step * float(index + 1)
return App.Vector(bbox["x_max"] + offset, y_pos, z_pos)
def build_fallback_terminal_slots(container, count):
bbox = collect_model_bounding_box(container)
slots = []
for index in range(max(0, count)):
slots.append(
{
"name": "SLOT_{0}".format(index + 1),
"label": "SLOT_{0}".format(index + 1),
"base": _fallback_slot_base(bbox, index, count),
"source": "fallback",
"source_object": None,
}
)
return slots
def load_sidecar_terminal_slots(model_path):
for sidecar_path in _sidecar_candidates(model_path):
if not sidecar_path.is_file():
continue
payload = _load_json_file(sidecar_path)
if payload is None:
continue
entries = payload.get("terminal_slots")
if entries is None:
entries = payload.get("terminals")
if not isinstance(entries, list):
continue
slots = []
for index, item in enumerate(entries):
slot = _slot_from_payload(item, "sidecar", index)
if slot is not None:
slots.append(slot)
if slots:
slots.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return slots
return []
def resolve_terminal_slots(device_group, model_path, desired_count):
desired_count = max(0, int(desired_count or 0))
hints = collect_terminal_hints(device_group)
sidecar_slots = load_sidecar_terminal_slots(model_path)
slots = []
slots.extend(hints[:desired_count])
if len(slots) < desired_count and sidecar_slots:
for slot in sidecar_slots:
if len(slots) >= desired_count:
break
slots.append(slot)
if len(slots) < desired_count:
fallback_slots = build_fallback_terminal_slots(device_group, desired_count)
for slot in fallback_slots:
if len(slots) >= desired_count:
break
slots.append(slot)
return slots[:desired_count]

@ -0,0 +1,325 @@
# FreeCADExchange terminal import helpers.
from collections import OrderedDict
import FreeCAD as App
import FreeCADGui as Gui
import DeviceImport
import TerminalObjects as TerminalObjects
import TemplateSemantics
class TerminalImportError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _normalize_terminal_entry(item, index):
if not isinstance(item, dict):
raise TerminalImportError(
"Terminal entry #{0} must be an object.".format(index)
)
terminal_uuid = (item.get("terminal_uuid") or "").strip()
if not terminal_uuid:
raise TerminalImportError(
"Terminal entry #{0} is missing terminal_uuid.".format(index)
)
instance_id = (item.get("instance_id") or "").strip()
element_uuid = (item.get("element_uuid") or "").strip()
return {
"terminal_uuid": terminal_uuid,
"instance_id": instance_id,
"element_uuid": element_uuid,
}
def _ensure_visible(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = True
except Exception:
pass
def _hide_object(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = False
except Exception:
pass
def _terminal_existing_index(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
if terminal_uuid and terminal_uuid not in index:
index[terminal_uuid] = obj
return index
def _device_key(device_group):
return getattr(device_group, "QetInstanceId", "").strip() or getattr(
device_group, "QetElementUuid", ""
).strip()
def _locate_device_group(doc, entry):
instance_id = entry["instance_id"]
element_uuid = entry["element_uuid"]
device_group = None
if instance_id:
device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id)
if device_group is None and element_uuid:
device_group = DeviceImport._find_device_group(doc, element_uuid)
return device_group
def _terminal_container_for_device(doc, device_group, project_uuid):
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
return TerminalObjects.ensure_terminal_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=device_instance_id,
)
def _is_device_group(obj):
try:
return (
obj is not None
and getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX)
and "QetElementUuid" in getattr(obj, "PropertiesList", [])
)
except Exception:
return False
def _terminal_slot_label(slot, terminal_uuid):
label = (slot.get("label") or "").strip()
if label:
return label
return terminal_uuid
def _slot_base(slot):
base = slot.get("base")
if isinstance(base, App.Vector):
return base
return App.Vector(0, 0, 0)
def _slot_placement(slot):
base = _slot_base(slot)
rotation = App.Rotation()
rotation_value = slot.get("rotation")
if isinstance(rotation_value, dict):
axis = rotation_value.get("axis")
angle = rotation_value.get("angle")
if isinstance(axis, App.Vector) and angle is not None:
try:
rotation = App.Rotation(axis, float(angle))
except Exception:
rotation = App.Rotation()
return App.Placement(base, rotation)
def _create_terminal_object(doc, terminal_uuid, slot, terminal_group, project_uuid, element_uuid, instance_id):
name_hint = "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid))
terminal_obj = TerminalObjects.create_lcs_object(
doc,
name_hint,
placement=_slot_placement(slot),
label=_terminal_slot_label(slot, terminal_uuid),
)
terminal_group.addObject(terminal_obj)
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
element_uuid,
terminal_uuid,
instance_id,
label=_terminal_slot_label(slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
_ensure_visible(terminal_obj)
return terminal_obj
def import_terminals_from_payload(payload, scene_path=""):
_append_debug_log("TerminalImport.import_terminals_from_payload entered")
if not isinstance(payload, dict):
raise TerminalImportError("Exchange payload must be an object.")
project_uuid = (payload.get("project_uuid") or "").strip()
if not project_uuid:
raise TerminalImportError("Field 'project_uuid' is required for terminal import.")
doc = DeviceImport._ensure_document(scene_path)
root_group = DeviceImport._ensure_root_group(doc, project_uuid)
_ = root_group
terminal_entries = payload.get("terminals", [])
if not isinstance(terminal_entries, list):
raise TerminalImportError("Field 'terminals' must be a list.")
report = {
"document_name": doc.Name,
"scene_path": scene_path or "",
"project_uuid": project_uuid,
"total_terminals": 0,
"imported_terminals": 0,
"updated_terminals": 0,
"removed_terminals": 0,
"reused_template_hints": 0,
"skipped_missing_device": 0,
"skipped_invalid_entry": 0,
"warnings": [],
}
grouped = OrderedDict()
for index, item in enumerate(terminal_entries):
report["total_terminals"] += 1
try:
entry = _normalize_terminal_entry(item, index)
except TerminalImportError as exc:
report["skipped_invalid_entry"] += 1
report["warnings"].append(str(exc))
continue
device_group = _locate_device_group(doc, entry)
if device_group is None:
report["skipped_missing_device"] += 1
report["warnings"].append(
"Terminal {0} could not find its parent device.".format(
entry["terminal_uuid"]
)
)
continue
key = device_group.Name
if key not in grouped:
grouped[key] = {"device_group": device_group, "entries": []}
grouped[key]["entries"].append(entry)
device_candidates = []
if root_group is not None:
device_candidates.extend(list(getattr(root_group, "Group", []) or []))
if not device_candidates:
device_candidates.extend(doc.Objects)
for device_group in device_candidates:
if not _is_device_group(device_group):
continue
item = grouped.get(device_group.Name, {"entries": []})
entries = item["entries"]
device_element_uuid = getattr(device_group, "QetElementUuid", "").strip()
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
resolved_model_path = getattr(device_group, "QetResolvedModelPath", "").strip()
terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
existing_by_uuid = _terminal_existing_index(terminal_group)
used_uuids = set()
slots = TemplateSemantics.resolve_terminal_slots(
device_group,
resolved_model_path,
len(entries),
)
for index, entry in enumerate(entries):
terminal_uuid = entry["terminal_uuid"]
payload_instance_id = entry["instance_id"]
if payload_instance_id and payload_instance_id != device_instance_id:
report["warnings"].append(
"Terminal {0} references instance_id {1} but device {2} uses {3}. The device value was kept."
.format(terminal_uuid, payload_instance_id, device_element_uuid, device_instance_id)
)
slot = slots[index] if index < len(slots) else {
"name": "SLOT_{0}".format(index + 1),
"label": terminal_uuid,
"base": App.Vector(0, 0, 0),
"source": "fallback",
"source_object": None,
}
terminal_obj = existing_by_uuid.get(terminal_uuid)
if terminal_obj is None:
terminal_obj = _create_terminal_object(
doc,
terminal_uuid,
slot,
terminal_group,
project_uuid,
device_element_uuid,
device_instance_id,
)
report["imported_terminals"] += 1
else:
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_slot_label(slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
try:
terminal_obj.Placement = _slot_placement(slot)
except Exception:
pass
_ensure_visible(terminal_obj)
report["updated_terminals"] += 1
if terminal_obj not in getattr(terminal_group, "Group", []):
terminal_group.addObject(terminal_obj)
used_uuids.add(terminal_uuid)
source_obj = slot.get("source_object")
if source_obj is not None:
_hide_object(source_obj)
report["reused_template_hints"] += 1
for terminal_uuid, terminal_obj in list(existing_by_uuid.items()):
if terminal_uuid in used_uuids:
continue
report["warnings"].append(
"Removed stale terminal {0} from device {1}.".format(
terminal_uuid, device_element_uuid
)
)
TerminalObjects.remove_object_tree(doc, terminal_obj)
report["removed_terminals"] += 1
doc.recompute()
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
_append_debug_log(
"TerminalImport finished: imported={0}, updated={1}, removed={2}".format(
report["imported_terminals"],
report["updated_terminals"],
report["removed_terminals"],
)
)
return report

@ -0,0 +1,386 @@
# FreeCADExchange terminal and object helpers.
import json
import os
from pathlib import Path
import FreeCAD as App
ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices"
DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
TERMINAL_GROUP_KIND = "Terminals"
WIRE_GROUP_KIND = "Wires"
TERMINAL_ROLE = "Terminal"
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 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 _unique_object_name(doc, base_name):
name = safe_token(base_name) or "QETObject"
if doc.getObject(name) is None:
return name
suffix = 1
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(name, suffix)
def _group_kind(obj):
return (getattr(obj, "QetGroupKind", "") or "").strip()
def _is_group_candidate(obj):
try:
return bool(obj and obj.isDerivedFrom("App::DocumentObjectGroup"))
except Exception:
return False
def ensure_root_group(doc, project_uuid=""):
root = doc.getObject(ROOT_GROUP_NAME)
if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
root.Label = ROOT_GROUP_LABEL
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
ensure_string_property(
root,
"QetProjectUuid",
"QET Exchange",
"Project UUID for the exchange document",
project_uuid,
)
return root
def ensure_named_child_group(
doc,
parent_group,
name_prefix,
label,
group_kind,
project_uuid="",
element_uuid="",
instance_id="",
):
target_element_uuid = (element_uuid or "").strip()
preferred_name = name_prefix + safe_token(target_element_uuid)
group = doc.getObject(preferred_name)
if group is None and _is_group_candidate(parent_group):
for candidate in getattr(parent_group, "Group", []) or []:
if _group_kind(candidate) != group_kind:
continue
if target_element_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_element_uuid:
continue
group = candidate
break
if group is None:
group = doc.addObject(
"App::DocumentObjectGroup",
_unique_object_name(doc, preferred_name),
)
if parent_group is not None and group not in getattr(parent_group, "Group", []):
parent_group.addObject(group)
group.Label = label
project_uuid = (project_uuid or "").strip() or getattr(group, "QetProjectUuid", "").strip()
element_uuid = (element_uuid or "").strip() or getattr(group, "QetElementUuid", "").strip()
instance_id = (instance_id or "").strip() or getattr(group, "QetInstanceId", "").strip()
ensure_string_property(
group,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
group_kind,
)
ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID for the exchange document",
project_uuid,
)
ensure_string_property(
group,
"QetElementUuid",
"QET Exchange",
"Parent element UUID for the exchange group",
element_uuid,
)
ensure_string_property(
group,
"QetInstanceId",
"QET Exchange",
"Parent instance UUID for the exchange group",
instance_id,
)
return group
def ensure_terminal_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
label = "QET Terminals"
return ensure_named_child_group(
doc,
device_group,
TERMINAL_GROUP_PREFIX,
label,
TERMINAL_GROUP_KIND,
project_uuid=project_uuid,
element_uuid=element_uuid,
instance_id=instance_id,
)
def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
label = "QET Wires"
return ensure_named_child_group(
doc,
device_group,
WIRE_GROUP_PREFIX,
label,
WIRE_GROUP_KIND,
project_uuid=project_uuid,
element_uuid=element_uuid,
instance_id=instance_id,
)
def find_child_group_by_kind(parent_group, group_kind):
if parent_group is None:
return None
for candidate in getattr(parent_group, "Group", []) or []:
if _group_kind(candidate) == group_kind:
return candidate
return None
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 DEVICE_GROUP_PREFIX not in getattr(candidate, "Name", ""):
continue
if "QetElementUuid" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetElementUuid", "").strip() == target_uuid:
return candidate
return None
def find_device_group_by_instance_id(doc, instance_id):
target_instance_id = (instance_id or "").strip()
if not target_instance_id:
return None
for candidate in doc.Objects:
if "QetInstanceId" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetInstanceId", "").strip() == target_instance_id:
if getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return candidate
return None
def is_lcs_like(obj):
if obj is None:
return False
try:
if obj.isDerivedFrom("App::LocalCoordinateSystem"):
return True
except Exception:
pass
return getattr(obj, "TypeId", "") in {
"Part::LocalCoordinateSystem",
"PartDesign::CoordinateSystem",
}
def is_terminal_hint_object(obj):
if not is_lcs_like(obj):
return False
role = getattr(obj, "Role", "")
return isinstance(role, str) and role.strip() == TERMINAL_ROLE
def is_terminal_object(obj):
if not is_terminal_hint_object(obj):
return False
if "QetTerminalUuid" not in getattr(obj, "PropertiesList", []):
return False
if "CanWire" not in getattr(obj, "PropertiesList", []):
return False
return bool(getattr(obj, "CanWire", False))
def terminal_origin(obj):
try:
placement = getattr(obj, "Placement", None)
if placement is not None:
return App.Vector(placement.Base.x, placement.Base.y, placement.Base.z)
except Exception:
pass
return App.Vector(0, 0, 0)
def create_lcs_object(doc, name_hint, placement=None, label=None):
base_name = safe_token(name_hint) or "QETTerminal"
object_name = _unique_object_name(doc, base_name)
lcs = None
for type_name in ("Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"):
try:
lcs = doc.addObject(type_name, object_name)
break
except Exception:
lcs = None
if lcs is None:
raise RuntimeError("FreeCAD does not provide a usable LCS object type.")
if label:
lcs.Label = label
elif name_hint:
lcs.Label = name_hint
if placement is not None:
try:
lcs.Placement = placement
except Exception:
pass
return lcs
def set_terminal_semantics(
obj,
project_uuid,
element_uuid,
terminal_uuid,
instance_id,
label="",
slot_name="",
):
ensure_string_property(
obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID for this terminal",
project_uuid,
)
ensure_string_property(
obj,
"QetElementUuid",
"QET Exchange",
"Parent element UUID for this terminal",
element_uuid,
)
ensure_string_property(
obj,
"QetTerminalUuid",
"QET Exchange",
"Terminal UUID from QET",
terminal_uuid,
)
ensure_string_property(
obj,
"QetInstanceId",
"QET Exchange",
"Parent instance UUID for this terminal",
instance_id,
)
ensure_string_property(
obj,
"Role",
"QET Exchange",
"Terminal role marker",
TERMINAL_ROLE,
)
ensure_bool_property(
obj,
"CanWire",
"QET Exchange",
"Whether the terminal can be used for wiring",
True,
)
if slot_name:
ensure_string_property(
obj,
"QetTemplateSlotName",
"QET Exchange",
"Template slot name",
slot_name,
)
terminal_label = (label or "").strip() or terminal_uuid or "QET Terminal"
obj.Label = terminal_label
return obj
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 collect_terminal_objects(container):
result = []
if container is None:
return result
children = list(getattr(container, "Group", []) or [])
for child in children:
if is_terminal_object(child):
result.append(child)
continue
if _is_group_candidate(child):
result.extend(collect_terminal_objects(child))
return result

@ -0,0 +1,192 @@
import sys
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
if str(MODULE_DIR) not in sys.path:
sys.path.insert(0, str(MODULE_DIR))
def _install_fake_freecad():
class Vector:
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
class Rotation:
def __init__(self, axis=None, angle=None):
self.Axis = axis
self.Angle = angle
class Placement:
def __init__(self, base=None, rotation=None):
self.Base = base
self.Rotation = rotation
fake_freecad = types.ModuleType("FreeCAD")
fake_freecad.Vector = Vector
fake_freecad.Rotation = Rotation
fake_freecad.Placement = Placement
fake_freecad.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
fake_freecad.ActiveDocument = None
sys.modules["FreeCAD"] = fake_freecad
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
fake_importgui = types.ModuleType("ImportGui")
fake_importgui.insert = lambda *args, **kwargs: None
sys.modules["ImportGui"] = fake_importgui
fake_part = types.ModuleType("Part")
fake_part.makePolygon = lambda points: tuple(points)
sys.modules["Part"] = fake_part
class FakeViewObject:
def __init__(self):
self.Visibility = True
self.LineWidth = None
self.LineColor = None
class FakeObject:
def __init__(self, name, type_id):
self.Name = name
self.Label = name
self.TypeId = type_id
self.PropertiesList = []
self.Group = []
self.ViewObject = FakeViewObject()
self.Shape = None
def isDerivedFrom(self, type_name):
if self.TypeId == type_name:
return True
if type_name == "App::DocumentObjectGroup":
return self.TypeId == "App::DocumentObjectGroup"
if type_name == "App::LocalCoordinateSystem":
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
return False
def addProperty(self, prop_type, prop_name, group_name, description):
if prop_name not in self.PropertiesList:
self.PropertiesList.append(prop_name)
def addObject(self, child):
if child not in self.Group:
self.Group.append(child)
class FakeDocument:
def __init__(self):
self.Objects = []
self.Name = "FakeDoc"
def addObject(self, type_name, name):
obj = FakeObject(name, type_name)
self.Objects.append(obj)
return obj
def getObject(self, name):
for obj in self.Objects:
if obj.Name == name:
return obj
return None
def removeObject(self, name):
self.Objects = [obj for obj in self.Objects if obj.Name != name]
def recompute(self):
return None
def _reload_modules():
for name in [
"TerminalObjects",
"DeviceImport",
"ManualWiring",
]:
sys.modules.pop(name, None)
import DeviceImport
import ManualWiring
import TerminalObjects
return DeviceImport, ManualWiring, TerminalObjects
class ManualWiringGroupTest(unittest.TestCase):
def test_manual_wire_is_added_to_device_wire_group(self):
_install_fake_freecad()
device_import, manual_wiring, terminal_objects = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device_group = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device_group)
terminal_objects.ensure_string_property(
device_group,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device_group,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
terminal_objects.ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
start_terminal = FakeObject("TerminalStart", "Part::LocalCoordinateSystem")
terminal_objects.set_terminal_semantics(
start_terminal,
"project-1",
"device-a",
"terminal-start",
"instance-a",
label="Start",
)
end_terminal = FakeObject("TerminalEnd", "Part::LocalCoordinateSystem")
terminal_objects.set_terminal_semantics(
end_terminal,
"project-1",
"device-a",
"terminal-end",
"instance-a",
label="End",
)
wire = manual_wiring.create_manual_wire(doc, start_terminal, end_terminal)
wire_group = terminal_objects.find_child_group_by_kind(
device_group,
terminal_objects.WIRE_GROUP_KIND,
)
self.assertIsNotNone(wire_group)
self.assertIn(wire, wire_group.Group)
self.assertNotIn(wire, root.Group)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,162 @@
import importlib
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
if str(MODULE_DIR) not in sys.path:
sys.path.insert(0, str(MODULE_DIR))
def _install_fake_freecad():
class Vector:
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
class Rotation:
def __init__(self, axis=None, angle=None):
self.axis = axis
self.angle = angle
class Placement:
def __init__(self, base=None, rotation=None):
self.Base = base
self.Rotation = rotation
fake_freecad = types.ModuleType("FreeCAD")
fake_freecad.Vector = Vector
fake_freecad.Rotation = Rotation
fake_freecad.Placement = Placement
fake_freecad.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
fake_freecad.ActiveDocument = None
fake_freecad.newDocument = lambda name: types.SimpleNamespace(Name=name, Objects=[], getObject=lambda item: None)
sys.modules["FreeCAD"] = fake_freecad
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
fake_importgui = types.ModuleType("ImportGui")
fake_importgui.insert = lambda *args, **kwargs: None
sys.modules["ImportGui"] = fake_importgui
def _reload_exchange_modules():
for name in [
"TerminalObjects",
"TemplateSemantics",
"DeviceImport",
"TerminalImport",
]:
sys.modules.pop(name, None)
template_semantics = importlib.import_module("TemplateSemantics")
terminal_import = importlib.import_module("TerminalImport")
return template_semantics, terminal_import
class TemplateSemanticsRotationTest(unittest.TestCase):
def test_terminal_hint_keeps_source_object_rotation(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
fake_lcs = types.SimpleNamespace(
Name="TerminalA1",
Label="Terminal A1",
TypeId="Part::LocalCoordinateSystem",
Role="Terminal",
Placement=types.SimpleNamespace(
Base=sys.modules["FreeCAD"].Vector(4, 5, 6),
Rotation=sys.modules["FreeCAD"].Rotation(
sys.modules["FreeCAD"].Vector(0, 1, 0),
37.5,
),
),
)
container = types.SimpleNamespace(Group=[fake_lcs])
hints = template_semantics.collect_terminal_hints(container)
self.assertEqual(1, len(hints))
self.assertIn("rotation", hints[0])
self.assertEqual(37.5, hints[0]["rotation"]["angle"])
self.assertEqual(0.0, hints[0]["rotation"]["axis"].x)
self.assertEqual(1.0, hints[0]["rotation"]["axis"].y)
self.assertEqual(0.0, hints[0]["rotation"]["axis"].z)
def test_sidecar_rotation_is_normalized_from_payload(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "Relay.step"
model_path.write_text("", encoding="utf-8")
sidecar_path = Path(temp_dir) / "Relay.qet_template.json"
sidecar_path.write_text(
json.dumps(
{
"terminal_slots": [
{
"name": "A1",
"label": "A1",
"position": {"x": 10, "y": 20, "z": 30},
"rotation": {
"axis": {"x": 0, "y": 0, "z": 1},
"angle": 90,
},
}
]
}
),
encoding="utf-8",
)
slots = template_semantics.load_sidecar_terminal_slots(str(model_path))
self.assertEqual(1, len(slots))
self.assertIn("rotation", slots[0])
self.assertEqual(90.0, slots[0]["rotation"]["angle"])
self.assertEqual(0.0, slots[0]["rotation"]["axis"].x)
self.assertEqual(0.0, slots[0]["rotation"]["axis"].y)
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z)
class TerminalPlacementTest(unittest.TestCase):
def test_slot_placement_uses_rotation_metadata(self):
_install_fake_freecad()
_, terminal_import = _reload_exchange_modules()
slot = {
"base": sys.modules["FreeCAD"].Vector(1, 2, 3),
"rotation": {
"axis": sys.modules["FreeCAD"].Vector(0, 0, 1),
"angle": 45.0,
},
}
placement = terminal_import._slot_placement(slot)
self.assertEqual(1.0, placement.Base.x)
self.assertEqual(2.0, placement.Base.y)
self.assertEqual(3.0, placement.Base.z)
self.assertIsNotNone(placement.Rotation)
self.assertEqual(45.0, placement.Rotation.angle)
self.assertEqual(0.0, placement.Rotation.axis.x)
self.assertEqual(0.0, placement.Rotation.axis.y)
self.assertEqual(1.0, placement.Rotation.axis.z)
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save