diff --git a/docs/FreeCAD 3D模型端子设计方案.md b/docs/FreeCAD 3D模型端子设计方案.md new file mode 100644 index 0000000..7d5aa09 --- /dev/null +++ b/docs/FreeCAD 3D模型端子设计方案.md @@ -0,0 +1,277 @@ +# FreeCAD 3D 模型端子设计方案 + +## 1. 目标 + +本文档定义电气设备 3D 模型上的“端子”如何设计、保存、生成和参与布线。 + +当前正式路线固定为: + +```text +STEP / STP / STE 原始几何 + -> FreeCAD 添加模板端子 + -> 保存为 FCStd 设备模板 + -> QET 绑定该 FCStd 作为设备 3D 资产 + -> 3D 视图打开 FreeCAD + -> FreeCAD 从模板端子生成工程端子 + -> 工程端子参与 3D 手动布线 +``` + +STEP / STP / STE 只作为几何输入。正式可复用的电气 3D 设备资产是 `.FCStd`。 + +## 2. 名词约定 + +### 2.1 端子 + +本文档统一使用“端子”作为电气设备上可接线位置的名称。 + +在不同软件或资料中,它也可能被称为: + +- 接线端子 +- 接线点 +- 连接点 +- terminal +- connection terminal + +后续代码和文档尽量使用“端子”,避免继续使用泛化的 `connectionPoint` 作为正式设计依据。 + +### 2.2 模板端子 + +模板端子存在于设备模板 `.FCStd` 中,表示“这个设备模型上哪里可以接线”。 + +模板端子是跨工程复用的槽位,不属于某一个具体项目。 + +例如电流互感器模板可以有: + +```text +电流互感器.FCStd + Body / imported geometry + Terminal_P1 + Terminal_P2 +``` + +`Terminal_P1` 和 `Terminal_P2` 是 FreeCAD LCS 对象。它们的位置表示真实接线位置,方向表示后续出线方向。 + +### 2.3 工程端子 + +工程端子存在于具体项目的 `scene.FCStd` 中,表示“当前工程中某个设备实例上的可布线端子”。 + +工程端子从模板端子生成。它继承模板端子的位置、方向和槽位语义,同时补上当前工程的设备实例信息。 + +如果 QET 已传入真实 `terminal_uuid`,工程端子可以和 2D 端子准确绑定。如果 QET 没有传入 `terminal_uuid`,FreeCAD 只能生成本地 3D 工程端子,用于 3D 手动布线验证,不能可靠回写到 QET 2D 端子。 + +## 3. 模板端子设计 + +模板端子使用 FreeCAD LCS 表示,推荐类型: + +```text +Part::LocalCoordinateSystem +PartDesign::CoordinateSystem +``` + +模板端子至少保存: + +| 属性 | 说明 | +| --- | --- | +| `Role = "Terminal"` | 标识该 LCS 是端子 | +| `CanWire = true` | 表示该端子允许接线 | +| `QetTemplateSlotName` | 模板内稳定槽位名,例如 `P1`、`P2`、`A1` | +| `QetTerminalLabel` | 显示名称,可与槽位名一致 | +| `QetTerminalType` | 端子类型,例如 `generic`、`primary`、`power`、`control` | +| `Placement.Base` | 端子在设备模板局部坐标系中的位置 | +| `Placement.Rotation` | 端子方向,默认把 LCS 本地 +Z 作为出线方向 | + +模板端子禁止保存: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +``` + +原因是这些字段属于某个具体工程。如果写进模板,同一个设备模板传给别人或放到另一个工程里就会污染绑定关系。 + +## 4. 工程端子设计 + +工程端子同样使用 LCS 表示,但它属于当前工程。 + +工程端子至少保存: + +| 属性 | 说明 | +| --- | --- | +| `QetProjectUuid` | 当前项目 UUID | +| `QetElementUuid` | 2D 设备实例 UUID | +| `QetInstanceId` | 3D 设备实例 ID | +| `QetTerminalUuid` | QET 2D 端子 UUID;没有 QET 绑定时可使用 `local:*` 本地 UUID | +| `Role = "Terminal"` | 标识为端子 | +| `CanWire = true` | 允许被手动布线命令选择 | +| `QetTemplateSlotName` | 来源模板槽位名 | + +工程端子创建后放在设备组下面: + +```text +QETExchangeDevices + QETDevice_ + imported model objects + QETTerminals_ + P1 + P2 +``` + +手动布线命令只允许选择工程端子,不应该直接连接模板端子。 + +## 5. 端子方向 + +端子方向对后续手动布线和自动布线都很重要。 + +当前约定: + +```text +LCS 本地 +Z 方向 = 端子出线方向 +``` + +例如模板端子 `P1` 的 LCS 放在接线孔中心,LCS 的 +Z 指向电线离开设备的方向。后续手动布线或自动布线可以先沿该方向生成一小段出线段,再进入线槽或自由走线路径。 + +如果第一版模板制作界面暂时只能准确放置位置,方向可以先用默认方向;但正式设备模板最终应补齐方向,否则后续布线容易出现电线穿过设备或从错误方向出线。 + +## 6. 自动生成工程端子规则 + +QET 点击 `3D视图` 打开 FreeCAD 后,FreeCAD 可以自动创建工程端子,但必须满足条件: + +1. `2d_to_3d.json` 中存在端子数据。 +2. 端子数据至少有 `terminal_uuid` 和 `instance_id`。 +3. FreeCAD 能通过 `instance_id` 找到对应设备实例。 +4. 该设备实例导入的 `.FCStd` 内有模板端子。 + +如果设备没有模板端子,FreeCAD 不自动凭空生成工程端子。 + +这样做是为了避免把端子生成在错误位置。没有模板端子的设备,应先补模板端子,或者由用户在 FreeCAD 中手动建立模板端子后再保存成新的 `.FCStd`。 + +## 7. 手动生成工程端子规则 + +当 QET 没有传端子数据,或者某些设备没有自动生成工程端子时,用户可以在 FreeCAD 中手动生成工程端子。 + +推荐操作: + +1. 在树目录中选中 `QETDevice_xxx` 设备组。 +2. 点击 `QET模板 -> 生成工程端子`。 +3. FreeCAD 扫描该设备里的模板端子。 +4. 按模板端子的位置、方向和槽位名生成工程端子。 +5. 工程端子放入该设备下的 `QETTerminals_xxx` 分组。 + +如果不选设备,命令可以尝试处理当前工程中所有 `QETDevice_xxx`。 + +手动生成工程端子时必须优先以模板端子为准,不能再用设备包围盒猜测端子位置。 + +如果没有 QET 真实 `terminal_uuid`,手动生成的工程端子使用本地 UUID: + +```text +local:: +``` + +这种端子可以用于 3D 手动布线,但不能当作 QET 2D 端子的可靠回写依据。 + +## 8. QET 数据传输责任边界 + +QET/zh 侧负责把 2D 端子和 3D 设备实例的绑定关系传给 FreeCAD。 + +第一版必须传: + +```json +{ + "terminal_uuid": "2d-terminal-uuid", + "instance_id": "3d-device-instance-id", + "element_uuid": "2d-device-element-uuid" +} +``` + +其中: + +- `terminal_uuid` 是 2D 端子主键,也是第一版端子绑定唯一依据。 +- `instance_id` 表示该 2D 端子属于哪个 3D 设备实例。 +- `element_uuid` 可帮助 FreeCAD 定位父设备,但第一版端子绑定仍以 `terminal_uuid` 为核心。 + +QET 不需要解析 `.FCStd` 内部 LCS,也不需要计算端子坐标。端子空间位置由 FreeCAD 模板负责。 + +后续如果需要更准确地把 2D 端子匹配到模板槽位,可以在交换 JSON 里增加非主键提示字段,例如: + +```json +{ + "terminal_uuid": "2d-terminal-uuid", + "instance_id": "3d-device-instance-id", + "slot_name_hint": "P1", + "terminal_label": "P1" +} +``` + +这些字段只能作为匹配提示,不能替代 `terminal_uuid`。 + +当前 FreeCAD 侧已经支持用 `slot_name_hint` 或 `terminal_label` 优先匹配模板槽位,再退回顺序匹配。也就是说,zh 侧后续只要把端子提示名补上,FreeCAD 就能更稳定地把 2D 端子落到正确的模板端子上。 + +## 9. 与布线的关系 + +手动布线连接的是工程端子,不是模板端子。 + +完整关系是: + +```text +模板端子 + -> 定义设备上哪里能接线 + -> 提供局部坐标和出线方向 + +工程端子 + -> 从模板端子生成 + -> 绑定当前工程设备实例 + -> 带 QET terminal_uuid 或 local UUID + -> 被手动布线命令选择 + +导线 + -> 连接两个工程端子 + -> 起点和终点记录端子 UUID + -> 路径几何保存在 scene.FCStd +``` + +工程端子位置应跟随设备移动。实现上,工程端子挂在设备组下,并使用模板端子的局部坐标;计算布线路径时再取端子的全局坐标。 + +## 10. 当前不采用的方案 + +### 10.1 不把 STEP 当成最终电气资产 + +STEP 可以稳定保存几何,但不能可靠保存 FreeCAD LCS、动态属性、端子角色和接线资格。因此 STEP 只作为制作模板的输入。 + +### 10.2 不依赖 `connectionPoint` + +已有代码或旧文档中的 `connectionPoint` 可以作为历史参考,但当前正式端子方案不以它为核心。正式方案统一使用 FreeCAD 模板端子和工程端子。 + +### 10.3 不再用 bbox fallback 作为正式端子 + +包围盒猜点容易把端子放到错误位置。当前正式方案中,设备没有模板端子时不自动创建工程端子。需要先补模板端子,或者由用户明确手动创建。 + +## 11. 当前开发下一步 + +下一步优先做“工程端子生成和可验证流程”,不是继续扩大模板工具范围。 + +建议顺序: + +1. 保持模板端子默认隐藏,只显示工程端子。 +2. `生成工程端子` 命令只从模板端子生成工程端子。 +3. 没有模板端子的设备不自动生成工程端子,并给出清晰提示。 +4. 有 QET `terminal_uuid` 的端子生成真实工程端子。 +5. 没有 QET `terminal_uuid` 但用户手动生成时,生成 `local:*` 本地工程端子。 +6. 手动布线只允许连接工程端子。 +7. `3d_to_2d.json` 回写时区分真实 QET 端子和本地端子,本地端子不作为 2D 绑定结果。 + +## 12. 验收标准 + +第一版验收以电流互感器为例: + +1. 从 STEP 制作 `电流互感器.FCStd`。 +2. 模板中有 `P1`、`P2` 模板端子。 +3. QET 绑定该 `.FCStd`。 +4. QET 点击 `3D视图` 打开 FreeCAD。 +5. FreeCAD 能导入设备模型。 +6. 选中设备执行 `生成工程端子` 后,工程端子落在模板端子位置。 +7. 移动设备后,工程端子跟随设备移动或能正确计算全局位置。 +8. 选择两个设备上的工程端子可以创建手动导线。 +9. 保存后重新打开,设备、工程端子和导线仍存在。 +10. 如果 QET 提供真实 `terminal_uuid`,`3d_to_2d.json` 中能回写端子绑定;如果是 `local:*` 端子,则只用于 3D 本地布线。 diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 5423e9f..01c0514 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -130,11 +130,11 @@ STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电 > 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。 -所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级: +所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。当前正式方案采用下面的优先级: 1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 2. 过渡方式:STEP + sidecar JSON,在同目录下保存端子槽位坐标。 -3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置。 +3. 没有模板端子时,不自动创建正式工程端子。 sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。 @@ -142,6 +142,8 @@ sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotatio FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。 +包围盒 fallback 只能作为历史验证思路或调试函数存在,不再作为正式工程端子的生成依据。原因是包围盒猜点无法保证端子落在真实接线位置,容易导致后续手动布线和自动布线都建立在错误坐标上。 + ### 3.1 FCStd 设备模板制作约定 FCStd 设备模板用于解决“这个模型本身就带端子语义”的问题。模板端子是跨工程复用的槽位,不绑定某个具体工程里的 `terminal_uuid`。 @@ -224,6 +226,64 @@ zwl/QET 侧只需要支持 `.FCStd` 的选择、复制、保存和导出路径 详细设计见: - `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` +- `docs/FreeCAD 3D模型端子设计方案.md` + +### 3.4 当前端子生成正式规则 + +当前端子方案区分两类对象: + +1. 模板端子:存在于 `.FCStd` 设备模板中,定义设备模型哪里可以接线。 +2. 工程端子:存在于当前项目 `scene.FCStd` 中,真正参与 3D 手动布线和保存回写。 + +模板端子只保存跨工程稳定信息,例如: + +```text +Role = Terminal +CanWire = true +QetTemplateSlotName = P1 +QetTerminalLabel = P1 +QetTerminalType = primary +Placement.Base = 模板局部坐标 +Placement.Rotation = 端子方向 +``` + +工程端子在模板端子基础上生成,并补当前工程绑定信息: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +Role = Terminal +CanWire = true +QetTemplateSlotName +``` + +端子方向约定为: + +```text +LCS 本地 +Z 方向 = 出线方向 +``` + +因此模板制作时不只要放准端子位置,还应尽量让 LCS 的 +Z 指向电线离开设备的方向。这个方向会影响后续手动布线的出线段和自动布线的起始方向。 + +自动创建工程端子的前提是: + +1. QET `2d_to_3d.json` 中存在 `terminal_uuid / instance_id`。 +2. FreeCAD 能通过 `instance_id` 找到对应 `QETDevice_xxx`。 +3. 该设备实例导入的 `.FCStd` 中存在模板端子。 + +如果设备没有模板端子,不再自动凭空创建工程端子。此时应先补设备模板端子,或者由用户在 FreeCAD 里手动制作模板端子后保存新的 `.FCStd`。 + +手动生成工程端子的规则是: + +1. 用户选中 `QETDevice_xxx`。 +2. 点击 `QET模板 -> 生成工程端子`。 +3. FreeCAD 扫描该设备下的模板端子。 +4. 按模板端子的局部坐标和方向生成工程端子。 +5. 如果没有 QET 真实 `terminal_uuid`,使用 `local::` 作为本地端子 UUID。 + +`local:*` 端子可以用于 3D 手动布线,但不能当作 QET 2D 端子的可靠回写依据。要准确回写 2D,仍需要 zh/QET 侧导出真实 `terminal_uuid + instance_id` 绑定。 ## 4. 为什么要先落地设备模板 @@ -318,7 +378,8 @@ FreeCADExchange/ TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 TemplateAuthoringPanel.py # 新增:CAD 人员使用的端子制作任务面板 - TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 + TerminalImport.py # 新增:根据 terminals 创建/更新真实工程端子对象 + TemplateInstantiation.py # 新增:把模板端子实例化为工程端子,支持本地 local:* 端子 TerminalObjects.py # 新增:端子对象属性、查找、校验工具 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 ExchangeWriteBack.py # 新增:生成 3d_to_2d.json @@ -361,7 +422,7 @@ ManualWiring.py - `QET_Template_AddTerminal` - `QET_Template_SaveAsFCStd` - 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。 -- 第一版端子方向默认使用单位旋转,后续再补出线方向编辑。 +- 第一版允许先使用默认方向,但正式设备模板应补齐出线方向;当前约定 LCS 本地 +Z 为出线方向。 模板端子属性: @@ -377,7 +438,7 @@ ManualWiring.py - 添加 `P1`、`P2` 两个端子。 - 保存为 `电流互感器.FCStd`。 - 重新打开该 FCStd 后,端子 LCS 仍存在,属性仍存在。 -- 在项目导入流程中引用该 FCStd,端子位置优先来自模板 LCS,而不是 bbox fallback。 +- 在项目导入流程中引用该 FCStd,工程端子位置来自模板 LCS,不再依赖 bbox fallback。 ### 阶段 A:本地模板导入基线 @@ -417,10 +478,10 @@ ManualWiring.py 端子位置策略: 1. 如果 FCStd 模板已有 `Role="Terminal"` 的 LCS,则优先复用模板 LCS。 -2. 如果有 sidecar JSON,则按 sidecar 坐标创建。 -3. 如果只有 STEP,则先按设备包围盒生成临时端子排列,用于打通流程。 +2. 如果有 sidecar JSON,则只作为过渡方案按 sidecar 坐标创建。 +3. 如果没有模板端子,也没有 sidecar,不自动创建正式工程端子。 -临时端子排列只用于第一版验证,不作为长期物理端子定义。 +包围盒临时端子只作为历史调试思路,不再作为正式工程端子的创建依据。 验收: @@ -542,7 +603,7 @@ ManualWiring.py - 可选 sidecar:只作为过渡或校验,不作为正式交付优先方案 - 模板说明:原点、朝向、尺寸单位、端子数量 -常用设备建议优先补齐 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 +常用设备建议优先补齐 `FCStd LCS`,让工程端子始终来自真实可用的模板端子坐标。 ## 10. 单人开发优先级 @@ -598,4 +659,9 @@ ManualWiring.py - 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,并已同步到运行目录验证模块可导入。 +- 2026-05-21:新增手动布线结果回写能力,`3d_to_2d.json` 现在会输出 `manual_wires`,包含起止端子 UUID、起止设备实例 ID、路线类型和 3D 路径点;已用单元测试验证,待 QET 侧决定是否消费该可选字段。 +- 2026-05-22:完善 3D 手动布线对象模型,新增场景级 `QETWiring` 分组及任务、载体、预览、已布线、诊断分区;手动导线优先使用 Draft Wire,按端子 LCS 本地 +Z 方向生成出线段,同时继续兼容设备下 `QETWires_*` 旧分组;`3d_to_2d.json` 回写现在优先收集 `QETWiring_04_Routed` 中的已布线对象,并避免和旧设备分组重复。已通过 FreeCADExchange Python 单元测试和运行目录 `FreeCADCmd.exe` 模块导入验证。自动布线算法仍由后续独立模块接入。 +- 2026-05-23:补充独立文档 `FreeCAD 3D模型端子设计方案.md`,明确正式路线为 `STEP -> FreeCAD 添加模板端子 -> 保存 FCStd -> 生成工程端子 -> 手动布线`;同步更新本文档,去掉 bbox fallback 作为正式工程端子的说法,明确手动生成工程端子必须以模板端子为准,`local:*` 端子只用于 3D 本地布线,准确回写 QET 仍依赖 `terminal_uuid + instance_id`。 +- 2026-05-23:收口工程端子生成和回写边界,新增 `QetTerminalBindingMode` 标记本地/真实端子,手动生成工程端子时无模板槽位则直接提示并跳过;`3d_to_2d.json` 现在会过滤 `local:*` 端子和本地导线,确保只回写真实 QET 绑定。已通过 FreeCADExchange Python 单元测试全量验证。 +- 2026-05-23:补充 `slot_name_hint / terminal_label` 的端子匹配支持,`TerminalImport` 现在优先按提示名对齐模板槽位,再退回顺序匹配;已用 FreeCADExchange Python 单元测试验证反序端子导入也能准确落到对应模板槽位。 ``` diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index bdcf78b..58b3690 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -11,8 +11,10 @@ set(FreeCADExchange_Scripts TemplateAuthoringPanel.py TemplateInstantiation.py TerminalImport.py + WiringObjects.py ExchangeWriteBack.py ManualWiring.py + ManualWiringPanel.py ) add_custom_target(FreeCADExchangeScripts ALL diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index f229188..6cc64fb 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -6,6 +6,7 @@ import FreeCAD as App import FreeCADGui as Gui import ImportGui import DevicePreview +import TerminalObjects ROOT_GROUP_NAME = "QETExchangeDevices" @@ -444,6 +445,7 @@ def _import_model_into_group(doc, device_group, model_path, merge=False, use_lin for obj in top_level_objects: if obj not in getattr(device_group, "Group", []): device_group.addObject(obj) + TerminalObjects.hide_template_terminal_hints(device_group) return top_level_objects @@ -473,6 +475,7 @@ def _import_fcstd_into_group(doc, device_group, model_path): device_group.addObject(copied_obj) copied_objects.append(copied_obj) + TerminalObjects.hide_template_terminal_hints(device_group) return copied_objects finally: if should_close and source_doc is not None: diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index 79494e0..0e66c5c 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -15,6 +15,10 @@ try: import TerminalImport except Exception: TerminalImport = None +try: + import WiringObjects +except Exception: + WiringObjects = None try: from PySide6 import QtCore, QtWidgets @@ -517,6 +521,22 @@ def _build_summary(payload, json_path): } +def _initialize_wiring_scene(payload): + if WiringObjects is None: + _append_debug_log("wiring scene initialization skipped: WiringObjects module unavailable") + return None + + try: + return WiringObjects.initialize_wiring_scene( + App.ActiveDocument, + payload.get("project_uuid", "") if isinstance(payload, dict) else "", + ) + except Exception as exc: + _append_debug_log("wiring scene initialization failed: {0}".format(exc)) + _append_debug_log(traceback.format_exc()) + return None + + def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None): lines = [ "QET exchange file loaded successfully.", @@ -759,6 +779,8 @@ def _run_scheduled_device_import(attempt=0): setattr(App, STATE_TERMINAL_IMPORT_REPORT, terminal_report) + _initialize_wiring_scene(payload) + if ExchangeWriteBack is None: _append_debug_log("write-back skipped: ExchangeWriteBack module unavailable") writeback_report = None diff --git a/src/Mod/FreeCADExchange/ExchangeWriteBack.py b/src/Mod/FreeCADExchange/ExchangeWriteBack.py index 2e2f28d..447aa9d 100644 --- a/src/Mod/FreeCADExchange/ExchangeWriteBack.py +++ b/src/Mod/FreeCADExchange/ExchangeWriteBack.py @@ -7,9 +7,18 @@ from pathlib import Path import FreeCAD as App -import DeviceImport import TerminalObjects as TerminalObjects +try: + import DeviceImport +except Exception: + DeviceImport = None + +try: + import WiringObjects +except ImportError: + WiringObjects = None + try: import FreeCADGui as Gui except ImportError: @@ -24,6 +33,8 @@ class ExchangeWriteBackError(RuntimeError): def _append_debug_log(message): + if DeviceImport is None: + return try: DeviceImport._append_debug_log(message) except Exception: @@ -49,7 +60,7 @@ def _is_device_group(obj): if obj is None: return False try: - if not obj.Name.startswith(DeviceImport.DEVICE_GROUP_PREFIX): + if not obj.Name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX): return False return "QetElementUuid" in getattr(obj, "PropertiesList", []) except Exception: @@ -79,6 +90,44 @@ def _iter_terminal_objects(device_group): return TerminalObjects.collect_terminal_objects(terminal_container) +def _iter_wire_objects(device_group): + wire_container = TerminalObjects.find_child_group_by_kind( + device_group, + TerminalObjects.WIRE_GROUP_KIND, + ) + if wire_container is None: + return [] + + result = [] + + def walk(obj): + if obj is None: + return + for child in list(getattr(obj, "Group", []) or []): + if _is_manual_wire_object(child): + result.append(child) + continue + try: + if child.isDerivedFrom("App::DocumentObjectGroup"): + walk(child) + except Exception: + pass + + walk(wire_container) + return result + + +def _is_manual_wire_object(obj): + if obj is None: + return False + properties = getattr(obj, "PropertiesList", []) + if "QetStartTerminalUuid" not in properties: + return False + if "QetEndTerminalUuid" not in properties: + return False + return (getattr(obj, "RouteType", "") or "").strip() == "Manual" + + def _scene_path_from_doc(doc, scene_path=""): candidate = (scene_path or "").strip() if candidate: @@ -143,6 +192,8 @@ def _collect_terminal_bindings(doc): 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 TerminalObjects.is_local_terminal_uuid(terminal_uuid): + continue if not terminal_uuid or not terminal_instance_id: continue key = (terminal_uuid, terminal_instance_id) @@ -158,6 +209,104 @@ def _collect_terminal_bindings(doc): return bindings +def _point_from_vector(vector): + return { + "x": float(getattr(vector, "x", 0.0)), + "y": float(getattr(vector, "y", 0.0)), + "z": float(getattr(vector, "z", 0.0)), + } + + +def _wire_shape_points(wire_obj): + shape = getattr(wire_obj, "Shape", None) + if shape is None: + return [] + + if isinstance(shape, (list, tuple)): + return [_point_from_vector(point) for point in shape] + + vertices = getattr(shape, "Vertexes", None) + if vertices: + points = [] + for vertex in vertices: + point = getattr(vertex, "Point", None) + if point is not None: + points.append(_point_from_vector(point)) + return points + + return [] + + +def _manual_wire_object_key(wire_obj): + return ( + getattr(wire_obj, "QetWireUuid", "").strip(), + getattr(wire_obj, "QetStartTerminalUuid", "").strip(), + getattr(wire_obj, "QetEndTerminalUuid", "").strip(), + getattr(wire_obj, "QetStartInstanceId", "").strip(), + getattr(wire_obj, "QetEndInstanceId", "").strip(), + getattr(wire_obj, "Name", ""), + ) + + +def _legacy_wire_payload(wire_obj): + start_terminal_uuid = getattr(wire_obj, "QetStartTerminalUuid", "").strip() + end_terminal_uuid = getattr(wire_obj, "QetEndTerminalUuid", "").strip() + start_instance_id = getattr(wire_obj, "QetStartInstanceId", "").strip() + end_instance_id = getattr(wire_obj, "QetEndInstanceId", "").strip() + route_type = (getattr(wire_obj, "RouteType", "") or "").strip() or "Manual" + if not start_terminal_uuid or not end_terminal_uuid: + return None + if TerminalObjects.is_local_terminal_uuid(start_terminal_uuid) or TerminalObjects.is_local_terminal_uuid(end_terminal_uuid): + return None + + return { + "start_terminal_uuid": start_terminal_uuid, + "end_terminal_uuid": end_terminal_uuid, + "start_instance_id": start_instance_id, + "end_instance_id": end_instance_id, + "route_type": route_type, + "points": _wire_shape_points(wire_obj), + } + + +def _collect_manual_wires(doc): + wires = [] + seen = set() + seen_objects = set() + + if WiringObjects is not None: + try: + for wire_obj in WiringObjects.iter_routed_wire_objects(doc): + payload = WiringObjects.wire_payload_from_object(wire_obj) + if not payload.get("start_terminal_uuid") or not payload.get("end_terminal_uuid"): + continue + if TerminalObjects.is_local_terminal_uuid(payload.get("start_terminal_uuid")) or TerminalObjects.is_local_terminal_uuid(payload.get("end_terminal_uuid")): + continue + key = _manual_wire_object_key(wire_obj) + seen.add(key) + seen_objects.add(id(wire_obj)) + wires.append(payload) + except Exception as exc: + _append_debug_log("collect scene routed wires failed: {0}".format(exc)) + + for device_group in _iter_device_groups(doc): + for wire_obj in _iter_wire_objects(device_group): + if id(wire_obj) in seen_objects: + continue + + payload = _legacy_wire_payload(wire_obj) + if payload is None: + continue + + key = _manual_wire_object_key(wire_obj) + if key in seen: + continue + seen.add(key) + seen_objects.add(id(wire_obj)) + wires.append(payload) + return wires + + def _project_uuid_from_doc(doc, payload=None): root = _root_group(doc) if root is not None: @@ -192,6 +341,7 @@ def write_back_document(doc=None, scene_path="", payload=None): "generated_at": _format_timestamp(), "instances": _collect_instance_bindings(doc), "terminals": _collect_terminal_bindings(doc), + "manual_wires": _collect_manual_wires(doc), "output_path": output_path, } @@ -205,6 +355,7 @@ def write_back_document(doc=None, scene_path="", payload=None): "generated_at": report["generated_at"], "instances": report["instances"], "terminals": report["terminals"], + "manual_wires": report["manual_wires"], }, ensure_ascii=False, indent=2, @@ -213,9 +364,10 @@ def write_back_document(doc=None, scene_path="", payload=None): ) _append_debug_log( - "write_back_document completed: instances={0}, terminals={1}, path={2}".format( + "write_back_document completed: instances={0}, terminals={1}, manual_wires={2}, path={3}".format( len(report["instances"]), len(report["terminals"]), + len(report["manual_wires"]), output_path, ) ) @@ -287,9 +439,10 @@ class CommandWriteBack: report = write_back_document(App.ActiveDocument) try: App.Console.PrintMessage( - "[FreeCADExchange] Write-back completed: {0} instances, {1} terminals\n".format( + "[FreeCADExchange] Write-back completed: {0} instances, {1} terminals, {2} manual wires\n".format( len(report["instances"]), len(report["terminals"]), + len(report["manual_wires"]), ) ) except Exception: diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 3517ea1..76975bc 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -15,6 +15,7 @@ COMMANDS = [ "QET_Template_ImportInstance", "QET_Template_CreateEngineeringTerminals", "QET_Exchange_CreateManualWire", + "QET_Exchange_OpenManualWiringPanel", ] @@ -63,6 +64,7 @@ def _register_exchange_commands( ): exchange_write_back = safe_import("ExchangeWriteBack") manual_wiring = safe_import("ManualWiring") + manual_wiring_panel = safe_import("ManualWiringPanel") template_authoring = safe_import("TemplateAuthoring") template_authoring_panel = safe_import("TemplateAuthoringPanel") template_instantiation = safe_import("TemplateInstantiation") @@ -97,6 +99,16 @@ def _register_exchange_commands( ) ) + try: + if manual_wiring_panel is not None: + manual_wiring_panel.register_commands() + except Exception: + append_init_log( + "InitGui failed to register manual wiring panel command:\n{0}".format( + traceback_module.format_exc() + ) + ) + try: if template_authoring is not None: template_authoring.register_commands() diff --git a/src/Mod/FreeCADExchange/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py index 10a0f3d..8e28150 100644 --- a/src/Mod/FreeCADExchange/ManualWiring.py +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -1,5 +1,7 @@ # FreeCADExchange manual wiring helpers. +import json + import FreeCAD as App try: @@ -7,8 +9,13 @@ try: except ImportError: Gui = None -import DeviceImport import TerminalObjects as TerminalObjects +import WiringObjects + +try: + import DeviceImport +except Exception: + DeviceImport = None class ManualWiringError(RuntimeError): @@ -16,72 +23,334 @@ class ManualWiringError(RuntimeError): def _append_debug_log(message): + if DeviceImport is None: + return 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)) +def _vector_from_point(point): + if isinstance(point, App.Vector): + return point + if isinstance(point, (list, tuple)) and len(point) >= 3: + return App.Vector(float(point[0]), float(point[1]), float(point[2])) + return None + + +def _vector_length(vector): + return ( + float(getattr(vector, "x", 0.0)) ** 2 + + float(getattr(vector, "y", 0.0)) ** 2 + + float(getattr(vector, "z", 0.0)) ** 2 + ) ** 0.5 + + +def _normalize_vector(vector, fallback=None): + length = _vector_length(vector) + if length <= 0.000001: + return fallback or App.Vector(0, 0, 1) + return App.Vector( + float(getattr(vector, "x", 0.0)) / length, + float(getattr(vector, "y", 0.0)) / length, + float(getattr(vector, "z", 0.0)) / length, + ) + + +def _offset_point(point, direction, distance): + return App.Vector( + float(getattr(point, "x", 0.0)) + float(getattr(direction, "x", 0.0)) * distance, + float(getattr(point, "y", 0.0)) + float(getattr(direction, "y", 0.0)) * distance, + float(getattr(point, "z", 0.0)) + float(getattr(direction, "z", 0.0)) * distance, + ) + + +def _vector_close(left, right, tolerance=0.000001): + return ( + abs(float(getattr(left, "x", 0.0)) - float(getattr(right, "x", 0.0))) <= tolerance + and abs(float(getattr(left, "y", 0.0)) - float(getattr(right, "y", 0.0))) <= tolerance + and abs(float(getattr(left, "z", 0.0)) - float(getattr(right, "z", 0.0))) <= tolerance + ) + + +def _axis_value(vector, axis): + return float(getattr(vector, axis, 0.0)) + + +def _vector_with_axis(vector, axis, value): + return App.Vector( + float(value) if axis == "x" else float(getattr(vector, "x", 0.0)), + float(value) if axis == "y" else float(getattr(vector, "y", 0.0)), + float(value) if axis == "z" else float(getattr(vector, "z", 0.0)), + ) + + +def _dominant_axis(vector): + if vector is None: + return None + components = { + "x": abs(float(getattr(vector, "x", 0.0))), + "y": abs(float(getattr(vector, "y", 0.0))), + "z": abs(float(getattr(vector, "z", 0.0))), + } + axis = max(components, key=components.get) + if components[axis] <= 0.000001: + return None + return axis + + +def _coerce_waypoint(point_like): + if isinstance(point_like, dict): + point = _vector_from_point( + point_like.get("point") + or point_like.get("base") + or point_like.get("position") + or point_like.get("origin") + ) + if point is None and {"x", "y", "z"}.issubset(set(point_like.keys())): + point = App.Vector( + float(point_like.get("x", 0.0)), + float(point_like.get("y", 0.0)), + float(point_like.get("z", 0.0)), + ) + if point is None: + return None + + support_axis = (point_like.get("support_axis", "") or "").strip().lower() + if support_axis not in {"x", "y", "z"}: + support_axis = None + + support_normal = _vector_from_point(point_like.get("support_normal")) + if support_axis is None: + support_axis = _dominant_axis(support_normal) + + return { + "point": point, + "support_axis": support_axis, + "anchor_kind": (point_like.get("anchor_kind", "") or "").strip(), + "source_label": (point_like.get("source_label", "") or "").strip(), + "subelement_name": (point_like.get("subelement_name", "") or "").strip(), + } + + point = _vector_from_point(point_like) + if point is None: + return None + return { + "point": point, + "support_axis": None, + "anchor_kind": "", + "source_label": "", + "subelement_name": "", + } + + +def _orthogonal_segment_points(start_point, end_point, preferred_axis=None): + if _vector_close(start_point, end_point): + return [start_point] + + axis_order = [] + if preferred_axis in {"x", "y", "z"}: + axis_order.append(preferred_axis) + + remaining = sorted( + ("x", "y", "z"), + key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)), + reverse=True, + ) + for axis in remaining: + if axis not in axis_order: + axis_order.append(axis) + + points = [start_point] + current = start_point + for axis in axis_order: + target = _axis_value(end_point, axis) + if abs(_axis_value(current, axis) - target) <= 0.000001: + continue + current = _vector_with_axis(current, axis, target) + if not _vector_close(current, points[-1]): + points.append(current) + + if not _vector_close(points[-1], end_point): + points.append(end_point) return points +def _terminal_exit_direction(terminal): + placement = None + try: + if hasattr(terminal, "getGlobalPlacement"): + placement = terminal.getGlobalPlacement() + except Exception: + placement = None + + if placement is None: + placement = getattr(terminal, "Placement", None) + + rotation = getattr(placement, "Rotation", None) + if rotation is not None: + try: + return _normalize_vector(rotation.multVec(App.Vector(0, 0, 1))) + except Exception: + pass + + return App.Vector(0, 0, 1) + + +def _manual_waypoints_payload(waypoints): + payload = [] + for waypoint in waypoints or []: + point = waypoint.get("point") + if point is None: + continue + payload.append( + { + "point": { + "x": float(getattr(point, "x", 0.0)), + "y": float(getattr(point, "y", 0.0)), + "z": float(getattr(point, "z", 0.0)), + }, + "support_axis": waypoint.get("support_axis", ""), + "anchor_kind": waypoint.get("anchor_kind", ""), + "source_label": waypoint.get("source_label", ""), + "subelement_name": waypoint.get("subelement_name", ""), + } + ) + return payload + + +def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit_length=0.0): + start_origin = TerminalObjects.terminal_origin(start_terminal) + end_origin = TerminalObjects.terminal_origin(end_terminal) + exit_length = max(float(terminal_exit_length or 0.0), 0.0) + + points = [start_origin] + normalized_waypoints = [] + if exit_length > 0: + points.append( + _offset_point( + start_origin, + _terminal_exit_direction(start_terminal), + exit_length, + ) + ) + + for point_like in waypoints or []: + waypoint = _coerce_waypoint(point_like) + if waypoint is None: + continue + normalized_waypoints.append(waypoint) + if not _vector_close(points[-1], waypoint["point"]): + points.append(waypoint["point"]) + + if exit_length > 0: + end_exit = _offset_point( + end_origin, + _terminal_exit_direction(end_terminal), + exit_length, + ) + if not _vector_close(points[-1], end_exit): + points.append(end_exit) + if len(points) < 2 or not _vector_close(points[-1], end_origin): + points.append(end_origin) + return points, normalized_waypoints + + 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( +def _set_wire_properties( + obj, + project_uuid, + start_terminal, + end_terminal, + wire_uuid="", + wire_label="", + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, + manual_waypoints=None, +): + WiringObjects.set_routed_wire_semantics( obj, - "QetProjectUuid", - "QET Exchange", - "Project UUID for this wire", project_uuid, - ) - TerminalObjects.ensure_string_property( - obj, - "QetStartTerminalUuid", - "QET Exchange", - "Start terminal UUID", + wire_uuid, + wire_label, 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(), + route_type="Manual", + route_status="Routed", + route_mode="Manual", + net_uuid=net_uuid, + group_uuid=group_uuid, + wire_mark=wire_mark, + wire_mark_is_manual=wire_mark_is_manual, ) - TerminalObjects.ensure_string_property( - obj, - "RouteType", - "QET Exchange", - "Wire route type", - "Manual", - ) + try: + if "QetManualWaypointsJson" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyString", + "QetManualWaypointsJson", + "QET Wiring", + "Manual waypoint metadata", + ) + obj.QetManualWaypointsJson = json.dumps( + _manual_waypoints_payload(manual_waypoints or []), + ensure_ascii=False, + ) + except Exception: + pass + + +def _set_wire_points(obj, points): + try: + if "Points" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyVectorList", + "Points", + "QET Wiring", + "Manual route points", + ) + except Exception: + pass + try: + obj.Points = list(points) + except Exception: + pass + + +def _create_wire_geometry(doc, wire_name, points): + if getattr(App, "ActiveDocument", None) is doc: + try: + import Draft + + wire_obj = Draft.make_wire( + points, + closed=False, + placement=None, + face=None, + support=None, + bs2wire=False, + ) + if wire_obj is not None: + _set_wire_points(wire_obj, points) + return wire_obj + except Exception as exc: + _append_debug_log("Draft wire creation failed, falling back to Part polygon: {0}".format(exc)) + + import Part + + wire_obj = doc.addObject("Part::Feature", wire_name) + wire_obj.Shape = Part.makePolygon(points) + _set_wire_points(wire_obj, points) + return wire_obj def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None): @@ -105,7 +374,20 @@ def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback return fallback_group -def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent_group=None): +def create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=None, + parent_group=None, + terminal_exit_length=0.0, + wire_uuid="", + wire_label="", + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, +): 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): @@ -116,7 +398,7 @@ def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent project_uuid = ( getattr(start_terminal, "QetProjectUuid", "").strip() or getattr(end_terminal, "QetProjectUuid", "").strip() - or getattr(DeviceImport._ensure_root_group(doc), "QetProjectUuid", "").strip() + or getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() ) if not project_uuid: raise ManualWiringError("A project UUID is required to create a wire.") @@ -130,23 +412,35 @@ def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent 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) + points, normalized_waypoints = _terminal_points( + start_terminal, + end_terminal, + waypoints=waypoints, + terminal_exit_length=terminal_exit_length, + ) if len(points) < 2: raise ManualWiringError("A wire requires at least two points.") - import Part - - wire_obj.Shape = Part.makePolygon(points) + wire_obj = _create_wire_geometry(doc, wire_name, points) + wire_obj.Label = wire_label or "QET Manual Wire" _set_wire_properties( wire_obj, project_uuid, start_terminal, end_terminal, + wire_uuid=wire_uuid, + wire_label=wire_label, + net_uuid=net_uuid, + group_uuid=group_uuid, + wire_mark=wire_mark, + wire_mark_is_manual=wire_mark_is_manual, + manual_waypoints=normalized_waypoints, ) + routed_group = WiringObjects.ensure_routed_group(doc, project_uuid) + if wire_obj not in getattr(routed_group, "Group", []): + routed_group.addObject(wire_obj) + if parent_group is None: try: parent_group = _wire_parent_group( @@ -154,12 +448,22 @@ def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent project_uuid, start_terminal, end_terminal, - fallback_group=DeviceImport._ensure_root_group(doc, project_uuid), + fallback_group=TerminalObjects.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) + if parent_group is not None: + try: + if ( + getattr(parent_group, "Name", "").startswith(TerminalObjects.WIRE_GROUP_PREFIX) + or (getattr(parent_group, "QetGroupKind", "") or "").strip() + == TerminalObjects.WIRE_GROUP_KIND + ): + parent_group.ViewObject.Visibility = False + except Exception: + pass try: wire_obj.ViewObject.LineWidth = 2.0 diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py new file mode 100644 index 0000000..cc8f0d6 --- /dev/null +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -0,0 +1,535 @@ +# FreeCADExchange GUI panel for guided manual 3D wiring. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +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 ManualWiring +import TerminalObjects +import WiringObjects + +try: + import ExchangeWriteBack +except Exception: + ExchangeWriteBack = None + + +COMMAND_NAME = "QET_Exchange_OpenManualWiringPanel" +DEFAULT_TERMINAL_EXIT_LENGTH = 20.0 + + +class ManualWiringPanelError(RuntimeError): + pass + + +def _console_message(message): + try: + App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +def _console_error(message): + try: + App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +def _active_document(): + doc = getattr(App, "ActiveDocument", None) + if doc is None: + raise ManualWiringPanelError("请先打开 QET 3D 工程文档。") + return doc + + +def _selection(): + if Gui is None: + return [] + try: + return list(Gui.Selection.getSelection() or []) + except Exception: + return [] + + +def _selection_ex(): + if Gui is None: + return [] + try: + return list(Gui.Selection.getSelectionEx() or []) + except Exception: + return [] + + +def _shape_center(shape): + 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, + ) + + +def _selected_point(): + for picked in _selection_ex(): + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + + for sub_object in list(getattr(picked, "SubObjects", []) or []): + center = _shape_center(sub_object) + if center is not None: + return center + center = getattr(sub_object, "CenterOfMass", None) + if center is not None: + return center + + obj = getattr(picked, "Object", None) + center = _shape_center(getattr(obj, "Shape", None)) + if center is not None: + return center + + for obj in _selection(): + center = _shape_center(getattr(obj, "Shape", None)) + if center is not None: + return center + return None + + +def _selected_terminal(): + for obj in _selection(): + if TerminalObjects.is_terminal_object(obj): + return obj + return None + + +def _terminal_label(obj): + return ( + getattr(obj, "Label", "") + or getattr(obj, "QetTerminalUuid", "") + or getattr(obj, "Name", "") + or "端子" + ) + + +def _point_label(point_like, index): + if not isinstance(point_like, dict): + return "折点 {0}".format(index) + + label = (point_like.get("source_label", "") or "").strip() + subelement = (point_like.get("subelement_name", "") or "").strip() + anchor_kind = (point_like.get("anchor_kind", "") or "").strip() + parts = [] + if label: + parts.append(label) + if subelement: + parts.append(subelement) + if anchor_kind: + parts.append(anchor_kind) + if not parts: + parts.append("折点") + return "{0} {1}".format(index, " / ".join(parts)) + + +def _preview_vector(point_like): + if isinstance(point_like, dict): + return point_like.get("point") + return point_like + + +def _dominant_axis(vector): + if vector is None: + return None + components = { + "x": abs(float(getattr(vector, "x", 0.0))), + "y": abs(float(getattr(vector, "y", 0.0))), + "z": abs(float(getattr(vector, "z", 0.0))), + } + axis = max(components, key=components.get) + if components[axis] <= 0.000001: + return None + return axis + + +def _selected_waypoint(): + for picked in _selection_ex(): + picked_points = list(getattr(picked, "PickedPoints", []) or []) + point = picked_points[0] if picked_points else None + + sub_objects = list(getattr(picked, "SubObjects", []) or []) + subelement_names = list(getattr(picked, "SubElementNames", []) or []) + obj = getattr(picked, "Object", None) + support_axis = None + anchor_kind = "" + + if point is None and sub_objects: + point = _shape_center(sub_objects[0]) + if point is None and obj is not None: + point = _shape_center(getattr(obj, "Shape", None)) + if point is None: + continue + + if sub_objects: + sub_object = sub_objects[0] + anchor_kind = (getattr(sub_object, "ShapeType", "") or "subobject").strip() + anchor_kind = anchor_kind.lower() + if anchor_kind == "face" and hasattr(sub_object, "normalAt"): + try: + support_axis = _dominant_axis(sub_object.normalAt(0.5, 0.5)) + except Exception: + support_axis = None + elif anchor_kind == "edge" and hasattr(sub_object, "normalAt"): + try: + support_axis = _dominant_axis(sub_object.normalAt(0.5)) + except Exception: + support_axis = None + elif anchor_kind == "vertex": + support_axis = None + + return { + "point": point, + "support_axis": support_axis, + "anchor_kind": anchor_kind, + "source_label": getattr(obj, "Label", "") if obj is not None else "", + "subelement_name": subelement_names[0] if subelement_names else "", + } + return None + + +def _preview_group(doc): + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + return WiringObjects.ensure_preview_group(doc, project_uuid) + + +def _create_preview_point(doc, waypoint, index): + point = _preview_vector(waypoint) + if point is None: + return None + + preview_name = "QETWaypoint_{0}".format(index) + preview = None + try: + import Draft + + if hasattr(Draft, "make_point"): + preview = Draft.make_point( + point, + color=(0.95, 0.55, 0.0), + name=preview_name, + point_size=7, + ) + except Exception: + preview = None + + if preview is None: + preview = doc.addObject("Part::Vertex", preview_name) + + try: + preview.Label = _point_label(waypoint, index) + except Exception: + pass + try: + preview.Placement = App.Placement(point, App.Rotation()) + except Exception: + pass + try: + preview.ViewObject.Visibility = True + except Exception: + pass + try: + preview.ViewObject.PointColor = (0.95, 0.55, 0.0) + except Exception: + pass + try: + preview.ViewObject.PointSize = 7 + except Exception: + pass + + group = _preview_group(doc) + if preview not in getattr(group, "Group", []): + group.addObject(preview) + return preview + + +class ManualWiringController: + def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH): + self.terminal_exit_length = float(terminal_exit_length or 0.0) + self.start_terminal = None + self.waypoints = [] + self.preview_objects = [] + self.last_wire = None + + def _clear_preview_objects(self): + doc = getattr(App, "ActiveDocument", None) + if doc is None: + self.preview_objects = [] + return + + group = None + try: + group = doc.getObject("QETWiring_03_Previews") + except Exception: + group = None + + for preview in list(self.preview_objects): + try: + if group is not None and preview in getattr(group, "Group", []): + if hasattr(group, "removeObject"): + group.removeObject(preview) + else: + group.Group = [obj for obj in group.Group if obj is not preview] + except Exception: + pass + try: + if doc.getObject(getattr(preview, "Name", "")) is not None: + doc.removeObject(preview.Name) + except Exception: + pass + self.preview_objects = [] + + def _reset_route_state(self): + self.start_terminal = None + self.waypoints = [] + self.last_wire = None + self._clear_preview_objects() + + def set_start_from_selection(self): + terminal = _selected_terminal() + if terminal is None: + raise ManualWiringPanelError("请先选择一个工程端子,再点击“设为起点”。") + self._reset_route_state() + self.start_terminal = terminal + return terminal + + def add_waypoint_from_selection(self): + waypoint = _selected_waypoint() + if waypoint is None: + raise ManualWiringPanelError("请先在模型、机柜、线槽或导轨上选择一个点/面/边。") + self.waypoints.append(waypoint) + preview = _create_preview_point(_active_document(), waypoint, len(self.waypoints)) + if preview is not None: + self.preview_objects.append(preview) + return waypoint + + def set_end_from_selection_and_generate(self): + doc = _active_document() + if self.start_terminal is None: + raise ManualWiringPanelError("请先设置起点端子。") + end_terminal = _selected_terminal() + if end_terminal is None: + raise ManualWiringPanelError("请先选择一个工程端子,再点击“设为终点并生成”。") + wire = ManualWiring.create_manual_wire( + doc, + self.start_terminal, + end_terminal, + waypoints=list(self.waypoints), + terminal_exit_length=self.terminal_exit_length, + ) + self.last_wire = wire + return wire + + def clear(self): + self._reset_route_state() + + def save_and_write_back(self): + doc = _active_document() + try: + file_name = getattr(doc, "FileName", "") + if file_name and hasattr(doc, "save"): + doc.save() + except Exception as exc: + raise ManualWiringPanelError("保存 FreeCAD 文档失败:{0}".format(exc)) + + if ExchangeWriteBack is None: + raise ManualWiringPanelError("回写模块不可用。") + return ExchangeWriteBack.write_back_document(doc) + + def state_text(self): + start_text = "未设置" + if self.start_terminal is not None: + start_text = _terminal_label(self.start_terminal) + wire_text = "未生成" + if self.last_wire is not None: + wire_text = getattr(self.last_wire, "Label", "") or getattr(self.last_wire, "Name", "") + waypoint_text = "无" + if self.waypoints: + waypoint_text = ";".join( + _point_label(waypoint, index + 1) + for index, waypoint in enumerate(self.waypoints[:3]) + ) + if len(self.waypoints) > 3: + waypoint_text += ";..." + return "起点:{0};折点:{1} 个;最近导线:{2};折点明细:{3}".format( + start_text, + len(self.waypoints), + wire_text, + waypoint_text, + ) + + +class ManualWiringTaskPanel: + def __init__(self, controller=None): + if QtWidgets is None: + raise ManualWiringPanelError("Qt widgets are not available.") + + self.controller = controller or ManualWiringController() + self.form = QtWidgets.QWidget() + self.form.setWindowTitle("3D手动布线") + + layout = QtWidgets.QVBoxLayout(self.form) + + self.start_button = QtWidgets.QPushButton("设为起点") + self.waypoint_button = QtWidgets.QPushButton("添加折点") + self.end_button = QtWidgets.QPushButton("设为终点并生成") + self.clear_button = QtWidgets.QPushButton("清除草稿") + self.save_button = QtWidgets.QPushButton("保存并回写") + + layout.addWidget(self.start_button) + layout.addWidget(self.waypoint_button) + layout.addWidget(self.end_button) + layout.addWidget(self.clear_button) + layout.addWidget(self.save_button) + + self.waypoint_list = QtWidgets.QListWidget() + layout.addWidget(self.waypoint_list) + + self.status_label = QtWidgets.QLabel("") + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + self.start_button.clicked.connect(self.set_start) + self.waypoint_button.clicked.connect(self.add_waypoint) + self.end_button.clicked.connect(self.set_end_and_generate) + self.clear_button.clicked.connect(self.clear) + self.save_button.clicked.connect(self.save_and_write_back) + + self._refresh_waypoint_list() + self._set_status(self.controller.state_text()) + + def _set_status(self, message): + self.status_label.setText(message) + _console_message(message) + + def _set_error(self, message): + self.status_label.setText(message) + _console_error(message) + + def _refresh_waypoint_list(self): + self.waypoint_list.clear() + for index, waypoint in enumerate(self.controller.waypoints, start=1): + point = _preview_vector(waypoint) + if point is None: + continue + self.waypoint_list.addItem( + "{0}. {1} ({2:.2f}, {3:.2f}, {4:.2f})".format( + index, + _point_label(waypoint, index), + float(getattr(point, "x", 0.0)), + float(getattr(point, "y", 0.0)), + float(getattr(point, "z", 0.0)), + ) + ) + + def set_start(self): + try: + terminal = self.controller.set_start_from_selection() + self._refresh_waypoint_list() + self._set_status("已设置起点:{0}".format(_terminal_label(terminal))) + except Exception as exc: + self._set_error(str(exc)) + + def add_waypoint(self): + try: + self.controller.add_waypoint_from_selection() + self._refresh_waypoint_list() + self._set_status(self.controller.state_text()) + except Exception as exc: + self._set_error(str(exc)) + + def set_end_and_generate(self): + try: + wire = self.controller.set_end_from_selection_and_generate() + self._set_status("已生成导线:{0}".format(getattr(wire, "Name", ""))) + try: + if Gui is not None: + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + except Exception as exc: + self._set_error(str(exc)) + + def clear(self): + self.controller.clear() + self._refresh_waypoint_list() + self._set_status("已清除草稿。") + + def save_and_write_back(self): + try: + report = self.controller.save_and_write_back() + output_path = "" + if isinstance(report, dict): + output_path = report.get("output_path", "") + self._set_status("已保存并回写:{0}".format(output_path or "完成")) + except Exception as exc: + self._set_error(str(exc)) + + def accept(self): + return True + + def reject(self): + return True + + +class CommandOpenManualWiringPanel: + def GetResources(self): + return { + "MenuText": "3D手动布线", + "ToolTip": "打开 3D 手动布线面板", + } + + 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(ManualWiringTaskPanel()) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + if not hasattr(Gui, "addCommand"): + return + try: + Gui.addCommand(COMMAND_NAME, CommandOpenManualWiringPanel()) + _COMMANDS_REGISTERED = True + except Exception as exc: + _console_error("注册 3D 手动布线命令失败:{0}".format(exc)) + + +register_commands() diff --git a/src/Mod/FreeCADExchange/TemplateInstantiation.py b/src/Mod/FreeCADExchange/TemplateInstantiation.py index de4a86b..64a9bfd 100644 --- a/src/Mod/FreeCADExchange/TemplateInstantiation.py +++ b/src/Mod/FreeCADExchange/TemplateInstantiation.py @@ -138,8 +138,21 @@ def ensure_engineering_terminals_for_device(doc, device_group): "created_terminals": 0, "updated_terminals": 0, "skipped_slots": 0, + "skipped_devices_without_template_slots": 0, + "local_terminals": 0, + "warnings": [], } + if not slots: + report["skipped_devices_without_template_slots"] = 1 + report["warnings"].append( + "设备 {0} 没有模板端子,未生成工程端子。请先制作带模板端子的 FCStd。".format( + getattr(device_group, "Label", "") or getattr(device_group, "Name", "") + ) + ) + _debug(report["warnings"][-1]) + return report + for index, slot in enumerate(slots): slot_name = (slot.get("name") or "SLOT_{0}".format(index + 1)).strip() if not slot_name: @@ -169,6 +182,8 @@ def ensure_engineering_terminals_for_device(doc, device_group): terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip() if not terminal_uuid: terminal_uuid = _local_terminal_uuid(instance_id, element_uuid, slot_name) + if TerminalObjects.is_local_terminal_uuid(terminal_uuid): + report["local_terminals"] += 1 TerminalObjects.set_terminal_semantics( terminal_obj, @@ -236,6 +251,15 @@ def ensure_engineering_terminals_for_selection_or_all(doc=None): "devices": len(reports), "created_terminals": sum(item["created_terminals"] for item in reports), "updated_terminals": sum(item["updated_terminals"] for item in reports), + "skipped_devices_without_template_slots": sum( + item.get("skipped_devices_without_template_slots", 0) for item in reports + ), + "local_terminals": sum(item.get("local_terminals", 0) for item in reports), + "warnings": [ + warning + for item in reports + for warning in list(item.get("warnings", []) or []) + ], "reports": reports, } @@ -338,12 +362,16 @@ class CommandCreateEngineeringTerminals: report = ensure_engineering_terminals_for_selection_or_all(App.ActiveDocument) try: App.Console.PrintMessage( - "[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2} 个。\n".format( + "[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2} 个,本地端子 {3} 个,跳过无模板设备 {4} 个。\n".format( report["devices"], report["created_terminals"], report["updated_terminals"], + report["local_terminals"], + report["skipped_devices_without_template_slots"], ) ) + for warning in list(report.get("warnings", []) or []): + App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) except Exception: pass except Exception as exc: diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py index 37b5faf..8cdeff8 100644 --- a/src/Mod/FreeCADExchange/TemplateSemantics.py +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -357,11 +357,4 @@ def resolve_terminal_slots(device_group, model_path, 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 index 12a1db8..91368a1 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -35,11 +35,19 @@ def _normalize_terminal_entry(item, index): instance_id = (item.get("instance_id") or "").strip() element_uuid = (item.get("element_uuid") or "").strip() + slot_name_hint = ( + item.get("slot_name_hint") + or item.get("terminal_label") + or item.get("slot_name") + or item.get("display_tag") + or "" + ).strip() return { "terminal_uuid": terminal_uuid, "instance_id": instance_id, "element_uuid": element_uuid, + "slot_name_hint": slot_name_hint, } @@ -148,6 +156,51 @@ def _terminal_slot_label(slot, terminal_uuid): return terminal_uuid +def _normalize_slot_name(value): + return (value or "").strip().lower() + + +def _slot_lookup_key(slot): + return _normalize_slot_name(slot.get("name", "")), _normalize_slot_name(slot.get("label", "")) + + +def _build_slot_lookup(slots): + lookup = {} + for slot in slots or []: + for key in _slot_lookup_key(slot): + if key and key not in lookup: + lookup[key] = slot + return lookup + + +def _resolve_entry_slot(entry, slots, fallback_slots, used_slot_names, index): + hint = _normalize_slot_name(entry.get("slot_name_hint", "")) + if hint: + slot = _build_slot_lookup(slots).get(hint) + if slot is not None: + slot_name = _normalize_slot_name(slot.get("name", "")) + if not slot_name or slot_name not in used_slot_names: + return slot + + if index < len(fallback_slots): + slot = fallback_slots[index] + slot_name = _normalize_slot_name(slot.get("name", "")) + if not slot_name or slot_name not in used_slot_names: + return slot + + for slot in slots: + slot_name = _normalize_slot_name(slot.get("name", "")) + if not slot_name or slot_name not in used_slot_names: + return slot + + for slot in fallback_slots: + slot_name = _normalize_slot_name(slot.get("name", "")) + if not slot_name or slot_name not in used_slot_names: + return slot + + return None + + def _slot_base(slot): base = slot.get("base") if isinstance(base, App.Vector): @@ -223,6 +276,8 @@ def import_terminals_from_payload(payload, scene_path=""): "updated_terminals": 0, "removed_terminals": 0, "reused_template_hints": 0, + "matched_by_slot_hint": 0, + "skipped_missing_slot": 0, "skipped_missing_device": 0, "skipped_invalid_entry": 0, "skipped_unmatched_parent": 0, @@ -277,11 +332,14 @@ def import_terminals_from_payload(payload, scene_path=""): 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( + used_slot_names = set() + template_slots = TemplateSemantics.collect_terminal_hints(device_group) + fallback_slots = TemplateSemantics.resolve_terminal_slots( device_group, resolved_model_path, len(entries), ) + slot_lookup = _build_slot_lookup(template_slots) for index, entry in enumerate(entries): terminal_uuid = entry["terminal_uuid"] @@ -292,13 +350,32 @@ def import_terminals_from_payload(payload, scene_path=""): .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, - } + slot = None + slot_hint = _normalize_slot_name(entry.get("slot_name_hint", "")) + if slot_hint: + hinted_slot = slot_lookup.get(slot_hint) + if hinted_slot is not None: + hinted_name = _normalize_slot_name(hinted_slot.get("name", "")) + if not hinted_name or hinted_name not in used_slot_names: + slot = hinted_slot + report["matched_by_slot_hint"] += 1 + + if slot is None: + slot = _resolve_entry_slot(entry, template_slots, fallback_slots, used_slot_names, index) + + if slot is None: + report["skipped_missing_slot"] += 1 + report["warnings"].append( + "Terminal {0} was skipped because device {1} has no matching FCStd template terminal slot.".format( + terminal_uuid, + device_element_uuid or device_instance_id or device_group.Name, + ) + ) + continue + + slot_name = _normalize_slot_name(slot.get("name", "")) + if slot_name: + used_slot_names.add(slot_name) terminal_obj = existing_by_uuid.get(terminal_uuid) if terminal_obj is None: @@ -356,11 +433,12 @@ def import_terminals_from_payload(payload, scene_path=""): pass _append_debug_log( - "TerminalImport finished: imported={0}, updated={1}, removed={2}, skipped_unmatched_parent={3}".format( + "TerminalImport finished: imported={0}, updated={1}, removed={2}, skipped_unmatched_parent={3}, skipped_missing_slot={4}".format( report["imported_terminals"], report["updated_terminals"], report["removed_terminals"], report["skipped_unmatched_parent"], + report["skipped_missing_slot"], ) ) return report diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py index 868f598..6aba212 100644 --- a/src/Mod/FreeCADExchange/TerminalObjects.py +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -1,6 +1,7 @@ # FreeCADExchange terminal and object helpers. import json +import math import os from pathlib import Path @@ -15,6 +16,8 @@ WIRE_GROUP_PREFIX = "QETWires_" TERMINAL_GROUP_KIND = "Terminals" WIRE_GROUP_KIND = "Wires" TERMINAL_ROLE = "Terminal" +TERMINAL_BINDING_MODE_LOCAL = "local" +TERMINAL_BINDING_MODE_QET = "qet" def safe_token(value): @@ -38,6 +41,10 @@ def native_path(value): return os.path.normpath(os.path.expandvars(os.path.expanduser(text))) +def is_local_terminal_uuid(value): + return (value or "").strip().lower().startswith("local:") + + 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) @@ -173,7 +180,7 @@ def ensure_terminal_group(doc, device_group, project_uuid="", 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( + group = ensure_named_child_group( doc, device_group, WIRE_GROUP_PREFIX, @@ -183,6 +190,11 @@ def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""): element_uuid=element_uuid, instance_id=instance_id, ) + try: + group.ViewObject.Visibility = False + except Exception: + pass + return group def find_child_group_by_kind(parent_group, group_kind): @@ -258,6 +270,29 @@ def is_terminal_object(obj): return bool(getattr(obj, "CanWire", False)) +def is_template_terminal_object(obj): + return is_terminal_hint_object(obj) and not is_terminal_object(obj) + + +def hide_template_terminal_hints(container): + hidden = 0 + if container is None: + return hidden + + for child in list(getattr(container, "Group", []) or []): + if is_template_terminal_object(child): + try: + child.ViewObject.Visibility = False + hidden += 1 + except Exception: + pass + continue + + if _is_group_candidate(child): + hidden += hide_template_terminal_hints(child) + return hidden + + def terminal_origin(obj): try: if hasattr(obj, "getGlobalPlacement"): @@ -311,6 +346,73 @@ def terminal_origin(obj): return App.Vector(0, 0, 0) +def _apply_rotation(rotation, vector): + if rotation is None: + return vector + try: + rotated = rotation.multVec(vector) + if rotated is not None: + return App.Vector(rotated.x, rotated.y, rotated.z) + except Exception: + pass + return vector + + +def _normalized_direction(vector): + try: + length = math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z) + except Exception: + return App.Vector(0, 0, 1) + if length <= 1e-9: + return App.Vector(0, 0, 1) + return App.Vector(vector.x / length, vector.y / length, vector.z / length) + + +def terminal_direction(obj): + direction = App.Vector(0, 0, 1) + + try: + if hasattr(obj, "getGlobalPlacement"): + placement = obj.getGlobalPlacement() + rotation = getattr(placement, "Rotation", None) + return _normalized_direction(_apply_rotation(rotation, direction)) + except Exception: + pass + + try: + placement = getattr(obj, "Placement", None) + if placement is not None: + direction = _apply_rotation(getattr(placement, "Rotation", None), direction) + + parent_chain = [] + current = obj + visited = set() + while True: + parents = list(getattr(current, "InList", []) or []) + parent = None + for candidate in parents: + if id(candidate) in visited: + continue + if getattr(candidate, "Placement", None) is not None: + parent = candidate + break + if parent is None: + break + visited.add(id(parent)) + parent_chain.append(parent) + current = parent + + for parent in parent_chain: + parent_placement = getattr(parent, "Placement", None) + if parent_placement is None: + continue + direction = _apply_rotation(getattr(parent_placement, "Rotation", None), direction) + return _normalized_direction(direction) + except Exception: + pass + return App.Vector(0, 0, 1) + + 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) @@ -367,6 +469,18 @@ def set_terminal_semantics( "Terminal UUID from QET", terminal_uuid, ) + binding_mode = ( + TERMINAL_BINDING_MODE_LOCAL + if is_local_terminal_uuid(terminal_uuid) + else TERMINAL_BINDING_MODE_QET + ) + ensure_string_property( + obj, + "QetTerminalBindingMode", + "QET Exchange", + "Whether this terminal is bound to QET or local-only", + binding_mode, + ) ensure_string_property( obj, "QetInstanceId", diff --git a/src/Mod/FreeCADExchange/WiringObjects.py b/src/Mod/FreeCADExchange/WiringObjects.py new file mode 100644 index 0000000..102c489 --- /dev/null +++ b/src/Mod/FreeCADExchange/WiringObjects.py @@ -0,0 +1,402 @@ +# FreeCADExchange wiring object helpers. + +import json + +import FreeCAD as App + +import TerminalObjects + + +WIRING_GROUP_NAME = "QETWiring" +WIRING_GROUP_LABEL = "QET Wiring" +WIRING_GROUP_KIND = "Wiring" +WIRING_BUCKET_KIND = "WiringBucket" +WIRE_TASK_KIND = "WireTask" +ROUTED_WIRE_KIND = "RoutedWire" + +WIRING_BUCKETS = [ + ("QETWiring_01_Tasks", "01_Tasks"), + ("QETWiring_02_Carriers", "02_Carriers"), + ("QETWiring_03_Previews", "03_Previews"), + ("QETWiring_04_Routed", "04_Routed"), + ("QETWiring_05_Diagnostics", "05_Diagnostics"), +] + + +def _set_string_property(obj, prop_name, value, description="QET wiring property"): + TerminalObjects.ensure_string_property( + obj, + prop_name, + "QET Wiring", + description, + value, + ) + + +def _set_bool_property(obj, prop_name, value, description="QET wiring property"): + TerminalObjects.ensure_bool_property( + obj, + prop_name, + "QET Wiring", + description, + value, + ) + + +def _add_to_parent(parent, child): + if parent is not None and child not in getattr(parent, "Group", []): + parent.addObject(child) + + +def _ensure_group(doc, name, label, kind, parent=None, project_uuid=""): + group = doc.getObject(name) + if group is None: + group = doc.addObject("App::DocumentObjectGroup", name) + + _add_to_parent(parent, group) + group.Label = label + _set_string_property(group, "QetGroupKind", kind, "FreeCADExchange group kind") + _set_string_property(group, "QetProjectUuid", project_uuid, "Project UUID for wiring") + return group + + +def ensure_wiring_root_group(doc, project_uuid=""): + root = TerminalObjects.ensure_root_group(doc, project_uuid) + wiring_root = _ensure_group( + doc, + WIRING_GROUP_NAME, + WIRING_GROUP_LABEL, + WIRING_GROUP_KIND, + parent=root, + project_uuid=project_uuid, + ) + ensure_wiring_buckets(doc, wiring_root, project_uuid=project_uuid) + return wiring_root + + +def _hide_object(obj): + try: + obj.ViewObject.Visibility = False + except Exception: + pass + + +def _is_legacy_wire_group(obj): + if obj is None: + return False + if getattr(obj, "Name", "").startswith(TerminalObjects.WIRE_GROUP_PREFIX): + return True + return (getattr(obj, "QetGroupKind", "") or "").strip() == TerminalObjects.WIRE_GROUP_KIND + + +def hide_legacy_wire_groups(doc): + hidden = 0 + for obj in list(getattr(doc, "Objects", []) or []): + if not _is_legacy_wire_group(obj): + continue + _hide_object(obj) + hidden += 1 + return hidden + + +def initialize_wiring_scene(doc, project_uuid=""): + wiring_root = ensure_wiring_root_group(doc, project_uuid) + hide_legacy_wire_groups(doc) + return wiring_root + + +def ensure_wiring_buckets(doc, wiring_root, project_uuid=""): + buckets = {} + for name, label in WIRING_BUCKETS: + buckets[label] = _ensure_group( + doc, + name, + label, + WIRING_BUCKET_KIND, + parent=wiring_root, + project_uuid=project_uuid, + ) + return buckets + + +def ensure_bucket_group(doc, bucket_label, project_uuid=""): + wiring_root = ensure_wiring_root_group(doc, project_uuid) + for name, label in WIRING_BUCKETS: + if label == bucket_label: + return _ensure_group( + doc, + name, + label, + WIRING_BUCKET_KIND, + parent=wiring_root, + project_uuid=project_uuid, + ) + raise ValueError("Unknown wiring bucket: {0}".format(bucket_label)) + + +def ensure_task_group(doc, project_uuid=""): + return ensure_bucket_group(doc, "01_Tasks", project_uuid=project_uuid) + + +def ensure_carrier_group(doc, project_uuid=""): + return ensure_bucket_group(doc, "02_Carriers", project_uuid=project_uuid) + + +def ensure_preview_group(doc, project_uuid=""): + return ensure_bucket_group(doc, "03_Previews", project_uuid=project_uuid) + + +def ensure_routed_group(doc, project_uuid=""): + return ensure_bucket_group(doc, "04_Routed", project_uuid=project_uuid) + + +def ensure_diagnostic_group(doc, project_uuid=""): + return ensure_bucket_group(doc, "05_Diagnostics", project_uuid=project_uuid) + + +def _wire_safe_name(prefix, wire_uuid, fallback="Wire"): + token = TerminalObjects.safe_token((wire_uuid or "").strip()) + return "{0}_{1}".format(prefix, token or fallback) + + +def _wire_common_properties( + obj, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, +): + _set_string_property(obj, "QetProjectUuid", project_uuid, "Project UUID") + _set_string_property(obj, "QetWireUuid", wire_uuid, "Wire UUID") + _set_string_property(obj, "QetWireLabel", wire_label, "Wire label") + _set_string_property(obj, "QetStartTerminalUuid", start_terminal_uuid, "Start terminal UUID") + _set_string_property(obj, "QetEndTerminalUuid", end_terminal_uuid, "End terminal UUID") + _set_string_property(obj, "QetStartInstanceId", start_instance_id, "Start instance ID") + _set_string_property(obj, "QetEndInstanceId", end_instance_id, "End instance ID") + + +def set_wire_task_semantics( + obj, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, +): + _wire_common_properties( + obj, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + ) + _set_string_property(obj, "QetNetUuid", net_uuid, "Net UUID") + _set_string_property(obj, "QetGroupUuid", group_uuid, "Group UUID") + _set_string_property(obj, "QetWireMark", wire_mark, "Wire mark") + _set_bool_property(obj, "QetWireMarkIsManual", wire_mark_is_manual, "Whether wire mark is manual") + _set_string_property(obj, "RouteType", "Task", "Wire task type") + _set_string_property(obj, "RouteStatus", "Task", "Wire task status") + return obj + + +def set_routed_wire_semantics( + obj, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + route_type="Manual", + route_status="Routed", + route_mode="Manual", + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, +): + _wire_common_properties( + obj, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + ) + _set_string_property(obj, "QetNetUuid", net_uuid, "Net UUID") + _set_string_property(obj, "QetGroupUuid", group_uuid, "Group UUID") + _set_string_property(obj, "QetWireMark", wire_mark, "Wire mark") + _set_bool_property(obj, "QetWireMarkIsManual", wire_mark_is_manual, "Whether wire mark is manual") + _set_string_property(obj, "RouteType", route_type, "Wire route type") + _set_string_property(obj, "RouteStatus", route_status, "Wire route status") + _set_string_property(obj, "RouteMode", route_mode, "Wire route mode") + return obj + + +def create_wire_task( + doc, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, +): + task_name = _wire_safe_name("QETWireTask", wire_uuid) + task = doc.addObject("App::DocumentObjectGroup", task_name) + task.Label = wire_label or wire_uuid or "QET Wire Task" + set_wire_task_semantics( + task, + project_uuid, + wire_uuid, + wire_label, + start_terminal_uuid, + end_terminal_uuid, + start_instance_id, + end_instance_id, + net_uuid=net_uuid, + group_uuid=group_uuid, + wire_mark=wire_mark, + wire_mark_is_manual=wire_mark_is_manual, + ) + _add_to_parent(ensure_task_group(doc, project_uuid), task) + return task + + +def _point_from_vector(vector): + return { + "x": float(getattr(vector, "x", 0.0)), + "y": float(getattr(vector, "y", 0.0)), + "z": float(getattr(vector, "z", 0.0)), + } + + +def wire_shape_points(wire_obj): + if wire_obj is None: + return [] + + points = list(getattr(wire_obj, "Points", []) or []) + if points: + return points + + shape = getattr(wire_obj, "Shape", None) + if isinstance(shape, (list, tuple)): + return [ + point + for point in shape + if hasattr(point, "x") and hasattr(point, "y") and hasattr(point, "z") + ] + + vertexes = getattr(shape, "Vertexes", None) + if vertexes: + result = [] + for vertex in vertexes: + point = getattr(vertex, "Point", None) + if point is not None: + result.append(point) + return result + + ordered_vertexes = getattr(shape, "OrderedVertexes", None) + if ordered_vertexes: + result = [] + for vertex in ordered_vertexes: + point = getattr(vertex, "Point", None) + if point is not None: + result.append(point) + return result + + return [] + + +def wire_payload_from_object(wire_obj): + start_terminal_uuid = getattr(wire_obj, "QetStartTerminalUuid", "").strip() + end_terminal_uuid = getattr(wire_obj, "QetEndTerminalUuid", "").strip() + if TerminalObjects.is_local_terminal_uuid(start_terminal_uuid) or TerminalObjects.is_local_terminal_uuid(end_terminal_uuid): + return { + "wire_uuid": getattr(wire_obj, "QetWireUuid", "").strip(), + "wire_label": getattr(wire_obj, "QetWireLabel", "").strip(), + "route_type": getattr(wire_obj, "RouteType", "").strip(), + "route_status": getattr(wire_obj, "RouteStatus", "").strip(), + "route_mode": getattr(wire_obj, "RouteMode", "").strip(), + "start_terminal_uuid": "", + "end_terminal_uuid": "", + "start_instance_id": getattr(wire_obj, "QetStartInstanceId", "").strip(), + "end_instance_id": getattr(wire_obj, "QetEndInstanceId", "").strip(), + "net_uuid": getattr(wire_obj, "QetNetUuid", "").strip(), + "group_uuid": getattr(wire_obj, "QetGroupUuid", "").strip(), + "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), + "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), + "points": [], + } + points = [_point_from_vector(point) for point in wire_shape_points(wire_obj)] + return { + "wire_uuid": getattr(wire_obj, "QetWireUuid", "").strip(), + "wire_label": getattr(wire_obj, "QetWireLabel", "").strip(), + "route_type": getattr(wire_obj, "RouteType", "").strip(), + "route_status": getattr(wire_obj, "RouteStatus", "").strip(), + "route_mode": getattr(wire_obj, "RouteMode", "").strip(), + "start_terminal_uuid": start_terminal_uuid, + "end_terminal_uuid": end_terminal_uuid, + "start_instance_id": getattr(wire_obj, "QetStartInstanceId", "").strip(), + "end_instance_id": getattr(wire_obj, "QetEndInstanceId", "").strip(), + "net_uuid": getattr(wire_obj, "QetNetUuid", "").strip(), + "group_uuid": getattr(wire_obj, "QetGroupUuid", "").strip(), + "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), + "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), + "points": points, + } + + +def is_routed_wire_object(obj): + if obj is None: + return False + properties = getattr(obj, "PropertiesList", []) + return ( + "QetStartTerminalUuid" in properties + and "QetEndTerminalUuid" in properties + and (getattr(obj, "RouteType", "") or "").strip() in {"Manual", "GuidedManual", "AutoSuggested"} + ) + + +def iter_routed_wire_objects(doc): + root = doc.getObject(WIRING_GROUP_NAME) + if root is None: + return [] + + routed_group = None + for candidate in list(getattr(root, "Group", []) or []): + if getattr(candidate, "Name", "") == "QETWiring_04_Routed": + routed_group = candidate + break + if routed_group is None: + return [] + + result = [] + for obj in list(getattr(routed_group, "Group", []) or []): + if is_routed_wire_object(obj): + result.append(obj) + return result + + +def serialize_routed_wires(doc): + return [wire_payload_from_object(obj) for obj in iter_routed_wire_objects(doc)] diff --git a/tests/python/freecad_exchange_bootstrap_wiring_test.py b/tests/python/freecad_exchange_bootstrap_wiring_test.py new file mode 100644 index 0000000..0485286 --- /dev/null +++ b/tests/python/freecad_exchange_bootstrap_wiring_test.py @@ -0,0 +1,86 @@ +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 = object() + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + PrintLog=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_gui = types.ModuleType("FreeCADGui") + fake_gui.getMainWindow = lambda: None + sys.modules["FreeCADGui"] = fake_gui + + fake_device_import = types.ModuleType("DeviceImport") + fake_device_import.DeviceImportError = RuntimeError + fake_device_import.import_devices_from_payload = lambda *args, **kwargs: {} + sys.modules["DeviceImport"] = fake_device_import + + fake_device_preview = types.ModuleType("DevicePreview") + fake_device_preview.find_parent_qet_device_object = lambda obj: None + fake_device_preview.is_preview_document_name = lambda name: False + fake_device_preview.open_preview_for_device_object = lambda obj: None + sys.modules["DevicePreview"] = fake_device_preview + + fake_terminal_import = types.ModuleType("TerminalImport") + fake_terminal_import.TerminalImportError = RuntimeError + sys.modules["TerminalImport"] = fake_terminal_import + + calls = [] + fake_wiring = types.ModuleType("WiringObjects") + fake_wiring.initialize_wiring_scene = lambda doc, project_uuid="": calls.append((doc, project_uuid)) or "root" + sys.modules["WiringObjects"] = fake_wiring + + class _QObject: + def __init__(self, *args, **kwargs): + pass + + fake_qt_core = types.SimpleNamespace( + QObject=_QObject, + QEvent=types.SimpleNamespace(MouseButtonDblClick=1), + QTimer=types.SimpleNamespace(singleShot=lambda *args, **kwargs: None), + ) + fake_qt_widgets = types.SimpleNamespace( + QWidget=object, + QMessageBox=types.SimpleNamespace( + information=lambda *args, **kwargs: None, + critical=lambda *args, **kwargs: None, + ), + ) + fake_pyside = types.ModuleType("PySide6") + fake_pyside.QtCore = fake_qt_core + fake_pyside.QtWidgets = fake_qt_widgets + sys.modules["PySide6"] = fake_pyside + + return fake_freecad, calls + + +class ExchangeBootstrapWiringTest(unittest.TestCase): + def test_initialize_wiring_scene_uses_active_document_and_project_uuid(self): + app, calls = _install_fake_modules() + sys.modules.pop("ExchangeBootstrap", None) + bootstrap = importlib.import_module("ExchangeBootstrap") + + result = bootstrap._initialize_wiring_scene({"project_uuid": "project-1"}) + + self.assertEqual("root", result) + self.assertEqual([(app.ActiveDocument, "project-1")], calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py new file mode 100644 index 0000000..3d54ea2 --- /dev/null +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -0,0 +1,336 @@ +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, w_axis=None): + self.Axis = axis + self.Angle = angle + self.WAxis = w_axis + + def multVec(self, vector): + if self.WAxis is not None and getattr(vector, "z", None) == 1: + return self.WAxis + return vector + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or Rotation() + + def multVec(self, vector): + return Vector( + self.Base.x + vector.x, + self.Base.y + vector.y, + self.Base.z + vector.z, + ) + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + fake_freecad.ActiveDocument = None + fake_freecad.GuiUp = True + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + PrintLog=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + selection_state = {"selection": [], "selection_ex": []} + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None + fake_freecadgui.Selection = types.SimpleNamespace( + getSelection=lambda: list(selection_state["selection"]), + getSelectionEx=lambda: list(selection_state["selection_ex"]), + ) + fake_freecadgui.Control = types.SimpleNamespace( + activeDialog=lambda: False, + showDialog=lambda panel: panel, + closeDialog=lambda: None, + ) + 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 + + fake_draft = types.ModuleType("Draft") + + def make_wire(points, closed=False, placement=None, face=None, support=None, bs2wire=False): + doc = fake_freecad.ActiveDocument + obj = doc.addObject("Part::FeaturePython", "Wire") + obj.Points = list(points) + return obj + + def make_point(X=0, Y=0, Z=0, color=None, name="Point", point_size=5): + doc = fake_freecad.ActiveDocument + obj = doc.addObject("Part::FeaturePython", name) + if isinstance(X, fake_freecad.Vector): + point = X + else: + point = fake_freecad.Vector(X, Y, Z) + obj.Point = point + obj.Placement = fake_freecad.Placement(point, fake_freecad.Rotation()) + obj.PointColor = color + obj.PointSize = point_size + return obj + + fake_draft.make_wire = make_wire + fake_draft.make_point = make_point + sys.modules["Draft"] = fake_draft + return selection_state + + +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 + self.Points = [] + self.Placement = sys.modules["FreeCAD"].Placement() + self.InList = [] + + 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) + if self not in child.InList: + child.InList.append(self) + + +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", + "WiringObjects", + "ManualWiring", + "TemplateAuthoring", + "ExchangeWriteBack", + "ManualWiringPanel", + ]: + sys.modules.pop(name, None) + terminal_objects = importlib.import_module("TerminalObjects") + panel = importlib.import_module("ManualWiringPanel") + return terminal_objects, panel + + +class ManualWiringPanelTest(unittest.TestCase): + def test_controller_creates_preview_point_and_records_face_anchor(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement( + app.Vector(1, 2, 3), + app.Rotation(w_axis=app.Vector(0, 1, 0)), + ) + device.addObject(start_terminal) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-a", + "terminal-start", + "instance-a", + label="Start", + ) + + controller = panel.ManualWiringController(terminal_exit_length=10.0) + + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + + face = types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(1, 0, 0), + ) + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(10, 20, 30)], + SubObjects=[face], + SubElementNames=["Face1"], + Object=types.SimpleNamespace(Name="CabinetFace", Label="柜体面"), + ) + ] + waypoint = controller.add_waypoint_from_selection() + + preview_group = doc.getObject("QETWiring_03_Previews") + self.assertIsNotNone(preview_group) + self.assertEqual(1, len(controller.waypoints)) + self.assertEqual("face", waypoint["anchor_kind"]) + self.assertEqual("x", waypoint["support_axis"]) + self.assertEqual(1, len(controller.preview_objects)) + self.assertIn(controller.preview_objects[0], preview_group.Group) + self.assertEqual( + (10.0, 20.0, 30.0), + ( + controller.preview_objects[0].Point.x, + controller.preview_objects[0].Point.y, + controller.preview_objects[0].Point.z, + ), + ) + + def test_controller_generates_direct_wire_from_waypoint_and_end_selection(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement( + app.Vector(1, 2, 3), + app.Rotation(w_axis=app.Vector(0, 1, 0)), + ) + device.addObject(start_terminal) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-a", + "terminal-start", + "instance-a", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement( + app.Vector(9, 8, 7), + app.Rotation(w_axis=app.Vector(0, 0, 1)), + ) + device.addObject(end_terminal) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-a", + "terminal-end", + "instance-a", + label="End", + ) + + controller = panel.ManualWiringController(terminal_exit_length=10.0) + + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(10, 20, 30)], + SubObjects=[ + types.SimpleNamespace( + ShapeType="Face", + normalAt=lambda u, v: app.Vector(1, 0, 0), + ) + ], + SubElementNames=["Face1"], + Object=types.SimpleNamespace(Name="CabinetFace", Label="柜体面"), + ) + ] + controller.add_waypoint_from_selection() + + selection_state["selection"] = [end_terminal] + wire = controller.set_end_from_selection_and_generate() + + routed_group = doc.getObject("QETWiring_04_Routed") + self.assertIsNotNone(routed_group) + self.assertIn(wire, routed_group.Group) + self.assertEqual("terminal-start", getattr(wire, "QetStartTerminalUuid", "")) + self.assertEqual("terminal-end", getattr(wire, "QetEndTerminalUuid", "")) + self.assertEqual(5, len(getattr(wire, "Points", []))) + self.assertEqual( + (1.0, 12.0, 3.0), + ( + wire.Points[1].x, + wire.Points[1].y, + wire.Points[1].z, + ), + ) + self.assertEqual((10.0, 20.0, 30.0), (wire.Points[2].x, wire.Points[2].y, wire.Points[2].z)) + self.assertEqual((9.0, 8.0, 17.0), (wire.Points[3].x, wire.Points[3].y, wire.Points[3].z)) + self.assertEqual((9.0, 8.0, 7.0), (wire.Points[4].x, wire.Points[4].y, wire.Points[4].z)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_template_instantiation_test.py b/tests/python/freecad_exchange_template_instantiation_test.py index d31aaf4..0fb6a3f 100644 --- a/tests/python/freecad_exchange_template_instantiation_test.py +++ b/tests/python/freecad_exchange_template_instantiation_test.py @@ -160,9 +160,35 @@ class TemplateInstantiationTest(unittest.TestCase): self.assertEqual("inst-1", terminals[0].QetInstanceId) self.assertEqual("ct-1", terminals[0].QetElementUuid) self.assertEqual("P1", terminals[0].QetTemplateSlotName) + self.assertEqual("local", terminals[0].QetTerminalBindingMode) self.assertTrue(terminals[0].CanWire) self.assertFalse(p1.ViewObject.Visibility) + def test_device_without_template_slots_reports_no_created_terminals(self): + _install_fake_freecad() + template_instantiation, terminal_objects = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_ct_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "ct-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "inst-1") + terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1") + + report = template_instantiation.ensure_engineering_terminals_for_device(doc, device) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + + self.assertEqual(0, report["slots"]) + self.assertEqual(0, report["created_terminals"]) + self.assertEqual(1, report["skipped_devices_without_template_slots"]) + self.assertIn("没有模板端子", report["warnings"][0]) + self.assertEqual([], terminal_objects.collect_terminal_objects(terminal_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 index 14a34bc..97e4021 100644 --- a/tests/python/freecad_exchange_template_semantics_test.py +++ b/tests/python/freecad_exchange_template_semantics_test.py @@ -154,6 +154,41 @@ class TemplateSemanticsRotationTest(unittest.TestCase): self.assertEqual(1.0, slots[0]["rotation"]["axis"].z) +class TerminalSlotResolutionPolicyTest(unittest.TestCase): + def test_resolve_terminal_slots_returns_empty_when_model_has_no_template_slots(self): + _install_fake_freecad() + template_semantics, _ = _reload_exchange_modules() + + container = types.SimpleNamespace(Group=[]) + + slots = template_semantics.resolve_terminal_slots(container, "", 2) + + self.assertEqual([], slots) + + def test_resolve_terminal_slots_does_not_pad_template_hints_with_bbox_fallback(self): + _install_fake_freecad() + template_semantics, _ = _reload_exchange_modules() + + fake_lcs = types.SimpleNamespace( + Name="Terminal_P1", + Label="P1", + TypeId="Part::LocalCoordinateSystem", + Role="Terminal", + QetTemplateSlotName="P1", + Placement=types.SimpleNamespace( + Base=sys.modules["FreeCAD"].Vector(10, 20, 30), + Rotation=sys.modules["FreeCAD"].Rotation(), + ), + ) + container = types.SimpleNamespace(Group=[fake_lcs]) + + slots = template_semantics.resolve_terminal_slots(container, "", 2) + + self.assertEqual(1, len(slots)) + self.assertEqual("P1", slots[0]["name"]) + self.assertNotEqual("fallback", slots[0].get("source")) + + class TerminalPlacementTest(unittest.TestCase): def test_slot_placement_uses_rotation_metadata(self): _install_fake_freecad() diff --git a/tests/python/freecad_exchange_terminal_import_template_slots_test.py b/tests/python/freecad_exchange_terminal_import_template_slots_test.py new file mode 100644 index 0000000..2685e36 --- /dev/null +++ b/tests/python/freecad_exchange_terminal_import_template_slots_test.py @@ -0,0 +1,290 @@ +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: + pass + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or 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_device_preview = types.ModuleType("DevicePreview") + fake_device_preview.find_main_exchange_document = lambda *args, **kwargs: None + sys.modules["DevicePreview"] = fake_device_preview + + +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.InList = [] + self.ViewObject = FakeViewObject() + self.Placement = sys.modules["FreeCAD"].Placement() + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId in {"App::DocumentObjectGroup", "App::Part"} + 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) + if self not in child.InList: + child.InList.append(self) + + +class FakeDocument: + def __init__(self): + self.Name = "QETScene" + self.Objects = [] + + 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 [ + "DeviceImport", + "TemplateSemantics", + "TerminalImport", + "TerminalObjects", + ]: + sys.modules.pop(name, None) + terminal_import = importlib.import_module("TerminalImport") + terminal_objects = importlib.import_module("TerminalObjects") + device_import = importlib.import_module("DeviceImport") + return terminal_import, terminal_objects, device_import + + +class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): + def test_import_skips_terminal_when_device_has_no_template_slots(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "element_uuid": "device-a", + "instance_id": "instance-a", + } + ], + "terminals": [ + { + "terminal_uuid": "terminal-a", + "element_uuid": "device-a", + "instance_id": "instance-a", + } + ], + } + ) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + + self.assertEqual(0, report["imported_terminals"]) + self.assertEqual(1, report.get("skipped_missing_slot")) + self.assertEqual([], terminal_objects.collect_terminal_objects(terminal_group)) + + def test_import_uses_slot_name_hint_to_match_template_slots(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + + slot_p1 = doc.addObject("Part::LocalCoordinateSystem", "Terminal_P1") + slot_p1.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation()) + slot_p1.addProperty("App::PropertyString", "Role", "QET Template", "") + slot_p1.Role = "Terminal" + slot_p1.addProperty("App::PropertyBool", "CanWire", "QET Template", "") + slot_p1.CanWire = True + slot_p1.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + slot_p1.QetTemplateSlotName = "P1" + slot_p1.addProperty("App::PropertyString", "QetTerminalLabel", "QET Template", "") + slot_p1.QetTerminalLabel = "P1" + device.addObject(slot_p1) + + slot_p2 = doc.addObject("Part::LocalCoordinateSystem", "Terminal_P2") + slot_p2.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + slot_p2.addProperty("App::PropertyString", "Role", "QET Template", "") + slot_p2.Role = "Terminal" + slot_p2.addProperty("App::PropertyBool", "CanWire", "QET Template", "") + slot_p2.CanWire = True + slot_p2.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") + slot_p2.QetTemplateSlotName = "P2" + slot_p2.addProperty("App::PropertyString", "QetTerminalLabel", "QET Template", "") + slot_p2.QetTerminalLabel = "P2" + device.addObject(slot_p2) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "element_uuid": "device-a", + "instance_id": "instance-a", + } + ], + "terminals": [ + { + "terminal_uuid": "terminal-p2", + "element_uuid": "device-a", + "instance_id": "instance-a", + "slot_name_hint": "P2", + "terminal_label": "P2", + }, + { + "terminal_uuid": "terminal-p1", + "element_uuid": "device-a", + "instance_id": "instance-a", + "slot_name_hint": "P1", + "terminal_label": "P1", + }, + ], + } + ) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + terminals = { + getattr(obj, "QetTerminalUuid", ""): obj + for obj in terminal_objects.collect_terminal_objects(terminal_group) + } + + self.assertEqual(2, report["imported_terminals"]) + self.assertEqual(2, len(terminals)) + self.assertEqual(20.0, terminals["terminal-p2"].Placement.Base.x) + self.assertEqual(10.0, terminals["terminal-p1"].Placement.Base.x) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_terminal_objects_test.py b/tests/python/freecad_exchange_terminal_objects_test.py new file mode 100644 index 0000000..dcce4af --- /dev/null +++ b/tests/python/freecad_exchange_terminal_objects_test.py @@ -0,0 +1,135 @@ +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, transform=None): + self._transform = transform + + def multVec(self, vector): + if self._transform is None: + return Vector(vector.x, vector.y, vector.z) + return self._transform(vector) + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or Rotation() + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + sys.modules["FreeCAD"] = fake_freecad + + +class FakeObject: + def __init__(self, name, type_id="App::DocumentObjectGroup"): + self.Name = name + self.TypeId = type_id + self.Group = [] + self.InList = [] + self.Placement = sys.modules["FreeCAD"].Placement() + self.PropertiesList = [] + self.ViewObject = types.SimpleNamespace(Visibility=True) + + def addObject(self, child): + if child not in self.Group: + self.Group.append(child) + if self not in child.InList: + child.InList.append(self) + + def addProperty(self, prop_type, prop_name, group_name, description): + if prop_name not in self.PropertiesList: + self.PropertiesList.append(prop_name) + + 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 _reload_module(): + sys.modules.pop("TerminalObjects", None) + return importlib.import_module("TerminalObjects") + + +class TerminalDirectionTest(unittest.TestCase): + def test_terminal_direction_uses_terminal_and_parent_rotation(self): + _install_fake_freecad() + terminal_objects = _reload_module() + app = sys.modules["FreeCAD"] + + terminal_rotates_z_to_x = app.Rotation( + lambda vector: app.Vector(vector.z, vector.y, -vector.x) + ) + parent_rotates_x_to_y = app.Rotation( + lambda vector: app.Vector(-vector.y, vector.x, vector.z) + ) + parent = FakeObject("QETDevice_ct_1") + parent.Placement = app.Placement(app.Vector(100, 0, 0), parent_rotates_x_to_y) + terminal = FakeObject("Terminal_P1", "Part::LocalCoordinateSystem") + terminal.Placement = app.Placement(app.Vector(10, 0, 0), terminal_rotates_z_to_x) + parent.addObject(terminal) + + direction = terminal_objects.terminal_direction(terminal) + + self.assertAlmostEqual(0.0, direction.x) + self.assertAlmostEqual(1.0, direction.y) + self.assertAlmostEqual(0.0, direction.z) + + +class TemplateTerminalVisibilityTest(unittest.TestCase): + def test_hide_template_terminal_hints_hides_template_lcs_but_keeps_engineering_terminal_visible(self): + _install_fake_freecad() + terminal_objects = _reload_module() + + container = FakeObject("QETDevice_ct_1") + + template_terminal = FakeObject("D1", "Part::LocalCoordinateSystem") + template_terminal.Role = "Terminal" + template_terminal.CanWire = True + template_terminal.PropertiesList = ["Role", "CanWire", "QetTemplateSlotName"] + container.addObject(template_terminal) + + engineering_terminal = FakeObject("QETTerminal_D1", "Part::LocalCoordinateSystem") + terminal_objects.set_terminal_semantics( + engineering_terminal, + "project-1", + "device-1", + "terminal-1", + "instance-1", + label="D1", + slot_name="D1", + ) + container.addObject(engineering_terminal) + + hidden = terminal_objects.hide_template_terminal_hints(container) + + self.assertEqual(1, hidden) + self.assertFalse(template_terminal.ViewObject.Visibility) + self.assertTrue(engineering_terminal.ViewObject.Visibility) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_wiring_test.py b/tests/python/freecad_exchange_wiring_test.py new file mode 100644 index 0000000..144f40e --- /dev/null +++ b/tests/python/freecad_exchange_wiring_test.py @@ -0,0 +1,354 @@ +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, w_axis=None): + self.Axis = axis + self.Angle = angle + self.WAxis = w_axis + + def multVec(self, vector): + if self.WAxis is not None and vector.z == 1: + return self.WAxis + return vector + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or Rotation() + + def multVec(self, vector): + return Vector( + self.Base.x + vector.x, + self.Base.y + vector.y, + self.Base.z + vector.z, + ) + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + fake_freecad.ActiveDocument = None + fake_freecad.GuiUp = True + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + PrintLog=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None + fake_freecadgui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=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 FakeDraftWire: + def __init__(self, obj): + obj.addProperty("App::PropertyVectorList", "Points", "Draft", "Wire points") + + fake_draft = types.ModuleType("Draft") + + def make_wire(points, closed=False, placement=None, face=None, support=None, bs2wire=False): + doc = fake_freecad.ActiveDocument + obj = doc.addObject("Part::FeaturePython", "Wire") + obj.Points = list(points) + obj.Closed = bool(closed) + obj.AttachmentSupport = support + obj.Placement = placement or fake_freecad.Placement() + FakeDraftWire(obj) + return obj + + fake_draft.make_wire = make_wire + sys.modules["Draft"] = fake_draft + + +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 + self.Points = [] + self.Placement = sys.modules["FreeCAD"].Placement() + self.InList = [] + + 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) + if self not in child.InList: + child.InList.append(self) + + +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", "WiringObjects", "ManualWiring", "ExchangeWriteBack"]: + sys.modules.pop(name, None) + import TerminalObjects + import WiringObjects + import ManualWiring + import ExchangeWriteBack + return TerminalObjects, WiringObjects, ManualWiring, ExchangeWriteBack + + +class WiringTest(unittest.TestCase): + def test_ensure_wiring_root_group_creates_scene_buckets(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _manual_wiring, _write_back = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + wiring_root = wiring_objects.ensure_wiring_root_group(doc, "project-1") + + self.assertIn(wiring_root, root.Group) + self.assertEqual("QETWiring", wiring_root.Name) + self.assertEqual("QET Wiring", wiring_root.Label) + self.assertIsNotNone(doc.getObject("QETWiring_01_Tasks")) + self.assertIsNotNone(doc.getObject("QETWiring_04_Routed")) + + def test_initialize_wiring_scene_creates_root_and_hides_legacy_wire_groups(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _manual_wiring, _write_back = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a") + + legacy_group = terminal_objects.ensure_wire_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-a", + ) + legacy_group.ViewObject.Visibility = True + + wiring_root = wiring_objects.initialize_wiring_scene(doc, "project-1") + + self.assertEqual("QETWiring", wiring_root.Name) + self.assertIsNotNone(doc.getObject("QETWiring_04_Routed")) + self.assertFalse(legacy_group.ViewObject.Visibility) + + def test_create_manual_wire_preserves_manual_waypoints_as_direct_segments(self): + _install_fake_freecad() + terminal_objects, wiring_objects, manual_wiring, _write_back = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + root = terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_wiring_root_group(doc, "project-1") + + start_device = doc.addObject("App::DocumentObjectGroup", "QETDevice_start") + root.addObject(start_device) + terminal_objects.ensure_string_property( + start_device, + "QetElementUuid", + "QET Exchange", + "", + "device-start", + ) + terminal_objects.ensure_string_property( + start_device, + "QetInstanceId", + "QET Exchange", + "", + "instance-start", + ) + terminal_objects.ensure_string_property( + start_device, + "QetProjectUuid", + "QET Exchange", + "", + "project-1", + ) + + end_device = doc.addObject("App::DocumentObjectGroup", "QETDevice_end") + root.addObject(end_device) + terminal_objects.ensure_string_property( + end_device, + "QetElementUuid", + "QET Exchange", + "", + "device-end", + ) + terminal_objects.ensure_string_property( + end_device, + "QetInstanceId", + "QET Exchange", + "", + "instance-end", + ) + terminal_objects.ensure_string_property( + end_device, + "QetProjectUuid", + "QET Exchange", + "", + "project-1", + ) + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement( + app.Vector(1, 2, 3), + app.Rotation(w_axis=app.Vector(0, 1, 0)), + ) + start_device.addObject(start_terminal) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement( + app.Vector(9, 8, 7), + app.Rotation(w_axis=app.Vector(0, 0, 1)), + ) + end_device.addObject(end_terminal) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(4, 5, 6), + "support_axis": "x", + "anchor_kind": "face", + "source_label": "柜体面", + } + ], + terminal_exit_length=20.0, + ) + + routed_group = doc.getObject("QETWiring_04_Routed") + self.assertIsNotNone(routed_group) + self.assertIn(wire, routed_group.Group) + self.assertEqual("Manual", getattr(wire, "RouteType", "")) + self.assertEqual("terminal-start", getattr(wire, "QetStartTerminalUuid", "")) + self.assertEqual("terminal-end", getattr(wire, "QetEndTerminalUuid", "")) + self.assertEqual(5, len(getattr(wire, "Points", []))) + self.assertEqual((1.0, 22.0, 3.0), (wire.Points[1].x, wire.Points[1].y, wire.Points[1].z)) + self.assertTrue(any(point.x == 4.0 and point.y == 5.0 and point.z == 6.0 for point in wire.Points)) + self.assertEqual((4.0, 5.0, 6.0), (wire.Points[2].x, wire.Points[2].y, wire.Points[2].z)) + self.assertEqual((9.0, 8.0, 27.0), (wire.Points[3].x, wire.Points[3].y, wire.Points[3].z)) + self.assertEqual((9.0, 8.0, 7.0), (wire.Points[4].x, wire.Points[4].y, wire.Points[4].z)) + self.assertIn("QetManualWaypointsJson", getattr(wire, "PropertiesList", [])) + self.assertIn('"support_axis": "x"', getattr(wire, "QetManualWaypointsJson", "")) + + def test_wire_writeback_serializes_scene_routed_wire(self): + _install_fake_freecad() + terminal_objects, wiring_objects, manual_wiring, write_back = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + wiring_objects.ensure_wiring_root_group(doc, "project-1") + routed_group = doc.getObject("QETWiring_04_Routed") + wire = doc.addObject("Part::Feature", "QETWire_terminal_start_terminal_end") + wire.Shape = [ + sys.modules["FreeCAD"].Vector(1, 2, 3), + sys.modules["FreeCAD"].Vector(4, 5, 6), + ] + terminal_objects.ensure_string_property(wire, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_objects.ensure_string_property(wire, "QetStartTerminalUuid", "QET Exchange", "", "terminal-start") + terminal_objects.ensure_string_property(wire, "QetEndTerminalUuid", "QET Exchange", "", "terminal-end") + terminal_objects.ensure_string_property(wire, "QetStartInstanceId", "QET Exchange", "", "instance-start") + terminal_objects.ensure_string_property(wire, "QetEndInstanceId", "QET Exchange", "", "instance-end") + terminal_objects.ensure_string_property(wire, "RouteType", "QET Exchange", "", "Manual") + routed_group.addObject(wire) + + report = write_back.write_back_document(doc, scene_path=r"D:\tmp\scene.FCStd", payload={"project_uuid": "project-1"}) + + self.assertEqual(1, len(report["manual_wires"])) + self.assertEqual("terminal-start", report["manual_wires"][0]["start_terminal_uuid"]) + self.assertEqual(2, len(report["manual_wires"][0]["points"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/freecad_exchange_writeback_test.py b/tests/python/freecad_exchange_writeback_test.py new file mode 100644 index 0000000..bee459f --- /dev/null +++ b/tests/python/freecad_exchange_writeback_test.py @@ -0,0 +1,252 @@ +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) + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + fake_freecad.addDocumentObserver = lambda observer: None + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + sys.modules["FreeCADGui"] = fake_freecadgui + + fake_importgui = types.ModuleType("ImportGui") + fake_importgui.insert = lambda *args, **kwargs: None + sys.modules["ImportGui"] = fake_importgui + + fake_device_preview = types.ModuleType("DevicePreview") + fake_device_preview.find_main_exchange_document = lambda preferred_name="": None + sys.modules["DevicePreview"] = fake_device_preview + + +class FakeObject: + def __init__(self, name, type_id="App::DocumentObjectGroup"): + self.Name = name + self.Label = name + self.TypeId = type_id + self.PropertiesList = [] + self.Group = [] + self.InList = [] + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId in {"App::DocumentObjectGroup", "App::Part"} + 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) + if self not in child.InList: + child.InList.append(self) + + +class FakeDocument: + def __init__(self): + self.Name = "QETScene" + self.FileName = "" + self.Objects = [] + + 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 _reload_writeback(): + for name in ["DeviceImport", "TerminalObjects", "ExchangeWriteBack"]: + sys.modules.pop(name, None) + return importlib.import_module("ExchangeWriteBack"), importlib.import_module("TerminalObjects") + + +class ExchangeWriteBackManualWireTest(unittest.TestCase): + def test_write_back_skips_local_terminal_bindings(self): + _install_fake_freecad() + exchange_writeback, terminal_objects = _reload_writeback() + doc = FakeDocument() + + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_device_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-1") + + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-1", + ) + qet_terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_real") + terminal_group.addObject(qet_terminal) + terminal_objects.set_terminal_semantics( + qet_terminal, + "project-1", + "device-1", + "terminal-real", + "instance-1", + ) + local_terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_local") + terminal_group.addObject(local_terminal) + terminal_objects.set_terminal_semantics( + local_terminal, + "project-1", + "device-1", + "local:instance-1:P1", + "instance-1", + ) + + with tempfile.TemporaryDirectory() as temp_dir: + scene_path = str(Path(temp_dir) / "scene.FCStd") + report = exchange_writeback.write_back_document( + doc, + scene_path=scene_path, + payload={"project_uuid": "project-1"}, + ) + payload = json.loads(Path(report["output_path"]).read_text(encoding="utf-8")) + + self.assertEqual( + [{"terminal_uuid": "terminal-real", "instance_id": "instance-1"}], + report["terminals"], + ) + self.assertEqual(report["terminals"], payload["terminals"]) + + def test_write_back_includes_manual_wires_with_route_points(self): + _install_fake_freecad() + exchange_writeback, terminal_objects = _reload_writeback() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_device_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-1") + + wire_group = terminal_objects.ensure_wire_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-1", + ) + wire = doc.addObject("Part::Feature", "QETWire_terminal_1_terminal_2") + wire_group.addObject(wire) + terminal_objects.ensure_string_property(wire, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_objects.ensure_string_property(wire, "QetStartTerminalUuid", "QET Exchange", "", "terminal-1") + terminal_objects.ensure_string_property(wire, "QetEndTerminalUuid", "QET Exchange", "", "terminal-2") + terminal_objects.ensure_string_property(wire, "QetStartInstanceId", "QET Exchange", "", "instance-1") + terminal_objects.ensure_string_property(wire, "QetEndInstanceId", "QET Exchange", "", "instance-2") + terminal_objects.ensure_string_property(wire, "RouteType", "QET Exchange", "", "Manual") + wire.Shape = ( + app.Vector(1, 2, 3), + app.Vector(4, 5, 6), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + scene_path = str(Path(temp_dir) / "scene.FCStd") + report = exchange_writeback.write_back_document( + doc, + scene_path=scene_path, + payload={"project_uuid": "project-1"}, + ) + payload = json.loads(Path(report["output_path"]).read_text(encoding="utf-8")) + + self.assertEqual(1, len(report["manual_wires"])) + self.assertEqual(1, len(payload["manual_wires"])) + self.assertEqual( + { + "start_terminal_uuid": "terminal-1", + "end_terminal_uuid": "terminal-2", + "start_instance_id": "instance-1", + "end_instance_id": "instance-2", + "route_type": "Manual", + "points": [ + {"x": 1.0, "y": 2.0, "z": 3.0}, + {"x": 4.0, "y": 5.0, "z": 6.0}, + ], + }, + payload["manual_wires"][0], + ) + + def test_write_back_skips_manual_wires_with_local_terminal_endpoints(self): + _install_fake_freecad() + exchange_writeback, terminal_objects = _reload_writeback() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::Part", "QETDevice_device_1") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-1") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-1") + + wire_group = terminal_objects.ensure_wire_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-1", + ) + wire = doc.addObject("Part::Feature", "QETWire_local_terminal") + wire_group.addObject(wire) + terminal_objects.ensure_string_property(wire, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_objects.ensure_string_property(wire, "QetStartTerminalUuid", "QET Exchange", "", "local:instance-1:P1") + terminal_objects.ensure_string_property(wire, "QetEndTerminalUuid", "QET Exchange", "", "terminal-2") + terminal_objects.ensure_string_property(wire, "QetStartInstanceId", "QET Exchange", "", "instance-1") + terminal_objects.ensure_string_property(wire, "QetEndInstanceId", "QET Exchange", "", "instance-2") + terminal_objects.ensure_string_property(wire, "RouteType", "QET Exchange", "", "Manual") + wire.Shape = ( + app.Vector(1, 2, 3), + app.Vector(4, 5, 6), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + scene_path = str(Path(temp_dir) / "scene.FCStd") + report = exchange_writeback.write_back_document( + doc, + scene_path=scene_path, + payload={"project_uuid": "project-1"}, + ) + payload = json.loads(Path(report["output_path"]).read_text(encoding="utf-8")) + + self.assertEqual([], report["manual_wires"]) + self.assertEqual([], payload["manual_wires"]) + + +if __name__ == "__main__": + unittest.main()