diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md new file mode 100644 index 0000000..00a7460 --- /dev/null +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -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` + +当前已经做到: + +- 导出 `/.qet_freecad/2d_to_3d.json` +- 返回 `/.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_ + 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_` 组里,优先跟随起点端子所属设备。 + +第一版连线路径保存在 `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_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。 +``` diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 2000ba1..93c3e7b 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -5,6 +5,11 @@ set(FreeCADExchange_Scripts ExchangeBootstrap.py DeviceImport.py DevicePreview.py + TerminalObjects.py + TemplateSemantics.py + TerminalImport.py + ExchangeWriteBack.py + ManualWiring.py ) add_custom_target(FreeCADExchangeScripts ALL diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index 2239df7..09b6c30 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import uuid import FreeCAD as App import FreeCADGui as Gui @@ -11,6 +12,10 @@ ROOT_GROUP_NAME = "QETExchangeDevices" ROOT_GROUP_LABEL = "QET Exchange Devices" CABINET_MODEL_GROUP_NAME = "QETCabinetModel" DEVICE_GROUP_PREFIX = "QETDevice_" +TERMINAL_GROUP_PREFIX = "QETTerminals_" +WIRE_GROUP_PREFIX = "QETWires_" +GROUP_KIND_TERMINALS = "Terminals" +GROUP_KIND_WIRES = "Wires" class DeviceImportError(RuntimeError): @@ -88,6 +93,60 @@ def _ensure_bool_property(obj, prop_name, group_name, description, 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): 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) @@ -116,7 +175,7 @@ def _cabinet_label_text(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) if root is None: 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", 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 @@ -269,6 +336,33 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path, ) if created_now: 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 @@ -286,9 +380,19 @@ def _remove_object_tree(doc, obj): def _clear_group_contents(doc, group): 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) +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): suffix = Path(model_path).suffix.lower() return suffix in { @@ -397,7 +501,8 @@ def import_devices_from_payload(payload, scene_path=""): _append_debug_log("DeviceImport.import_devices_from_payload entered") doc = _ensure_document(scene_path) 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) report = { @@ -457,6 +562,13 @@ def import_devices_from_payload(payload, scene_path=""): continue 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( doc, root_group, diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index 2df476e..e087051 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -7,6 +7,8 @@ import FreeCAD as App import FreeCADGui as Gui import DeviceImport import DevicePreview +import ExchangeWriteBack +import TerminalImport try: from PySide6 import QtCore, QtWidgets @@ -24,6 +26,8 @@ STATE_FLAG = "_qet_exchange_bootstrapped" STATE_PAYLOAD = "_qet_exchange_payload" STATE_SUMMARY = "_qet_exchange_summary" 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_TREE_FILTER = "_qet_exchange_tree_filter" 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 = [ "QET exchange file loaded successfully.", "", @@ -593,9 +597,38 @@ def _summary_message(summary, import_report=None): if 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("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) @@ -641,6 +674,7 @@ def _run_scheduled_device_import(attempt=0): _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()) @@ -680,8 +714,54 @@ def _run_scheduled_device_import(attempt=0): 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) + + 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)) + _show_info( + "QET Exchange", + _summary_message(summary, import_report, terminal_report, writeback_report), + ) _append_debug_log("summary dialog shown") diff --git a/src/Mod/FreeCADExchange/ExchangeWriteBack.py b/src/Mod/FreeCADExchange/ExchangeWriteBack.py new file mode 100644 index 0000000..2e2f28d --- /dev/null +++ b/src/Mod/FreeCADExchange/ExchangeWriteBack.py @@ -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() diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 1c82d5f..83530ef 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -12,6 +12,8 @@ except ImportError: from PySide import QtCore import ExchangeBootstrap +import ExchangeWriteBack +import ManualWiring def _append_init_log(message): @@ -30,5 +32,20 @@ def _append_init_log(message): _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) diff --git a/src/Mod/FreeCADExchange/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py new file mode 100644 index 0000000..7860965 --- /dev/null +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -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() diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py new file mode 100644 index 0000000..1e0d3b7 --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -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] diff --git a/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py new file mode 100644 index 0000000..1778a1c --- /dev/null +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -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 diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py new file mode 100644 index 0000000..c35cf94 --- /dev/null +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -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 diff --git a/tests/python/freecad_exchange_manual_wiring_test.py b/tests/python/freecad_exchange_manual_wiring_test.py new file mode 100644 index 0000000..082752b --- /dev/null +++ b/tests/python/freecad_exchange_manual_wiring_test.py @@ -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() diff --git a/tests/python/freecad_exchange_template_semantics_test.py b/tests/python/freecad_exchange_template_semantics_test.py new file mode 100644 index 0000000..bfedecf --- /dev/null +++ b/tests/python/freecad_exchange_template_semantics_test.py @@ -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()