From 14b81c5786fe01358ce837d64e2e88b30b6515b0 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 14:26:49 +0800 Subject: [PATCH 01/12] docs: add freecad terminal template semantics spec --- ...ecad-terminal-template-semantics-design.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-freecad-terminal-template-semantics-design.md diff --git a/docs/superpowers/specs/2026-05-20-freecad-terminal-template-semantics-design.md b/docs/superpowers/specs/2026-05-20-freecad-terminal-template-semantics-design.md new file mode 100644 index 0000000..19f0955 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-freecad-terminal-template-semantics-design.md @@ -0,0 +1,190 @@ +# FreeCAD 端子模板语义设计 + +> 目标:把端子位置、端子命名、端子朝向和接线资格从几何模型里分离出来,放进可维护的模板语义层。 + +**Goal:** 让 FreeCAD 的端子显示、手动连线和最小保存回写依赖真实模板语义,而不是长期依赖包围盒猜位。 + +**Architecture:** 以 `FreeCADExchange` 的 Python 层为主体。`DeviceImport.py` 负责把设备模型导入到设备组,`TemplateSemantics.py` 负责把模板输入统一成端子槽位,`TerminalImport.py` 负责创建或更新端子 LCS,`ManualWiring.py` 负责端子到端子的人工连线,`ExchangeWriteBack.py` 负责生成 `3d_to_2d.json`。FreeCAD 文档 `scene.FCStd` 是 3D 状态真相源,数据库只保留第一版最小绑定表。 + +**Tech Stack:** FreeCAD Python API, `App::DocumentObjectGroup`, `Part::LocalCoordinateSystem` / `PartDesign::CoordinateSystem`, JSON sidecar files, FreeCAD document observer. + +--- + +## 1. 设计目标 + +1. 端子必须有稳定的语义来源。 +2. 端子必须能被看见、选中、更新。 +3. 手动连线只能从端子连到端子。 +4. 保存时只回写第一版最小结果,不把 3D 位姿塞回数据库。 +5. 常用设备最终要补成真实模板,不能长期靠 `bbox fallback`。 + +## 2. 设计边界 + +本设计只覆盖以下内容: + +- 端子模板语义解析 +- 端子对象创建 / 更新 +- 手动连线对象创建 +- 保存时生成 `3d_to_2d.json` + +本设计不覆盖: + +- 自动布线 +- FreeCAD C/C++ 核心修改 +- `D:\code\zwl\sources\ThreeD` 旧 3D 引擎 +- 新增 3D 场景数据库表 +- 将完整线几何回写到 QET + +## 3. 模板语义优先级 + +端子位置来源按下面优先级解析: + +1. `FCStd` 模板里已有的 `LCS` +2. 同目录 sidecar JSON +3. `bbox fallback` + +解释: + +- `FCStd` 模板是正式答案,适合已经整理过的常用设备。 +- sidecar JSON 是过渡方案,适合 STEP 几何已经有了,但还没整理成 FCStd 模板的设备。 +- `bbox fallback` 只用于打通流程,不代表真实端子位置。 + +## 4. 模板资产约定 + +### 4.1 FCStd 模板 + +FCStd 模板里,端子用 LCS 表达,建议对象满足: + +- 类型是 `Part::LocalCoordinateSystem` 或 `PartDesign::CoordinateSystem` +- `Role = "Terminal"` +- `CanWire = true` + +这些属性的作用是: + +- `Role` 让 FreeCADExchange 识别它是端子 +- `CanWire` 让连线命令只接受有效端子 +- 端子对象的 `QetTerminalUuid` 由导入流程补齐,不依赖模板文件本身已有的 2D 绑定字段 + +### 4.2 sidecar JSON + +sidecar JSON 与模型同目录,当前支持的文件名后缀约定: + +- `.terminals.json` +- `.qet_terminals.json` +- `.terminal_slots.json` +- `.qet_template.json` + +sidecar 中可接受的槽位字段: + +- `name` +- `label` +- `position` +- `base` +- `origin` +- `point` +- `x / y / z` + +如果某个槽位已经有明确位置,就直接作为端子坐标。名称和标签只用于显示和调试,不作为业务主键。 + +### 4.3 bbox fallback + +当模板里没有 LCS,也没有 sidecar JSON 时,才用包围盒生成临时端子位置。 + +这个 fallback 只做三件事: + +- 让导入链路跑通 +- 让用户能先看到端子对象 +- 给后续补模板留出验证空间 + +它不能作为最终模板方案。 + +## 5. 端子对象模型 + +每个端子对象都必须挂在对应设备实例下的 `QETTerminals_` 组内。 + +端子对象必须保存这些语义属性: + +- `QetProjectUuid` +- `QetElementUuid` +- `QetTerminalUuid` +- `QetInstanceId` +- `Role = "Terminal"` +- `CanWire = true` + +补充属性: + +- `QetTemplateSlotName` + +识别规则只认 `terminal_uuid`,不依赖 `terminal_key`、`connection_point_key` 这类旧字段。 + +## 6. 连线对象模型 + +手动连线只允许连接两个端子。 + +连线对象保存: + +- `QetProjectUuid` +- `QetStartTerminalUuid` +- `QetEndTerminalUuid` +- `QetStartInstanceId` +- `QetEndInstanceId` +- `RouteType = "Manual"` + +连线几何保存在 `scene.FCStd`,第一版不把完整路径几何写回数据库。 + +## 7. 数据流 + +1. QET 导出 `2d_to_3d.json`。 +2. FreeCAD 读取 `devices / terminals / device_models`。 +3. `DeviceImport.py` 导入设备模型并形成 `QETExchangeDevices` 树。 +4. `TemplateSemantics.py` 为每个设备解析端子槽位。 +5. `TerminalImport.py` 创建或更新端子对象。 +6. 用户通过命令手动连线。 +7. 保存文档时 `ExchangeWriteBack.py` 输出 `3d_to_2d.json`。 + +## 8. 实现约束 + +1. 第一版只改 `FreeCADExchange` Python 层。 +2. 第一版不改 FreeCAD C/C++。 +3. 第一版不依赖旧 3D 场景表。 +4. 第一版 3D 状态以 FreeCAD 文档为准。 +5. 第一版绑定只认: + - `project_uuid` + - `element_uuid` + - `terminal_uuid` + - `instance_id` + +## 9. 模板建设顺序 + +建议先补这几类常用设备: + +1. 机柜 / 安装板 +2. DIN 导轨 +3. 断路器或继电器 +4. 端子排 + +先把这些设备补成 FCStd LCS 或 sidecar JSON,可以最快验证“端子显示 + 手动连线 + 保存回写”这条链路。 + +## 10. 失败处理 + +1. 模型文件不存在,跳过设备并记录警告。 +2. 没有端子模板,退回 sidecar。 +3. 没有 sidecar,退回 bbox fallback。 +4. 端子 `terminal_uuid` 缺失,直接跳过该条。 +5. 同一个 `terminal_uuid` 已存在时,更新旧端子而不是重复创建。 +6. 保存回写失败时,保留 FreeCAD 文档状态,错误写入调试日志。 + +## 11. 验收标准 + +满足下面结果,就说明这套设计可用: + +- 设备模型导入后,树里能看到 `QETDevice_` +- 每个设备下有对应的 `QETTerminals_` +- 端子对象带 `QetTerminalUuid` 和 `Role="Terminal"` +- 用户只能从端子连到端子 +- 保存后生成 `3d_to_2d.json` +- `3d_to_2d.json` 里只保留设备实例和端子实例的最小回写结果 + +## 12. 结论 + +这条路线的核心不是“先把所有模型做完”,而是先把模板语义建立起来。只要常用设备的端子位置从 fallback 升级到 FCStd LCS 或 sidecar JSON,FreeCAD 这条 3D 线就能从临时验证变成可持续开发。 From 79d39fcf2a875aef4a86b2c7ecc488647fc4e779 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 14:55:37 +0800 Subject: [PATCH 02/12] chore: ignore local worktree directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3755196..a7a5059 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ ipch/ /Run-FreeCAD-E.bat /path-backup-HKCU-Environment.reg /path-backup-HKLM-Environment.reg +/.worktrees/ From 73ce289d986beb1f53d60744598f9ea61bb61a0e Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 16:58:20 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feature/=E7=AB=AF=E5=AD=90=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=BF=9E=E7=BA=BF=E4=BF=9D=E5=AD=98=E5=9B=9E=E5=86=99?= =?UTF-8?q?-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...子显示连线保存回写开发文档.md | 461 ++++++++++++++++++ src/Mod/FreeCADExchange/CMakeLists.txt | 5 + src/Mod/FreeCADExchange/DeviceImport.py | 116 ++++- src/Mod/FreeCADExchange/ExchangeBootstrap.py | 86 +++- src/Mod/FreeCADExchange/ExchangeWriteBack.py | 323 ++++++++++++ src/Mod/FreeCADExchange/InitGui.py | 17 + src/Mod/FreeCADExchange/ManualWiring.py | 236 +++++++++ src/Mod/FreeCADExchange/TemplateSemantics.py | 363 ++++++++++++++ src/Mod/FreeCADExchange/TerminalImport.py | 325 ++++++++++++ src/Mod/FreeCADExchange/TerminalObjects.py | 386 +++++++++++++++ .../freecad_exchange_manual_wiring_test.py | 192 ++++++++ ...reecad_exchange_template_semantics_test.py | 162 ++++++ 12 files changed, 2667 insertions(+), 5 deletions(-) create mode 100644 docs/FreeCAD 端子显示连线保存回写开发文档.md create mode 100644 src/Mod/FreeCADExchange/ExchangeWriteBack.py create mode 100644 src/Mod/FreeCADExchange/ManualWiring.py create mode 100644 src/Mod/FreeCADExchange/TemplateSemantics.py create mode 100644 src/Mod/FreeCADExchange/TerminalImport.py create mode 100644 src/Mod/FreeCADExchange/TerminalObjects.py create mode 100644 tests/python/freecad_exchange_manual_wiring_test.py create mode 100644 tests/python/freecad_exchange_template_semantics_test.py 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() From 0866f0049c13fb06851afa01a877d0dd5e4a2dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJYN=E2=80=9D?= Date: Wed, 20 May 2026 17:43:08 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat/=E7=AE=80=E5=8C=96=E5=B8=AE=E5=8A=A9?= =?UTF-8?q?=E5=8F=8A=E6=94=B9=E5=90=8D=E6=9B=BF=E6=8D=A2-csm-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Gui/CommandStd.cpp | 2 +- src/Gui/Dialogs/AboutApplication.ui | 6 +++--- src/Gui/Dialogs/DlgAbout.cpp | 4 ++-- src/Gui/OnlineDocumentation.cpp | 4 ++-- src/Gui/Workbench.cpp | 17 +++-------------- src/Main/MainGui.cpp | 12 ++++++------ src/Mod/Start/Gui/Manipulator.cpp | 9 +++++++-- 7 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/Gui/CommandStd.cpp b/src/Gui/CommandStd.cpp index 1055bd6..1112def 100644 --- a/src/Gui/CommandStd.cpp +++ b/src/Gui/CommandStd.cpp @@ -351,7 +351,7 @@ void StdCmdRestartInSafeMode::activated(int iMsg) QMessageBox restartBox(Gui::getMainWindow()); restartBox.setIcon(QMessageBox::Warning); restartBox.setWindowTitle(QObject::tr("Restart in Safe Mode")); - restartBox.setText(QObject::tr("Restart FreeCAD and enter safe mode?")); + restartBox.setText(QObject::tr("Restart Light works 3D and enter safe mode?")); restartBox.setInformativeText( QObject::tr("Safe mode temporarily disables the configuration and addons.") ); diff --git a/src/Gui/Dialogs/AboutApplication.ui b/src/Gui/Dialogs/AboutApplication.ui index 34d61cc..f96c166 100644 --- a/src/Gui/Dialogs/AboutApplication.ui +++ b/src/Gui/Dialogs/AboutApplication.ui @@ -268,12 +268,12 @@ <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } </style></head><body style=" font-family:'Fira Sans'; font-size:9pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:16px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:x-large; font-weight:600;">FreeCAD license </span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The FreeCAD application is licensed under the terms of the LGPL2+ license, as stated below.</p> +<p style=" margin-top:16px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:x-large; font-weight:600;">Light works 3D license </span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The Light works 3D application is licensed under the terms of the LGPL2+ license, as stated below.</p> <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:14pt; font-weight:600;">Third-party libraries licenses</span></p> <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:14pt; font-weight:600;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The different libraries used in FreeCAD and their respective licenses are described on the<a href="https://www.freecad.org/wiki/Third_Party_Libraries"><span style=" text-decoration: underline; color:#0000ff;">Third Party Libraries wiki page</span></a>.</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The different libraries used in Light works 3D and their respective licenses are described in the documentation.</p> <hr /> <hr /> <hr /> diff --git a/src/Gui/Dialogs/DlgAbout.cpp b/src/Gui/Dialogs/DlgAbout.cpp index bd38ef5..e80a6a7 100644 --- a/src/Gui/Dialogs/DlgAbout.cpp +++ b/src/Gui/Dialogs/DlgAbout.cpp @@ -289,7 +289,7 @@ void AboutDialog::setupLabels() } if (url == QStringLiteral("Unknown")) { - url = QStringLiteral("https://github.com/FreeCAD/FreeCAD"); // Just take a guess + url = QStringLiteral("https://github.com/LightWorks/LightWorks3D"); // Just take a guess } // This may only create valid URLs for Github, but some other hosts use the same format @@ -328,7 +328,7 @@ void AboutDialog::showCredits() QString creditsHTML = QStringLiteral("

%1

%2

%3

    ") .arg(tr("Credits", "Header for the Credits tab of the About screen")) - .arg(tr("FreeCAD would not be possible without the contributions of:")) + .arg(tr("Light works 3D would not be possible without the contributions of:")) .arg(tr("Individuals", "Header for the list of individual people in the Credits list.")); QTextStream stream(&creditsFile); diff --git a/src/Gui/OnlineDocumentation.cpp b/src/Gui/OnlineDocumentation.cpp index 84c2e27..8e3afe1 100644 --- a/src/Gui/OnlineDocumentation.cpp +++ b/src/Gui/OnlineDocumentation.cpp @@ -176,7 +176,7 @@ QByteArray PythonOnlineHelp::fileNotFound() const "" "" "" "
     
    " - " 
    FreeCAD " + " 
    Light works 3D " "Documentation
    " " 
    " @@ -211,7 +211,7 @@ QByteArray PythonOnlineHelp::loadFailed(const QString& error) const "" " 
    " " 
    FreeCAD " + "arial\"> 
    Light works 3D " "Documentation
    " "" " " diff --git a/src/Gui/Workbench.cpp b/src/Gui/Workbench.cpp index 227b61d..37aa2d7 100644 --- a/src/Gui/Workbench.cpp +++ b/src/Gui/Workbench.cpp @@ -815,13 +815,6 @@ MenuItem* StdWorkbench::setupMenuBar() const // Help auto help = new MenuItem(menuBar); help->setCommand("&Help"); - *help << "Std_WhatsThis" - << "Separator" - // Start page and additional separator are dynamically inserted here - << "Std_FreeCADUserHub" << "Std_FreeCADForum" << "Std_ReportBug" << "Separator" - << "Std_RestartInSafeMode" << "Separator" - << "Std_DevHandbook" << "Std_PythonHelp" << "Separator" - << "Std_FreeCADWebsite" << "Std_FreeCADDonation" << "Std_About"; return menuBar; } @@ -879,11 +872,6 @@ ToolBarItem* StdWorkbench::setupToolBars() const structure->setCommand("Structure"); *structure << "Std_Part" << "Std_Group" << "Std_LinkActions" << "Std_VarSet"; - // Help - auto help = new ToolBarItem(root); - help->setCommand("Help"); - *help << "Std_WhatsThis"; - return root; } @@ -1027,7 +1015,6 @@ MenuItem* NoneWorkbench::setupMenuBar() const // Help auto help = new MenuItem(menuBar); help->setCommand("&Help"); - *help << "Std_OnlineHelp" << "Std_About"; return menuBar; } @@ -1067,7 +1054,9 @@ MenuItem* TestWorkbench::setupMenuBar() const MenuItem* menuBar = StdWorkbench::setupMenuBar(); MenuItem* item = menuBar->findItem("&Help"); - item->removeItem(item->findItem("Std_WhatsThis")); + if (auto whatsThis = item->findItem("Std_WhatsThis")) { + item->removeItem(whatsThis); + } // Test commands auto test = new MenuItem; diff --git a/src/Main/MainGui.cpp b/src/Main/MainGui.cpp index f977b75..5a203ba 100644 --- a/src/Main/MainGui.cpp +++ b/src/Main/MainGui.cpp @@ -55,8 +55,8 @@ void PrintInitHelp(); const auto sBanner = fmt::format( - "(C) 2001-{} FreeCAD contributors\n" - "FreeCAD is free and open-source software licensed under the terms of LGPL2+ license.\n\n", + "(C) 2001-{} LightWorks contributors\n" + "Light works 3D is free and open-source software licensed under the terms of LGPL2+ license.\n\n", FCCopyrightYear ); @@ -188,10 +188,10 @@ int main(int argc, char** argv) #endif // Name and Version of the Application - App::Application::Config()["ExeName"] = "FreeCAD"; - App::Application::Config()["ExeVendor"] = "FreeCAD"; + App::Application::Config()["ExeName"] = "Light works 3D"; + App::Application::Config()["ExeVendor"] = "LightWorks"; App::Application::Config()["AppDataSkipVendor"] = "true"; - App::Application::Config()["MaintainerUrl"] = "https://freecad.org"; + App::Application::Config()["MaintainerUrl"] = ""; // set the banner (for logging and console) App::Application::Config()["CopyrightInfo"] = sBanner; @@ -207,7 +207,7 @@ int main(int argc, char** argv) App::Application::Config()["SplashWarningColor"] = "#CA333B"; App::Application::Config()["SplashInfoColor"] = "#000000"; App::Application::Config()["SplashInfoPosition"] = "6,75"; - App::Application::Config()["DesktopFileName"] = "org.freecad.FreeCAD"; + App::Application::Config()["DesktopFileName"] = "com.lightworks.LightWorks3D"; try { // Init phase =========================================================== diff --git a/src/Mod/Start/Gui/Manipulator.cpp b/src/Mod/Start/Gui/Manipulator.cpp index ab96853..aaf28b1 100644 --- a/src/Mod/Start/Gui/Manipulator.cpp +++ b/src/Mod/Start/Gui/Manipulator.cpp @@ -72,11 +72,16 @@ void StartGui::Manipulator::modifyMenuBar(Gui::MenuItem* menuBar) } Gui::MenuItem* helpMenu = menuBar->findItem("&Help"); + if (!helpMenu) { + return; + } Gui::MenuItem* loadStart = new Gui::MenuItem(); Gui::MenuItem* loadSeparator = new Gui::MenuItem(); loadStart->setCommand("Start_Start"); loadSeparator->setCommand("Separator"); Gui::MenuItem* firstItem = helpMenu->findItem("Std_FreeCADUserHub"); - helpMenu->insertItem(firstItem, loadStart); - helpMenu->insertItem(firstItem, loadSeparator); + if (firstItem) { + helpMenu->insertItem(firstItem, loadStart); + helpMenu->insertItem(firstItem, loadSeparator); + } } From 5fc2b9b04aab696cebe790dc7d5a668346a7661b Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 19:01:24 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feature/FCStd=E8=AE=BE=E5=A4=87=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=AB=AF=E5=AD=90=E5=88=B6=E4=BD=9C-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 二次开发说明.md | 86 +++ ...子显示连线保存回写开发文档.md | 105 +++- ...-05-20-freecad-fcstd-template-authoring.md | 590 ++++++++++++++++++ ...freecad-fcstd-template-authoring-design.md | 189 ++++++ src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/InitGui.py | 6 + src/Mod/FreeCADExchange/TemplateAuthoring.py | 253 ++++++++ ...reecad_exchange_template_authoring_test.py | 163 +++++ 8 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md create mode 100644 docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md create mode 100644 src/Mod/FreeCADExchange/TemplateAuthoring.py create mode 100644 tests/python/freecad_exchange_template_authoring_test.py diff --git a/docs/FreeCAD 二次开发说明.md b/docs/FreeCAD 二次开发说明.md index 0108a5e..72656f6 100644 --- a/docs/FreeCAD 二次开发说明.md +++ b/docs/FreeCAD 二次开发说明.md @@ -243,6 +243,31 @@ FreeCAD 原生能力足够支持下面这些事: 这个方案不要求现在就修改 FreeCAD 原生源码,但已经足够支撑后续做一个结构清晰、规则明确的电气端子与 3D 接线系统。 +### 13.1 FCStd 设备模板作为正式资产 + +对于从厂家、网络或已有资源库拿到的 `.step`、`.stp`、`.ste` 模型,不建议把它们直接当作最终电气设备模板。原因是 STEP 系列文件主要表达几何,不能可靠保存 FreeCAD LCS、动态属性和二次开发语义。 + +当前推荐的正式资产流程是: + +```text +STEP / STP / STE 几何模型 + -> FreeCAD 模板制作 + -> 添加 LCS 端子 + -> 写入端子槽位语义 + -> 保存为 FCStd 设备模板 +``` + +这样得到的 `.FCStd` 才是后续工程复用、交付给其他人、放入设备资源库的主文件。 + +模板端子是“通用槽位”,不是“某个工程中的端子实例”。例如电流互感器模板中可以保存: + +```text +Terminal_P1 +Terminal_P2 +``` + +这些对象表示模型上 P1/P2 的真实接线位置和方向,但不保存某个工程里的 `terminal_uuid`。项目导入时,FreeCADExchange 再根据 `2d_to_3d.json` 把工程端子 UUID 绑定到模板槽位上。 + ## 14. 电气柜与设备装配 除了端子与接线,电气柜场景通常还会遇到另一个基础问题: @@ -630,6 +655,61 @@ LCS 不负责: 电气语义仍建议放在端子对象本身的属性里。 +### 21.3 模板端子和工程端子的区别 + +后续 FreeCAD 二次开发中需要区分两类端子: + +1. 模板端子 +2. 工程端子 + +模板端子存在于设备模板 `.FCStd` 中,职责是描述“这个设备模型上哪里可以接线”。它只保存跨工程稳定的信息: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` +- LCS 的位置和方向 + +模板端子不保存: + +- `project_uuid` +- `element_uuid` +- `terminal_uuid` +- `instance_id` +- 数据库绑定字段 + +工程端子存在于具体项目的 `scene.FCStd` 中,职责是描述“当前工程里的哪个 2D 端子绑定到了哪个 3D 连接点”。它由 FreeCADExchange 根据 `2d_to_3d.json` 生成或更新,才会保存: + +- `QetProjectUuid` +- `QetElementUuid` +- `QetTerminalUuid` +- `QetInstanceId` +- `CanWire` + +因此,FCStd 设备模板是可复用的电气几何资产,项目场景 FCStd 是某个工程的装配和接线结果。不要把两者的职责混在同一个对象里。 + +### 21.4 模板制作工具方向 + +为了让普通 STEP 模型变成可复用电气模板,建议在 `FreeCADExchange` Python 层增加模板制作工具,而不是修改 FreeCAD C++ 内核。 + +第一版工具目标: + +1. 导入 STEP / STP / STE 几何模型。 +2. 用户选择模型上的接线位置。 +3. 输入端子槽位名,例如 `P1`、`P2`、`A1`、`A2`。 +4. 自动创建 LCS。 +5. 自动写入模板端子属性。 +6. 保存为 `.FCStd`。 + +第一版建议新增模块: + +```text +src/Mod/FreeCADExchange/TemplateAuthoring.py +``` + +该模块只负责设备模板制作,不负责项目导入、手动连线或数据库回写。 + ## 22. 3D 端子二开实施步骤 ### 阶段 1:读绑定,不做自动路由 @@ -647,6 +727,12 @@ LCS 不负责: - 2D 设备能不能在 3D 找到对应设备 - 2D 端子能不能在 3D 找到对应连接点 +补充说明: + +- 项目端子创建前,优先读取 FCStd 设备模板中的 `Role="Terminal"` LCS。 +- 如果模板没有端子语义,才使用 sidecar 或 bbox fallback。 +- 长期目标是让常用设备都具备 FCStd 模板端子,而不是长期依赖 fallback。 + ### 阶段 2:让 3D 连线只认端子对象 下一步完成: diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 00a7460..863d6a3 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -113,16 +113,28 @@ QET 导出的 `device_models[].resolved_model_path` 本来就是给 FreeCAD 使 - `.brp` - `.fcstd` -因此本地 STEP 文件可以直接作为第一批模板资源。 +因此本地 STEP 文件可以直接作为第一批几何资源导入。 + +但从当前开发方向开始,正式可复用的设备模板建议统一保存为 `.FCStd`。也就是说: + +```text +STEP / STP / STE 原始几何 + -> 在 FreeCAD 里添加端子 LCS 和电气语义 + -> 保存为 FCStd 设备模板 + -> 后续不同工程、不同人员统一使用这个 FCStd +``` + +STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电气语义的最终交付文件。因为 STEP 可以稳定保存几何,但不能可靠保存 FreeCAD LCS、动态属性、端子角色、接线资格等二次开发语义。 但需要注意: > 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。 -所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版可以采用两种方式: +所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级: -1. 优先方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 +1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 2. 过渡方式:STEP + sidecar JSON,在同目录下保存端子槽位坐标。 +3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置。 sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。 @@ -130,6 +142,46 @@ sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotatio FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。 +### 3.1 FCStd 设备模板制作约定 + +FCStd 设备模板用于解决“这个模型本身就带端子语义”的问题。模板端子是跨工程复用的槽位,不绑定某个具体工程里的 `terminal_uuid`。 + +推荐模板结构: + +```text +电流互感器.FCStd + ModelGeometry + Terminal_P1 + Terminal_P2 +``` + +模板端子使用 LCS 表示,至少保存: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName = "P1"` / `"P2"` +- `QetTerminalLabel = "P1"` / `"P2"` +- `QetTerminalType = "primary"` 等可选分类 + +模板端子不保存: + +- `QetTerminalUuid` +- `QetInstanceId` +- `project_uuid` +- 任意工程内绑定字段 + +这些工程级字段由导入项目时的 `2d_to_3d.json` 和 FreeCADExchange 运行时补齐。这样同一个 FCStd 设备模板才能在多个工程、多台机器、多人之间复用。 + +### 3.2 模板制作工具目标 + +后续在 `FreeCADExchange` 中新增模板制作能力,目标是让用户不需要手工给 LCS 添加属性: + +1. 导入 STEP / STP / STE 几何模型。 +2. 在模型真实接线位置添加端子,例如 `P1`、`P2`。 +3. 自动创建 LCS,并写入模板端子语义。 +4. 保存为 `.FCStd` 设备模板。 +5. 后续 LightWork3D 工程引用该 `.FCStd` 后,自动识别模板端子并生成工程端子对象。 + ## 4. 为什么要先落地设备模板 这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。 @@ -221,6 +273,7 @@ FreeCADExchange/ ExchangeBootstrap.py # 启动入口,读取 JSON,调度导入流程 DeviceImport.py # 设备导入,已存在 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 + TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalObjects.py # 新增:端子对象属性、查找、校验工具 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 @@ -249,11 +302,45 @@ ManualWiring.py ## 7. 开发步骤 +### 阶段 A0:FCStd 设备模板制作 + +目标: + +- 用户可以导入 STEP / STP / STE 几何模型。 +- 用户可以给模型添加带电气语义的端子 LCS。 +- 模板保存为 `.FCStd` 后,可以脱离当前工程复用。 + +实现建议: + +- 新增 `TemplateAuthoring.py`。 +- 提供最小命令: + - `QET_Template_AddTerminal` + - `QET_Template_SaveAsFCStd` +- 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。 +- 第一版端子方向默认使用单位旋转,后续再补出线方向编辑。 + +模板端子属性: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` + +验收: + +- 打开一个普通 STEP 模型。 +- 添加 `P1`、`P2` 两个端子。 +- 保存为 `电流互感器.FCStd`。 +- 重新打开该 FCStd 后,端子 LCS 仍存在,属性仍存在。 +- 在项目导入流程中引用该 FCStd,端子位置优先来自模板 LCS,而不是 bbox fallback。 + ### 阶段 A:本地模板导入基线 目标: - 本地 STEP / FCStd 可以稳定导入。 +- 正式设备资源优先使用带端子语义的 FCStd 模板。 - 设备组有 `QetElementUuid / QetInstanceId`。 - 重新打开同一个项目时,不重复创建同一设备。 @@ -265,7 +352,7 @@ ManualWiring.py 验收: -- 使用本地 STEP 文件导入设备。 +- 使用本地 FCStd 模板导入设备。 - FreeCAD 树中可看到设备组。 - 关闭并重新打开,不产生重复设备组。 @@ -405,11 +492,13 @@ ManualWiring.py 每个模板至少准备: -- 原始模型文件:`.STEP` 或 `.FCStd` -- 可选 sidecar:端子槽位坐标 +- 原始几何文件:`.STEP` / `.STP` / `.STE` +- 正式模板文件:`.FCStd` +- 模板内 LCS 端子:`Role="Terminal"`,带槽位名和接线资格 +- 可选 sidecar:只作为过渡或校验,不作为正式交付优先方案 - 模板说明:原点、朝向、尺寸单位、端子数量 -常用设备建议优先补齐 `sidecar JSON` 或 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 +常用设备建议优先补齐 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 ## 10. 单人开发优先级 @@ -458,4 +547,6 @@ ManualWiring.py - 2026-05-20:补上 sidecar `rotation` 解析与端子 Placement 应用,端子模板现在可以同时带位置和方向;已用单元测试验证解析和放置逻辑。 - 2026-05-20:补上 FCStd 模板 LCS 朝向保留,模板端子现在可以从源对象直接继承 Placement 方向;已用单元测试验证。 - 2026-05-20:补上手动连线对象归属到设备 `QETWires_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。 +- 2026-05-20:明确 A 方案:STEP/STP/STE 只作为原始几何输入,正式可复用设备资源统一保存为带 LCS 电气端子的 FCStd 模板;后续设计 `TemplateAuthoring.py` 做模板制作工具。 +- 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 ``` diff --git a/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md b/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md new file mode 100644 index 0000000..3ce693d --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md @@ -0,0 +1,590 @@ +# FreeCAD FCStd Template Authoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a FreeCADExchange Python authoring tool that turns imported STEP/STP/STE geometry into reusable FCStd equipment templates with electrical terminal LCS semantics. + +**Architecture:** Keep the feature in the FreeCADExchange Python layer. `TemplateAuthoring.py` owns template terminal creation, validation, and command registration; `TerminalObjects.py` remains the shared low-level semantic property helper; `InitGui.py` registers authoring commands when FreeCAD starts. + +**Tech Stack:** FreeCAD Python API, `Part::LocalCoordinateSystem` / `PartDesign::CoordinateSystem`, Python `unittest`, existing FreeCADExchange command registration pattern. + +--- + +## File Structure + +- Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` + - Build template terminal LCS objects. + - Write template-only electrical semantics. + - Validate template terminal objects. + - Register FreeCAD commands. +- Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` + - Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`. +- Modify: `src/Mod/FreeCADExchange/InitGui.py` + - Import `TemplateAuthoring`. + - Call `TemplateAuthoring.register_commands()`. +- Create: `tests/python/freecad_exchange_template_authoring_test.py` + - Fake enough FreeCAD API to test terminal creation and validation without launching FreeCAD. +- Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` + - Add development result entry after implementation. + +--- + +### Task 1: Add Failing Tests For Template Terminal Creation + +**Files:** +- Create: `tests/python/freecad_exchange_template_authoring_test.py` + +- [ ] **Step 1: Write the failing test file** + +Create `tests/python/freecad_exchange_template_authoring_test.py` with: + +```python +import importlib +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.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.Selection = types.SimpleNamespace(getSelectionEx=lambda: []) + sys.modules["FreeCADGui"] = fake_freecadgui + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = 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.Placement = 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.Name = "TemplateDoc" + self.Objects = [] + self.recomputed = False + + 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 recompute(self): + self.recomputed = True + + +def _reload_modules(): + for name in ["TerminalObjects", "TemplateAuthoring"]: + sys.modules.pop(name, None) + import TemplateAuthoring + return TemplateAuthoring + + +class TemplateAuthoringTest(unittest.TestCase): + def test_create_template_terminal_writes_lcs_semantics(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + terminal = template_authoring.create_template_terminal( + doc, + "P1", + app.Vector(10, 20, 30), + terminal_type="primary", + ) + + self.assertEqual("Terminal_P1", terminal.Name) + self.assertEqual("P1", terminal.Label) + self.assertEqual("Terminal", terminal.Role) + self.assertTrue(terminal.CanWire) + self.assertEqual("P1", terminal.QetTemplateSlotName) + self.assertEqual("P1", terminal.QetTerminalLabel) + self.assertEqual("primary", terminal.QetTerminalType) + self.assertEqual(10.0, terminal.Placement.Base.x) + self.assertEqual(20.0, terminal.Placement.Base.y) + self.assertEqual(30.0, terminal.Placement.Base.z) + self.assertTrue(doc.recomputed) + + def test_validate_template_terminals_reports_missing_slot_name(self): + _install_fake_freecad() + template_authoring = _reload_modules() + doc = FakeDocument() + terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal") + terminal.addProperty("App::PropertyString", "Role", "QET Template", "role") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyBool", "CanWire", "QET Template", "can wire") + terminal.CanWire = True + + report = template_authoring.validate_template_terminals(doc) + + self.assertEqual(1, report["total_terminals"]) + self.assertEqual(0, report["valid_terminals"]) + self.assertEqual(1, len(report["warnings"])) + self.assertIn("QetTemplateSlotName", report["warnings"][0]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```powershell +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: fails with `ModuleNotFoundError: No module named 'TemplateAuthoring'`. + +--- + +### Task 2: Implement TemplateAuthoring Core + +**Files:** +- Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` + +- [ ] **Step 1: Create the implementation** + +Create `src/Mod/FreeCADExchange/TemplateAuthoring.py` with: + +```python +# FreeCADExchange FCStd template authoring helpers. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +import TerminalObjects + + +TEMPLATE_PROPERTY_GROUP = "QET Template" +DEFAULT_TERMINAL_TYPE = "generic" + + +class TemplateAuthoringError(RuntimeError): + pass + + +def _safe_slot_name(slot_name): + value = (slot_name or "").strip() + if not value: + raise TemplateAuthoringError("Terminal slot name is required.") + return value + + +def _terminal_object_name(slot_name): + return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name)) + + +def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"): + if prop_type == "App::PropertyBool": + TerminalObjects.ensure_bool_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + bool(value), + ) + else: + TerminalObjects.ensure_string_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + value, + ) + + +def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + slot_name = _safe_slot_name(slot_name) + label = (label or "").strip() or slot_name + terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE + + _ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE) + _ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool") + _ensure_template_property(obj, "QetTemplateSlotName", slot_name) + _ensure_template_property(obj, "QetTerminalLabel", label) + _ensure_template_property(obj, "QetTerminalType", terminal_type) + obj.Label = label + return obj + + +def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + if doc is None: + raise TemplateAuthoringError("An active FreeCAD document is required.") + + slot_name = _safe_slot_name(slot_name) + if position is None: + raise TemplateAuthoringError("A terminal position is required.") + + if rotation is None: + rotation = App.Rotation() + placement = App.Placement(position, rotation) + terminal = TerminalObjects.create_lcs_object( + doc, + _terminal_object_name(slot_name), + placement=placement, + label=(label or slot_name), + ) + set_template_terminal_semantics( + terminal, + slot_name, + label=label or slot_name, + terminal_type=terminal_type, + ) + try: + terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0) + except Exception: + pass + doc.recompute() + return terminal + + +def is_template_terminal(obj): + if obj is None: + return False + return TerminalObjects.is_terminal_hint_object(obj) + + +def _has_property(obj, prop_name): + return prop_name in getattr(obj, "PropertiesList", []) + + +def validate_template_terminals(doc): + report = { + "document_name": getattr(doc, "Name", ""), + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + "terminals": [], + } + if doc is None: + report["warnings"].append("No active FreeCAD document.") + return report + + for obj in list(getattr(doc, "Objects", []) or []): + if not is_template_terminal(obj): + continue + + report["total_terminals"] += 1 + slot_name = getattr(obj, "QetTemplateSlotName", "").strip() + can_wire = bool(getattr(obj, "CanWire", False)) + item = { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "slot_name": slot_name, + "can_wire": can_wire, + } + report["terminals"].append(item) + + valid = True + if not _has_property(obj, "QetTemplateSlotName") or not slot_name: + report["warnings"].append( + "Template terminal {0} is missing QetTemplateSlotName.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if not can_wire: + report["warnings"].append( + "Template terminal {0} has CanWire disabled.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if valid: + report["valid_terminals"] += 1 + + return report + + +def _selection_position(): + if Gui is None: + return None + try: + selection_ex = Gui.Selection.getSelectionEx() + except Exception: + return None + if not selection_ex: + return None + + picked = selection_ex[0] + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + + obj = getattr(picked, "Object", None) + shape = getattr(obj, "Shape", None) + bound_box = getattr(shape, "BoundBox", None) + if bound_box is None: + return None + return App.Vector( + (bound_box.XMin + bound_box.XMax) * 0.5, + (bound_box.YMin + bound_box.YMax) * 0.5, + (bound_box.ZMin + bound_box.ZMax) * 0.5, + ) +``` + +- [ ] **Step 2: Run the focused test and verify GREEN** + +Run: + +```powershell +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: both tests pass. + +--- + +### Task 3: Add FreeCAD Commands And Registration + +**Files:** +- Modify: `src/Mod/FreeCADExchange/TemplateAuthoring.py` +- Modify: `src/Mod/FreeCADExchange/InitGui.py` +- Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` + +- [ ] **Step 1: Extend TemplateAuthoring.py with commands** + +Append this code to `src/Mod/FreeCADExchange/TemplateAuthoring.py`: + +```python +class CommandAddTemplateTerminal: + def GetResources(self): + return { + "MenuText": "Add Template Terminal", + "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", + } + + def IsActive(self): + return App.ActiveDocument is not None and Gui is not None + + def Activated(self): + if Gui is None: + return + position = _selection_position() + if position is None: + App.Console.PrintWarning( + "Select a model point or model object before adding a template terminal.\n" + ) + return + + slot_name = "T{0}".format(len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1) + try: + create_template_terminal(App.ActiveDocument, slot_name, position) + App.Console.PrintMessage( + "[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format(slot_name) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] template terminal creation failed: {0}\n".format(exc) + ) + + +class CommandValidateTemplateTerminals: + def GetResources(self): + return { + "MenuText": "Validate Template Terminals", + "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", + } + + def IsActive(self): + return App.ActiveDocument is not None + + def Activated(self): + report = validate_template_terminals(App.ActiveDocument) + App.Console.PrintMessage( + "[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format( + report["total_terminals"], + report["valid_terminals"], + ) + ) + for warning in report["warnings"]: + App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) + Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + _COMMANDS_REGISTERED = True + + +register_commands() +``` + +- [ ] **Step 2: Modify InitGui.py** + +Add the import near the existing FreeCADExchange imports: + +```python +import TemplateAuthoring +``` + +Add registration after the existing command registration blocks: + +```python +try: + TemplateAuthoring.register_commands() +except Exception: + pass +``` + +- [ ] **Step 3: Modify CMakeLists.txt** + +Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`: + +```cmake + TemplateAuthoring.py +``` + +Place it beside `TemplateSemantics.py`. + +- [ ] **Step 4: Verify syntax and tests** + +Run: + +```powershell +python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: py_compile exits 0, tests pass. + +--- + +### Task 4: Add Development Result Documentation + +**Files:** +- Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` + +- [ ] **Step 1: Add the result entry** + +Append to the development log section: + +```markdown +- 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 +``` + +- [ ] **Step 2: Run final verification** + +Run: + +```powershell +python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/TerminalObjects.py +python -m unittest tests.python.freecad_exchange_template_authoring_test tests.python.freecad_exchange_template_semantics_test tests.python.freecad_exchange_manual_wiring_test -v +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +Run: + +```powershell +git add src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/CMakeLists.txt tests/python/freecad_exchange_template_authoring_test.py "docs/FreeCAD 端子显示连线保存回写开发文档.md" "docs/FreeCAD 二次开发说明.md" docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md +git commit -m "feature/FCStd设备模板端子制作-zwl-0520" +``` + +Expected: commit succeeds and does not include `artifacts/`, `video_frames/`, or generated cache files. + +--- + +## Self-Review + +Spec coverage: + +- FCStd as formal asset is covered by Tasks 2 and 4. +- Template-only terminal semantics are covered by Task 1 and Task 2. +- Command entry points are covered by Task 3. +- Existing project import flow remains unchanged. + +Placeholder scan: + +- The plan contains no unresolved placeholders or open-ended implementation steps. + +Type consistency: + +- The planned module exposes `create_template_terminal`, `set_template_terminal_semantics`, `validate_template_terminals`, and `register_commands`. +- Command names are `QET_Template_AddTerminal` and `QET_Template_ValidateTerminals`. diff --git a/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md b/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md new file mode 100644 index 0000000..2e2751a --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md @@ -0,0 +1,189 @@ +# FreeCAD FCStd 电气设备模板制作设计 + +## 1. 目标 + +把普通 STEP / STP / STE 几何模型加工成可复用的 `.FCStd` 电气设备模板。 + +模板保存设备几何和端子槽位语义。后续不同工程、不同人员导入这个 FCStd 时,FreeCADExchange 能识别其中的端子 LCS,并把工程里的真实 `terminal_uuid` 绑定到这些槽位上。 + +## 2. 非目标 + +- 不修改 FreeCAD C/C++ 内核。 +- 不在模板文件里保存工程级 `terminal_uuid`、`project_uuid`、`instance_id`。 +- 不做自动布线。 +- 不把完整接线路径写入数据库。 +- 不要求第一版做完整 UI 面板。 + +## 3. 核心原则 + +STEP / STP / STE 是几何输入,FCStd 是正式设备模板输出。 + +模板端子描述“这个设备模型哪里可以接线”。工程端子描述“当前工程的哪个 2D 端子绑定到哪个 3D 端子对象”。两者不能混用。 + +## 4. 模板文件结构 + +以电流互感器为例: + +```text +电流互感器.FCStd + ModelGeometry + Terminal_P1 + Terminal_P2 +``` + +`Terminal_P1` 和 `Terminal_P2` 是 LCS 对象。它们的位置就是模型上真实接线位置,方向就是后续出线方向参考。 + +## 5. 模板端子属性 + +模板端子至少写入: + +```text +Role = Terminal +CanWire = true +QetTemplateSlotName = P1 +QetTerminalLabel = P1 +QetTerminalType = primary +``` + +模板端子不写入: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +``` + +这些字段只属于具体工程场景。 + +## 6. 新模块 + +新增模块: + +```text +src/Mod/FreeCADExchange/TemplateAuthoring.py +``` + +职责: + +- 创建模板端子 LCS。 +- 给 LCS 写入模板语义属性。 +- 校验当前文档中的模板端子。 +- 保存或辅助保存 `.FCStd` 模板。 + +不负责: + +- 工程 `2d_to_3d.json` 导入。 +- 工程端子 UUID 绑定。 +- 手动连线。 +- `3d_to_2d.json` 回写。 + +## 7. 第一版命令 + +第一版先做两个命令: + +```text +QET_Template_AddTerminal +QET_Template_ValidateTerminals +``` + +`QET_Template_AddTerminal`: + +1. 用户选择一个模型对象或模型上的点位。 +2. 执行命令。 +3. 输入端子槽位名,例如 `P1`。 +4. 在选择位置创建 LCS。 +5. 自动写入模板端子属性。 + +`QET_Template_ValidateTerminals`: + +1. 扫描当前文档中的端子 LCS。 +2. 检查是否有 `Role="Terminal"`。 +3. 检查是否有 `QetTemplateSlotName`。 +4. 输出端子列表和问题。 + +保存动作第一版直接使用 FreeCAD 自带保存为 `.FCStd`。后续再补 `QET_Template_SaveAsFCStd`。 + +## 8. 第一版点位策略 + +第一版按简单稳定策略实现: + +1. 如果用户选择对象的子元素,并且能取到选择点,就使用该点。 +2. 如果只能选到对象,则使用对象包围盒中心。 +3. 如果没有选择,则拒绝创建端子。 + +端子方向第一版使用默认旋转。后续再增加方向编辑,例如沿面法向、沿用户选择边方向或手动输入轴角。 + +## 9. 与现有导入流程的关系 + +现有项目导入流程保持不变: + +```text +ExchangeBootstrap + -> DeviceImport + -> TerminalImport + -> ExchangeWriteBack +``` + +`TerminalImport` 已经支持读取 FCStd 模板中的 `Role="Terminal"` LCS。模板制作工具只负责把这些 LCS 方便、规范地放进 FCStd。 + +项目导入时的优先级仍是: + +1. FCStd 模板 LCS。 +2. sidecar JSON。 +3. bbox fallback。 + +## 10. 用户流程 + +以电流互感器为例: + +1. 打开 FreeCAD。 +2. 导入 `电流互感器.step`。 +3. 选择 P1 接线位置。 +4. 执行 `QET_Template_AddTerminal`,输入 `P1`。 +5. 选择 P2 接线位置。 +6. 执行 `QET_Template_AddTerminal`,输入 `P2`。 +7. 执行 `QET_Template_ValidateTerminals`。 +8. 保存为 `电流互感器.FCStd`。 +9. 在 LightWork3D 设备 3D 资源中使用该 FCStd。 + +## 11. 验收标准 + +- 能从普通 STEP 模型创建 `Terminal_P1` 和 `Terminal_P2`。 +- 端子对象是 LCS。 +- 端子对象带 `Role="Terminal"` 和 `CanWire=true`。 +- 端子对象带 `QetTemplateSlotName`。 +- 保存为 FCStd 后重新打开,端子和属性仍存在。 +- 使用该 FCStd 作为工程模型时,端子位置来自模板 LCS,不再使用 bbox fallback。 + +## 12. 风险和处理 + +选择点不可用: + +- 第一版退回对象包围盒中心。 +- 如果连对象也没有选择,则直接提示错误。 + +端子重名: + +- 第一版自动使用唯一对象名,例如 `Terminal_P1_1`。 +- `QetTemplateSlotName` 保持用户输入,用于槽位语义。 + +模板里混入工程端子属性: + +- 校验命令提示警告。 +- 第一版不自动删除,避免误删用户数据。 + +方向不准确: + +- 第一版只保证位置和语义。 +- 后续补方向编辑命令。 + +## 13. 实施顺序 + +1. 新增 `TemplateAuthoring.py`。 +2. 在 `CMakeLists.txt` 加入该模块。 +3. 在 `InitGui.py` 注册模板命令。 +4. 实现添加端子命令。 +5. 实现校验命令。 +6. 增加 Python 单元测试,覆盖属性写入和校验逻辑。 +7. 用电流互感器模型手工验证一次完整流程。 diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 93c3e7b..77254a2 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -7,6 +7,7 @@ set(FreeCADExchange_Scripts DevicePreview.py TerminalObjects.py TemplateSemantics.py + TemplateAuthoring.py TerminalImport.py ExchangeWriteBack.py ManualWiring.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 83530ef..09fe20c 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -14,6 +14,7 @@ except ImportError: import ExchangeBootstrap import ExchangeWriteBack import ManualWiring +import TemplateAuthoring def _append_init_log(message): @@ -47,5 +48,10 @@ try: except Exception: pass +try: + TemplateAuthoring.register_commands() +except Exception: + pass + QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py new file mode 100644 index 0000000..1abd26c --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -0,0 +1,253 @@ +# FreeCADExchange FCStd template authoring helpers. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +import TerminalObjects + + +TEMPLATE_PROPERTY_GROUP = "QET Template" +DEFAULT_TERMINAL_TYPE = "generic" + + +class TemplateAuthoringError(RuntimeError): + pass + + +def _safe_slot_name(slot_name): + value = (slot_name or "").strip() + if not value: + raise TemplateAuthoringError("Terminal slot name is required.") + return value + + +def _terminal_object_name(slot_name): + return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name)) + + +def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"): + if prop_type == "App::PropertyBool": + TerminalObjects.ensure_bool_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + bool(value), + ) + else: + TerminalObjects.ensure_string_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + value, + ) + + +def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + slot_name = _safe_slot_name(slot_name) + label = (label or "").strip() or slot_name + terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE + + _ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE) + _ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool") + _ensure_template_property(obj, "QetTemplateSlotName", slot_name) + _ensure_template_property(obj, "QetTerminalLabel", label) + _ensure_template_property(obj, "QetTerminalType", terminal_type) + obj.Label = label + return obj + + +def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + if doc is None: + raise TemplateAuthoringError("An active FreeCAD document is required.") + + slot_name = _safe_slot_name(slot_name) + if position is None: + raise TemplateAuthoringError("A terminal position is required.") + + if rotation is None: + rotation = App.Rotation() + placement = App.Placement(position, rotation) + terminal = TerminalObjects.create_lcs_object( + doc, + _terminal_object_name(slot_name), + placement=placement, + label=(label or slot_name), + ) + set_template_terminal_semantics( + terminal, + slot_name, + label=label or slot_name, + terminal_type=terminal_type, + ) + try: + terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0) + except Exception: + pass + doc.recompute() + return terminal + + +def is_template_terminal(obj): + if obj is None: + return False + return TerminalObjects.is_terminal_hint_object(obj) + + +def _has_property(obj, prop_name): + return prop_name in getattr(obj, "PropertiesList", []) + + +def validate_template_terminals(doc): + report = { + "document_name": getattr(doc, "Name", ""), + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + "terminals": [], + } + if doc is None: + report["warnings"].append("No active FreeCAD document.") + return report + + for obj in list(getattr(doc, "Objects", []) or []): + if not is_template_terminal(obj): + continue + + report["total_terminals"] += 1 + slot_name = getattr(obj, "QetTemplateSlotName", "").strip() + can_wire = bool(getattr(obj, "CanWire", False)) + item = { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "slot_name": slot_name, + "can_wire": can_wire, + } + report["terminals"].append(item) + + valid = True + if not _has_property(obj, "QetTemplateSlotName") or not slot_name: + report["warnings"].append( + "Template terminal {0} is missing QetTemplateSlotName.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if not can_wire: + report["warnings"].append( + "Template terminal {0} has CanWire disabled.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if valid: + report["valid_terminals"] += 1 + + return report + + +def _selection_position(): + if Gui is None: + return None + try: + selection_ex = Gui.Selection.getSelectionEx() + except Exception: + return None + if not selection_ex: + return None + + picked = selection_ex[0] + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + + obj = getattr(picked, "Object", None) + shape = getattr(obj, "Shape", None) + bound_box = getattr(shape, "BoundBox", None) + if bound_box is None: + return None + return App.Vector( + (bound_box.XMin + bound_box.XMax) * 0.5, + (bound_box.YMin + bound_box.YMax) * 0.5, + (bound_box.ZMin + bound_box.ZMax) * 0.5, + ) + + +class CommandAddTemplateTerminal: + def GetResources(self): + return { + "MenuText": "Add Template Terminal", + "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", + } + + def IsActive(self): + return App.ActiveDocument is not None and Gui is not None + + def Activated(self): + if Gui is None: + return + position = _selection_position() + if position is None: + App.Console.PrintWarning( + "Select a model point or model object before adding a template terminal.\n" + ) + return + + slot_name = "T{0}".format( + len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1 + ) + try: + create_template_terminal(App.ActiveDocument, slot_name, position) + App.Console.PrintMessage( + "[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format( + slot_name + ) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] template terminal creation failed: {0}\n".format(exc) + ) + + +class CommandValidateTemplateTerminals: + def GetResources(self): + return { + "MenuText": "Validate Template Terminals", + "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", + } + + def IsActive(self): + return App.ActiveDocument is not None + + def Activated(self): + report = validate_template_terminals(App.ActiveDocument) + App.Console.PrintMessage( + "[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format( + report["total_terminals"], + report["valid_terminals"], + ) + ) + for warning in report["warnings"]: + App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) + Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + _COMMANDS_REGISTERED = True + + +register_commands() diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py new file mode 100644 index 0000000..bdb1722 --- /dev/null +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -0,0 +1,163 @@ +import importlib +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.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.Selection = types.SimpleNamespace(getSelectionEx=lambda: []) + sys.modules["FreeCADGui"] = fake_freecadgui + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = 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.Placement = 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.Name = "TemplateDoc" + self.Objects = [] + self.recomputed = False + + 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 recompute(self): + self.recomputed = True + + +def _reload_modules(): + for name in ["TerminalObjects", "TemplateAuthoring"]: + sys.modules.pop(name, None) + return importlib.import_module("TemplateAuthoring") + + +class TemplateAuthoringTest(unittest.TestCase): + def test_create_template_terminal_writes_lcs_semantics(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + terminal = template_authoring.create_template_terminal( + doc, + "P1", + app.Vector(10, 20, 30), + terminal_type="primary", + ) + + self.assertEqual("Terminal_P1", terminal.Name) + self.assertEqual("P1", terminal.Label) + self.assertEqual("Terminal", terminal.Role) + self.assertTrue(terminal.CanWire) + self.assertEqual("P1", terminal.QetTemplateSlotName) + self.assertEqual("P1", terminal.QetTerminalLabel) + self.assertEqual("primary", terminal.QetTerminalType) + self.assertEqual(10.0, terminal.Placement.Base.x) + self.assertEqual(20.0, terminal.Placement.Base.y) + self.assertEqual(30.0, terminal.Placement.Base.z) + self.assertTrue(doc.recomputed) + + def test_validate_template_terminals_reports_missing_slot_name(self): + _install_fake_freecad() + template_authoring = _reload_modules() + doc = FakeDocument() + terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal") + terminal.addProperty("App::PropertyString", "Role", "QET Template", "role") + terminal.Role = "Terminal" + terminal.addProperty( + "App::PropertyBool", + "CanWire", + "QET Template", + "can wire", + ) + terminal.CanWire = True + + report = template_authoring.validate_template_terminals(doc) + + self.assertEqual(1, report["total_terminals"]) + self.assertEqual(0, report["valid_terminals"]) + self.assertEqual(1, len(report["warnings"])) + self.assertIn("QetTemplateSlotName", report["warnings"][0]) + + +if __name__ == "__main__": + unittest.main() From 8831112b8c9080dc57ca3d52c6a0365bc02cf6c5 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 19:16:43 +0800 Subject: [PATCH 06/12] =?UTF-8?q?docs/A=E6=96=B9=E6=A1=88FCStd=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E6=B5=81=E8=BD=AC=E8=AE=BE=E8=AE=A1-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2D-3D交换协议.md | 31 +- docs/FreeCAD 二次开发说明.md | 15 + ...子显示连线保存回写开发文档.md | 24 ++ ...6-05-20-freecad-fcstd-asset-flow-design.md | 265 ++++++++++++++++++ 4 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md diff --git a/docs/2D-3D交换协议.md b/docs/2D-3D交换协议.md index b0d1474..ac29600 100644 --- a/docs/2D-3D交换协议.md +++ b/docs/2D-3D交换协议.md @@ -10,7 +10,7 @@ 这些内容统一放在: -- [数据库设计.md](D:\project\LightWork3D\FreeCAD\docs\数据库设计.md) +- [数据库设计.md](D:\LightWork3D\docs\数据库设计.md) --- @@ -317,7 +317,34 @@ 让 FreeCAD 只负责消费结果。 -### 7.4 为什么这里允许同时带 `parts_3d` +### 8.5 `.FCStd` 设备资产支持 + +A 方案下,`.FCStd` 是正式可复用设备资产格式。QET 导出时不需要解析 `.FCStd` 内部内容,只需要把设备资产路径解析成 `resolved_model_path` 交给 FreeCAD。 + +示例: + +```json +{ + "element_uuid": "elem-1001", + "device_id": 123, + "parts_3d": "models/mccb/MCCB_1P.FCStd", + "resolved_model_path": "C:/Users/Admin/Documents/MingTuProject/models/mccb/MCCB_1P.FCStd" +} +``` + +FreeCAD 根据 `resolved_model_path` 的扩展名导入 `.FCStd`,并在导入后的设备组内优先扫描 `Role="Terminal"` 的 LCS 作为模板端子槽位。 + +如果 QET 侧已经在 `device_3d_asset.format` 中保存了资源格式,可以在后续协议中追加可选字段: + +```json +{ + "format": "fcstd" +} +``` + +第一版 FreeCAD 不应强依赖该字段,避免旧版本 JSON 缺少 `format` 时无法导入。 + +### 8.6 为什么这里允许同时带 `parts_3d` 注意: diff --git a/docs/FreeCAD 二次开发说明.md b/docs/FreeCAD 二次开发说明.md index 72656f6..20e8017 100644 --- a/docs/FreeCAD 二次开发说明.md +++ b/docs/FreeCAD 二次开发说明.md @@ -268,6 +268,21 @@ Terminal_P2 这些对象表示模型上 P1/P2 的真实接线位置和方向,但不保存某个工程里的 `terminal_uuid`。项目导入时,FreeCADExchange 再根据 `2d_to_3d.json` 把工程端子 UUID 绑定到模板槽位上。 +### 13.2 A 方案下 zwl 与 FreeCAD 的文件职责 + +A 方案要求 `D:\code\zwl` 支持 `.FCStd` 作为设备 3D 资产格式,但不要求 zwl 解析 `.FCStd` 文件。 + +职责划分如下: + +- zwl/QET:选择 `.FCStd`、复制到工程资产目录、保存到 `device_3d_asset`、导出 `resolved_model_path`。 +- FreeCADExchange:导入 `.FCStd`、扫描模板内 `Role="Terminal"` 的 LCS、创建工程端子对象、保存 3D 装配和手动连线。 + +也就是说,`.FCStd` 内部的端子、LCS、动态属性和装配结构都是 FreeCAD 侧语义;zwl 只把 `.FCStd` 当作一种可流转的 3D 资源文件。 + +详细设计见: + +- `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` + ## 14. 电气柜与设备装配 除了端子与接线,电气柜场景通常还会遇到另一个基础问题: diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 863d6a3..7208208 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -182,6 +182,29 @@ FCStd 设备模板用于解决“这个模型本身就带端子语义”的问 4. 保存为 `.FCStd` 设备模板。 5. 后续 LightWork3D 工程引用该 `.FCStd` 后,自动识别模板端子并生成工程端子对象。 +### 3.3 A 方案资产流转约定 + +A 方案下,`.FCStd` 是正式可复用设备资产,STEP / STP / STE 只作为模板制作的原始几何输入。 + +完整流转固定为: + +```text +STEP / STP / STE 原始几何 + -> FreeCAD 中添加端子 LCS 和模板语义 + -> 保存为 FCStd 设备模板 + -> zwl/QET 选择该 FCStd 作为设备 3D 资产 + -> 3D 视图导出 2d_to_3d.json + -> FreeCAD 导入 FCStd 并绑定工程端子 + -> 手动连线并保存 scene.FCStd + -> 生成 3d_to_2d.json +``` + +zwl/QET 侧只需要支持 `.FCStd` 的选择、复制、保存和导出路径,不解析 `.FCStd` 内部的 LCS、端子属性或装配结构。`.FCStd` 的内部语义由 FreeCADExchange 读取。 + +详细设计见: + +- `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` + ## 4. 为什么要先落地设备模板 这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。 @@ -549,4 +572,5 @@ ManualWiring.py - 2026-05-20:补上手动连线对象归属到设备 `QETWires_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。 - 2026-05-20:明确 A 方案:STEP/STP/STE 只作为原始几何输入,正式可复用设备资源统一保存为带 LCS 电气端子的 FCStd 模板;后续设计 `TemplateAuthoring.py` 做模板制作工具。 - 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 +- 2026-05-20:补充 A 方案资产流转设计,明确 `.FCStd` 为正式设备资产;zwl/QET 只负责选择、保存、导出 `.FCStd` 路径,FreeCADExchange 负责读取 LCS 端子语义并生成工程端子。 ``` diff --git a/docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md b/docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md new file mode 100644 index 0000000..1e68c56 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md @@ -0,0 +1,265 @@ +# FreeCAD FCStd 设备资产流转 A 方案设计 + +## 1. 设计目标 + +A 方案把 `.FCStd` 定义为正式的 3D 电气设备资产格式。 + +完整链路是: + +```text +STEP / STP / STE 原始几何 + -> FreeCAD 模板制作 + -> 添加端子 LCS 和电气语义 + -> 保存为 FCStd 设备模板 + -> zwl/QET 选择该 FCStd 作为设备 3D 资产 + -> 3D 视图导出 2d_to_3d.json + -> FreeCAD 导入 FCStd 并绑定工程端子 + -> 手动连线并保存 scene.FCStd + -> 生成 3d_to_2d.json +``` + +这个设计解决的问题是:模型文件交给别人、换工程使用、重新打开工程时,端子位置和接线语义仍然跟着模型走。 + +## 2. 职责边界 + +### 2.1 LightWork3D / FreeCAD 负责 + +- 导入 STEP / STP / STE 原始几何。 +- 在模型真实接线位置创建模板端子 LCS。 +- 给模板端子写入: + - `Role = "Terminal"` + - `CanWire = true` + - `QetTemplateSlotName` + - `QetTerminalLabel` + - `QetTerminalType` +- 保存为 `.FCStd` 设备模板。 +- 项目导入时读取 FCStd 模板里的端子 LCS。 +- 根据 `2d_to_3d.json` 把工程级 `terminal_uuid` 绑定到 3D 端子对象。 +- 在 FreeCAD 文档里保存设备位姿、装配状态、端子位置和手动连线。 + +### 2.2 D:\code\zwl / QET 负责 + +- 支持选择 `.FCStd` 作为设备 3D 资产。 +- 将 `.FCStd` 保存到 `device_3d_asset`,格式识别为 `fcstd`。 +- 继续通过 `device_3d_asset.uri` 优先提供 3D 资源路径。 +- 导出 `2d_to_3d.json` 时保持 `parts_3d` 和 `resolved_model_path`。 +- 启动 FreeCAD 并传递: + - `QET_2D_TO_3D_JSON` + - `QET_FREECAD_SCENE_FILE` + +zwl 不解析 `.FCStd` 内部结构,不读取 LCS,不判断端子位置。`.FCStd` 内部端子语义由 FreeCADExchange 读取。 + +## 3. 模板与工程实例的区别 + +### 3.1 FCStd 设备模板保存通用槽位 + +模板中的端子表示“这个设备模型哪里可以接线”,例如: + +```text +电流互感器.FCStd + ModelGeometry + Terminal_P1 + Terminal_P2 +``` + +模板端子保存: + +```text +QetTemplateSlotName = P1 +QetTerminalLabel = P1 +Role = Terminal +CanWire = true +``` + +模板端子不保存: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +``` + +### 3.2 工程场景保存真实绑定 + +工程打开 3D 视图后,FreeCAD 根据 `2d_to_3d.json` 创建或更新工程端子对象。 + +工程端子保存: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +Role = Terminal +CanWire = true +``` + +这样同一个 FCStd 模板可以被多个工程复用,而不会把某个工程的端子 UUID 写死在模板里。 + +## 4. zwl 侧需要支持的最小改动 + +### 4.1 设备 3D 资产选择 + +`D:\code\zwl\sources\DeviceManager\deviceeditor.cpp` 中的设备 3D 文件选择过滤器需要加入 `.fcstd`: + +```text +3D 文件 (*.fcstd *.FCStd *.step *.stp *.iges *.igs *.stl *.obj) +``` + +提示文案从“STEP/IGES 文件”改成“3D 模型文件”,避免用户以为 FCStd 不是合法资产。 + +### 4.2 格式识别 + +`detect3dFormat(...)` / `normalize3dFormatFromPathInternal(...)` 需要把: + +```text +.fcstd / .FCStd -> fcstd +``` + +写入 `device_3d_asset.format`。 + +### 4.3 资产复制 + +当前设备编辑器选择工程外文件时会复制到工程 3D 资产目录。该流程应继续适用于 `.FCStd`。 + +如果源文件旁边存在 sidecar JSON,可以继续复制;但对正式 A 方案来说,FCStd 内 LCS 是优先来源,sidecar 只作为过渡兼容。 + +### 4.4 导出给 FreeCAD + +`FreeCADExchangeExportService.cpp` 已经导出: + +```json +{ + "parts_3d": "...", + "resolved_model_path": "..." +} +``` + +`.FCStd` 第一版不需要额外字段也能工作,因为 FreeCAD 可从 `resolved_model_path` 的扩展名判断格式。 + +后续可以把 `device_3d_asset.format` 作为可选调试字段导出: + +```json +{ + "format": "fcstd" +} +``` + +但第一版不应让 FreeCAD 依赖这个字段,避免旧 JSON 失效。 + +### 4.5 旧 3D 空间对象入口 + +`qetdiagrameditor.cpp` 里旧的空间对象 STEP/IGES 选择入口属于旧 3D 场景功能。A 方案第一版不建议把它作为设备模板主入口。 + +如果后续仍要用它给机柜、导轨、安装板选择模型,可以单独扩展为: + +```text +空间对象模型 (*.fcstd *.FCStd *.step *.stp *.iges *.igs) +``` + +但它不负责设备端子语义。 + +## 5. FreeCAD 侧需要保持的读取优先级 + +项目导入设备后,端子槽位解析顺序固定为: + +1. FCStd 模板内已有的 `Role="Terminal"` LCS。 +2. STEP 同目录 sidecar JSON。 +3. bbox fallback 临时端子。 + +长期目标是让常用设备都进入第 1 类。第 3 类只用于没有模板时打通流程,不能作为正式端子位置。 + +## 6. 手动连线与保存回写 + +手动连线只允许从工程端子对象连到工程端子对象。 + +连线对象保存在 `scene.FCStd`,第一版不把导线几何路径写入数据库。 + +保存时 `3d_to_2d.json` 只回写最小绑定: + +```json +{ + "schema_version": "1.0", + "project_uuid": "string", + "instances": [ + { + "element_uuid": "string", + "instance_id": "string" + } + ], + "terminals": [ + { + "terminal_uuid": "string", + "instance_id": "string" + } + ] +} +``` + +这符合当前数据库约束:第一版只依赖 `project_2d3d_symbol_binding` 和 `project_2d3d_terminal_binding`,不依赖旧 3D 场景表。 + +## 7. 用户流程 + +### 7.1 制作设备模板 + +1. 打开 FreeCAD。 +2. 导入厂家 STEP / STP / STE 模型。 +3. 在真实接线位置添加端子,例如 `P1`、`P2`。 +4. 执行模板端子校验。 +5. 保存为 `设备名.FCStd`。 + +### 7.2 在 zwl/QET 中使用模板 + +1. 打开设备管理或设备属性。 +2. 选择 3D 资产。 +3. 选择刚制作好的 `.FCStd`。 +4. 保存设备资产绑定。 +5. 在工程中点击 `3D视图`。 +6. zwl 导出 `2d_to_3d.json` 并启动 FreeCAD。 +7. FreeCAD 导入 `.FCStd`,读取模板端子,生成工程端子对象。 +8. 用户选择端子并创建手动连线。 +9. 保存 FreeCAD 工程,生成 `3d_to_2d.json`。 + +## 8. 第一版验收标准 + +- zwl 设备 3D 资产选择框能选择 `.FCStd`。 +- `.FCStd` 能写入 `device_3d_asset`,格式为 `fcstd`。 +- `2d_to_3d.json` 中 `resolved_model_path` 指向 `.FCStd`。 +- FreeCAD 能导入该 `.FCStd`。 +- 模板中的 LCS 端子优先用于工程端子创建。 +- 工程端子对象带真实 `terminal_uuid`。 +- 用户能选择两个端子生成手动连线。 +- 保存 FreeCAD 后生成 `3d_to_2d.json`。 +- 不依赖 `project_3d_scene_instance`、`project_3d_space_object`、`project_2d3d_link`、`start_end_terminal_matches`。 + +## 9. 实施顺序 + +1. 在 zwl 侧补 `.FCStd` 文件过滤、格式识别和提示文案。 +2. 确认 `.FCStd` 资产复制、哈希、保存流程不被阻断。 +3. 确认 `2d_to_3d.json` 能输出 `.FCStd` 的 `resolved_model_path`。 +4. 在 FreeCAD 中用现有 `TemplateAuthoring.py` 制作一个样板设备。 +5. 用该 FCStd 样板设备跑通导入、端子显示、手动连线、保存回写。 +6. 再补更友好的模板端子编辑 UI。 + +## 10. 风险和处理 + +`.FCStd` 无法被 zwl 预览尺寸: + +- 不阻断资产绑定。 +- 尺寸字段可以为空或由后续 FreeCAD 侧读取后回填。 + +模板端子槽位与 2D 端子数量不一致: + +- 第一版按数量顺序匹配可用槽位。 +- 后续再引入显式槽位映射 UI。 + +用户误选普通 STEP: + +- 仍允许导入,但只能走 sidecar 或 bbox fallback。 +- 文档和提示中明确“正式可复用资产推荐 FCStd”。 + +旧空间对象入口和新设备模板入口混淆: + +- 第一版设备端子语义只走设备 3D 资产入口。 +- 旧空间对象入口只作为柜体、导轨、安装板模型入口。 From 0ea8998070bf6adbc16ae58afd71dce2697e4bb6 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 19:34:45 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feature/FCStd=E6=A8=A1=E6=9D=BF=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=91=BD=E4=BB=A4-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...子显示连线保存回写开发文档.md | 1 + src/Mod/FreeCADExchange/TemplateAuthoring.py | 79 +++++++++++++++++++ ...reecad_exchange_template_authoring_test.py | 17 ++++ 3 files changed, 97 insertions(+) diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 7208208..51899f5 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -573,4 +573,5 @@ ManualWiring.py - 2026-05-20:明确 A 方案:STEP/STP/STE 只作为原始几何输入,正式可复用设备资源统一保存为带 LCS 电气端子的 FCStd 模板;后续设计 `TemplateAuthoring.py` 做模板制作工具。 - 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 - 2026-05-20:补充 A 方案资产流转设计,明确 `.FCStd` 为正式设备资产;zwl/QET 只负责选择、保存、导出 `.FCStd` 路径,FreeCADExchange 负责读取 LCS 端子语义并生成工程端子。 +- 2026-05-20:补上 `QET_Template_SaveAsFCStd` 模板保存命令,保存前会校验至少存在一个有效模板端子,并自动补 `.FCStd` 后缀;已用单元测试验证保存路径和端子校验结果。 ``` diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py index 1abd26c..a4c4a7b 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoring.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -7,6 +7,17 @@ try: except ImportError: Gui = None +try: + from PySide6 import QtWidgets +except ImportError: + try: + from PySide2 import QtWidgets + except ImportError: + try: + from PySide import QtGui as QtWidgets + except ImportError: + QtWidgets = None + import TerminalObjects @@ -151,6 +162,35 @@ def validate_template_terminals(doc): return report +def _fcstd_path(path): + value = (path or "").strip() + if not value: + raise TemplateAuthoringError("A target FCStd path is required.") + if not value.lower().endswith(".fcstd"): + value = value + ".FCStd" + return value + + +def save_template_as_fcstd(doc, path): + if doc is None: + raise TemplateAuthoringError("An active FreeCAD document is required.") + + target_path = _fcstd_path(path) + report = validate_template_terminals(doc) + if report["total_terminals"] <= 0: + raise TemplateAuthoringError("At least one template terminal is required before saving.") + if report["warnings"]: + raise TemplateAuthoringError( + "Template terminals must be valid before saving: {0}".format( + "; ".join(report["warnings"]) + ) + ) + + doc.saveAs(target_path) + report["path"] = target_path + return report + + def _selection_position(): if Gui is None: return None @@ -236,6 +276,44 @@ class CommandValidateTemplateTerminals: App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) +class CommandSaveTemplateAsFCStd: + def GetResources(self): + return { + "MenuText": "Save Template As FCStd", + "ToolTip": "Validate and save the current document as a reusable FCStd equipment template", + } + + def IsActive(self): + return App.ActiveDocument is not None + + def Activated(self): + if QtWidgets is None: + App.Console.PrintError("[FreeCADExchange] Qt file dialog is not available.\n") + return + + file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( + None, + "Save FCStd Equipment Template", + "", + "FreeCAD template (*.FCStd *.fcstd);;All files (*.*)", + ) + if not file_path: + return + + try: + report = save_template_as_fcstd(App.ActiveDocument, file_path) + App.Console.PrintMessage( + "[FreeCADExchange] Saved FCStd template: {0} ({1} terminals)\n".format( + report["path"], + report["valid_terminals"], + ) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] template save failed: {0}\n".format(exc) + ) + + _COMMANDS_REGISTERED = False @@ -247,6 +325,7 @@ def register_commands(): return Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd()) _COMMANDS_REGISTERED = True diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py index bdb1722..52c401a 100644 --- a/tests/python/freecad_exchange_template_authoring_test.py +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -88,6 +88,7 @@ class FakeDocument: self.Name = "TemplateDoc" self.Objects = [] self.recomputed = False + self.saved_path = "" def addObject(self, type_name, name): obj = FakeObject(name, type_name) @@ -103,6 +104,9 @@ class FakeDocument: def recompute(self): self.recomputed = True + def saveAs(self, path): + self.saved_path = path + def _reload_modules(): for name in ["TerminalObjects", "TemplateAuthoring"]: @@ -158,6 +162,19 @@ class TemplateAuthoringTest(unittest.TestCase): self.assertEqual(1, len(report["warnings"])) self.assertIn("QetTemplateSlotName", report["warnings"][0]) + def test_save_template_as_fcstd_adds_extension_and_saves_valid_template(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3)) + + report = template_authoring.save_template_as_fcstd(doc, "D:/tmp/current-transformer") + + self.assertEqual("D:/tmp/current-transformer.FCStd", doc.saved_path) + self.assertEqual("D:/tmp/current-transformer.FCStd", report["path"]) + self.assertEqual(1, report["valid_terminals"]) + if __name__ == "__main__": unittest.main() From 9bd8e15f316dad2d9f81e32a59958decde3c1e76 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 19:51:22 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix/FCStd=E6=A8=A1=E6=9D=BF=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=8C=E5=AF=BC=E5=85=A5=E5=85=BC=E5=AE=B9-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...CAD 端子显示连线保存回写开发文档.md | 1 + src/Mod/FreeCADExchange/TemplateAuthoring.py | 2 +- .../freecad_exchange_template_authoring_test.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 51899f5..b2b75e4 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -574,4 +574,5 @@ ManualWiring.py - 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 - 2026-05-20:补充 A 方案资产流转设计,明确 `.FCStd` 为正式设备资产;zwl/QET 只负责选择、保存、导出 `.FCStd` 路径,FreeCADExchange 负责读取 LCS 端子语义并生成工程端子。 - 2026-05-20:补上 `QET_Template_SaveAsFCStd` 模板保存命令,保存前会校验至少存在一个有效模板端子,并自动补 `.FCStd` 后缀;已用单元测试验证保存路径和端子校验结果。 +- 2026-05-20:修复 `TemplateAuthoring.py` 在 `FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。 ``` diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py index a4c4a7b..acf6273 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoring.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -321,7 +321,7 @@ def register_commands(): global _COMMANDS_REGISTERED if _COMMANDS_REGISTERED: return - if Gui is None: + if Gui is None or not hasattr(Gui, "addCommand"): return Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py index 52c401a..8f0517a 100644 --- a/tests/python/freecad_exchange_template_authoring_test.py +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -46,6 +46,11 @@ def _install_fake_freecad(): sys.modules["FreeCADGui"] = fake_freecadgui +def _install_fake_freecad_without_gui_commands(): + _install_fake_freecad() + sys.modules["FreeCADGui"] = types.ModuleType("FreeCADGui") + + class FakeViewObject: def __init__(self): self.Visibility = True @@ -115,6 +120,13 @@ def _reload_modules(): class TemplateAuthoringTest(unittest.TestCase): + def test_import_skips_command_registration_when_gui_has_no_add_command(self): + _install_fake_freecad_without_gui_commands() + + template_authoring = _reload_modules() + + self.assertTrue(hasattr(template_authoring, "save_template_as_fcstd")) + def test_create_template_terminal_writes_lcs_semantics(self): _install_fake_freecad() template_authoring = _reload_modules() From 70b05349644dd464956be9543b40e912dd9cbd09 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 20:37:24 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feature/=E8=AE=BE=E5=A4=87=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=AB=AF=E5=AD=90=E5=88=B6=E4=BD=9C=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 二次开发说明.md | 25 ++ ...子显示连线保存回写开发文档.md | 23 ++ src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/InitGui.py | 6 + .../FreeCADExchange/TemplateAuthoringPanel.py | 260 ++++++++++++++++++ ..._exchange_template_authoring_panel_test.py | 85 ++++++ 6 files changed, 400 insertions(+) create mode 100644 src/Mod/FreeCADExchange/TemplateAuthoringPanel.py create mode 100644 tests/python/freecad_exchange_template_authoring_panel_test.py diff --git a/docs/FreeCAD 二次开发说明.md b/docs/FreeCAD 二次开发说明.md index 20e8017..76fcfd5 100644 --- a/docs/FreeCAD 二次开发说明.md +++ b/docs/FreeCAD 二次开发说明.md @@ -283,6 +283,31 @@ A 方案要求 `D:\code\zwl` 支持 `.FCStd` 作为设备 3D 资产格式,但 - `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` +### 13.3 面向 CAD 人员的模板制作交互 + +Python 控制台方式只作为开发验证手段,不能作为正式 CAD 工作流。A 方案第一版需要补一个 FreeCAD 任务面板,把端子制作流程封装成按钮和输入框。 + +推荐交互: + +1. 用户打开 STEP / STP / STE 模型。 +2. 打开“设备模板端子制作”面板。 +3. 在端子名输入框填写 `P1`。 +4. 用鼠标选中模型上的端子位置。 +5. 点击“添加端子”。 +6. 重复添加 `P2` 等端子。 +7. 点击“校验端子”。 +8. 点击“保存为 FCStd”。 + +面板背后仍然使用 LCS 作为端子对象,仍然写入: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` + +这样既保持当前技术路线稳定,又让非开发人员可以直接使用。 + ## 14. 电气柜与设备装配 除了端子与接线,电气柜场景通常还会遇到另一个基础问题: diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index b2b75e4..5423e9f 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -182,6 +182,26 @@ FCStd 设备模板用于解决“这个模型本身就带端子语义”的问 4. 保存为 `.FCStd` 设备模板。 5. 后续 LightWork3D 工程引用该 `.FCStd` 后,自动识别模板端子并生成工程端子对象。 +### 3.2.1 方案 2:设备模板端子制作面板 + +当前 Python 控制台方式只适合开发验证,不适合 CAD 工作人员。下一步开发目标改为在 FreeCAD 右侧任务区提供“设备模板端子制作”面板。 + +第一版面板能力: + +- 显示当前文档中的模板端子列表。 +- 输入端子名,例如 `P1`、`P2`。 +- 用户选择模型上的孔、点或对象后,点击“添加端子”。 +- 点击“校验端子”显示总数、有效数和警告。 +- 点击“保存为 FCStd”选择路径并保存模板。 + +该面板只包装现有 Python 能力,不改 FreeCAD C/C++ 源码: + +- `TemplateAuthoring.create_template_terminal` +- `TemplateAuthoring.validate_template_terminals` +- `TemplateAuthoring.save_template_as_fcstd` + +目标是让 CAD 工作人员只通过鼠标选择和按钮点击完成模板制作,不再直接输入 Python 代码。 + ### 3.3 A 方案资产流转约定 A 方案下,`.FCStd` 是正式可复用设备资产,STEP / STP / STE 只作为模板制作的原始几何输入。 @@ -297,6 +317,7 @@ FreeCADExchange/ DeviceImport.py # 设备导入,已存在 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 + TemplateAuthoringPanel.py # 新增:CAD 人员使用的端子制作任务面板 TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalObjects.py # 新增:端子对象属性、查找、校验工具 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 @@ -575,4 +596,6 @@ ManualWiring.py - 2026-05-20:补充 A 方案资产流转设计,明确 `.FCStd` 为正式设备资产;zwl/QET 只负责选择、保存、导出 `.FCStd` 路径,FreeCADExchange 负责读取 LCS 端子语义并生成工程端子。 - 2026-05-20:补上 `QET_Template_SaveAsFCStd` 模板保存命令,保存前会校验至少存在一个有效模板端子,并自动补 `.FCStd` 后缀;已用单元测试验证保存路径和端子校验结果。 - 2026-05-20:修复 `TemplateAuthoring.py` 在 `FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。 +- 2026-05-20:确定方案 2 开发目标:新增“设备模板端子制作”任务面板,让 CAD 工作人员通过输入端子名、选择模型位置、点击按钮完成添加端子、校验端子和保存 FCStd,不再依赖 Python 控制台。 +- 2026-05-20:新增 `TemplateAuthoringPanel.py`,提供“设备模板端子制作”任务面板和 `QET_Template_OpenAuthoringPanel` 命令;面板支持输入端子名、添加端子、校验端子、保存 FCStd,并已同步到运行目录验证模块可导入。 ``` diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 77254a2..f9684bd 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -8,6 +8,7 @@ set(FreeCADExchange_Scripts TerminalObjects.py TemplateSemantics.py TemplateAuthoring.py + TemplateAuthoringPanel.py TerminalImport.py ExchangeWriteBack.py ManualWiring.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 09fe20c..ed607e2 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -15,6 +15,7 @@ import ExchangeBootstrap import ExchangeWriteBack import ManualWiring import TemplateAuthoring +import TemplateAuthoringPanel def _append_init_log(message): @@ -53,5 +54,10 @@ try: except Exception: pass +try: + TemplateAuthoringPanel.register_commands() +except Exception: + pass + QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py new file mode 100644 index 0000000..4dd68b3 --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py @@ -0,0 +1,260 @@ +# FreeCADExchange GUI panel for FCStd equipment template authoring. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +try: + from PySide6 import QtGui, QtWidgets +except ImportError: + try: + from PySide2 import QtGui, QtWidgets + except ImportError: + try: + from PySide import QtGui + from PySide import QtGui as QtWidgets + except ImportError: + QtGui = None + QtWidgets = None + +import TemplateAuthoring + + +COMMAND_NAME = "QET_Template_OpenAuthoringPanel" +MENU_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_MenuAction" + + +def next_slot_name(report): + terminals = list((report or {}).get("terminals", []) or []) + return "T{0}".format(len(terminals) + 1) + + +def terminal_list_text(report): + rows = [] + for item in list((report or {}).get("terminals", []) or []): + slot_name = (item.get("slot_name", "") or "").strip() or "(unnamed)" + object_name = (item.get("name", "") or "").strip() or "(object)" + suffix = "" if item.get("can_wire", False) and slot_name != "(unnamed)" else " [invalid]" + rows.append("{0} - {1}{2}".format(slot_name, object_name, suffix)) + return rows + + +class TemplateAuthoringTaskPanel: + def __init__(self): + if QtWidgets is None: + raise TemplateAuthoring.TemplateAuthoringError("Qt widgets are not available.") + + self.form = QtWidgets.QWidget() + self.form.setWindowTitle("设备模板端子制作") + + layout = QtWidgets.QVBoxLayout(self.form) + + name_row = QtWidgets.QHBoxLayout() + name_row.addWidget(QtWidgets.QLabel("端子名")) + self.slot_name_edit = QtWidgets.QLineEdit() + self.slot_name_edit.setPlaceholderText("P1") + name_row.addWidget(self.slot_name_edit) + layout.addLayout(name_row) + + type_row = QtWidgets.QHBoxLayout() + type_row.addWidget(QtWidgets.QLabel("端子类型")) + self.terminal_type_combo = QtWidgets.QComboBox() + self.terminal_type_combo.addItems(["generic", "primary", "power", "control"]) + type_row.addWidget(self.terminal_type_combo) + layout.addLayout(type_row) + + self.add_button = QtWidgets.QPushButton("添加端子") + self.validate_button = QtWidgets.QPushButton("校验端子") + self.save_button = QtWidgets.QPushButton("保存为 FCStd") + self.refresh_button = QtWidgets.QPushButton("刷新列表") + + layout.addWidget(self.add_button) + layout.addWidget(self.validate_button) + layout.addWidget(self.save_button) + layout.addWidget(self.refresh_button) + + self.terminal_list = QtWidgets.QListWidget() + layout.addWidget(self.terminal_list) + + self.status_label = QtWidgets.QLabel("") + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + self.add_button.clicked.connect(self.add_terminal) + self.validate_button.clicked.connect(self.validate_terminals) + self.save_button.clicked.connect(self.save_template) + self.refresh_button.clicked.connect(self.refresh) + + self.refresh() + + def _document(self): + doc = getattr(App, "ActiveDocument", None) + if doc is None: + raise TemplateAuthoring.TemplateAuthoringError("请先打开或创建一个 FreeCAD 文档。") + return doc + + def _set_status(self, message): + self.status_label.setText(message) + try: + App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + def _set_error(self, message): + self.status_label.setText(message) + try: + App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + def refresh(self): + try: + report = TemplateAuthoring.validate_template_terminals(getattr(App, "ActiveDocument", None)) + except Exception as exc: + self._set_error(str(exc)) + return + + self.terminal_list.clear() + for row in terminal_list_text(report): + self.terminal_list.addItem(row) + + if not self.slot_name_edit.text().strip(): + self.slot_name_edit.setText(next_slot_name(report)) + + self.status_label.setText( + "端子:{0} 个,有效:{1} 个".format( + report.get("total_terminals", 0), + report.get("valid_terminals", 0), + ) + ) + + def add_terminal(self): + try: + doc = self._document() + slot_name = self.slot_name_edit.text().strip() + position = TemplateAuthoring._selection_position() + if position is None: + raise TemplateAuthoring.TemplateAuthoringError("请先在模型上选择端子位置。") + terminal_type = self.terminal_type_combo.currentText().strip() or "generic" + TemplateAuthoring.create_template_terminal( + doc, + slot_name, + position, + terminal_type=terminal_type, + ) + self.slot_name_edit.clear() + self.refresh() + self._set_status("已添加端子:{0}".format(slot_name)) + except Exception as exc: + self._set_error(str(exc)) + + def validate_terminals(self): + try: + report = TemplateAuthoring.validate_template_terminals(self._document()) + self.refresh() + if report.get("warnings"): + self._set_error("校验发现问题:{0}".format("; ".join(report["warnings"]))) + else: + self._set_status( + "校验通过:{0}/{1} 个端子有效".format( + report.get("valid_terminals", 0), + report.get("total_terminals", 0), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def save_template(self): + try: + doc = self._document() + file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( + self.form, + "保存 FCStd 设备模板", + "", + "FreeCAD template (*.FCStd *.fcstd);;All files (*.*)", + ) + if not file_path: + return + report = TemplateAuthoring.save_template_as_fcstd(doc, file_path) + self.refresh() + self._set_status("已保存模板:{0}".format(report["path"])) + except Exception as exc: + self._set_error(str(exc)) + + def accept(self): + return True + + def reject(self): + return True + + +class CommandOpenTemplateAuthoringPanel: + def GetResources(self): + return { + "MenuText": "设备模板端子制作", + "ToolTip": "打开 FCStd 设备模板端子制作面板", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + if Gui is None or not hasattr(Gui, "Control"): + return + if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog(): + Gui.Control.closeDialog() + Gui.Control.showDialog(TemplateAuthoringTaskPanel()) + + +_COMMANDS_REGISTERED = False +_MENU_ACTION_INSTALLED = False + + +def _tools_menu(): + if Gui is None or QtWidgets is None or QtGui is None: + return None + if not hasattr(Gui, "getMainWindow"): + return None + main_window = Gui.getMainWindow() + if main_window is None: + return None + menu_bar = main_window.menuBar() + for action in menu_bar.actions(): + menu = action.menu() + if menu is not None and action.text().replace("&", "") in {"工具", "Tools"}: + return menu + return menu_bar.addMenu("工具") + + +def install_menu_action(): + global _MENU_ACTION_INSTALLED + if _MENU_ACTION_INSTALLED: + return + menu = _tools_menu() + if menu is None or QtGui is None: + return + for action in menu.actions(): + if action.objectName() == MENU_ACTION_OBJECT_NAME: + _MENU_ACTION_INSTALLED = True + return + action = QtGui.QAction("设备模板端子制作", menu) + action.setObjectName(MENU_ACTION_OBJECT_NAME) + action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME)) + menu.addAction(action) + _MENU_ACTION_INSTALLED = True + + +def register_commands(): + global _COMMANDS_REGISTERED + if Gui is None or not hasattr(Gui, "addCommand"): + return + if not _COMMANDS_REGISTERED: + Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel()) + _COMMANDS_REGISTERED = True + install_menu_action() + + +register_commands() diff --git a/tests/python/freecad_exchange_template_authoring_panel_test.py b/tests/python/freecad_exchange_template_authoring_panel_test.py new file mode 100644 index 0000000..67933a7 --- /dev/null +++ b/tests/python/freecad_exchange_template_authoring_panel_test.py @@ -0,0 +1,85 @@ +import importlib +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_modules(): + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.Control = types.SimpleNamespace( + activeDialog=lambda: False, + showDialog=lambda panel: panel, + closeDialog=lambda: None, + ) + sys.modules["FreeCADGui"] = fake_freecadgui + + fake_template_authoring = types.ModuleType("TemplateAuthoring") + fake_template_authoring.validate_template_terminals = lambda doc: { + "terminals": [], + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + } + fake_template_authoring._selection_position = lambda: None + fake_template_authoring.create_template_terminal = lambda *args, **kwargs: None + fake_template_authoring.save_template_as_fcstd = lambda *args, **kwargs: {} + sys.modules["TemplateAuthoring"] = fake_template_authoring + + +def _reload_panel_module(): + sys.modules.pop("TemplateAuthoringPanel", None) + return importlib.import_module("TemplateAuthoringPanel") + + +class TemplateAuthoringPanelTest(unittest.TestCase): + def test_next_slot_name_uses_next_terminal_number(self): + _install_fake_modules() + panel_module = _reload_panel_module() + + self.assertEqual( + "T3", + panel_module.next_slot_name( + { + "terminals": [ + {"slot_name": "P1"}, + {"slot_name": "P2"}, + ] + } + ), + ) + + def test_terminal_list_text_marks_invalid_terminal(self): + _install_fake_modules() + panel_module = _reload_panel_module() + + rows = panel_module.terminal_list_text( + { + "terminals": [ + {"name": "Terminal_P1", "slot_name": "P1", "can_wire": True}, + {"name": "Broken", "slot_name": "", "can_wire": False}, + ] + } + ) + + self.assertEqual(["P1 - Terminal_P1", "(unnamed) - Broken [invalid]"], rows) + + +if __name__ == "__main__": + unittest.main() From 8569ccd6681eb128bafee433fba6f08d95a2b4ba Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 20:47:26 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix/=E8=AE=BE=E5=A4=87=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=85=A5=E5=8F=A3=E6=98=BE=E7=A4=BA-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FreeCADExchange/TemplateAuthoringPanel.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py index 4dd68b3..248eaf3 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py @@ -25,6 +25,8 @@ import TemplateAuthoring COMMAND_NAME = "QET_Template_OpenAuthoringPanel" MENU_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_MenuAction" +TOOLBAR_OBJECT_NAME = "QET_Template_Authoring_Toolbar" +TOOLBAR_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_ToolbarAction" def next_slot_name(report): @@ -211,6 +213,7 @@ class CommandOpenTemplateAuthoringPanel: _COMMANDS_REGISTERED = False _MENU_ACTION_INSTALLED = False +_TOOLBAR_ACTION_INSTALLED = False def _tools_menu(): @@ -224,7 +227,8 @@ def _tools_menu(): menu_bar = main_window.menuBar() for action in menu_bar.actions(): menu = action.menu() - if menu is not None and action.text().replace("&", "") in {"工具", "Tools"}: + text = action.text().replace("&", "").strip() + if menu is not None and (text.startswith("工具") or text.startswith("Tools")): return menu return menu_bar.addMenu("工具") @@ -247,6 +251,38 @@ def install_menu_action(): _MENU_ACTION_INSTALLED = True +def install_toolbar_action(): + global _TOOLBAR_ACTION_INSTALLED + if _TOOLBAR_ACTION_INSTALLED: + return + if Gui is None or QtGui is None or QtWidgets is None or not hasattr(Gui, "getMainWindow"): + return + main_window = Gui.getMainWindow() + if main_window is None: + return + + toolbar = None + for candidate in main_window.findChildren(QtWidgets.QToolBar): + if candidate.objectName() == TOOLBAR_OBJECT_NAME: + toolbar = candidate + break + if toolbar is None: + toolbar = main_window.addToolBar("QET模板") + toolbar.setObjectName(TOOLBAR_OBJECT_NAME) + + for action in toolbar.actions(): + if action.objectName() == TOOLBAR_ACTION_OBJECT_NAME: + _TOOLBAR_ACTION_INSTALLED = True + return + + action = QtGui.QAction("设备模板端子制作", toolbar) + action.setObjectName(TOOLBAR_ACTION_OBJECT_NAME) + action.setToolTip("打开设备模板端子制作面板") + action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME)) + toolbar.addAction(action) + _TOOLBAR_ACTION_INSTALLED = True + + def register_commands(): global _COMMANDS_REGISTERED if Gui is None or not hasattr(Gui, "addCommand"): @@ -255,6 +291,7 @@ def register_commands(): Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel()) _COMMANDS_REGISTERED = True install_menu_action() + install_toolbar_action() register_commands() From 0015b92d01671a46efd709cf0130267ecff5028e Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 20:57:45 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix/=E6=B3=A8=E5=86=8CQET=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=85=A5=E5=8F=A3-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mod/FreeCADExchange/InitGui.py | 95 +++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index ed607e2..3b9785f 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -3,6 +3,8 @@ import os from pathlib import Path +import FreeCADGui as Gui + try: from PySide6 import QtCore except ImportError: @@ -18,6 +20,14 @@ import TemplateAuthoring import TemplateAuthoringPanel +COMMANDS = [ + TemplateAuthoringPanel.COMMAND_NAME, + "QET_Template_AddTerminal", + "QET_Template_ValidateTerminals", + "QET_Template_SaveAsFCStd", +] + + def _append_init_log(message): try: local_app_data = os.environ.get("LOCALAPPDATA", "").strip() @@ -34,30 +44,75 @@ def _append_init_log(message): _append_init_log("InitGui imported") -try: - ExchangeWriteBack.ensure_document_observer_installed() -except Exception: - pass +def _register_exchange_commands(): + try: + ExchangeWriteBack.ensure_document_observer_installed() + except Exception: + pass -try: - ExchangeWriteBack.register_commands() -except Exception: - pass + try: + ExchangeWriteBack.register_commands() + except Exception: + pass -try: - ManualWiring.register_commands() -except Exception: - pass + try: + ManualWiring.register_commands() + except Exception: + pass -try: - TemplateAuthoring.register_commands() -except Exception: - pass + try: + TemplateAuthoring.register_commands() + except Exception: + pass -try: - TemplateAuthoringPanel.register_commands() -except Exception: - pass + try: + TemplateAuthoringPanel.register_commands() + except Exception: + pass + + +class FreeCADExchangeWorkbench(Gui.Workbench): + MenuText = "QET模板" + ToolTip = "QET / FreeCAD 设备模板端子制作工具" + Icon = """ + /* XPM */ + static const char *qet_template_xpm[]={ + "16 16 3 1", + "a c #1f6feb", + "b c #ffffff", + ". c None", + "................", + "...aaaaaaaaaa...", + "..abbbbbbbbba..", + "..abaaaaaabba..", + "..ababbbaabba..", + "..ababbbaabba..", + "..abaaaaaabba..", + "..abbbbbbbbba..", + "..abbaaabbbba..", + "..abbaabababa..", + "..abbaabababa..", + "..abbaaabbbba..", + "..abbbbbbbbba..", + "...aaaaaaaaaa...", + "................", + "................"}; + """ + + def Initialize(self): + _register_exchange_commands() + self.appendToolbar("QET模板", COMMANDS) + self.appendMenu("QET模板", COMMANDS) + _append_init_log("FreeCADExchangeWorkbench initialized") + + def Activated(self): + _register_exchange_commands() + _append_init_log("FreeCADExchangeWorkbench activated") + + def Deactivated(self): + pass +_register_exchange_commands() +Gui.addWorkbench(FreeCADExchangeWorkbench()) QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) From 82be30e69ce5da213ee33eb605eb9b35db94ecd2 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 22:45:01 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix/FreeCAD=E6=A8=A1=E6=9D=BF=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E7=95=8C=E9=9D=A2=E4=B8=8E=E5=90=AF=E5=8A=A8=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Mod/FreeCADExchange/InitGui.py | 181 +++++++++++++----- src/Mod/FreeCADExchange/TemplateAuthoring.py | 14 +- .../FreeCADExchange/TemplateAuthoringPanel.py | 47 ++++- ..._exchange_template_authoring_panel_test.py | 27 +++ ...reecad_exchange_template_authoring_test.py | 17 ++ 5 files changed, 229 insertions(+), 57 deletions(-) diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 3b9785f..67e9b7f 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -2,73 +2,141 @@ import os from pathlib import Path +import traceback import FreeCADGui as Gui -try: - from PySide6 import QtCore -except ImportError: - try: - from PySide2 import QtCore - except ImportError: - from PySide import QtCore - -import ExchangeBootstrap -import ExchangeWriteBack -import ManualWiring -import TemplateAuthoring -import TemplateAuthoringPanel - COMMANDS = [ - TemplateAuthoringPanel.COMMAND_NAME, + "QET_Template_OpenAuthoringPanel", "QET_Template_AddTerminal", "QET_Template_ValidateTerminals", "QET_Template_SaveAsFCStd", ] -def _append_init_log(message): +def _append_init_log(message, os_module=os, path_class=Path): try: - local_app_data = os.environ.get("LOCALAPPDATA", "").strip() + local_app_data = os_module.environ.get("LOCALAPPDATA", "").strip() if local_app_data: - log_path = os.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log") + log_path = os_module.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log") else: - log_path = os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log") - os.makedirs(os.path.dirname(log_path), exist_ok=True) + log_path = os_module.path.join( + str(path_class.home()), + "AppData", + "Local", + "QETDeps", + "freecad_exchange_bootstrap.log", + ) + os_module.makedirs(os_module.path.dirname(log_path), exist_ok=True) with open(log_path, "a", encoding="utf-8") as handle: handle.write(message + "\n") except Exception: pass -_append_init_log("InitGui imported") +_append_init_log("InitGui start") -def _register_exchange_commands(): + +def _safe_import(module_name, append_init_log=_append_init_log, traceback_module=traceback): try: - ExchangeWriteBack.ensure_document_observer_installed() + module = __import__(module_name) + append_init_log("InitGui imported {0}".format(module_name)) + return module except Exception: - pass + append_init_log( + "InitGui failed to import {0}:\n{1}".format( + module_name, + traceback_module.format_exc(), + ) + ) + return None + + +def _register_exchange_commands( + safe_import=_safe_import, + append_init_log=_append_init_log, + traceback_module=traceback, +): + exchange_write_back = safe_import("ExchangeWriteBack") + manual_wiring = safe_import("ManualWiring") + template_authoring = safe_import("TemplateAuthoring") + template_authoring_panel = safe_import("TemplateAuthoringPanel") try: - ExchangeWriteBack.register_commands() + if exchange_write_back is not None: + exchange_write_back.ensure_document_observer_installed() except Exception: - pass + append_init_log( + "InitGui failed to install write-back observer:\n{0}".format( + traceback_module.format_exc() + ) + ) try: - ManualWiring.register_commands() + if exchange_write_back is not None: + exchange_write_back.register_commands() except Exception: - pass + append_init_log( + "InitGui failed to register write-back commands:\n{0}".format( + traceback_module.format_exc() + ) + ) try: - TemplateAuthoring.register_commands() + if manual_wiring is not None: + manual_wiring.register_commands() except Exception: - pass + append_init_log( + "InitGui failed to register wiring commands:\n{0}".format( + traceback_module.format_exc() + ) + ) try: - TemplateAuthoringPanel.register_commands() + if template_authoring is not None: + template_authoring.register_commands() except Exception: - pass + append_init_log( + "InitGui failed to register template authoring commands:\n{0}".format( + traceback_module.format_exc() + ) + ) + + try: + if template_authoring_panel is not None: + template_authoring_panel.register_commands() + except Exception: + append_init_log( + "InitGui failed to register template panel command:\n{0}".format( + traceback_module.format_exc() + ) + ) + + +def _bootstrap_if_requested( + safe_import=_safe_import, + append_init_log=_append_init_log, + traceback_module=traceback, +): + exchange_bootstrap = safe_import("ExchangeBootstrap") + if exchange_bootstrap is None: + return + + try: + exchange_bootstrap.bootstrap_if_requested() + except Exception: + append_init_log( + "InitGui bootstrap_if_requested failed:\n{0}".format( + traceback_module.format_exc() + ) + ) + + +globals()["FreeCADExchange_COMMANDS"] = COMMANDS +globals()["FreeCADExchange_append_init_log"] = _append_init_log +globals()["FreeCADExchange_register_exchange_commands"] = _register_exchange_commands +globals()["FreeCADExchange_bootstrap_if_requested"] = _bootstrap_if_requested class FreeCADExchangeWorkbench(Gui.Workbench): @@ -99,20 +167,47 @@ class FreeCADExchangeWorkbench(Gui.Workbench): "................"}; """ - def Initialize(self): - _register_exchange_commands() - self.appendToolbar("QET模板", COMMANDS) - self.appendMenu("QET模板", COMMANDS) - _append_init_log("FreeCADExchangeWorkbench initialized") - - def Activated(self): - _register_exchange_commands() - _append_init_log("FreeCADExchangeWorkbench activated") + def Initialize( + self, + register_exchange_commands=FreeCADExchange_register_exchange_commands, + append_init_log=FreeCADExchange_append_init_log, + commands=FreeCADExchange_COMMANDS, + ): + register_exchange_commands() + self.appendToolbar("QET模板", commands) + self.appendMenu("QET模板", commands) + append_init_log("FreeCADExchangeWorkbench initialized") + + def Activated( + self, + register_exchange_commands=FreeCADExchange_register_exchange_commands, + append_init_log=FreeCADExchange_append_init_log, + ): + register_exchange_commands() + append_init_log("FreeCADExchangeWorkbench activated") def Deactivated(self): pass + def GetClassName(self): + return "Gui::PythonWorkbench" + -_register_exchange_commands() Gui.addWorkbench(FreeCADExchangeWorkbench()) -QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) +_append_init_log("InitGui workbench registered") + +try: + from PySide6 import QtCore +except ImportError: + try: + from PySide2 import QtCore + except ImportError: + try: + from PySide import QtCore + except ImportError: + QtCore = None + +if QtCore is not None: + QtCore.QTimer.singleShot(0, FreeCADExchange_bootstrap_if_requested) +else: + FreeCADExchange_bootstrap_if_requested() diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py index acf6273..1b8fa9e 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoring.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -221,8 +221,8 @@ def _selection_position(): class CommandAddTemplateTerminal: def GetResources(self): return { - "MenuText": "Add Template Terminal", - "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", + "MenuText": "添加模板端子", + "ToolTip": "在 FCStd 设备模板中创建可接线端子 LCS", } def IsActive(self): @@ -257,8 +257,8 @@ class CommandAddTemplateTerminal: class CommandValidateTemplateTerminals: def GetResources(self): return { - "MenuText": "Validate Template Terminals", - "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", + "MenuText": "校验模板端子", + "ToolTip": "校验当前 FCStd 模板中的电气端子 LCS", } def IsActive(self): @@ -279,8 +279,8 @@ class CommandValidateTemplateTerminals: class CommandSaveTemplateAsFCStd: def GetResources(self): return { - "MenuText": "Save Template As FCStd", - "ToolTip": "Validate and save the current document as a reusable FCStd equipment template", + "MenuText": "保存模板为 FCStd", + "ToolTip": "校验并保存当前文档为可复用 FCStd 设备模板", } def IsActive(self): @@ -293,7 +293,7 @@ class CommandSaveTemplateAsFCStd: file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( None, - "Save FCStd Equipment Template", + "保存 FCStd 设备模板", "", "FreeCAD template (*.FCStd *.fcstd);;All files (*.*)", ) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py index 248eaf3..81efca9 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py @@ -27,6 +27,26 @@ COMMAND_NAME = "QET_Template_OpenAuthoringPanel" MENU_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_MenuAction" TOOLBAR_OBJECT_NAME = "QET_Template_Authoring_Toolbar" TOOLBAR_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_ToolbarAction" +TERMINAL_TYPE_OPTIONS = [ + ("通用", "generic"), + ("主回路", "primary"), + ("电源", "power"), + ("控制", "control"), +] + + +def terminal_type_value(combo): + value = None + if hasattr(combo, "currentData"): + value = combo.currentData() + if value: + return str(value).strip() + + text = combo.currentText().strip() if hasattr(combo, "currentText") else "" + for label, option_value in TERMINAL_TYPE_OPTIONS: + if text == label or text == option_value: + return option_value + return "generic" def next_slot_name(report): @@ -64,7 +84,8 @@ class TemplateAuthoringTaskPanel: type_row = QtWidgets.QHBoxLayout() type_row.addWidget(QtWidgets.QLabel("端子类型")) self.terminal_type_combo = QtWidgets.QComboBox() - self.terminal_type_combo.addItems(["generic", "primary", "power", "control"]) + for label, value in TERMINAL_TYPE_OPTIONS: + self.terminal_type_combo.addItem(label, value) type_row.addWidget(self.terminal_type_combo) layout.addLayout(type_row) @@ -140,7 +161,7 @@ class TemplateAuthoringTaskPanel: position = TemplateAuthoring._selection_position() if position is None: raise TemplateAuthoring.TemplateAuthoringError("请先在模型上选择端子位置。") - terminal_type = self.terminal_type_combo.currentText().strip() or "generic" + terminal_type = terminal_type_value(self.terminal_type_combo) TemplateAuthoring.create_template_terminal( doc, slot_name, @@ -283,6 +304,15 @@ def install_toolbar_action(): _TOOLBAR_ACTION_INSTALLED = True +def _warn_register_commands_failed(target, exc): + try: + App.Console.PrintWarning( + "[FreeCADExchange] {0} installation skipped: {1}\n".format(target, exc) + ) + except Exception: + pass + + def register_commands(): global _COMMANDS_REGISTERED if Gui is None or not hasattr(Gui, "addCommand"): @@ -290,8 +320,11 @@ def register_commands(): if not _COMMANDS_REGISTERED: Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel()) _COMMANDS_REGISTERED = True - install_menu_action() - install_toolbar_action() - - -register_commands() + try: + install_menu_action() + except RuntimeError as exc: + _warn_register_commands_failed("menu action", exc) + try: + install_toolbar_action() + except RuntimeError as exc: + _warn_register_commands_failed("toolbar action", exc) diff --git a/tests/python/freecad_exchange_template_authoring_panel_test.py b/tests/python/freecad_exchange_template_authoring_panel_test.py index 67933a7..4dd9b06 100644 --- a/tests/python/freecad_exchange_template_authoring_panel_test.py +++ b/tests/python/freecad_exchange_template_authoring_panel_test.py @@ -49,6 +49,33 @@ def _reload_panel_module(): class TemplateAuthoringPanelTest(unittest.TestCase): + def test_register_commands_ignores_menu_install_runtime_errors(self): + _install_fake_modules() + panel_module = _reload_panel_module() + panel_module._COMMANDS_REGISTERED = False + + def raise_deleted_menu_error(): + raise RuntimeError("Internal C++ object already deleted") + + panel_module.install_menu_action = raise_deleted_menu_error + panel_module.install_toolbar_action = raise_deleted_menu_error + + panel_module.register_commands() + + def test_terminal_type_options_show_chinese_labels_with_stable_values(self): + _install_fake_modules() + panel_module = _reload_panel_module() + + self.assertEqual( + [ + ("通用", "generic"), + ("主回路", "primary"), + ("电源", "power"), + ("控制", "control"), + ], + panel_module.TERMINAL_TYPE_OPTIONS, + ) + def test_next_slot_name_uses_next_terminal_number(self): _install_fake_modules() panel_module = _reload_panel_module() diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py index 8f0517a..2c4a839 100644 --- a/tests/python/freecad_exchange_template_authoring_test.py +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -120,6 +120,23 @@ def _reload_modules(): class TemplateAuthoringTest(unittest.TestCase): + def test_template_authoring_command_titles_are_chinese(self): + _install_fake_freecad() + template_authoring = _reload_modules() + + self.assertEqual( + "添加模板端子", + template_authoring.CommandAddTemplateTerminal().GetResources()["MenuText"], + ) + self.assertEqual( + "校验模板端子", + template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"], + ) + self.assertEqual( + "保存模板为 FCStd", + template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"], + ) + def test_import_skips_command_registration_when_gui_has_no_add_command(self): _install_fake_freecad_without_gui_commands()