feature/FreeCAD手动布线与回写-zwl-0525

dev
Zhaowenlong 2 days ago
parent ad3e17234a
commit 076f20b2e8

@ -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_<element_uuid>
imported model objects
QETTerminals_<element_uuid>
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:<instance_id>:<slot_name>
```
这种端子可以用于 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 本地布线。

@ -130,11 +130,11 @@ STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电
> 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。 > 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。
所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级: 所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。当前正式方案采用下面的优先级:
1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。
2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。 2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。
3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置 3. 没有模板端子时,不自动创建正式工程端子
sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。 sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。
@ -142,6 +142,8 @@ sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotatio
FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。 FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。
包围盒 fallback 只能作为历史验证思路或调试函数存在,不再作为正式工程端子的生成依据。原因是包围盒猜点无法保证端子落在真实接线位置,容易导致后续手动布线和自动布线都建立在错误坐标上。
### 3.1 FCStd 设备模板制作约定 ### 3.1 FCStd 设备模板制作约定
FCStd 设备模板用于解决“这个模型本身就带端子语义”的问题。模板端子是跨工程复用的槽位,不绑定某个具体工程里的 `terminal_uuid` FCStd 设备模板用于解决“这个模型本身就带端子语义”的问题。模板端子是跨工程复用的槽位,不绑定某个具体工程里的 `terminal_uuid`
@ -224,6 +226,64 @@ zwl/QET 侧只需要支持 `.FCStd` 的选择、复制、保存和导出路径
详细设计见: 详细设计见:
- `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` - `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:<instance_id>:<slot_name>` 作为本地端子 UUID。
`local:*` 端子可以用于 3D 手动布线,但不能当作 QET 2D 端子的可靠回写依据。要准确回写 2D仍需要 zh/QET 侧导出真实 `terminal_uuid + instance_id` 绑定。
## 4. 为什么要先落地设备模板 ## 4. 为什么要先落地设备模板
@ -318,7 +378,8 @@ FreeCADExchange/
TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位
TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板
TemplateAuthoringPanel.py # 新增CAD 人员使用的端子制作任务面板 TemplateAuthoringPanel.py # 新增CAD 人员使用的端子制作任务面板
TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalImport.py # 新增:根据 terminals 创建/更新真实工程端子对象
TemplateInstantiation.py # 新增:把模板端子实例化为工程端子,支持本地 local:* 端子
TerminalObjects.py # 新增:端子对象属性、查找、校验工具 TerminalObjects.py # 新增:端子对象属性、查找、校验工具
ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性
ExchangeWriteBack.py # 新增:生成 3d_to_2d.json ExchangeWriteBack.py # 新增:生成 3d_to_2d.json
@ -361,7 +422,7 @@ ManualWiring.py
- `QET_Template_AddTerminal` - `QET_Template_AddTerminal`
- `QET_Template_SaveAsFCStd` - `QET_Template_SaveAsFCStd`
- 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。 - 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。
- 第一版端子方向默认使用单位旋转,后续再补出线方向编辑 - 第一版允许先使用默认方向,但正式设备模板应补齐出线方向;当前约定 LCS 本地 +Z 为出线方向
模板端子属性: 模板端子属性:
@ -377,7 +438,7 @@ ManualWiring.py
- 添加 `P1`、`P2` 两个端子。 - 添加 `P1`、`P2` 两个端子。
- 保存为 `电流互感器.FCStd` - 保存为 `电流互感器.FCStd`
- 重新打开该 FCStd 后,端子 LCS 仍存在,属性仍存在。 - 重新打开该 FCStd 后,端子 LCS 仍存在,属性仍存在。
- 在项目导入流程中引用该 FCStd端子位置优先来自模板 LCS而不是 bbox fallback。 - 在项目导入流程中引用该 FCStd工程端子位置来自模板 LCS不再依赖 bbox fallback。
### 阶段 A本地模板导入基线 ### 阶段 A本地模板导入基线
@ -417,10 +478,10 @@ ManualWiring.py
端子位置策略: 端子位置策略:
1. 如果 FCStd 模板已有 `Role="Terminal"` 的 LCS则优先复用模板 LCS。 1. 如果 FCStd 模板已有 `Role="Terminal"` 的 LCS则优先复用模板 LCS。
2. 如果有 sidecar JSON则按 sidecar 坐标创建。 2. 如果有 sidecar JSON只作为过渡方案按 sidecar 坐标创建。
3. 如果只有 STEP则先按设备包围盒生成临时端子排列用于打通流程 3. 如果没有模板端子,也没有 sidecar不自动创建正式工程端子
临时端子排列只用于第一版验证,不作为长期物理端子定义 包围盒临时端子只作为历史调试思路,不再作为正式工程端子的创建依据
验收: 验收:
@ -542,7 +603,7 @@ ManualWiring.py
- 可选 sidecar只作为过渡或校验不作为正式交付优先方案 - 可选 sidecar只作为过渡或校验不作为正式交付优先方案
- 模板说明:原点、朝向、尺寸单位、端子数量 - 模板说明:原点、朝向、尺寸单位、端子数量
常用设备建议优先补齐 `FCStd LCS`把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 常用设备建议优先补齐 `FCStd LCS`让工程端子始终来自真实可用的模板端子坐标。
## 10. 单人开发优先级 ## 10. 单人开发优先级
@ -598,4 +659,9 @@ ManualWiring.py
- 2026-05-20修复 `TemplateAuthoring.py``FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。 - 2026-05-20修复 `TemplateAuthoring.py``FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。
- 2026-05-20确定方案 2 开发目标:新增“设备模板端子制作”任务面板,让 CAD 工作人员通过输入端子名、选择模型位置、点击按钮完成添加端子、校验端子和保存 FCStd不再依赖 Python 控制台。 - 2026-05-20确定方案 2 开发目标:新增“设备模板端子制作”任务面板,让 CAD 工作人员通过输入端子名、选择模型位置、点击按钮完成添加端子、校验端子和保存 FCStd不再依赖 Python 控制台。
- 2026-05-20新增 `TemplateAuthoringPanel.py`,提供“设备模板端子制作”任务面板和 `QET_Template_OpenAuthoringPanel` 命令;面板支持输入端子名、添加端子、校验端子、保存 FCStd并已同步到运行目录验证模块可导入。 - 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 单元测试验证反序端子导入也能准确落到对应模板槽位。
``` ```

@ -11,8 +11,10 @@ set(FreeCADExchange_Scripts
TemplateAuthoringPanel.py TemplateAuthoringPanel.py
TemplateInstantiation.py TemplateInstantiation.py
TerminalImport.py TerminalImport.py
WiringObjects.py
ExchangeWriteBack.py ExchangeWriteBack.py
ManualWiring.py ManualWiring.py
ManualWiringPanel.py
) )
add_custom_target(FreeCADExchangeScripts ALL add_custom_target(FreeCADExchangeScripts ALL

@ -6,6 +6,7 @@ import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import ImportGui import ImportGui
import DevicePreview import DevicePreview
import TerminalObjects
ROOT_GROUP_NAME = "QETExchangeDevices" 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: for obj in top_level_objects:
if obj not in getattr(device_group, "Group", []): if obj not in getattr(device_group, "Group", []):
device_group.addObject(obj) device_group.addObject(obj)
TerminalObjects.hide_template_terminal_hints(device_group)
return top_level_objects return top_level_objects
@ -473,6 +475,7 @@ def _import_fcstd_into_group(doc, device_group, model_path):
device_group.addObject(copied_obj) device_group.addObject(copied_obj)
copied_objects.append(copied_obj) copied_objects.append(copied_obj)
TerminalObjects.hide_template_terminal_hints(device_group)
return copied_objects return copied_objects
finally: finally:
if should_close and source_doc is not None: if should_close and source_doc is not None:

@ -15,6 +15,10 @@ try:
import TerminalImport import TerminalImport
except Exception: except Exception:
TerminalImport = None TerminalImport = None
try:
import WiringObjects
except Exception:
WiringObjects = None
try: try:
from PySide6 import QtCore, QtWidgets 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): def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None):
lines = [ lines = [
"QET exchange file loaded successfully.", "QET exchange file loaded successfully.",
@ -759,6 +779,8 @@ def _run_scheduled_device_import(attempt=0):
setattr(App, STATE_TERMINAL_IMPORT_REPORT, terminal_report) setattr(App, STATE_TERMINAL_IMPORT_REPORT, terminal_report)
_initialize_wiring_scene(payload)
if ExchangeWriteBack is None: if ExchangeWriteBack is None:
_append_debug_log("write-back skipped: ExchangeWriteBack module unavailable") _append_debug_log("write-back skipped: ExchangeWriteBack module unavailable")
writeback_report = None writeback_report = None

@ -7,9 +7,18 @@ from pathlib import Path
import FreeCAD as App import FreeCAD as App
import DeviceImport
import TerminalObjects as TerminalObjects import TerminalObjects as TerminalObjects
try:
import DeviceImport
except Exception:
DeviceImport = None
try:
import WiringObjects
except ImportError:
WiringObjects = None
try: try:
import FreeCADGui as Gui import FreeCADGui as Gui
except ImportError: except ImportError:
@ -24,6 +33,8 @@ class ExchangeWriteBackError(RuntimeError):
def _append_debug_log(message): def _append_debug_log(message):
if DeviceImport is None:
return
try: try:
DeviceImport._append_debug_log(message) DeviceImport._append_debug_log(message)
except Exception: except Exception:
@ -49,7 +60,7 @@ def _is_device_group(obj):
if obj is None: if obj is None:
return False return False
try: try:
if not obj.Name.startswith(DeviceImport.DEVICE_GROUP_PREFIX): if not obj.Name.startswith(TerminalObjects.DEVICE_GROUP_PREFIX):
return False return False
return "QetElementUuid" in getattr(obj, "PropertiesList", []) return "QetElementUuid" in getattr(obj, "PropertiesList", [])
except Exception: except Exception:
@ -79,6 +90,44 @@ def _iter_terminal_objects(device_group):
return TerminalObjects.collect_terminal_objects(terminal_container) 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=""): def _scene_path_from_doc(doc, scene_path=""):
candidate = (scene_path or "").strip() candidate = (scene_path or "").strip()
if candidate: if candidate:
@ -143,6 +192,8 @@ def _collect_terminal_bindings(doc):
for terminal_obj in _iter_terminal_objects(device_group): for terminal_obj in _iter_terminal_objects(device_group):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip() terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id 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: if not terminal_uuid or not terminal_instance_id:
continue continue
key = (terminal_uuid, terminal_instance_id) key = (terminal_uuid, terminal_instance_id)
@ -158,6 +209,104 @@ def _collect_terminal_bindings(doc):
return bindings 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): def _project_uuid_from_doc(doc, payload=None):
root = _root_group(doc) root = _root_group(doc)
if root is not None: if root is not None:
@ -192,6 +341,7 @@ def write_back_document(doc=None, scene_path="", payload=None):
"generated_at": _format_timestamp(), "generated_at": _format_timestamp(),
"instances": _collect_instance_bindings(doc), "instances": _collect_instance_bindings(doc),
"terminals": _collect_terminal_bindings(doc), "terminals": _collect_terminal_bindings(doc),
"manual_wires": _collect_manual_wires(doc),
"output_path": output_path, "output_path": output_path,
} }
@ -205,6 +355,7 @@ def write_back_document(doc=None, scene_path="", payload=None):
"generated_at": report["generated_at"], "generated_at": report["generated_at"],
"instances": report["instances"], "instances": report["instances"],
"terminals": report["terminals"], "terminals": report["terminals"],
"manual_wires": report["manual_wires"],
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,
@ -213,9 +364,10 @@ def write_back_document(doc=None, scene_path="", payload=None):
) )
_append_debug_log( _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["instances"]),
len(report["terminals"]), len(report["terminals"]),
len(report["manual_wires"]),
output_path, output_path,
) )
) )
@ -287,9 +439,10 @@ class CommandWriteBack:
report = write_back_document(App.ActiveDocument) report = write_back_document(App.ActiveDocument)
try: try:
App.Console.PrintMessage( 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["instances"]),
len(report["terminals"]), len(report["terminals"]),
len(report["manual_wires"]),
) )
) )
except Exception: except Exception:

@ -15,6 +15,7 @@ COMMANDS = [
"QET_Template_ImportInstance", "QET_Template_ImportInstance",
"QET_Template_CreateEngineeringTerminals", "QET_Template_CreateEngineeringTerminals",
"QET_Exchange_CreateManualWire", "QET_Exchange_CreateManualWire",
"QET_Exchange_OpenManualWiringPanel",
] ]
@ -63,6 +64,7 @@ def _register_exchange_commands(
): ):
exchange_write_back = safe_import("ExchangeWriteBack") exchange_write_back = safe_import("ExchangeWriteBack")
manual_wiring = safe_import("ManualWiring") manual_wiring = safe_import("ManualWiring")
manual_wiring_panel = safe_import("ManualWiringPanel")
template_authoring = safe_import("TemplateAuthoring") template_authoring = safe_import("TemplateAuthoring")
template_authoring_panel = safe_import("TemplateAuthoringPanel") template_authoring_panel = safe_import("TemplateAuthoringPanel")
template_instantiation = safe_import("TemplateInstantiation") 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: try:
if template_authoring is not None: if template_authoring is not None:
template_authoring.register_commands() template_authoring.register_commands()

@ -1,5 +1,7 @@
# FreeCADExchange manual wiring helpers. # FreeCADExchange manual wiring helpers.
import json
import FreeCAD as App import FreeCAD as App
try: try:
@ -7,8 +9,13 @@ try:
except ImportError: except ImportError:
Gui = None Gui = None
import DeviceImport
import TerminalObjects as TerminalObjects import TerminalObjects as TerminalObjects
import WiringObjects
try:
import DeviceImport
except Exception:
DeviceImport = None
class ManualWiringError(RuntimeError): class ManualWiringError(RuntimeError):
@ -16,72 +23,334 @@ class ManualWiringError(RuntimeError):
def _append_debug_log(message): def _append_debug_log(message):
if DeviceImport is None:
return
try: try:
DeviceImport._append_debug_log(message) DeviceImport._append_debug_log(message)
except Exception: except Exception:
pass pass
def _terminal_points(start_terminal, end_terminal, waypoints=None): def _vector_from_point(point):
points = [TerminalObjects.terminal_origin(start_terminal)] if isinstance(point, App.Vector):
for point in waypoints or []: return point
if isinstance(point, App.Vector): if isinstance(point, (list, tuple)) and len(point) >= 3:
points.append(point) return App.Vector(float(point[0]), float(point[1]), float(point[2]))
elif isinstance(point, (list, tuple)) and len(point) >= 3: return None
points.append(App.Vector(float(point[0]), float(point[1]), float(point[2])))
points.append(TerminalObjects.terminal_origin(end_terminal))
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 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): def _wire_object_name(start_terminal, end_terminal):
start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", "")) start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", ""))
end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", "")) end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", ""))
return "QETWire_{0}_{1}".format(start_uuid, end_uuid) return "QETWire_{0}_{1}".format(start_uuid, end_uuid)
def _set_wire_properties(obj, project_uuid, start_terminal, end_terminal): def _set_wire_properties(
TerminalObjects.ensure_string_property( 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, obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID for this wire",
project_uuid, project_uuid,
) wire_uuid,
TerminalObjects.ensure_string_property( wire_label,
obj,
"QetStartTerminalUuid",
"QET Exchange",
"Start terminal UUID",
getattr(start_terminal, "QetTerminalUuid", "").strip(), getattr(start_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndTerminalUuid",
"QET Exchange",
"End terminal UUID",
getattr(end_terminal, "QetTerminalUuid", "").strip(), getattr(end_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetStartInstanceId",
"QET Exchange",
"Start device instance ID",
getattr(start_terminal, "QetInstanceId", "").strip(), getattr(start_terminal, "QetInstanceId", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndInstanceId",
"QET Exchange",
"End device instance ID",
getattr(end_terminal, "QetInstanceId", "").strip(), 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( try:
obj, if "QetManualWaypointsJson" not in getattr(obj, "PropertiesList", []):
"RouteType", obj.addProperty(
"QET Exchange", "App::PropertyString",
"Wire route type", "QetManualWaypointsJson",
"Manual", "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): 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 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): if not TerminalObjects.is_terminal_object(start_terminal):
raise ManualWiringError("The start selection is not a valid terminal.") raise ManualWiringError("The start selection is not a valid terminal.")
if not TerminalObjects.is_terminal_object(end_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 = ( project_uuid = (
getattr(start_terminal, "QetProjectUuid", "").strip() getattr(start_terminal, "QetProjectUuid", "").strip()
or getattr(end_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: if not project_uuid:
raise ManualWiringError("A project UUID is required to create a wire.") 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) wire_name = "{0}_{1}".format(wire_base_name, suffix)
suffix += 1 suffix += 1
wire_obj = doc.addObject("Part::Feature", wire_name) points, normalized_waypoints = _terminal_points(
wire_obj.Label = "QET Manual Wire" start_terminal,
end_terminal,
points = _terminal_points(start_terminal, end_terminal, waypoints=waypoints) waypoints=waypoints,
terminal_exit_length=terminal_exit_length,
)
if len(points) < 2: if len(points) < 2:
raise ManualWiringError("A wire requires at least two points.") raise ManualWiringError("A wire requires at least two points.")
import Part wire_obj = _create_wire_geometry(doc, wire_name, points)
wire_obj.Label = wire_label or "QET Manual Wire"
wire_obj.Shape = Part.makePolygon(points)
_set_wire_properties( _set_wire_properties(
wire_obj, wire_obj,
project_uuid, project_uuid,
start_terminal, start_terminal,
end_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: if parent_group is None:
try: try:
parent_group = _wire_parent_group( parent_group = _wire_parent_group(
@ -154,12 +448,22 @@ def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent
project_uuid, project_uuid,
start_terminal, start_terminal,
end_terminal, end_terminal,
fallback_group=DeviceImport._ensure_root_group(doc, project_uuid), fallback_group=TerminalObjects.ensure_root_group(doc, project_uuid),
) )
except Exception: except Exception:
parent_group = None parent_group = None
if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []): if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []):
parent_group.addObject(wire_obj) 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: try:
wire_obj.ViewObject.LineWidth = 2.0 wire_obj.ViewObject.LineWidth = 2.0

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

@ -138,8 +138,21 @@ def ensure_engineering_terminals_for_device(doc, device_group):
"created_terminals": 0, "created_terminals": 0,
"updated_terminals": 0, "updated_terminals": 0,
"skipped_slots": 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): for index, slot in enumerate(slots):
slot_name = (slot.get("name") or "SLOT_{0}".format(index + 1)).strip() slot_name = (slot.get("name") or "SLOT_{0}".format(index + 1)).strip()
if not slot_name: if not slot_name:
@ -169,6 +182,8 @@ def ensure_engineering_terminals_for_device(doc, device_group):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip() terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
if not terminal_uuid: if not terminal_uuid:
terminal_uuid = _local_terminal_uuid(instance_id, element_uuid, slot_name) 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( TerminalObjects.set_terminal_semantics(
terminal_obj, terminal_obj,
@ -236,6 +251,15 @@ def ensure_engineering_terminals_for_selection_or_all(doc=None):
"devices": len(reports), "devices": len(reports),
"created_terminals": sum(item["created_terminals"] for item in reports), "created_terminals": sum(item["created_terminals"] for item in reports),
"updated_terminals": sum(item["updated_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, "reports": reports,
} }
@ -338,12 +362,16 @@ class CommandCreateEngineeringTerminals:
report = ensure_engineering_terminals_for_selection_or_all(App.ActiveDocument) report = ensure_engineering_terminals_for_selection_or_all(App.ActiveDocument)
try: try:
App.Console.PrintMessage( App.Console.PrintMessage(
"[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2}\n".format( "[FreeCADExchange] 工程端子生成完成:设备 {0} 个,新增 {1} 个,更新 {2},本地端子 {3} 个,跳过无模板设备 {4}\n".format(
report["devices"], report["devices"],
report["created_terminals"], report["created_terminals"],
report["updated_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: except Exception:
pass pass
except Exception as exc: except Exception as exc:

@ -357,11 +357,4 @@ def resolve_terminal_slots(device_group, model_path, desired_count):
break break
slots.append(slot) 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] return slots[:desired_count]

@ -35,11 +35,19 @@ def _normalize_terminal_entry(item, index):
instance_id = (item.get("instance_id") or "").strip() instance_id = (item.get("instance_id") or "").strip()
element_uuid = (item.get("element_uuid") 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 { return {
"terminal_uuid": terminal_uuid, "terminal_uuid": terminal_uuid,
"instance_id": instance_id, "instance_id": instance_id,
"element_uuid": element_uuid, "element_uuid": element_uuid,
"slot_name_hint": slot_name_hint,
} }
@ -148,6 +156,51 @@ def _terminal_slot_label(slot, terminal_uuid):
return 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): def _slot_base(slot):
base = slot.get("base") base = slot.get("base")
if isinstance(base, App.Vector): if isinstance(base, App.Vector):
@ -223,6 +276,8 @@ def import_terminals_from_payload(payload, scene_path=""):
"updated_terminals": 0, "updated_terminals": 0,
"removed_terminals": 0, "removed_terminals": 0,
"reused_template_hints": 0, "reused_template_hints": 0,
"matched_by_slot_hint": 0,
"skipped_missing_slot": 0,
"skipped_missing_device": 0, "skipped_missing_device": 0,
"skipped_invalid_entry": 0, "skipped_invalid_entry": 0,
"skipped_unmatched_parent": 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) terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
existing_by_uuid = _terminal_existing_index(terminal_group) existing_by_uuid = _terminal_existing_index(terminal_group)
used_uuids = set() 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, device_group,
resolved_model_path, resolved_model_path,
len(entries), len(entries),
) )
slot_lookup = _build_slot_lookup(template_slots)
for index, entry in enumerate(entries): for index, entry in enumerate(entries):
terminal_uuid = entry["terminal_uuid"] 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) .format(terminal_uuid, payload_instance_id, device_element_uuid, device_instance_id)
) )
slot = slots[index] if index < len(slots) else { slot = None
"name": "SLOT_{0}".format(index + 1), slot_hint = _normalize_slot_name(entry.get("slot_name_hint", ""))
"label": terminal_uuid, if slot_hint:
"base": App.Vector(0, 0, 0), hinted_slot = slot_lookup.get(slot_hint)
"source": "fallback", if hinted_slot is not None:
"source_object": 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) terminal_obj = existing_by_uuid.get(terminal_uuid)
if terminal_obj is None: if terminal_obj is None:
@ -356,11 +433,12 @@ def import_terminals_from_payload(payload, scene_path=""):
pass pass
_append_debug_log( _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["imported_terminals"],
report["updated_terminals"], report["updated_terminals"],
report["removed_terminals"], report["removed_terminals"],
report["skipped_unmatched_parent"], report["skipped_unmatched_parent"],
report["skipped_missing_slot"],
) )
) )
return report return report

@ -1,6 +1,7 @@
# FreeCADExchange terminal and object helpers. # FreeCADExchange terminal and object helpers.
import json import json
import math
import os import os
from pathlib import Path from pathlib import Path
@ -15,6 +16,8 @@ WIRE_GROUP_PREFIX = "QETWires_"
TERMINAL_GROUP_KIND = "Terminals" TERMINAL_GROUP_KIND = "Terminals"
WIRE_GROUP_KIND = "Wires" WIRE_GROUP_KIND = "Wires"
TERMINAL_ROLE = "Terminal" TERMINAL_ROLE = "Terminal"
TERMINAL_BINDING_MODE_LOCAL = "local"
TERMINAL_BINDING_MODE_QET = "qet"
def safe_token(value): def safe_token(value):
@ -38,6 +41,10 @@ def native_path(value):
return os.path.normpath(os.path.expandvars(os.path.expanduser(text))) 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): def ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []): if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description) 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=""): def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid = getattr(device_group, "QetElementUuid", "").strip() element_uuid = getattr(device_group, "QetElementUuid", "").strip()
label = "QET Wires" label = "QET Wires"
return ensure_named_child_group( group = ensure_named_child_group(
doc, doc,
device_group, device_group,
WIRE_GROUP_PREFIX, WIRE_GROUP_PREFIX,
@ -183,6 +190,11 @@ def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid=element_uuid, element_uuid=element_uuid,
instance_id=instance_id, instance_id=instance_id,
) )
try:
group.ViewObject.Visibility = False
except Exception:
pass
return group
def find_child_group_by_kind(parent_group, group_kind): 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)) 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): def terminal_origin(obj):
try: try:
if hasattr(obj, "getGlobalPlacement"): if hasattr(obj, "getGlobalPlacement"):
@ -311,6 +346,73 @@ def terminal_origin(obj):
return App.Vector(0, 0, 0) 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): def create_lcs_object(doc, name_hint, placement=None, label=None):
base_name = safe_token(name_hint) or "QETTerminal" base_name = safe_token(name_hint) or "QETTerminal"
object_name = _unique_object_name(doc, base_name) object_name = _unique_object_name(doc, base_name)
@ -367,6 +469,18 @@ def set_terminal_semantics(
"Terminal UUID from QET", "Terminal UUID from QET",
terminal_uuid, 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( ensure_string_property(
obj, obj,
"QetInstanceId", "QetInstanceId",

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

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

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

@ -160,9 +160,35 @@ class TemplateInstantiationTest(unittest.TestCase):
self.assertEqual("inst-1", terminals[0].QetInstanceId) self.assertEqual("inst-1", terminals[0].QetInstanceId)
self.assertEqual("ct-1", terminals[0].QetElementUuid) self.assertEqual("ct-1", terminals[0].QetElementUuid)
self.assertEqual("P1", terminals[0].QetTemplateSlotName) self.assertEqual("P1", terminals[0].QetTemplateSlotName)
self.assertEqual("local", terminals[0].QetTerminalBindingMode)
self.assertTrue(terminals[0].CanWire) self.assertTrue(terminals[0].CanWire)
self.assertFalse(p1.ViewObject.Visibility) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -154,6 +154,41 @@ class TemplateSemanticsRotationTest(unittest.TestCase):
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z) 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): class TerminalPlacementTest(unittest.TestCase):
def test_slot_placement_uses_rotation_metadata(self): def test_slot_placement_uses_rotation_metadata(self):
_install_fake_freecad() _install_fake_freecad()

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

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

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

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