feat: 完善 Face 参数化编辑和隔离执行

This commit is contained in:
2026-07-31 16:36:05 +08:00
parent 27e4f7236c
commit bb44e3920d
31 changed files with 5428 additions and 252 deletions
+2 -1
View File
@@ -19,10 +19,11 @@ dist/
*_diff_*.txt *_diff_*.txt
*_operation_history.json *_operation_history.json
*.log *.log
*.zip
assets/screenshots/ assets/screenshots/
教学.txt local/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+107 -48
View File
@@ -10,6 +10,7 @@
- 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step` - 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入 Edge、槽、孔、凸台、圆角等特征;不要每类只做一点。
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
## 怎么运行 ## 怎么运行
@@ -67,15 +68,14 @@ python main.py assets\models\cube_10mm.step
python scripts\generate_cube_step.py python scripts\generate_cube_step.py
``` ```
10. 验证立方体上的“只改一条 Edge 长度”基线 10. 验证几何修改基线。当前推进和测试优先级是先 Face,再 Edge、槽、孔等其它特征
```powershell ```powershell
python scripts\verify_edge_length_resize.py python scripts\verify_face_edit_suite.py
python scripts\verify_edge_round_chamfer.py python scripts\verify_isolated_face_edit.py
python scripts\verify_edge_edit_suite.py
python scripts\verify_shell_thickness_resize.py python scripts\verify_shell_thickness_resize.py
python scripts\verify_analytic_surface_resize.py python scripts\verify_analytic_surface_resize.py
python scripts\verify_face_resize_semantics.py --strategy local --axis width --target-size 15
python scripts\verify_face_resize_semantics.py --strategy owning --axis width --target-size 15
python scripts\verify_boss_resize.py python scripts\verify_boss_resize.py
python scripts\verify_hole_resize.py python scripts\verify_hole_resize.py
python scripts\verify_slot_resize.py python scripts\verify_slot_resize.py
@@ -87,6 +87,25 @@ python scripts\verify_slot_resize.py
C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py
``` ```
## Windows 文件夹版打包
如果你要把软件发给没有 Python 环境的人,当前先打包成文件夹版。推荐先激活 `pyocc`,再运行打包脚本:
```powershell
conda activate pyocc
powershell -ExecutionPolicy Bypass -File .\scripts\package_windows.ps1
```
脚本会先跑源码烟测,再用 PyInstaller 生成 `dist\GeometryParametric\main.exe`,并自动把仓库自带 STEP 模型复制到 `dist\GeometryParametric\model\`。如果本机存在 `local\几何参数化测试说明.txt`,打包后的文件夹里也会带同名说明文档,方便模型工程师直接按说明测试。
最终压缩包固定生成在:
```text
dist\GeometryParametric_windows_x64.zip
```
这个 `dist` 目录和 zip 包是本地构建产物,默认不提交到 Git。
开发时常用的本地检查: 开发时常用的本地检查:
```powershell ```powershell
@@ -104,7 +123,7 @@ git diff --check
- 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的特征,比如面偏移、偏移距离、面积、面宽/面高、中心,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。 - 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
@@ -117,6 +136,8 @@ git diff --check
所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。
当前 Face 的 high-risk 修改已经开始接入隔离子进程执行,包括 `推拉当前面``面偏移(当前面)``面积(当前面)``面宽/面高(当前面)``中心(当前面)``薄壁厚度(当前面)`,以及 `面积(整体)``面宽/面高(整体)``薄壁厚度(整体)`:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。
产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。
设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。 设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。
@@ -143,6 +164,7 @@ git diff --check
- 能导出选中的零件。 - 能导出选中的零件。
- 能撤销/重做编辑。 - 能撤销/重做编辑。
- 先实现少量可控编辑操作,用于验证后续特征编辑路线。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。
- 当前开发顺序固定为先集中完成 Face 修改能力,再进入 Edge、槽、孔、凸台、圆角和其它解析曲面;每一类要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。
## 能改哪些,一分钟版 ## 能改哪些,一分钟版
@@ -150,8 +172,8 @@ git diff --check
| 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | | Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 |
| Face / 面 | 平面偏移、只移动当前面、整体平移所属特征、面积、面宽/面高、中心、薄壁厚度。 | 推拉当前平面;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域。 | | Face / 面 | 面积、面宽/面高、中心、面偏移、薄壁厚度。 | 推拉当前平面;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域。 |
| 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 |
| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 | | 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 |
| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或推拉端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | | 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或推拉端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 |
@@ -164,13 +186,13 @@ git diff --check
| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 |
| --- | --- | --- | | --- | --- | --- |
| Face | 平面 Face 可以改 `面偏移``偏移距离``面``面``面高``中心`;这些行会在 `影响范围` 里选择 `只改当前面``推拉当前面``移动整个特征``调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面``调整整个特征`。 | `推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、复杂壳体局部区域和带内孔/复杂边界的面仍不能稳定做局部尺寸编辑。 | | Face | 平面 Face 可以改 `面``面``面``中心``面偏移`;这些行会在 `影响范围` 里选择 `只改当前面``推拉当前面``移动整个特征``调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面``调整整个特征`。 | `面宽/面高` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`面偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、圆柱端盖这类所属 Solid 含曲面的平面 Face、复杂壳体局部区域和带内孔/复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用 `只改当前面` 并在提示里说明具体原因,建议改用孔/槽专门入口、`推拉当前面` 或整体调整。 |
| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径``半径`,并通过 `影响范围` 选择 `只改孔/槽壁``调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | | 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径``半径`,并通过 `影响范围` 选择 `只改孔/槽壁``调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 |
| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度``调整整个特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | | 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度``调整整个特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁``调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | | 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁``调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | | 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径直径,并通过 `影响范围` 选择 `自动``相邻圆柱``缩放所属`;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径直径`圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)``参考直径(整体)``半角(整体)`;球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 | | 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)``参考直径(整体)``半角(整体)`;球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
@@ -328,6 +350,7 @@ git diff --check
- 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。 - 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。
- 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。 - 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。
- 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面``调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。 - 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面``调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。
- 对简单全平面薄板/盒体,`调整整个特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。
- 当前版本支持已有圆角/倒圆候选结构化识别: - 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate` - 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。 - 会估算已有圆角半径、圆弧角度和圆弧长度。
@@ -489,13 +512,19 @@ vertices: 3262
建议下一步按这个顺序推进: 建议下一步按这个顺序推进:
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例 1. 先集中完成 Face 修改能力:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面``推拉当前面``移动整个特征``调整整个特征` 等语义,并保证 `python scripts\verify_face_edit_suite.py` 能集中覆盖
2. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略,优先覆盖 Edge 长度、Face 编辑、孔/槽、凸台和圆角。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化 2. Face 闭环稳定后,再集中做 Edge:先把直线 Edge 长度、起点、中心、终点和倒圆/倒角语义做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback
3. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整(含整体缩放)、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败 3. Edge 闭环稳定后,再集中做槽/半孔:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度和中心距要统一验证
4. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选 4. 槽闭环稳定后,再集中做孔/圆柱、盲孔/盲槽、凸台、已有圆角、圆锥/球/环面等对象;每类都先补能力和验证,再进入下一类
5. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略 5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化
6. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则 6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离
7. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
Face 阶段的当前验收口径:
- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积``面宽``面高``中心``面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。
- 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。
- Face 阶段闭环后,再进入 Edge 阶段;不要在 Face 还没有稳定解释和验证时提前分散到槽、孔或圆锥/球/环面。
## 怎么使用 ## 怎么使用
@@ -567,7 +596,7 @@ vertices: 3262
- 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。
- 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。
- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
- `推拉平面` 行表示这个Face是平面,可以配合 `偏移距离` `推拉面` 使用 - `推拉平面` 行表示这个Face是平面;当前主要在 `当前选中对象``面偏移` 行里把 `影响范围` 选为 `推拉当前面` 来执行
- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径``调整圆柱孔径` 使用。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径``调整圆柱孔径` 使用。
- `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 - `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。
- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 - `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。
@@ -618,30 +647,20 @@ vertices: 3262
编辑: 编辑:
- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 - 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。
- `偏移距离` + `推拉平面`
- 先选择一个平面Face。
- 输入偏移距离。
- 正数表示沿程序判断的外法向加料。
- 负数表示沿内侧方向切削。
- 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对Face尺寸偏大,会先弹窗确认。
- 如果选中Face周围有共面且相接/重叠的碎面,程序会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`
- 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。
- 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。
- 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向``推拉向内方向``推拉方向置信度`
- 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
- 当前实现仍使用布尔加/减验证推拉路线。
- `面偏移` - `面偏移`
- 先选择一个平面 Face。 - 先选择一个平面 Face。
- 在当前选中对象表里修改目标面偏移,再用 `影响范围` 选择改法。 - 在当前选中对象表里修改 `面偏移`,再用 `影响范围` 选择改法。
- `推拉当前面` 沿当前推拉方向加料或切削,适合改变厚度、拉出凸起或切入凹槽 - `面偏移` 沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标
- `推拉当前面` 会把目标位置换算成这次要移动的距离,然后沿当前面的垂直方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。
- `只改当前面` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 - `只改当前面` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。
- `移动整个特征` 会沿当前面方向平移所属特征或 Solid;形状和内部尺寸不变。 - `移动整个特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。
- 如果选中Face周围有共面且相接/重叠的碎面,`推拉当前面` 会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`
- 当前推拉方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
- `面积` / `面宽` / `面高` - `面积` / `面宽` / `面高`
- 先选择一个 Face,在当前选中对象表里修改目标值,再用 `影响范围` 选择改法。 - 先选择一个 Face,在当前选中对象表里修改目标值,再用 `影响范围` 选择改法。
- `只改当前面` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。 - `只改当前面` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。
- `调整整个特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 - `调整整个特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。
- `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是全局 X/Y/Z 包围盒尺寸。 - `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。
- `中心` - `中心`
- 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `影响范围` 选择改法。 - 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `影响范围` 选择改法。
- `只改当前面` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 - `只改当前面` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。
@@ -900,15 +919,32 @@ pythonocc-step-editor/
geom_extract.step # 当前默认测试模型 geom_extract.step # 当前默认测试模型
cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作 cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作
screenshots/ # 调试截图和问题截图,默认不提交 screenshots/ # 调试截图和问题截图,默认不提交
local/ # 本机自用说明和教学文档,默认不提交
scripts/ scripts/
GeometryParametric.spec # PyInstaller 文件夹版打包配置
package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip
generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具
verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证解析曲面整体缩放 verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证圆锥半角、解析曲面整体缩放
verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改 verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改
verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证
verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放
verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线
verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改
verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起推拉
verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用只改当前面的局部变形,但保留向外推拉和向内切削
verify_face_edit_suite.py # 一键运行 Face 专项修改验证
verify_face_extreme_target_guards.py # 验证 Face 面积、面宽/面高、面偏移和薄壁厚度不能输入过大或过小的极端目标值
verify_face_invalid_target_guards.py # 验证 Face 面积、面宽/面高 和薄壁厚度不能输入非数字、0 或负数
verify_face_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面
verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留推拉
verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、面宽/面高、面偏移和推拉
verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止
verify_face_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何
verify_face_push_pull_limits.py # 验证 Face 向内推拉不会允许切穿实体厚度
verify_face_rectangular_axes.py # 验证矩形 Face 的面宽 和面高 方向不会混淆
verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义
verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改
verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改
step_editor/ step_editor/
@@ -936,7 +972,7 @@ pythonocc-step-editor/
README.md # 项目说明和使用说明 README.md # 项目说明和使用说明
``` ```
这次整理把根目录收窄到入口、环境配置和说明文档;仓库自带 STEP 测试模型放到 `assets/models/`,问题截图和调试截图放到 `assets/screenshots/``environment.yml` 保留在根目录,方便 Conda 用户直接发现并运行 `conda env create -f environment.yml`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。 这次整理把根目录收窄到入口、环境配置和说明文档;仓库自带 STEP 测试模型放到 `assets/models/`,问题截图和调试截图放到 `assets/screenshots/`,本机自用的 `教学.txt``几何参数化测试说明.txt` 放到 `local/``environment.yml` 保留在根目录,方便 Conda 用户直接发现并运行 `conda env create -f environment.yml`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。
`step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py``operations.py``export.py``transforms.py``polydata.py``step_io.py``geometry_utils.py` `step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py``operations.py``export.py``transforms.py``polydata.py``step_io.py``geometry_utils.py`
@@ -948,6 +984,7 @@ pythonocc-step-editor/
- `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。 - `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。
- `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略。 - `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略。
- `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。 - `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。
- `local/`:本机自用文档目录,例如 `教学.txt``几何参数化测试说明.txt`,默认不提交;打包脚本会把 `local/几何参数化测试说明.txt` 复制到 `main.exe` 同级目录。
## 当前版本边界 ## 当前版本边界
@@ -965,6 +1002,8 @@ pythonocc-step-editor/
最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。
当前开发节奏必须分阶段闭环:先把 Face 修改能力做完整并集中测试,再做 Edge;Edge 通过后再做槽/半孔、孔/圆柱、凸台、圆角和解析曲面。不要每个对象只做一点就切换方向。
### 运行方式 ### 运行方式
常用命令: 常用命令:
@@ -974,8 +1013,9 @@ conda activate pyocc
python main.py python main.py
python main.py assets\models\cube_10mm.step python main.py assets\models\cube_10mm.step
python scripts\generate_cube_step.py python scripts\generate_cube_step.py
python scripts\verify_edge_length_resize.py python scripts\verify_face_edit_suite.py
python scripts\verify_edge_round_chamfer.py python scripts\verify_isolated_face_edit.py
python scripts\verify_edge_edit_suite.py
python scripts\verify_shell_thickness_resize.py python scripts\verify_shell_thickness_resize.py
python scripts\verify_analytic_surface_resize.py python scripts\verify_analytic_surface_resize.py
python scripts\verify_slot_resize.py python scripts\verify_slot_resize.py
@@ -1001,13 +1041,28 @@ git diff --check
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step` - `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step` - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`
- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证圆锥参考半径、球面半径、环面主半径和环面小半径整体缩放。 - `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证圆锥参考半径、圆锥半角、球面半径、环面主半径和环面小半径整体缩放。
- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 - `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。
- `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 圆角/倒角/已有圆角修改和椭圆 Edge 主/小半径修改。
- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。 - `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。
- `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。 - `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。
- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的 `当前面` 局部尺寸编辑和 `整体` 所属对象尺寸编辑不会互相混淆 - `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改
- `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 推拉,并确认推拉后原逻辑 Face ID 会定位到新生成的整片顶面。
- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。
- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。
- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面积、面宽/面高 和薄壁厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数。
- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面积、面宽/面高、面偏移和薄壁厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把面偏移移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。
- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体面宽、局部/整体面积、局部/整体中心、局部/整体面偏移和推拉正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面。
- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `只改当前面``调整整个特征``移动整个特征``推拉当前面` 语义,在中心、面积、面宽/面高 和面偏移场景下都能保持单 Solid。
- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面宽/面高、面积、中心坐标和面偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `推拉当前面` 之间不会互相混淆。
- `scripts/verify_face_noop_guards.py`:验证 Face 的推拉、面偏移、中心、面积、面宽/面高 和薄壁厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。
- `scripts/verify_face_property_readback.py`:验证 Face 的面宽、面积、中心和面偏移编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `面偏移``只改当前面``移动整个特征``推拉当前面` 三种语义。
- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内推拉时会估算当前面背后的材料厚度;浅切削允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked。
- `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `面宽``面高` 两个方向上可以分别做 `只改当前面``调整整个特征`,另一个方向不会被误改,属性表也会读回新值。
- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路。 - `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路。
- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和薄壁厚度整体缩放两种语义 - `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的面偏移、面积、面宽/面高、中心和薄壁厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;这用于证明大参数 Face 编辑不会再直接冒险拖垮主界面进程
- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、面宽/面高 或面偏移编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `只改当前面` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。
- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。
- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。 - `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 - `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
@@ -1034,7 +1089,7 @@ git diff --check
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。
- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。
- 编辑:面偏移、偏移距离、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高和中心现在统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。 - 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高 和中心统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
@@ -1066,13 +1121,17 @@ git diff --check
Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。 Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。
Face 局部面偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `面偏移` 的影响范围设为 `只改当前面` 并改变 `1mm`,执行路径为 `local-face-plane-offset-deform`实际复用局部 Face 顶点移动和相邻平面重建能力;这不是推拉布尔,也不是整体平移所属对象 Face 面偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `面偏移` 正向或负向改变 `1mm``只改当前面` 会走 `local-face-plane-offset-deform`只移动当前 Face 顶点并重建相邻面;`移动整个特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`推拉当前面` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“面偏移”操作
Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。 Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。
Face 局部面宽基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽` 的影响范围设为 `只改当前面` 并从 `10` 改为 `15`,计划策略为 `local-face-width-only-deform`,内存执行成功。执行后检测到一个 `15 x 10`、面积 `150` 的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 Face 局部面宽/面高基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽``面高` 的影响范围设为 `只改当前面` 并从 `10` 改为 `15`,计划策略分别`local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。
Face 整体面宽基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽` 的影响范围设为 `调整整个特征` 并从 `10` 改为 `15`,计划策略为 `axis-scale-owning-shape-from-face-width`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该方向整体跟随变,同时避免通用仿射把平面盒子变成样条面。 Face 整体面宽/面高基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽``面高` 的影响范围设为 `调整整个特征` 并从 `10` 改为 `15`,计划策略分别`axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变,同时避免通用仿射把平面盒子变成样条面。
Face 面积基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面积``100` 改为 `144``只改当前面` 会走 `local-face-area-only-deform`,只把当前 Face 在自身平面内缩放到 12mm x 12mm 并重建相邻面;`调整整个特征` 会走 `uniform-scale-face-area-fallback`,把所属对象整体等比缩放到所有 10mm x 10mm 面都变为约 12mm x 12mm。
Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将中心按 `(2, 0, 3)` 偏移。`只改当前面` 会走 `local-face-only-deform`,当前 Face 被移到目标中心并重建相邻面,Face 自身尺寸保持约 10mm x 10mm`移动整个特征` 会走 `translate-part`,整个所属特征平移,所有面尺寸保持 10mm x 10mm。
孔/盲孔局部重建基线验证:`scripts/verify_hole_resize.py` 会临时生成一个通孔板件和一个盲孔块体。当前验证通过六条孔修改路线:通孔孔径 `6 -> 8`、通孔孔径 `6 -> 4`、通孔轴心偏移 `2mm`、完整通孔封堵、盲孔深度 `6 -> 8`、盲孔深度 `6 -> 4`。封堵会检查补料后仍是单 Solid、孔候选消失,并且体积接近完整板件;孔径缩小、孔轴心移动、封堵和盲孔变浅都会先补旧孔/旧底部再重切或补料;这些孔内补料 Fuse 必须关闭 OCCT glue,否则可能只生成一个插在孔里的独立圆柱 Solid,而不是和主体真正合并。 孔/盲孔局部重建基线验证:`scripts/verify_hole_resize.py` 会临时生成一个通孔板件和一个盲孔块体。当前验证通过六条孔修改路线:通孔孔径 `6 -> 8`、通孔孔径 `6 -> 4`、通孔轴心偏移 `2mm`、完整通孔封堵、盲孔深度 `6 -> 8`、盲孔深度 `6 -> 4`。封堵会检查补料后仍是单 Solid、孔候选消失,并且体积接近完整板件;孔径缩小、孔轴心移动、封堵和盲孔变浅都会先补旧孔/旧底部再重切或补料;这些孔内补料 Fuse 必须关闭 OCCT glue,否则可能只生成一个插在孔里的独立圆柱 Solid,而不是和主体真正合并。
@@ -1082,9 +1141,9 @@ Face 整体面宽基线验证:在 `assets/models/cube_10mm.step` 上选一个
Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子和已有圆角盒子。当前验证通过五条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`。已有圆角修改走 defeature + refillet;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离。 Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子和已有圆角盒子。当前验证通过五条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`。已有圆角修改走 defeature + refillet;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离。
薄壁厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过两条路线:`薄壁厚度``推拉当前面` 把厚度 `2 -> 3`,以及 `薄壁厚度``调整整个特征` 做厚度方向整体缩放。局部路线会保持相对面不动,整体路线会保持厚度中心基本不动。 薄壁厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3``2 -> 1``推拉当前面` 会保持相对面不动,`调整整个特征` 会保持厚度中心基本不动。
解析曲面整体缩放基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过条路线:圆锥参考半径 `4 -> 5`、球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;圆锥径向仿射后 OCCT 可能把面转成 B-spline,所以脚本用上下端盖直径高度校验几何结果。 解析曲面整体缩放基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过条路线:圆锥参考半径 `4 -> 5`圆锥半角 `11.3099° -> 16°`球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;圆锥径向仿射后 OCCT 可能把面转成 B-spline,所以脚本用上下端盖直径高度和反算半角校验几何结果。
椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine``ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。 椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine``ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。
@@ -1,27 +1,49 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [('assets', 'assets')] project_root = os.path.abspath(os.path.join(SPECPATH, '..'))
datas = [(os.path.join(project_root, 'assets'), 'assets')]
binaries = [] binaries = []
hiddenimports = [] hiddenimports = [
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
'PySide6.QtOpenGL',
'PySide6.QtOpenGLWidgets',
'vtkmodules.qt.QVTKRenderWindowInteractor',
]
tmp_ret = collect_all('OCC') tmp_ret = collect_all('OCC')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('vtkmodules') tmp_ret = collect_all('vtkmodules')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('PySide6')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis( a = Analysis(
['main.py'], [os.path.join(project_root, 'main.py')],
pathex=[], pathex=[project_root],
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[
'PySide6.QtWebEngineCore',
'PySide6.QtWebEngineWidgets',
'PySide6.QtWebEngineQuick',
'PySide6.QtQuick',
'PySide6.QtQml',
'PySide6.Qt3DAnimation',
'PySide6.Qt3DCore',
'PySide6.Qt3DExtras',
'PySide6.Qt3DInput',
'PySide6.Qt3DLogic',
'PySide6.Qt3DRender',
'PySide6.QtMultimedia',
'PySide6.QtMultimediaWidgets',
],
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )
@@ -32,7 +54,7 @@ exe = EXE(
a.scripts, a.scripts,
[], [],
exclude_binaries=True, exclude_binaries=True,
name='GeometryParametric', name='main',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
+125
View File
@@ -0,0 +1,125 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
Set-Location $projectRoot
function Assert-InProject {
param([string]$Path)
$resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
return
}
$rootText = $projectRoot.TrimEnd("\")
$pathText = $resolved.Path.TrimEnd("\")
if (-not $pathText.StartsWith($rootText, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove path outside project: $pathText"
}
}
function Invoke-Checked {
param(
[string]$FilePath,
[string[]]$Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')"
}
}
function Test-PackagingPython {
param([string]$Candidate)
if ($Candidate -ne "python" -and -not (Test-Path -LiteralPath $Candidate)) {
return $false
}
& $Candidate -c "import OCC; import PyInstaller" *> $null
return $LASTEXITCODE -eq 0
}
$pythonCandidates = @()
if ($env:CONDA_DEFAULT_ENV -eq "pyocc" -and $env:CONDA_PREFIX) {
$pythonCandidates += (Join-Path $env:CONDA_PREFIX "python.exe")
}
if ($env:USERPROFILE) {
$pythonCandidates += (Join-Path $env:USERPROFILE "miniforge3\envs\pyocc\python.exe")
$pythonCandidates += (Join-Path $env:USERPROFILE "miniconda3\envs\pyocc\python.exe")
$pythonCandidates += (Join-Path $env:USERPROFILE "anaconda3\envs\pyocc\python.exe")
}
$pythonCandidates += "python"
$python = $null
foreach ($candidate in $pythonCandidates) {
if (Test-PackagingPython $candidate) {
$python = $candidate
break
}
}
if (-not $python) {
throw "Could not find a Python environment with OCC and PyInstaller. Activate pyocc or install PyInstaller into pyocc."
}
Write-Host "Using Python: $python"
Write-Host "Running smoke test with source..."
Invoke-Checked $python @("main.py", "--smoke-test")
$buildDir = Join-Path $projectRoot "build"
$appDir = Join-Path $projectRoot "dist\GeometryParametric"
$zipPath = Join-Path $projectRoot "dist\GeometryParametric_windows_x64.zip"
$specPath = Join-Path $scriptDir "GeometryParametric.spec"
foreach ($target in @($buildDir, $appDir, $zipPath)) {
Assert-InProject $target
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
}
Write-Host "Building PyInstaller folder..."
Invoke-Checked $python @("-m", "PyInstaller", "--clean", "--noconfirm", $specPath)
if (-not (Test-Path -LiteralPath $appDir)) {
throw "PyInstaller did not create $appDir"
}
$guideName = (-join @(
[char]0x51E0,
[char]0x4F55,
[char]0x53C2,
[char]0x6570,
[char]0x5316,
[char]0x6D4B,
[char]0x8BD5,
[char]0x8BF4,
[char]0x660E
)) + ".txt"
$docSource = Join-Path (Join-Path $projectRoot "local") $guideName
if (Test-Path -LiteralPath $docSource) {
Copy-Item -LiteralPath $docSource -Destination (Join-Path $appDir $guideName) -Force
} else {
Write-Warning "Local test document not found: $docSource"
}
$modelSourceDir = Join-Path $projectRoot "assets\models"
$modelDestDir = Join-Path $appDir "model"
New-Item -ItemType Directory -Path $modelDestDir -Force | Out-Null
$modelFiles = @()
foreach ($pattern in @("*.step", "*.stp")) {
$modelFiles += Get-ChildItem -LiteralPath $modelSourceDir -Filter $pattern -File -ErrorAction SilentlyContinue
}
if ($modelFiles.Count -eq 0) {
throw "No STEP model files found in $modelSourceDir"
}
foreach ($file in $modelFiles) {
Copy-Item -LiteralPath $file.FullName -Destination $modelDestDir -Force
}
Write-Host "Running smoke test with packaged exe..."
Invoke-Checked (Join-Path $appDir "main.exe") @("--smoke-test")
Write-Host "Creating zip in dist..."
Compress-Archive -Path $appDir -DestinationPath $zipPath -Force
Write-Host "Package ready:"
Write-Host $zipPath
+86 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import math
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile import tempfile
@@ -52,6 +53,32 @@ def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int,
return best[0], best[1] return best[0], best[1]
def _nearest_cone_semi_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, float]:
best: tuple[int, float, float] | None = None
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cone":
continue
angle = info.get("semi_angle")
if not isinstance(angle, (int, float)):
continue
angle_degrees = abs(math.degrees(float(angle)))
score = abs(angle_degrees - target_angle_degrees)
if best is None or score < best[2]:
best = (face_id, angle_degrees, score)
if best is None:
diameters = _thin_cap_diameters(model)
if len(diameters) >= 2:
small = min(diameters)
large = max(diameters)
height = _bbox_z_size(model)
if height > 1e-9 and large > small:
angle_degrees = math.degrees(math.atan(((large - small) * 0.5) / height))
return -1, angle_degrees
raise SystemExit("no cone Face remained after edit and cap diameters could not recover the semi-angle")
return best[0], best[1]
def _nearest_torus_radii( def _nearest_torus_radii(
model: StepModel, model: StepModel,
target_major: float, target_major: float,
@@ -73,17 +100,22 @@ def _nearest_torus_radii(
def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool: def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool:
return any(abs(diameter - target_diameter) <= tolerance for diameter in _thin_cap_diameters(model))
def _thin_cap_diameters(model: StepModel) -> list[float]:
diameters: list[float] = []
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
info = model.face_info(face_id) info = model.face_info(face_id)
size = info.get("bbox_size") size = info.get("bbox_size")
if not isinstance(size, tuple) or len(size) != 3: if not isinstance(size, tuple) or len(size) != 3:
continue continue
dx, dy, dz = (float(size[0]), float(size[1]), float(size[2])) dx, dy, dz = (float(size[0]), float(size[1]), float(size[2]))
if dz > max(tolerance * 10.0, 1e-5): if dz > 1e-4:
continue continue
if abs(dx - target_diameter) <= tolerance and abs(dy - target_diameter) <= tolerance: if abs(dx - dy) <= max(max(abs(dx), abs(dy)) * 1e-5, 1e-5):
return True diameters.append((dx + dy) * 0.5)
return False return diameters
def _bbox_z_size(model: StepModel) -> float: def _bbox_z_size(model: StepModel) -> float:
@@ -128,6 +160,51 @@ def _run_cone_case(target_reference_radius: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii")) print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_cone_angle_case(target_angle_degrees: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cone_angle_") as temp_dir:
model_path = Path(temp_dir) / "cone.step"
_write_cone_model(model_path)
model = StepModel.load(model_path)
face_id = _first_face_by_surface(model, "cone")
before = model.stats()
info = model.face_info(face_id)
current_reference_radius = float(info.get("reference_radius") or 0.0)
current_semi_angle = abs(float(info.get("semi_angle") or 0.0))
current_top_radius = 2.0
scale = math.tan(math.radians(target_angle_degrees)) / math.tan(current_semi_angle)
target_reference_radius = current_reference_radius * scale
target_top_radius = current_top_radius * scale
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
if plan["status"] == "blocked":
raise SystemExit(f"cone angle plan was blocked: {plan['message']}")
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"cone angle resize changed solid count: before={before.solids}, after={after.solids}")
if abs(_bbox_z_size(model) - 10.0) > tolerance:
raise SystemExit(f"cone angle resize changed height unexpectedly: z_size={_bbox_z_size(model):g}")
if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance):
raise SystemExit(f"cone angle bottom cap diameter was not resized to {target_reference_radius * 2.0:g}")
if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance):
raise SystemExit(f"cone angle top cap diameter was not resized to {target_top_radius * 2.0:g}")
verified_face, angle_degrees = _nearest_cone_semi_angle(model, target_angle_degrees)
if abs(angle_degrees - target_angle_degrees) > tolerance:
raise SystemExit(
f"cone semi-angle verification failed: target={target_angle_degrees:g}, value={angle_degrees:g}"
)
print("mode=cone_semi_angle")
print(f"face_id={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_semi_angle_degrees={math.degrees(current_semi_angle):.6f}")
print(f"target_semi_angle_degrees={target_angle_degrees:.6f} verified={angle_degrees:.6f}")
print(f"target_reference_radius={target_reference_radius:.6f}")
print(f"target_top_radius={target_top_radius:.6f}")
print(f"verified_face={verified_face}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_sphere_case(target_radius: float, tolerance: float) -> None: def _run_sphere_case(target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir: with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir:
model_path = Path(temp_dir) / "sphere.step" model_path = Path(temp_dir) / "sphere.step"
@@ -199,19 +276,22 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--mode", "--mode",
default="all", default="all",
choices=["all", "cone", "sphere", "torus_major", "torus_minor"], choices=["all", "cone", "cone_angle", "sphere", "torus_major", "torus_minor"],
) )
parser.add_argument("--cone-reference-radius", type=float, default=5.0) parser.add_argument("--cone-reference-radius", type=float, default=5.0)
parser.add_argument("--cone-semi-angle-degrees", type=float, default=16.0)
parser.add_argument("--sphere-radius", type=float, default=6.25) parser.add_argument("--sphere-radius", type=float, default=6.25)
parser.add_argument("--torus-major-radius", type=float, default=10.0) parser.add_argument("--torus-major-radius", type=float, default=10.0)
parser.add_argument("--torus-minor-radius", type=float, default=3.0) parser.add_argument("--torus-minor-radius", type=float, default=3.0)
parser.add_argument("--tolerance", type=float, default=2e-4) parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args() args = parser.parse_args()
modes = ["cone", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode] modes = ["cone", "cone_angle", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode]
for mode in modes: for mode in modes:
if mode == "cone": if mode == "cone":
_run_cone_case(args.cone_reference_radius, args.tolerance) _run_cone_case(args.cone_reference_radius, args.tolerance)
elif mode == "cone_angle":
_run_cone_angle_case(args.cone_semi_angle_degrees, args.tolerance)
elif mode == "sphere": elif mode == "sphere":
_run_sphere_case(args.sphere_radius, args.tolerance) _run_sphere_case(args.sphere_radius, args.tolerance)
elif mode == "torus_major": elif mode == "torus_major":
+65 -1
View File
@@ -58,6 +58,17 @@ def _first_boss_face(model: StepModel) -> int:
return candidates[0] return candidates[0]
def _first_circle_edge_adjacent_to_face(model: StepModel, face_id: int) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
adjacent_face_ids = tuple(int(item) for item in info.get("adjacent_face_ids", ()) or ())
if face_id in adjacent_face_ids:
return edge_id
raise SystemExit(f"no circular edge adjacent to Face {face_id} was recognized")
def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id) info = model.face_info(face_id)
axis_point = info.get("axis_point") axis_point = info.get("axis_point")
@@ -185,6 +196,55 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii")) print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_boss_circle_edge_axis_") as temp_dir:
model_path = Path(temp_dir) / "boss.step"
_write_boss_model(model_path)
model = StepModel.load(model_path)
face_id = _first_boss_face(model)
edge_id = _first_circle_edge_adjacent_to_face(model, face_id)
before = model.stats()
current_center = _axis_center(model, face_id)
current_diameter = _diameter(model, face_id)
current_height = _height(model, face_id)
edge_info = model.edge_info(edge_id)
edge_center = edge_info.get("center")
if not isinstance(edge_center, tuple):
raise SystemExit(f"circle edge center is missing on Edge {edge_id}")
target_edge_center = (float(edge_center[0]) + offset, float(edge_center[1]), float(edge_center[2]))
target_axis_center = (current_center[0] + offset, current_center[1], current_center[2])
plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center)
if plan["status"] == "blocked":
raise SystemExit(f"circle_edge_axis_center plan was blocked: {plan['message']}")
result = model.move_circular_edge_axis_center(edge_id, target_edge_center)
after = model.stats()
verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter, target_axis_center)
center_error = _distance(center, target_axis_center)
height_error = abs(height - current_height)
if after.solids != before.solids:
raise SystemExit(f"circle_edge_axis_center changed solid count: before={before.solids}, after={after.solids}")
if center_error > tolerance or abs(diameter - current_diameter) > tolerance:
raise SystemExit(
f"circle_edge_axis_center verification failed: target={target_axis_center}, center={center}, "
f"center_error={center_error:g}, diameter={diameter:g}"
)
if height_error > tolerance:
raise SystemExit(
f"circle_edge_axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}"
)
print("mode=circle_edge_axis_center")
print(f"source_edge={edge_id}")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"delegated_mode={plan.get('circular_edge_cylinder_mode')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target_edge_center={target_edge_center}")
print(f"target_axis_center={target_axis_center} value={center} diameter={diameter:.6f} height={height:.6f} error={center_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_height_case(target: float, tolerance: float) -> None: def _run_height_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir: with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir:
model_path = Path(temp_dir) / "boss.step" model_path = Path(temp_dir) / "boss.step"
@@ -219,13 +279,14 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--mode", "--mode",
default="all", default="all",
choices=["all", "diameter", "diameter_shrink", "height", "axis_center"], choices=["all", "diameter", "diameter_shrink", "height", "axis_center", "circle_edge_axis_center"],
help="Boss edit mode to verify.", help="Boss edit mode to verify.",
) )
parser.add_argument("--diameter", type=float, default=8.0) parser.add_argument("--diameter", type=float, default=8.0)
parser.add_argument("--diameter-shrink", type=float, default=4.0) parser.add_argument("--diameter-shrink", type=float, default=4.0)
parser.add_argument("--height", type=float, default=7.0) parser.add_argument("--height", type=float, default=7.0)
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.") parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
parser.add_argument("--circle-edge-axis-center", type=float, default=2.0, help="Circle Edge center X offset to verify.")
parser.add_argument("--tolerance", type=float, default=2e-4) parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args() args = parser.parse_args()
@@ -235,6 +296,7 @@ def main() -> int:
("diameter_shrink", args.diameter_shrink), ("diameter_shrink", args.diameter_shrink),
("height", args.height), ("height", args.height),
("axis_center", args.axis_center), ("axis_center", args.axis_center),
("circle_edge_axis_center", args.circle_edge_axis_center),
] ]
if args.mode == "all" if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))] else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
@@ -246,6 +308,8 @@ def main() -> int:
_run_height_case(float(target), args.tolerance) _run_height_case(float(target), args.tolerance)
elif mode == "axis_center": elif mode == "axis_center":
_run_axis_center_case(float(target), args.tolerance) _run_axis_center_case(float(target), args.tolerance)
elif mode == "circle_edge_axis_center":
_run_circle_edge_axis_center_case(float(target), args.tolerance)
else: else:
raise SystemExit(f"unsupported mode: {mode}") raise SystemExit(f"unsupported mode: {mode}")
return 0 return 0
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge length auto strategy resolves to local deformation",
(
"verify_edge_length_resize.py",
"--strategy",
"auto",
"--anchor",
"keep-start",
"--expect-strategy",
"local-edge-only-deform",
),
),
(
"Edge length local deformation, fixed start point",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "keep-start"),
),
(
"Edge length local deformation, fixed end point",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "keep-end"),
),
(
"Edge length local deformation, fixed center",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "center"),
),
(
"Edge length move end plane, fixed start point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-start"),
),
(
"Edge length move end plane, fixed end point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-end"),
),
(
"Edge length scale owning object, fixed start point",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "keep-start"),
),
(
"Edge length scale owning object, fixed end point",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "keep-end"),
),
(
"Edge length scale owning object, fixed center",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "center"),
),
(
"Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize",
("verify_edge_round_chamfer.py",),
),
(
"Ellipse Edge major and minor radius resize",
("verify_ellipse_edge_resize.py",),
),
)
def main() -> int:
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
check=True,
)
print("\nEdge edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_holed_plate(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(30.0, 20.0, 8.0).Shape()
axis = gp_Ax2(gp_Pnt(15.0, 10.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 10.0).Shape()
cut = BRepAlgoAPI_Cut(plate, cutter)
shape = _finalize_boolean_result(cut, "verify holed planar face guard cut")
_write_step(shape, path)
def _first_holed_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if bool(info.get("has_inner_boundaries")):
return face_id
raise SystemExit("no planar Face with an inner boundary was found")
def _assert_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked for a holed planar Face: {plan}")
def _assert_push_pull_keeps_single_solid(path: Path, distance: float) -> str:
model = StepModel.load(path)
face_id = _first_holed_plane_face(model)
plan = model.push_pull_plan(face_id, distance)
if plan.get("status") == "blocked":
raise SystemExit(f"holed planar Face push/pull should remain available: {plan}")
result = model.push_pull_face(face_id, distance)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"holed planar Face push/pull should keep one solid: {stats}")
return result
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_boundary_") as temp_dir:
path = Path(temp_dir) / "holed_plate.step"
_write_holed_plate(path)
model = StepModel.load(path)
face_id = _first_holed_plane_face(model)
info = model.face_info(face_id)
if int(info.get("boundary_wires") or 0) < 2:
raise SystemExit(f"Face {face_id} should report at least two boundary wires: {info}")
if bool(info.get("local_face_deform_ready", True)):
raise SystemExit(f"Face {face_id} should not allow local Face deformation: {info}")
solid_raw = info.get("solid_id")
solid_id = int(solid_raw) if solid_raw is not None else -1
if solid_id < 0 or solid_id not in model._local_face_deform_readiness_cache:
raise SystemExit("local Face deformation readiness should be cached after face_info()")
other_face_id = next(
(item for item, item_solid_id in enumerate(model.face_solid_ids) if item_solid_id == solid_id and item != face_id),
None,
)
if other_face_id is None:
raise SystemExit("holed plate should expose another Face on the same Solid")
original_vertex_reader = model._local_deform_face_vertex_points
def fail_on_cache_miss(*_args: object, **_kwargs: object) -> list[tuple[float, float, float]]:
raise AssertionError("local Face readiness cache was not reused")
model._local_deform_face_vertex_points = fail_on_cache_miss # type: ignore[method-assign]
try:
cached_readiness = model._local_face_deform_readiness(other_face_id)
finally:
model._local_deform_face_vertex_points = original_vertex_reader # type: ignore[method-assign]
if bool(cached_readiness.get("local_face_deform_ready", True)):
raise SystemExit(f"cached readiness should keep local deformation disabled: {cached_readiness}")
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} does not expose a stable center")
target_center = (float(center[0]), float(center[1]), float(center[2]) + 1.0)
area = float(info.get("area") or 0.0)
if area <= 0:
raise SystemExit(f"Face {face_id} does not expose a stable area")
width = float(info.get("local_face_width") or 0.0)
if width <= 0:
raise SystemExit(f"Face {face_id} does not expose a measured Face width")
height = float(info.get("local_face_height") or 0.0)
if height <= 0:
raise SystemExit(f"Face {face_id} does not expose a measured Face height")
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_size_local_resize_plan(face_id, width * 1.1, axis="width"), "Face width local resize")
_assert_blocked(model.face_size_local_resize_plan(face_id, height * 1.1, axis="height"), "Face height local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
outward_result = _assert_push_pull_keeps_single_solid(path, 1.0)
inward_result = _assert_push_pull_keeps_single_solid(path, -1.0)
print(
"holed planar Face local edit guard ok: "
f"face_id={face_id}, boundary_wires={info.get('boundary_wires')}, "
f"blocker={info.get('local_face_deform_blocker')}, "
f"push_pull_outward={outward_result}, push_pull_inward={inward_result}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.gp import gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_split_top_box(path: Path) -> None:
left = BRepPrimAPI_MakeBox(5.0, 10.0, 10.0).Shape()
right = BRepPrimAPI_MakeBox(gp_Pnt(5.0, 0.0, 0.0), 5.0, 10.0, 10.0).Shape()
fuse = BRepAlgoAPI_Fuse(left, right)
fuse.Build()
if not fuse.IsDone():
raise SystemExit("failed to build split-top box")
_write_step(fuse.Shape(), path)
def _top_faces(model: StepModel, z_value: float, tolerance: float) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
if abs(float(center[2]) - z_value) <= tolerance:
face_ids.append(face_id)
return face_ids
def _has_top_face(model: StepModel, z_value: float, area: float, tolerance: float) -> bool:
for face_id in _top_faces(model, z_value, tolerance):
info = model.face_info(face_id)
if abs(float(info.get("area") or 0.0) - area) <= tolerance:
return True
return False
def _assert_logical_top_region(
model: StepModel,
logical_id: int,
z_value: float,
area: float,
tolerance: float,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"logical Face {logical_id} was not retained after coplanar push/pull")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"logical Face {logical_id} did not resolve into retained matches {matches}")
info = model.face_info(resolved)
center = info.get("area_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"retained logical Face {logical_id} lacks a stable center: {info}")
if abs(float(center[2]) - z_value) > tolerance:
raise SystemExit(f"retained logical Face {logical_id} should be at z={z_value:g}, got {center}")
if abs(float(info.get("area") or 0.0) - area) > tolerance:
raise SystemExit(f"retained logical Face {logical_id} should have area {area:g}, got {info.get('area')}")
return resolved
def main() -> int:
parser = argparse.ArgumentParser(description="Verify coplanar split Face push/pull as one plane region.")
parser.add_argument("--distance", type=float, default=1.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="geom_param_face_coplanar_") as temp_dir:
model_path = Path(temp_dir) / "split_top_box.step"
_write_split_top_box(model_path)
model = StepModel.load(model_path)
before = model.stats()
top_faces = _top_faces(model, 10.0, args.tolerance)
if len(top_faces) != 2:
raise SystemExit(f"expected two split top faces before push/pull, got {top_faces}")
face_id = top_faces[0]
logical_id = model.face_region_logical_id(face_id)
plan = model.push_pull_plan(face_id, args.distance)
scope_ids = tuple(plan.get("push_pull_scope_face_ids", ()))
if set(scope_ids) != set(top_faces):
raise SystemExit(f"expected push/pull scope {top_faces}, got {scope_ids}")
if plan["status"] == "blocked":
raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}")
result = model.push_pull_face(face_id, args.distance)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"solid count changed: before={before.solids}, after={after.solids}")
if not _has_top_face(model, 10.0 + args.distance, 100.0, args.tolerance):
raise SystemExit("pushed coplanar top region was not rebuilt as a 100 mm^2 top plane")
logical_face_id = _assert_logical_top_region(
model,
logical_id,
10.0 + args.distance,
100.0,
args.tolerance,
)
print(f"model={model_path}")
print(f"source_face={face_id}")
print(f"source_logical_face={logical_id}")
print(f"retained_logical_face={logical_face_id}")
print(f"scope_faces={scope_ids}")
print(f"before={before}")
print(f"after={after}")
print(f"distance={args.distance:.6f}")
print(f"strategy={plan.get('resize_strategy', 'push-pull-planar-face')}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"face width, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
),
(
"face width, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"),
),
(
"face height, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "height", "--target-size", "15"),
),
(
"face height, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "height", "--target-size", "15"),
),
(
"rectangular face width and height axes stay separate",
("verify_face_rectangular_axes.py",),
),
(
"face area, current face only",
("verify_face_resize_semantics.py", "--property", "area", "--strategy", "local", "--target-area", "144"),
),
(
"face area, owning feature",
("verify_face_resize_semantics.py", "--property", "area", "--strategy", "owning", "--target-area", "144"),
),
(
"face center, current face only",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "local", "--center-offset", "2,0,3"),
),
(
"face center, owning feature",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "owning", "--center-offset", "2,0,3"),
),
(
"face offset, push pull",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "1"),
),
(
"face offset, push pull inward cut",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "-1"),
),
(
"face offset, inward cut limits",
("verify_face_push_pull_limits.py",),
),
(
"face offset, current face only",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "local", "--offset-distance", "1"),
),
(
"face offset, owning feature",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "owning", "--offset-distance", "1"),
),
(
"coplanar split faces, push pull as one region",
("verify_face_coplanar_push_pull.py",),
),
(
"coplanar split faces, inward cut as one region",
("verify_face_coplanar_push_pull.py", "--distance", "-1"),
),
(
"holed planar Face disables local-only deformation",
("verify_face_complex_boundary_guard.py",),
),
(
"planar Face on curved Solid disables local-only deformation",
("verify_face_mixed_surface_guard.py",),
),
(
"non-rectangular planar Face local edits",
("verify_face_nonrectangular_local_edit.py",),
),
(
"thin wall thickness, current face and owning feature",
("verify_shell_thickness_resize.py",),
),
(
"thin wall thickness decrease, current face and owning feature",
("verify_shell_thickness_resize.py", "--target-thickness", "1"),
),
(
"property editor keeps generic Face edits out of cylindrical features",
("verify_property_editor_specs.py",),
),
(
"Face edited values read back into property rows",
("verify_face_property_readback.py",),
),
(
"Face logical selection stays on the edited face",
("verify_face_logical_selection_retention.py",),
),
(
"Face invalid positive targets are blocked",
("verify_face_invalid_target_guards.py",),
),
(
"Face extreme positive targets are blocked",
("verify_face_extreme_target_guards.py",),
),
(
"Face no-op plans are blocked",
("verify_face_noop_guards.py",),
),
)
def main() -> int:
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
check=True,
)
print("\nFace edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_extreme_blocked(label: str, plan: dict[str, object], expected_scale: float) -> None:
_assert_blocked(label, plan)
scale = (
plan.get("local_face_area_scale")
or plan.get("face_size_scale")
or plan.get("affine_scale")
)
if scale is None:
raise SystemExit(f"{label} plan did not expose the resulting scale: {plan}")
if abs(float(scale) - expected_scale) > max(abs(expected_scale) * 1e-6, 1e-9):
raise SystemExit(f"{label} scale mismatch: got={scale}, expected={expected_scale}")
def _assert_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} extreme target was not blocked: status={status}, message={message}")
def _run_cube_face_extreme_targets() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
info = model.face_info(face_id)
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
if area <= 0 or width <= 0 or height <= 0:
raise SystemExit(f"selected Face is missing stable size values: area={area}, width={width}, height={height}")
current_center = info.get("area_center") or info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face is missing a stable center")
_assert_extreme_blocked(
"Face area current face, too small",
model.face_area_local_resize_plan(face_id, area * 0.0001),
0.01,
)
_assert_extreme_blocked(
"Face area current face, too large",
model.face_area_local_resize_plan(face_id, area * 100.0),
10.0,
)
_assert_extreme_blocked(
"Face area owning feature, too small",
model.face_area_scale_plan(face_id, area * 0.0001),
0.01,
)
_assert_extreme_blocked(
"Face area owning feature, too large",
model.face_area_scale_plan(face_id, area * 100.0),
10.0,
)
for axis, current in (("width", width), ("height", height)):
_assert_extreme_blocked(
f"Face {axis} current face, too small",
model.face_size_local_resize_plan(face_id, current * 0.01, axis),
0.01,
)
_assert_extreme_blocked(
f"Face {axis} current face, too large",
model.face_size_local_resize_plan(face_id, current * 10.0, axis),
10.0,
)
_assert_extreme_blocked(
f"Face {axis} owning feature, too small",
model.face_size_owning_scale_plan(face_id, current * 0.01, axis),
0.01,
)
_assert_extreme_blocked(
f"Face {axis} owning feature, too large",
model.face_size_owning_scale_plan(face_id, current * 10.0, axis),
10.0,
)
far_center = (float(current_center[0]) + 500.0, float(current_center[1]), float(current_center[2]))
local_center_plan = model.face_center_local_move_plan(face_id, far_center)
_assert_blocked("Face center current face, too far", local_center_plan)
if float(local_center_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face center current face ratio did not exceed the guard: {local_center_plan}")
owning_center_plan = model.face_center_owning_translation_plan(face_id, far_center)
_assert_blocked("Face center owning feature, too far", owning_center_plan)
if float(owning_center_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face center owning feature ratio did not exceed the guard: {owning_center_plan}")
offset_distance = 500.0
local_offset_plan = model.face_plane_offset_local_plan(face_id, offset_distance)
_assert_blocked("Face offset current face, too large", local_offset_plan)
if float(local_offset_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face offset current face ratio did not exceed the guard: {local_offset_plan}")
push_pull_plan = model.push_pull_plan(face_id, offset_distance)
_assert_blocked("Face offset push/pull, too large", push_pull_plan)
push_pull_ratio = offset_distance / max(float(push_pull_plan.get("bbox_diagonal") or 0.0), 1e-9)
if push_pull_ratio <= 5.0:
raise SystemExit(f"Face push/pull ratio did not exceed the guard: {push_pull_plan}")
owning_offset_plan = model.face_plane_offset_owning_translation_plan(face_id, offset_distance)
_assert_blocked("Face offset owning feature, too large", owning_offset_plan)
if float(owning_offset_plan.get("face_offset_distance_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face offset owning feature ratio did not exceed the guard: {owning_offset_plan}")
def _run_shell_extreme_targets() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_extreme_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
current = float(model.feature_info(face_id).get("shell_thickness_estimate") or 0.0)
if current <= 0:
raise SystemExit("shell thickness candidate is missing a positive thickness")
for target, scale in ((current * 0.01, 0.01), (current * 10.0, 10.0)):
_assert_blocked(
f"thin wall current face target scale={scale:g}",
model.shell_thickness_plan(face_id, target),
)
_assert_extreme_blocked(
f"thin wall owning feature target scale={scale:g}",
model.shell_thickness_owning_scale_plan(face_id, target),
scale,
)
def main() -> int:
_run_cube_face_extreme_targets()
_run_shell_extreme_targets()
print("Face extreme target guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_positive_target_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} invalid target was not blocked: status={status}, message={message}")
if "大于 0" not in message and "大于0" not in message:
raise SystemExit(f"{label} invalid target should explain the positive range: {message}")
def _assert_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} invalid target was not blocked: status={status}, message={message}")
def _run_cube_invalid_targets() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
for target in (0.0, -1.0):
_assert_positive_target_blocked(
f"Face area current face target={target:g}",
model.face_area_local_resize_plan(face_id, target),
)
_assert_positive_target_blocked(
f"Face area owning feature target={target:g}",
model.face_area_scale_plan(face_id, target),
)
for axis in ("width", "height"):
_assert_positive_target_blocked(
f"Face {axis} current face target={target:g}",
model.face_size_local_resize_plan(face_id, target, axis),
)
_assert_positive_target_blocked(
f"Face {axis} owning feature target={target:g}",
model.face_size_owning_scale_plan(face_id, target, axis),
)
_assert_blocked("Face area current face non-numeric target", model.face_area_local_resize_plan(face_id, "abc"))
_assert_blocked("Face area owning feature non-numeric target", model.face_area_scale_plan(face_id, "abc"))
for axis in ("width", "height"):
_assert_blocked(
f"Face {axis} current face non-numeric target",
model.face_size_local_resize_plan(face_id, "abc", axis),
)
_assert_blocked(
f"Face {axis} owning feature non-numeric target",
model.face_size_owning_scale_plan(face_id, "abc", axis),
)
def _run_shell_invalid_targets() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_invalid_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
for target in (0.0, -1.0):
_assert_positive_target_blocked(
f"thin wall current face target={target:g}",
model.shell_thickness_plan(face_id, target),
)
_assert_positive_target_blocked(
f"thin wall owning feature target={target:g}",
model.shell_thickness_owning_scale_plan(face_id, target),
)
_assert_blocked("thin wall current face non-numeric target", model.shell_thickness_plan(face_id, "abc"))
_assert_blocked(
"thin wall owning feature non-numeric target",
model.shell_thickness_owning_scale_plan(face_id, "abc"),
)
def main() -> int:
_run_cube_invalid_targets()
_run_shell_invalid_targets()
print("Face invalid target guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,165 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
TOLERANCE = 1e-5
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
metrics.append(
(
face_id,
float(info.get("area") or 0.0),
float(info.get("local_face_width") or 0.0),
float(info.get("local_face_height") or 0.0),
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _first_plane_face_near_size(model: StepModel, width: float, height: float) -> int:
for face_id, _area, face_width, face_height, _center in _plane_metrics(model):
if abs(face_width - width) <= TOLERANCE and abs(face_height - height) <= TOLERANCE:
return face_id
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _center(info: dict[str, object]) -> tuple[float, float, float]:
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face lacks a stable center: {info}")
return float(center[0]), float(center[1]), float(center[2])
def _assert_logical_face(
model: StepModel,
logical_id: int,
label: str,
*,
area: float | None = None,
width: float | None = None,
height: float | None = None,
center: tuple[float, float, float] | None = None,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"{label}: logical Face {logical_id} was not retained")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"{label}: logical Face {logical_id} did not resolve into retained matches {matches}")
info = model.face_info(resolved)
if info.get("surface") != "plane":
raise SystemExit(f"{label}: retained logical Face should be planar, got {info.get('surface')}")
if area is not None and abs(float(info.get("area") or 0.0) - area) > TOLERANCE:
raise SystemExit(f"{label}: retained Face area should be {area:g}, got {info.get('area')}")
if width is not None and abs(float(info.get("local_face_width") or 0.0) - width) > TOLERANCE:
raise SystemExit(f"{label}: retained Face width should be {width:g}, got {info.get('local_face_width')}")
if height is not None and abs(float(info.get("local_face_height") or 0.0) - height) > TOLERANCE:
raise SystemExit(f"{label}: retained Face height should be {height:g}, got {info.get('local_face_height')}")
if center is not None and _distance(_center(info), center) > TOLERANCE:
raise SystemExit(f"{label}: retained Face center should be {center}, got {_center(info)}")
return resolved
def _fresh_selected_face() -> tuple[StepModel, int, int, dict[str, object]]:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
return model, face_id, model.face_region_logical_id(face_id), model.face_info(face_id)
def _target_center(info: dict[str, object], offset: tuple[float, float, float]) -> tuple[float, float, float]:
center = _center(info)
return center[0] + offset[0], center[1] + offset[1], center[2] + offset[2]
def _offset_target_center(model: StepModel, face_id: int, info: dict[str, object], distance: float) -> tuple[float, float, float]:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face lacks a stable plane offset frame")
_origin, direction, _position = frame
center = _center(info)
return (
center[0] + direction[0] * distance,
center[1] + direction[1] * distance,
center[2] + direction[2] * distance,
)
def main() -> int:
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_size_local(face_id, 15.0, "width")
resolved = _assert_logical_face(model, logical_id, "local Face width", width=15.0, height=10.0)
print(f"local width logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_size_owning_scale(face_id, 15.0, "width")
resolved = _assert_logical_face(model, logical_id, "owning Face width", width=15.0, height=10.0)
print(f"owning width logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_area_local(face_id, 144.0)
resolved = _assert_logical_face(model, logical_id, "local Face area", area=144.0)
print(f"local area logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_area(face_id, 144.0)
resolved = _assert_logical_face(model, logical_id, "owning Face area", area=144.0)
print(f"owning area logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, info = _fresh_selected_face()
center = _target_center(info, (2.0, 0.0, 3.0))
model.move_face_center_local(face_id, center)
resolved = _assert_logical_face(model, logical_id, "local Face center", center=center)
print(f"local center logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, info = _fresh_selected_face()
center = _target_center(info, (2.0, 0.0, 3.0))
model.move_face_center_owning(face_id, center)
resolved = _assert_logical_face(model, logical_id, "owning Face center", center=center)
print(f"owning center logical {logical_id} -> Face {resolved}")
for label, operation, distance in (
("local Face plane offset", "local", 1.0),
("owning Face plane offset", "owning", 1.0),
("push/pull Face offset", "push_pull", 1.0),
("push/pull inward Face offset", "push_pull", -1.0),
):
model, face_id, logical_id, info = _fresh_selected_face()
target_center = _offset_target_center(model, face_id, info, distance)
if operation == "local":
model.move_face_plane_offset_local(face_id, distance)
elif operation == "owning":
model.translate_face_plane_offset_owning(face_id, distance)
else:
model.push_pull_face(face_id, distance)
resolved = _assert_logical_face(model, logical_id, label, center=target_center)
print(f"{label} logical {logical_id} -> Face {resolved}")
print("Face logical selection retention ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_state import WindowStateMixin
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _write_cylinder(path: Path) -> None:
cylinder = BRepPrimAPI_MakeCylinder(4.0, 8.0).Shape()
_write_step(cylinder, path)
def _first_planar_cap_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no planar cylinder cap Face was found")
def _assert_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked for a planar Face on a curved Solid: {plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if "曲面" not in message:
raise SystemExit(f"{label} should explain that the owning Solid contains curved faces: {plan}")
def _specs(face_id: int, info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
return specs
def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]:
for item in specs:
if item.get("key") == key:
return item
raise SystemExit(f"{key} spec was not found")
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
spec = _spec(specs, key)
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or mode not in modes:
raise SystemExit(f"{key} has no scope mode {mode}")
selected = modes[mode]
if not isinstance(selected, dict):
raise SystemExit(f"{key} scope mode {mode} is invalid")
return selected
def _assert_local_scope_explains_curved_owner(specs: list[dict[str, object]], key: str) -> None:
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key}/local should be disabled for a planar Face on a curved Solid")
disabled_tip = str(local_mode.get("disabled_tip") or "")
if "曲面" not in disabled_tip:
raise SystemExit(f"{key}/local disabled tip should mention curved owner: {disabled_tip}")
def _bbox_height(model: StepModel) -> float:
info = model.part_info(1)
bbox_size = info.get("bbox_size")
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
raise SystemExit(f"part bbox_size is missing: {info}")
return float(bbox_size[2])
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_mixed_surface_") as temp_dir:
path = Path(temp_dir) / "cylinder.step"
_write_cylinder(path)
model = StepModel.load(path)
face_id = _first_planar_cap_face(model)
info = model.face_info(face_id)
if bool(info.get("local_face_deform_ready", True)):
raise SystemExit(f"planar cylinder cap should not allow local Face deformation: {info}")
blocker = str(info.get("local_face_deform_blocker") or "")
if "曲面" not in blocker:
raise SystemExit(f"planar cylinder cap blocker should mention curved owner: {info}")
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} does not expose a stable center")
target_center = (float(center[0]), float(center[1]), float(center[2]) + 1.0)
area = float(info.get("area") or 0.0)
if area <= 0:
raise SystemExit(f"Face {face_id} does not expose a stable area")
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
specs = _specs(face_id, info)
semantics = _spec(specs, "face_edit_semantics")
if "不能只改当前面" not in str(semantics.get("current_text") or ""):
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
if "曲面" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the curved-owner blocker: {semantics}")
for key in ("area", "face_center_position", "face_target_normal_position"):
_assert_local_scope_explains_curved_owner(specs, key)
push_pull_mode = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(push_pull_mode.get("enabled", False)):
raise SystemExit("planar cylinder cap push/pull scope should remain available")
before_height = _bbox_height(model)
push_plan = model.push_pull_plan(face_id, 1.0)
if push_plan.get("status") == "blocked":
raise SystemExit(f"planar cylinder cap push/pull should remain available: {push_plan}")
push_result = model.push_pull_face(face_id, 1.0)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"planar cylinder cap push/pull should keep one solid: {stats}")
after_height = _bbox_height(model)
if after_height <= before_height:
raise SystemExit(f"planar cylinder cap push/pull should increase bbox height: {before_height} -> {after_height}")
print(
"mixed-surface planar Face guard ok: "
f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, "
f"push_pull={push_result}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,224 @@
from __future__ import annotations
import math
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCC.Core.gp import gp_Pnt, gp_Vec
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_triangular_prism(path: Path) -> None:
polygon = BRepBuilderAPI_MakePolygon()
polygon.Add(gp_Pnt(0.0, 0.0, 0.0))
polygon.Add(gp_Pnt(12.0, 0.0, 0.0))
polygon.Add(gp_Pnt(0.0, 8.0, 0.0))
polygon.Close()
if hasattr(polygon, "IsDone") and not polygon.IsDone():
raise RuntimeError("Could not create triangular prism profile.")
face_maker = BRepBuilderAPI_MakeFace(polygon.Wire())
if hasattr(face_maker, "IsDone") and not face_maker.IsDone():
raise RuntimeError("Could not create triangular prism face.")
prism = BRepPrimAPI_MakePrism(face_maker.Face(), gp_Vec(0.0, 0.0, 6.0)).Shape()
_write_step(prism, path)
def _triangle_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if int(info.get("local_face_size_source_point_count") or 0) == 3:
return face_id
raise SystemExit("no triangular planar Face was found")
def _triangle_infos(model: StepModel) -> list[dict[str, object]]:
infos: list[dict[str, object]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if int(info.get("local_face_size_source_point_count") or 0) == 3:
infos.append(info)
return infos
def _center(info: dict[str, object]) -> tuple[float, float, float]:
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"triangular Face lacks a stable center: {info}")
return float(center[0]), float(center[1]), float(center[2])
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _nearest_center_error(model: StepModel, target: tuple[float, float, float]) -> float:
infos = _triangle_infos(model)
if not infos:
raise SystemExit("local edit removed every triangular Face")
return min(_distance(_center(info), target) for info in infos)
def _has_triangle_area(model: StepModel, target_area: float, tolerance: float = 1e-4) -> bool:
return any(abs(float(info.get("area") or 0.0) - target_area) <= tolerance for info in _triangle_infos(model))
def _has_triangle_size(
model: StepModel,
key: str,
target_size: float,
tolerance: float = 1e-4,
) -> bool:
return any(abs(float(info.get(key) or 0.0) - target_size) <= tolerance for info in _triangle_infos(model))
def _assert_not_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") == "blocked":
raise SystemExit(f"{label} should be available for a simple triangular Face: {plan}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label} should keep one Solid, got {stats}")
def _fresh_model(path: Path) -> tuple[StepModel, int, dict[str, object]]:
model = StepModel.load(path)
face_id = _triangle_face_id(model)
return model, face_id, model.face_info(face_id)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_triangle_") as temp_dir:
path = Path(temp_dir) / "triangular_prism.step"
_write_triangular_prism(path)
model, face_id, info = _fresh_model(path)
current_center = _center(info)
target_center = (current_center[0] + 1.0, current_center[1] + 0.5, current_center[2] + 1.5)
plan = model.face_center_local_move_plan(face_id, target_center)
_assert_not_blocked(plan, "triangular Face center move")
model.move_face_center_local(face_id, target_center)
_assert_single_solid(model, "triangular Face center move")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face center move did not reach the target center")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
plan = model.face_area_local_resize_plan(face_id, target_area)
_assert_not_blocked(plan, "triangular Face area resize")
model.resize_face_area_local(face_id, target_area)
_assert_single_solid(model, "triangular Face area resize")
if not _has_triangle_area(model, target_area):
raise SystemExit("triangular Face area resize did not create the target area")
for axis, key in (("width", "local_face_width"), ("height", "local_face_height")):
model, face_id, info = _fresh_model(path)
current_size = float(info.get(key) or 0.0)
target_size = current_size * 1.25
plan = model.face_size_local_resize_plan(face_id, target_size, axis)
_assert_not_blocked(plan, f"triangular Face {axis} resize")
model.resize_face_size_local(face_id, target_size, axis)
_assert_single_solid(model, f"triangular Face {axis} resize")
if not _has_triangle_size(model, key, target_size):
raise SystemExit(f"triangular Face {axis} resize did not create the target size")
model, face_id, info = _fresh_model(path)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("triangular Face lacks a stable plane offset frame")
_origin, direction, _position = frame
current_center = _center(info)
offset_distance = 1.0
target_center = (
current_center[0] + direction[0] * offset_distance,
current_center[1] + direction[1] * offset_distance,
current_center[2] + direction[2] * offset_distance,
)
plan = model.face_plane_offset_local_plan(face_id, offset_distance)
_assert_not_blocked(plan, "triangular Face plane offset")
model.move_face_plane_offset_local(face_id, offset_distance)
_assert_single_solid(model, "triangular Face plane offset")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face plane offset did not reach the target plane")
model, face_id, _info = _fresh_model(path)
plan = model.push_pull_plan(face_id, 1.0)
_assert_not_blocked(plan, "triangular Face push/pull")
model.push_pull_face(face_id, 1.0)
_assert_single_solid(model, "triangular Face push/pull")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
plan = model.face_area_scale_plan(face_id, target_area)
_assert_not_blocked(plan, "triangular Face owning area resize")
model.resize_face_area(face_id, target_area)
_assert_single_solid(model, "triangular Face owning area resize")
if not _has_triangle_area(model, target_area):
raise SystemExit("triangular Face owning area resize did not create the target area")
for axis, key in (("width", "local_face_width"), ("height", "local_face_height")):
model, face_id, info = _fresh_model(path)
current_size = float(info.get(key) or 0.0)
target_size = current_size * 1.25
plan = model.face_size_owning_scale_plan(face_id, target_size, axis)
_assert_not_blocked(plan, f"triangular Face owning {axis} resize")
model.resize_face_size_owning_scale(face_id, target_size, axis)
_assert_single_solid(model, f"triangular Face owning {axis} resize")
if not _has_triangle_size(model, key, target_size):
raise SystemExit(f"triangular Face owning {axis} resize did not create the target size")
model, face_id, info = _fresh_model(path)
current_center = _center(info)
offset = (1.0, 0.5, 1.5)
target_center = (
current_center[0] + offset[0],
current_center[1] + offset[1],
current_center[2] + offset[2],
)
plan = model.face_center_owning_translation_plan(face_id, target_center)
_assert_not_blocked(plan, "triangular Face owning center move")
model.move_face_center_owning(face_id, target_center)
_assert_single_solid(model, "triangular Face owning center move")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face owning center move did not reach the target center")
model, face_id, info = _fresh_model(path)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("triangular Face lacks a stable plane offset frame")
_origin, direction, _position = frame
current_center = _center(info)
target_center = (
current_center[0] + direction[0],
current_center[1] + direction[1],
current_center[2] + direction[2],
)
plan = model.face_plane_offset_owning_translation_plan(face_id, 1.0)
_assert_not_blocked(plan, "triangular Face owning plane offset")
model.translate_face_plane_offset_owning(face_id, 1.0)
_assert_single_solid(model, "triangular Face owning plane offset")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face owning plane offset did not reach the target plane")
print("non-rectangular planar Face edit semantics ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_noop_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message", ""))
if status != "blocked":
raise SystemExit(f"{label} no-op plan was not blocked: status={status}, message={message}")
if not any(fragment in message for fragment in ("不需要修改", "无需移动", "为 0", "几乎相同")):
raise SystemExit(f"{label} no-op blocker did not explain unchanged target: {message}")
def _run_cube_face_noops() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("plane Face center is missing")
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
_assert_noop_blocked("Face push/pull distance", model.push_pull_plan(face_id, 0.0))
_assert_noop_blocked("Face offset current face", model.face_plane_offset_local_plan(face_id, 0.0))
_assert_noop_blocked("Face offset owning feature", model.face_plane_offset_owning_translation_plan(face_id, 0.0))
_assert_noop_blocked("Face center current face", model.face_center_local_move_plan(face_id, center))
_assert_noop_blocked("Face center owning feature", model.face_center_owning_translation_plan(face_id, center))
_assert_noop_blocked("Face area current face", model.face_area_local_resize_plan(face_id, area))
_assert_noop_blocked("Face area owning feature", model.face_area_scale_plan(face_id, area))
_assert_noop_blocked("Face width current face", model.face_size_local_resize_plan(face_id, width, "width"))
_assert_noop_blocked("Face width owning feature", model.face_size_owning_scale_plan(face_id, width, "width"))
_assert_noop_blocked("Face height current face", model.face_size_local_resize_plan(face_id, height, "height"))
_assert_noop_blocked("Face height owning feature", model.face_size_owning_scale_plan(face_id, height, "height"))
def _run_shell_noops() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_noop_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
current = float(model.feature_info(face_id).get("shell_thickness_estimate") or 0.0)
_assert_noop_blocked("thin wall current face", model.shell_thickness_plan(face_id, current))
_assert_noop_blocked("thin wall owning feature", model.shell_thickness_owning_scale_plan(face_id, current))
def main() -> int:
_run_cube_face_noops()
_run_shell_noops()
print("Face no-op guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.window_state import WindowStateMixin
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _specs(face_id: int, info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
return specs
def _spec(face_id: int, info: dict[str, object], key: str) -> dict[str, object]:
for spec in _specs(face_id, info):
if spec.get("key") == key:
return spec
raise SystemExit(f"{key} spec was not found for Face {face_id}")
def _float(value: object, label: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"{label} should be numeric, got {value!r}") from exc
def _assert_close(label: str, value: object, expected: float, tolerance: float = 1e-5) -> None:
number = _float(value, label)
if abs(number - expected) > tolerance:
raise SystemExit(f"{label} should be {expected:g}, got {number:g}")
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
metrics.append(
(
face_id,
float(info.get("area") or 0.0),
float(info.get("local_face_width") or 0.0),
float(info.get("local_face_height") or 0.0),
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float = 1e-5) -> int:
for face_id, _area, face_width, face_height, _center in _plane_metrics(model):
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
if direct or swapped:
return face_id
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
def _nearest_plane_center(
model: StepModel,
target: tuple[float, float, float],
) -> tuple[int, tuple[float, float, float], float]:
best: tuple[int, tuple[float, float, float], float] | None = None
for face_id, _area, _width, _height, center in _plane_metrics(model):
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error)
if best is None:
raise SystemExit("no plane Face center could be measured")
return best
def _assert_spec_readback(
model: StepModel,
face_id: int,
key: str,
expected: float,
tolerance: float = 1e-5,
) -> None:
info = model.face_info(face_id)
spec = _spec(face_id, info, key)
_assert_close(f"{key} current_raw", spec.get("current_raw"), expected, tolerance)
_assert_close(f"{key} target_text", spec.get("target_text"), expected, tolerance)
if spec.get("status_text") == "不可修改":
raise SystemExit(f"{key} should remain editable after readback: {spec}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
model.resize_face_size_local(face_id, 15.0, "width")
face_id = _first_plane_face_near_size(model, 15.0, 10.0)
_assert_spec_readback(model, face_id, "local_face_width", 15.0)
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
model.resize_face_area_local(face_id, 144.0)
for candidate_id, area, _width, _height, _center in _plane_metrics(model):
if abs(area - 144.0) <= 1e-5:
_assert_spec_readback(model, candidate_id, "area", 144.0)
break
else:
raise SystemExit("no plane Face near area 144 after local area resize")
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("selected Face lacks a stable center")
target_center = (float(center[0]) + 2.0, float(center[1]), float(center[2]) + 3.0)
model.move_face_center_local(face_id, target_center)
moved_face_id, _moved_center, center_error = _nearest_plane_center(model, target_center)
if center_error > 1e-5:
raise SystemExit(f"no plane Face near moved center {target_center}")
moved_info = model.face_info(moved_face_id)
center_spec = _spec(moved_face_id, moved_info, "face_center_position")
if str(center_spec.get("target_text") or "").replace(" ", "") != "7,5,3":
raise SystemExit(f"Face center target_text did not read back the moved center: {center_spec}")
for strategy in ("local", "owning", "push_pull"):
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
info = model.face_info(face_id)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face lacks a stable plane offset frame")
_origin, direction, old_position = frame
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("selected Face lacks a stable center")
target_center = (
float(center[0]) + direction[0],
float(center[1]) + direction[1],
float(center[2]) + direction[2],
)
if strategy == "local":
model.move_face_plane_offset_local(face_id, 1.0)
elif strategy == "owning":
model.translate_face_plane_offset_owning(face_id, 1.0)
else:
model.push_pull_face(face_id, 1.0)
moved_face_id, _moved_center, center_error = _nearest_plane_center(model, target_center)
if center_error > 1e-5:
raise SystemExit(f"no plane Face near offset target for strategy {strategy}")
moved_info = model.face_info(moved_face_id)
spec = _spec(moved_face_id, moved_info, "face_target_normal_position")
current_raw = _float(spec.get("current_raw"), f"{strategy} face_target_normal_position current_raw")
target_text = _float(spec.get("target_text"), f"{strategy} face_target_normal_position target_text")
if abs(current_raw) <= 1e-6:
raise SystemExit(f"{strategy} offset readback still looks unchanged: {spec}")
if abs(current_raw - target_text) > 1e-5:
raise SystemExit(f"{strategy} offset target_text should match refreshed current value: {spec}")
if abs(abs(current_raw) - abs(old_position + 1.0)) > 1e-5:
raise SystemExit(f"{strategy} offset readback has unexpected plane position: {spec}")
print("Face property readback ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _assert_plan(label: str, plan: dict[str, object], status: str, risk: str | None = None) -> None:
actual_status = str(plan.get("status", ""))
actual_risk = str(plan.get("risk", ""))
if actual_status != status:
raise SystemExit(f"{label} expected status={status}, got {actual_status}: {plan}")
if risk is not None and actual_risk != risk:
raise SystemExit(f"{label} expected risk={risk}, got {actual_risk}: {plan}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
shallow = model.push_pull_plan(face_id, -1.0)
_assert_plan("shallow inward cut", shallow, "ready", "low")
if abs(float(shallow.get("push_pull_inward_material_depth") or 0.0) - 10.0) > 1e-5:
raise SystemExit(f"shallow cut should report 10mm inward material depth: {shallow}")
if abs(float(shallow.get("push_pull_inward_cut_ratio") or 0.0) - 0.1) > 1e-5:
raise SystemExit(f"shallow cut should report 0.1 inward cut ratio: {shallow}")
near_through = model.push_pull_plan(face_id, -9.0)
_assert_plan("near-through inward cut", near_through, "caution", "high")
if float(near_through.get("push_pull_inward_cut_ratio") or 0.0) < 0.85:
raise SystemExit(f"near-through cut should report a high inward cut ratio: {near_through}")
through = model.push_pull_plan(face_id, -10.0)
_assert_plan("through inward cut", through, "blocked", "blocked")
message = str(through.get("message") or through.get("blockers") or "")
if "材料厚度" not in message and "切空" not in message:
raise SystemExit(f"through cut blocker should explain material depth: {through}")
try:
model.push_pull_face(face_id, -10.0)
except ValueError:
pass
else:
raise SystemExit("push_pull_face should reject an inward cut that reaches full material depth")
print("Face push/pull limit guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_state import WindowStateMixin
SOURCE_WIDTH = 10.0
SOURCE_HEIGHT = 20.0
TARGET_WIDTH = 14.0
TARGET_HEIGHT = 26.0
TOLERANCE = 2e-4
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _write_rectangular_box(path: Path) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 10.0, 6.0).Shape()
_write_step(shape, path)
def _plane_infos(model: StepModel) -> list[tuple[int, float, float]]:
infos: list[tuple[int, float, float]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
infos.append((face_id, width, height))
return infos
def _face_near_size(model: StepModel, width: float, height: float) -> int:
for face_id, face_width, face_height in _plane_infos(model):
if abs(face_width - width) <= TOLERANCE and abs(face_height - height) <= TOLERANCE:
return face_id
raise SystemExit(f"no plane Face near width={width:g}, height={height:g}; got {_plane_infos(model)}")
def _assert_close(label: str, value: object, expected: float) -> None:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"{label} should be numeric, got {value!r}") from exc
if abs(number - expected) > TOLERANCE:
raise SystemExit(f"{label} should be {expected:g}, got {number:g}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label} should keep one Solid, got {stats}")
def _assert_size_specs(model: StepModel, face_id: int, width: float, height: float, label: str) -> None:
info = model.face_info(face_id)
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
by_key = {str(spec.get("key")): spec for spec in specs}
width_spec = by_key.get("local_face_width")
height_spec = by_key.get("local_face_height")
if width_spec is None or height_spec is None:
raise SystemExit(f"{label} should expose width and height specs: {sorted(by_key)}")
_assert_close(f"{label} width current_raw", width_spec.get("current_raw"), width)
_assert_close(f"{label} width target_text", width_spec.get("target_text"), width)
_assert_close(f"{label} height current_raw", height_spec.get("current_raw"), height)
_assert_close(f"{label} height target_text", height_spec.get("target_text"), height)
if width_spec.get("status_text") == "不可修改" or height_spec.get("status_text") == "不可修改":
raise SystemExit(f"{label} width/height should remain editable: {width_spec} {height_spec}")
def _axis_index(direction: object) -> int:
if not isinstance(direction, tuple) or len(direction) != 3:
raise SystemExit(f"axis direction should be a vector, got {direction!r}")
values = [abs(float(direction[0])), abs(float(direction[1])), abs(float(direction[2]))]
return max(range(3), key=lambda index: values[index])
def _bbox_size(model: StepModel) -> tuple[float, float, float]:
return tuple(float(value) for value in model.geometry_stats()["bbox_size"])
def _run_local_case(path: Path, axis: str, target_size: float, expected_width: float, expected_height: float) -> None:
model = StepModel.load(path)
face_id = _face_near_size(model, SOURCE_WIDTH, SOURCE_HEIGHT)
before = model.stats()
plan = model.face_size_local_resize_plan(face_id, target_size, axis)
if plan.get("status") == "blocked":
raise SystemExit(f"local rectangular {axis} plan was blocked: {plan}")
expected_strategy = f"local-face-{axis}-only-deform"
if plan.get("resize_strategy") != expected_strategy:
raise SystemExit(f"local rectangular {axis} expected {expected_strategy}, got {plan}")
result = model.resize_face_size_local(face_id, target_size, axis)
_assert_single_solid(model, f"local rectangular {axis}")
if model.stats().faces != before.faces:
raise SystemExit(f"local rectangular {axis} should keep face count stable: before={before}, after={model.stats()}")
edited_face_id = _face_near_size(model, expected_width, expected_height)
_assert_size_specs(model, edited_face_id, expected_width, expected_height, f"local rectangular {axis}")
print(f"local {axis}: Face {face_id} -> {edited_face_id}, size={expected_width:g} x {expected_height:g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_owning_case(path: Path, axis: str, target_size: float, expected_width: float, expected_height: float) -> None:
model = StepModel.load(path)
face_id = _face_near_size(model, SOURCE_WIDTH, SOURCE_HEIGHT)
before = model.stats()
before_bbox = _bbox_size(model)
plan = model.face_size_owning_scale_plan(face_id, target_size, axis)
if plan.get("status") == "blocked":
raise SystemExit(f"owning rectangular {axis} plan was blocked: {plan}")
expected_strategy = f"axis-scale-owning-shape-from-face-{axis}"
if plan.get("resize_strategy") != expected_strategy:
raise SystemExit(f"owning rectangular {axis} expected {expected_strategy}, got {plan}")
if plan.get("owning_face_size_rebuild_mode") != "planar-rebuild":
raise SystemExit(f"simple rectangular box should use planar rebuild for owning {axis}: {plan}")
resized_axis = _axis_index(plan.get("face_size_axis_direction"))
result = model.resize_face_size_owning_scale(face_id, target_size, axis)
_assert_single_solid(model, f"owning rectangular {axis}")
if model.stats().faces != before.faces:
raise SystemExit(f"owning rectangular {axis} should keep face count stable: before={before}, after={model.stats()}")
after_bbox = _bbox_size(model)
_assert_close(f"owning rectangular {axis} bbox axis {resized_axis}", after_bbox[resized_axis], target_size)
for index, before_size in enumerate(before_bbox):
if index == resized_axis:
continue
_assert_close(f"owning rectangular {axis} unchanged bbox axis {index}", after_bbox[index], before_size)
edited_face_id = _face_near_size(model, expected_width, expected_height)
_assert_size_specs(model, edited_face_id, expected_width, expected_height, f"owning rectangular {axis}")
print(f"owning {axis}: Face {face_id} -> {edited_face_id}, size={expected_width:g} x {expected_height:g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_rect_axes_") as temp_dir:
path = Path(temp_dir) / "rectangular_box.step"
_write_rectangular_box(path)
_run_local_case(path, "width", TARGET_WIDTH, TARGET_WIDTH, SOURCE_HEIGHT)
_run_local_case(path, "height", TARGET_HEIGHT, SOURCE_WIDTH, TARGET_HEIGHT)
_run_owning_case(path, "width", TARGET_WIDTH, TARGET_WIDTH, SOURCE_HEIGHT)
_run_owning_case(path, "height", TARGET_HEIGHT, SOURCE_WIDTH, TARGET_HEIGHT)
print("rectangular Face width/height axis semantics ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+237 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
from pathlib import Path from pathlib import Path
import subprocess
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -12,6 +13,20 @@ from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
FACE_CASES = (
("size", "local", "width"),
("size", "owning", "width"),
("size", "local", "height"),
("size", "owning", "height"),
("area", "local", "width"),
("area", "owning", "width"),
("center", "local", "width"),
("center", "owning", "width"),
("offset", "local", "width"),
("offset", "owning", "width"),
("offset", "push_pull", "width"),
)
def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int: def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int:
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
@@ -40,6 +55,30 @@ def _plane_sizes(model: StepModel) -> list[tuple[float, float, float]]:
return sorted(sizes) return sorted(sizes)
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
metrics.append(
(
face_id,
area,
width,
height,
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool: def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool:
for _area, face_width, face_height in _plane_sizes(model): for _area, face_width, face_height in _plane_sizes(model):
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
@@ -49,31 +88,197 @@ def _has_plane_size(model: StepModel, width: float, height: float, tolerance: fl
return False return False
def _has_plane_area(model: StepModel, area: float, tolerance: float) -> bool:
return any(abs(candidate_area - area) <= tolerance for _face_id, candidate_area, _w, _h, _c in _plane_metrics(model))
def _nearest_plane_center(
model: StepModel,
target: tuple[float, float, float],
) -> tuple[int, tuple[float, float, float], float, float, float]:
best: tuple[int, tuple[float, float, float], float, float, float] | None = None
for face_id, area, width, height, center in _plane_metrics(model):
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error, width, height)
if best is None:
raise SystemExit("no plane Face center could be measured")
return best
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _parse_vector3(text: str) -> tuple[float, float, float]:
parts = [part.strip() for part in text.replace("", ",").split(",")]
if len(parts) != 3:
raise SystemExit("--center-offset must be in X,Y,Z format")
try:
return float(parts[0]), float(parts[1]), float(parts[2])
except ValueError as exc:
raise SystemExit("--center-offset must contain numbers") from exc
def _run_all_cases(args: argparse.Namespace) -> int:
script = Path(__file__).resolve()
common = [
str(script),
str(args.model),
"--source-size",
str(args.source_size),
"--other-size",
str(args.other_size),
"--target-size",
str(args.target_size),
"--target-area",
str(args.target_area),
"--center-offset",
str(args.center_offset),
"--offset-distance",
str(args.offset_distance),
"--tolerance",
str(args.tolerance),
]
for property_name, strategy, axis in FACE_CASES:
print(f"\n=== Face case: property={property_name} strategy={strategy} axis={axis} ===", flush=True)
command = [
sys.executable,
*common,
"--property",
property_name,
"--strategy",
strategy,
"--axis",
axis,
]
completed = subprocess.run(command, check=False)
if completed.returncode != 0:
return completed.returncode
return 0
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.") parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.") parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--all", action="store_true", help="Run all Face semantic edit cases on fresh model loads.")
parser.add_argument("--property", default="size", choices=["size", "area", "center", "offset"])
parser.add_argument("--axis", default="width", choices=["width", "height"]) parser.add_argument("--axis", default="width", choices=["width", "height"])
parser.add_argument("--source-size", type=float, default=10.0) parser.add_argument("--source-size", type=float, default=10.0)
parser.add_argument("--other-size", type=float, default=10.0) parser.add_argument("--other-size", type=float, default=10.0)
parser.add_argument("--target-size", type=float, default=15.0) parser.add_argument("--target-size", type=float, default=15.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning"]) parser.add_argument("--target-area", type=float, default=144.0)
parser.add_argument("--center-offset", default="2,0,3")
parser.add_argument("--offset-distance", type=float, default=1.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull"])
parser.add_argument("--tolerance", type=float, default=1e-5) parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args() args = parser.parse_args()
if args.all:
return _run_all_cases(args)
model = StepModel.load(Path(args.model)) model = StepModel.load(Path(args.model))
face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance) face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance)
before = model.stats() before = model.stats()
current_info = model.face_info(face_id)
if args.strategy == "local": if args.strategy == "push_pull" and args.property != "offset":
raise SystemExit("--strategy push_pull is only valid with --property offset")
if args.property == "size" and args.strategy == "local":
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis) plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local(face_id, args.target_size, args.axis) result = model.resize_face_size_local(face_id, args.target_size, args.axis)
expected_strategy = f"local-face-{args.axis}-only-deform" expected_strategy = f"local-face-{args.axis}-only-deform"
else: elif args.property == "size":
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis) plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis) result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}" expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "area" and args.strategy == "local":
plan = model.face_area_local_resize_plan(face_id, args.target_area)
result = model.resize_face_area_local(face_id, args.target_area)
expected_strategy = "local-face-area-only-deform"
elif args.property == "area":
plan = model.face_area_scale_plan(face_id, args.target_area)
result = model.resize_face_area(face_id, args.target_area)
expected_strategy = "uniform-scale-face-area-fallback"
elif args.property == "center" and args.strategy == "local":
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
plan = model.face_center_local_move_plan(face_id, target_center)
result = model.move_face_center_local(face_id, target_center)
expected_strategy = "local-face-only-deform"
elif args.property == "center":
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
plan = model.face_center_owning_translation_plan(face_id, target_center)
result = model.move_face_center_owning(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
elif args.property == "offset" and args.strategy == "local":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_local_plan(face_id, args.offset_distance)
result = model.move_face_plane_offset_local(face_id, args.offset_distance)
expected_strategy = "local-face-plane-offset-deform"
elif args.property == "offset" and args.strategy == "owning":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_owning_translation_plan(face_id, args.offset_distance)
result = model.translate_face_plane_offset_owning(face_id, args.offset_distance)
expected_strategy = "translate-owning-shape-from-plane-offset"
else:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_plan(face_id, args.offset_distance)
result = model.push_pull_face(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face"
resolved_strategy = str(plan.get("resize_strategy", "")) resolved_strategy = str(plan.get("resize_strategy", ""))
if args.property == "offset" and args.strategy == "push_pull":
resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy: if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}") raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
@@ -83,13 +288,40 @@ def main() -> int:
if after.faces < before.faces: if after.faces < before.faces:
raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}") raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}")
extra_lines: list[str] = []
if args.property == "size":
width = args.target_size if args.axis == "width" else args.other_size width = args.target_size if args.axis == "width" else args.other_size
height = args.other_size if args.axis == "width" else args.target_size height = args.other_size if args.axis == "width" else args.target_size
if not _has_plane_size(model, width, height, args.tolerance): if not _has_plane_size(model, width, height, args.tolerance):
raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}") raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}")
extra_lines.append(f"target_size_pair=({width:.6f}, {height:.6f})")
elif args.property == "area":
if not _has_plane_area(model, args.target_area, args.tolerance):
raise SystemExit(f"no resulting plane Face near area {args.target_area:g}")
extra_lines.append(f"target_area={args.target_area:.6f}")
elif args.property == "center":
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
else:
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near offset target {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"offset_distance={args.offset_distance:.6f}")
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
print(f"model={Path(args.model)}") print(f"model={Path(args.model)}")
print(f"face_id={face_id}") print(f"face_id={face_id}")
print(f"property={args.property}")
print(f"strategy={args.strategy}") print(f"strategy={args.strategy}")
print(f"axis={args.axis}") print(f"axis={args.axis}")
print(f"resolved_strategy={resolved_strategy}") print(f"resolved_strategy={resolved_strategy}")
@@ -97,6 +329,8 @@ def main() -> int:
print(f"before={before}") print(f"before={before}")
print(f"after={after}") print(f"after={after}")
print(f"plane_sizes={_plane_sizes(model)}") print(f"plane_sizes={_plane_sizes(model)}")
for line in extra_lines:
print(line)
print(result.encode("ascii", "backslashreplace").decode("ascii")) print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0 return 0
+62
View File
@@ -72,6 +72,17 @@ def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int:
return candidates[0] return candidates[0]
def _first_circle_edge_adjacent_to_face(model: StepModel, face_id: int) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
adjacent_face_ids = tuple(int(item) for item in info.get("adjacent_face_ids", ()) or ())
if face_id in adjacent_face_ids:
return edge_id
raise SystemExit(f"no circular edge adjacent to Face {face_id} was recognized")
def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id) info = model.face_info(face_id)
axis_point = info.get("axis_point") axis_point = info.get("axis_point")
@@ -208,6 +219,52 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii")) print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_circle_edge_axis_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=False)
edge_id = _first_circle_edge_adjacent_to_face(model, face_id)
before = model.stats()
current_axis_center = _axis_center(model, face_id)
current_diameter = _diameter(model, face_id)
edge_info = model.edge_info(edge_id)
edge_center = edge_info.get("center")
if not isinstance(edge_center, tuple):
raise SystemExit(f"circle edge center is missing on Edge {edge_id}")
target_edge_center = (float(edge_center[0]) + offset, float(edge_center[1]), float(edge_center[2]))
target_axis_center = (current_axis_center[0] + offset, current_axis_center[1], current_axis_center[2])
plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center)
if plan["status"] == "blocked":
raise SystemExit(f"circle_edge_axis_center plan was blocked: {plan['message']}")
result = model.move_circular_edge_axis_center(edge_id, target_edge_center)
after = model.stats()
verified_face, diameter, center, _ = _nearest_hole_by_diameter(
model,
current_diameter,
target_axis_center,
blind=False,
)
center_error = _distance(center, target_axis_center)
if center_error > tolerance or abs(diameter - current_diameter) > tolerance:
raise SystemExit(
f"circle_edge_axis_center verification failed: target={target_axis_center}, center={center}, "
f"center_error={center_error:g}, diameter={diameter:g}"
)
print("mode=circle_edge_axis_center")
print(f"source_edge={edge_id}")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"delegated_mode={plan.get('circular_edge_cylinder_mode')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target_edge_center={target_edge_center}")
print(f"target_axis_center={target_axis_center} value={center} diameter={diameter:.6f} error={center_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_suppress_case(tolerance: float) -> None: def _run_suppress_case(tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir: with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step" model_path = Path(temp_dir) / "through_hole.step"
@@ -295,6 +352,7 @@ def main() -> int:
"diameter", "diameter",
"diameter_shrink", "diameter_shrink",
"axis_center", "axis_center",
"circle_edge_axis_center",
"suppress", "suppress",
"blind_depth", "blind_depth",
"blind_depth_shallow", "blind_depth_shallow",
@@ -304,6 +362,7 @@ def main() -> int:
parser.add_argument("--diameter", type=float, default=8.0) parser.add_argument("--diameter", type=float, default=8.0)
parser.add_argument("--diameter-shrink", type=float, default=4.0) parser.add_argument("--diameter-shrink", type=float, default=4.0)
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.") parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
parser.add_argument("--circle-edge-axis-center", type=float, default=2.0, help="Circle Edge center X offset to verify.")
parser.add_argument("--blind-depth", type=float, default=8.0) parser.add_argument("--blind-depth", type=float, default=8.0)
parser.add_argument("--blind-depth-shallow", type=float, default=4.0) parser.add_argument("--blind-depth-shallow", type=float, default=4.0)
parser.add_argument("--tolerance", type=float, default=2e-4) parser.add_argument("--tolerance", type=float, default=2e-4)
@@ -314,6 +373,7 @@ def main() -> int:
("diameter", args.diameter), ("diameter", args.diameter),
("diameter_shrink", args.diameter_shrink), ("diameter_shrink", args.diameter_shrink),
("axis_center", args.axis_center), ("axis_center", args.axis_center),
("circle_edge_axis_center", args.circle_edge_axis_center),
("suppress", None), ("suppress", None),
("blind_depth", args.blind_depth), ("blind_depth", args.blind_depth),
("blind_depth_shallow", args.blind_depth_shallow), ("blind_depth_shallow", args.blind_depth_shallow),
@@ -326,6 +386,8 @@ def main() -> int:
_run_diameter_case(float(target), args.tolerance) _run_diameter_case(float(target), args.tolerance)
elif mode == "axis_center": elif mode == "axis_center":
_run_axis_center_case(float(target), args.tolerance) _run_axis_center_case(float(target), args.tolerance)
elif mode == "circle_edge_axis_center":
_run_circle_edge_axis_center_case(float(target), args.tolerance)
elif mode == "suppress": elif mode == "suppress":
_run_suppress_case(args.tolerance) _run_suppress_case(args.tolerance)
elif mode in {"blind_depth", "blind_depth_shallow"}: elif mode in {"blind_depth", "blind_depth_shallow"}:
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _run_worker_case(
*,
label: str,
operation: str,
args: list[object],
validator,
input_path: Path = DEFAULT_MODEL,
):
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_face_verify_") as temp_dir:
temp_root = Path(temp_dir)
output_path = temp_root / "output.step"
request_path = temp_root / "request.json"
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"operation": operation,
"args": args,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
completed = subprocess.run(
[sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
check=False,
)
response_path = request_path.with_suffix(".response.json")
if completed.returncode != 0:
detail = response_path.read_text(encoding="utf-8") if response_path.exists() else completed.stderr
raise SystemExit(f"{label}: isolated worker failed with code {completed.returncode}: {detail}")
response = json.loads(response_path.read_text(encoding="utf-8"))
if not response.get("ok"):
raise SystemExit(f"{label}: isolated worker returned failure: {response}")
if not output_path.exists():
raise SystemExit(f"{label}: isolated worker did not produce output STEP")
model = StepModel.load(output_path)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label}: isolated Face edit changed solid count unexpectedly: {stats}")
validator(label, model)
print(f"isolated Face edit ok: {label}")
print(str(response.get("message", "")).encode("ascii", "backslashreplace").decode("ascii"))
return model
def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
try:
return abs(float(value) - target) <= tolerance
except (TypeError, ValueError):
return False
def _triple_close(value: object, target: tuple[float, float, float], tolerance: float = 1e-5) -> bool:
if not isinstance(value, (list, tuple)) or len(value) != 3:
return False
return all(abs(float(value[index]) - target[index]) <= tolerance for index in range(3))
def _assert_plane_position(label: str, model, target_position: float = 10.0) -> None:
matches: list[tuple[int, float]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
frame = model.face_plane_offset_frame(face_id)
if frame is None:
continue
_origin, _direction, position = frame
if abs(abs(float(position)) - target_position) <= 1e-5:
matches.append((face_id, float(position)))
if not matches:
raise SystemExit(f"{label}: output does not contain a plane at target position {target_position}")
print(f"matched_planes={matches}")
def _assert_face_area(label: str, model, target_area: float = 225.0) -> None:
matches = [
(face_id, float(info.get("area")))
for face_id in range(len(model.faces))
for info in (model.face_info(face_id),)
if _float_close(info.get("area"), target_area, tolerance=1e-4)
]
if not matches:
raise SystemExit(f"{label}: output does not contain a Face with area {target_area}")
print(f"matched_areas={matches}")
def _assert_face_size_axis(label: str, model, axis_key: str, target_size: float = 25.0) -> None:
matches = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if _float_close(info.get(axis_key), target_size, tolerance=1e-4):
matches.append((face_id, axis_key, float(info[axis_key])))
if not matches:
raise SystemExit(f"{label}: output does not contain a Face {axis_key} of {target_size}")
print(f"matched_sizes={matches}")
def _assert_face_width(label: str, model, target_size: float = 25.0) -> None:
_assert_face_size_axis(label, model, "local_face_width", target_size)
def _assert_face_height(label: str, model, target_size: float = 25.0) -> None:
_assert_face_size_axis(label, model, "local_face_height", target_size)
def _assert_face_center(label: str, model, target_center: tuple[float, float, float] = (15.0, 5.0, 0.0)) -> None:
matches = [
(face_id, info.get("area_center") or info.get("bbox_center"))
for face_id in range(len(model.faces))
for info in (model.face_info(face_id),)
if _triple_close(info.get("area_center") or info.get("bbox_center"), target_center, tolerance=1e-4)
]
if not matches:
raise SystemExit(f"{label}: output does not contain a Face centered at {target_center}")
print(f"matched_centers={matches}")
def _write_shell_plate(path: Path) -> None:
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), path)
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
thickness = float(info.get("shell_thickness_estimate") or 0.0)
if abs(thickness - source_thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {source_thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_shell_thickness(label: str, model: StepModel, target_thickness: float = 4.0) -> None:
size = tuple(float(value) for value in model.geometry_stats()["bbox_size"])
thickness = min(size)
if abs(thickness - target_thickness) > 1e-4:
raise SystemExit(f"{label}: output thickness should be {target_thickness:g}, got {thickness:g}")
print(f"matched_shell_thickness={thickness:g}, bbox_size={size}")
def main() -> int:
_run_worker_case(
label="面偏移(当前面)",
operation="move_face_plane_offset_local",
args=[0, 10.0],
validator=_assert_plane_position,
)
_run_worker_case(
label="面积(当前面)",
operation="resize_face_area_local",
args=[0, 225.0],
validator=_assert_face_area,
)
_run_worker_case(
label="面积(整体)",
operation="resize_face_area",
args=[0, 400.0],
validator=lambda label, model: _assert_face_area(label, model, 400.0),
)
_run_worker_case(
label="面宽(当前面)",
operation="resize_face_size_local",
args=[0, 25.0, "width"],
validator=_assert_face_width,
)
_run_worker_case(
label="面高(当前面)",
operation="resize_face_size_local",
args=[0, 25.0, "height"],
validator=_assert_face_height,
)
_run_worker_case(
label="面宽(整体)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "width"],
validator=lambda label, model: _assert_face_width(label, model, 25.0),
)
_run_worker_case(
label="面高(整体)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "height"],
validator=lambda label, model: _assert_face_height(label, model, 25.0),
)
_run_worker_case(
label="中心(当前面)",
operation="move_face_center_local",
args=[0, [15.0, 5.0, 0.0]],
validator=_assert_face_center,
)
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_shell_verify_") as temp_dir:
shell_path = Path(temp_dir) / "plate.step"
_write_shell_plate(shell_path)
shell_probe = StepModel.load(shell_path)
shell_face_id = _first_shell_face(shell_probe)
shell_plan = shell_probe.shell_thickness_plan(shell_face_id, 4.0)
if str(shell_plan.get("risk")) != "high":
raise SystemExit(f"shell thickness isolation case should be high risk, got {shell_plan}")
_run_worker_case(
label="薄壁厚度(当前面)",
operation="resize_shell_thickness",
args=[shell_face_id, 4.0],
validator=_assert_shell_thickness,
input_path=shell_path,
)
shell_owning_plan = shell_probe.shell_thickness_owning_scale_plan(shell_face_id, 4.0)
if str(shell_owning_plan.get("risk")) != "high":
raise SystemExit(f"shell thickness owning isolation case should be high risk, got {shell_owning_plan}")
_run_worker_case(
label="薄壁厚度(整体)",
operation="resize_shell_thickness_owning_scale",
args=[shell_face_id, 4.0],
validator=_assert_shell_thickness,
input_path=shell_path,
)
from step_editor.window_actions import WindowActionMixin
class _IsolatedJobProbe(WindowActionMixin):
def __init__(self) -> None:
self.model = StepModel.load(DEFAULT_MODEL)
self.step_path = DEFAULT_MODEL
probe = _IsolatedJobProbe()
for title in (
"面宽(当前面)",
"面高(当前面)",
"面宽(整体)",
"面高(整体)",
"薄壁厚度(整体)缩放所属对象",
):
if not probe._quick_edit_title_supports_isolation(title):
raise SystemExit(f"{title}: quick edit title should support isolated execution")
context = {
"operation_name": "面宽(当前面)",
"target": "Face 0",
"parameters": {"part_id": 1, "face_id": 0},
"target_kind": "face",
"target_id": 0,
"target_logical_id": 0,
"pick_position": None,
"show_same_domain_internal_edges": False,
"edit_result_deflection": 2.4,
}
snapshot = probe.model.snapshot()
result = probe._run_isolated_edit_job(
context=context,
isolation={
"operation": "resize_face_size_local",
"args": [0, 25.0, "width"],
"timeout_seconds": 120.0,
},
snapshot=snapshot,
before_stats=probe.model.stats(),
before_part_stats=probe.model.part_topology_stats(1),
before_geometry={},
)
if "隔离子进程" not in str(result.get("message", "")):
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
if probe.model.stats().solids != 1:
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
print("isolated window job ok")
owning_probe = _IsolatedJobProbe()
owning_plan = owning_probe.model.face_size_owning_scale_plan(0, 25.0, "width")
owning_isolation = owning_probe._isolation_for_plan(
owning_plan,
"resize_face_size_owning_scale",
[0, 25.0, "width"],
)
if owning_isolation is None:
raise SystemExit(f"Face owning size high-risk plan should request isolated execution: {owning_plan}")
owning_result = owning_probe._run_isolated_edit_job(
context={
**context,
"operation_name": "面宽(整体)",
},
isolation=owning_isolation,
snapshot=owning_probe.model.snapshot(),
before_stats=owning_probe.model.stats(),
before_part_stats=owning_probe.model.part_topology_stats(1),
before_geometry={},
)
if "隔离子进程" not in str(owning_result.get("message", "")):
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
print("isolated owning window job ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+541
View File
@@ -0,0 +1,541 @@
from __future__ import annotations
from pathlib import Path
import re
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.window_state import WindowStateMixin
class _PropertySpecProbe(WindowStateMixin):
def __init__(self) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = 1
self.selected_edge_id = None
self.selected_kind = "feature"
self.selected_part_id = 1
self.selected_solid_id = 1
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _spec_keys(info: dict[str, object]) -> set[str]:
return {str(spec.get("key", "")) for spec in _specs(info)}
def _specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
specs, _used = probe._editable_property_specs(info)
return specs
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
for spec in specs:
if spec.get("key") != key:
continue
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or mode not in modes:
raise SystemExit(f"{key} has no scope mode {mode}")
selected = modes[mode]
if not isinstance(selected, dict):
raise SystemExit(f"{key} scope mode {mode} is invalid")
return selected
raise SystemExit(f"{key} spec was not found")
def _scoped_effective_spec(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
spec = dict(_spec(specs, key))
base_label = str(spec.get("label", ""))
spec.update(_scope_mode(specs, key, mode))
spec["label"] = base_label
return spec
def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]:
for spec in specs:
if spec.get("key") == key:
return spec
raise SystemExit(f"{key} spec was not found")
def _assert_label(specs: list[dict[str, object]], key: str, label: str) -> None:
spec = _spec(specs, key)
actual = str(spec.get("label") or "")
if actual != label:
raise SystemExit(f"{key} label should be {label!r}, got {actual!r}")
def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None:
spec = _spec(specs, key)
actual_low = spec.get("min_value")
actual_high = spec.get("max_value")
if actual_low is None or actual_high is None:
raise SystemExit(f"{key} should expose a hard target range, got {spec}")
if abs(float(actual_low) - low) > 1e-7 or abs(float(actual_high) - high) > 1e-7:
raise SystemExit(
f"{key} hard range should be {low:g}..{high:g}, "
f"got {float(actual_low):g}..{float(actual_high):g}"
)
def _assert_vector_distance_limit(spec: dict[str, object], high: float, label: str) -> None:
actual = spec.get("max_vector_distance")
reference = spec.get("vector_distance_reference")
if actual is None or reference is None:
raise SystemExit(f"{label} should expose a vector distance limit, got {spec}")
if abs(float(actual) - high) > 1e-7:
raise SystemExit(f"{label} vector distance limit should be {high:g}, got {float(actual):g}")
def _assert_validation_error(
probe: _PropertySpecProbe,
spec: dict[str, object],
text: str,
should_error: bool,
label: str,
) -> None:
error = probe._property_target_validation_error(spec, text)
if should_error and not error:
raise SystemExit(f"{label} should be rejected")
if not should_error and error:
raise SystemExit(f"{label} should be accepted, got {error}")
def _assert_hint_fragments(hint: object, fragments: tuple[str, ...], label: str) -> None:
hint_text = str(hint or "")
missing = [fragment for fragment in fragments if fragment not in hint_text]
if missing:
raise SystemExit(f"{label} range hint missing {missing}: {hint_text}")
def _assert_scoped_hint_fragments(
specs: list[dict[str, object]],
key: str,
mode: str,
fragments: tuple[str, ...],
) -> None:
selected = _scope_mode(specs, key, mode)
_assert_hint_fragments(selected.get("range_hint"), fragments, f"{key}/{mode}")
def _assert_contains(keys: set[str], required: set[str], label: str) -> None:
missing = sorted(required - keys)
if missing:
raise SystemExit(f"{label} edit specs missing: {missing}")
def _assert_no_generic_face_leak(keys: set[str], label: str) -> None:
forbidden = {
"area",
"face_center_position",
"local_face_width",
"local_face_height",
"face_target_normal_position",
"push_pull_distance",
}
leaked = sorted(forbidden & keys)
if leaked:
raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}")
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
hits: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
# Internal keys/action names still use width/height/offset; only user-visible text is checked.
if key in {
"key",
"action",
"target_attr",
"target_attrs",
"target_transform",
"transform_context",
"used",
}:
continue
hits.extend(_collect_legacy_face_terms(child, f"{path}.{key}"))
elif isinstance(value, (list, tuple)):
for index, child in enumerate(value):
hits.extend(_collect_legacy_face_terms(child, f"{path}[{index}]"))
elif isinstance(value, str):
for term in legacy_terms:
if term in value:
hits.append(f"{path}: {term} in {value!r}")
return hits
def _assert_no_legacy_face_terms(specs: list[dict[str, object]]) -> None:
hits = _collect_legacy_face_terms(specs)
if hits:
raise SystemExit("legacy Face UI terms should not appear in property specs: " + "; ".join(hits))
def _assert_no_legacy_face_source_terms() -> None:
files = (
PROJECT_ROOT / "README.md",
PROJECT_ROOT / "step_editor" / "app.py",
PROJECT_ROOT / "step_editor" / "ui_helpers.py",
PROJECT_ROOT / "step_editor" / "window_state.py",
PROJECT_ROOT / "step_editor" / "window_actions.py",
PROJECT_ROOT / "step_editor" / "operations.py",
PROJECT_ROOT / "scripts" / "verify_isolated_face_edit.py",
)
patterns = (
re.compile(r"面内尺寸 1/2"),
re.compile(r"面内尺寸 1"),
re.compile(r"面内尺寸 2"),
re.compile(r"(?<![端平底])面位置"),
re.compile(r"偏移距离"),
)
hits: list[str] = []
for file_path in files:
text = file_path.read_text(encoding="utf-8")
for line_number, line in enumerate(text.splitlines(), start=1):
for pattern in patterns:
if pattern.search(line):
hits.append(f"{file_path.relative_to(PROJECT_ROOT)}:{line_number}: {line.strip()}")
break
if hits:
raise SystemExit("legacy Face UI terms should not appear in user-facing sources: " + "; ".join(hits))
def _assert_target_change_detection() -> None:
probe = _PropertySpecProbe()
number_spec = {
"label": "面积",
"current_raw": 100.0,
"current_text": "100",
"value_type": "positive",
}
if probe._property_target_changed(number_spec, "100"):
raise SystemExit("unchanged numeric Face target was treated as changed")
if not probe._property_target_changed(number_spec, "101"):
raise SystemExit("changed numeric Face target was not detected")
vector_spec = {
"label": "中心",
"current_raw": (5.0, 5.0, 0.0),
"current_text": "5, 5, 0",
"value_type": "vector3",
}
if probe._property_target_changed(vector_spec, "5,5,0"):
raise SystemExit("unchanged vector Face target was treated as changed")
if not probe._property_target_changed(vector_spec, "5,5,1"):
raise SystemExit("changed vector Face target was not detected")
def _assert_holed_plane_local_scopes_disabled() -> None:
specs = _specs(
{
"surface": "plane",
"area": 280.0,
"area_center": (15.0, 10.0, 8.0),
"bbox_center": (15.0, 10.0, 8.0),
"local_face_width": 30.0,
"local_face_height": 20.0,
"plane_origin": (0.0, 0.0, 8.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0),
"boundary_wires": 2,
"inner_boundary_wires": 1,
"has_inner_boundaries": True,
"local_face_deform_ready": False,
"local_face_deform_blocker": "has inner boundary",
}
)
for key in ("area", "local_face_width", "local_face_height", "face_center_position"):
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face")
disabled_tip = str(local_mode.get("disabled_tip") or "")
if "has inner boundary" not in disabled_tip:
raise SystemExit(f"{key} local disabled tip should explain the blocker: {disabled_tip}")
owning_mode = _scope_mode(specs, key, "owning")
if not bool(owning_mode.get("enabled", False)):
raise SystemExit(f"{key} owning scope should remain available for a holed planar Face")
offset_local = _scope_mode(specs, "face_target_normal_position", "local")
if bool(offset_local.get("enabled", True)):
raise SystemExit("Face plane-offset local scope should be disabled for a holed planar Face")
offset_tip = str(offset_local.get("disabled_tip") or "")
if "has inner boundary" not in offset_tip:
raise SystemExit(f"Face plane-offset local disabled tip should explain the blocker: {offset_tip}")
offset_push_pull = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(offset_push_pull.get("enabled", False)):
raise SystemExit("Face plane-offset push/pull scope should remain available for a holed planar Face")
semantics = _spec(specs, "face_edit_semantics")
if "不能只改当前面" not in str(semantics.get("current_text") or ""):
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
if "has inner boundary" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the blocker: {semantics}")
def main() -> int:
plane_info = {
"surface": "plane",
"area": 100.0,
"area_center": (5.0, 5.0, 0.0),
"bbox_center": (5.0, 5.0, 0.0),
"bbox_diagonal": 14.1421356237,
"local_face_width": 10.0,
"local_face_height": 10.0,
"plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0),
}
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_contains(
plane_keys,
{
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
},
"plane Face",
)
_assert_label(plane_specs, "local_face_width", "面宽")
_assert_label(plane_specs, "local_face_height", "面高")
_assert_label(plane_specs, "face_target_normal_position", "面偏移")
_assert_no_legacy_face_terms(plane_specs)
_assert_hard_range(plane_specs, "area", 0.25, 2500.0)
_assert_hard_range(plane_specs, "local_face_width", 0.5, 50.0)
_assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0)
_assert_hard_range(plane_specs, "face_target_normal_position", -70.7106781185, 70.7106781185)
probe = _PropertySpecProbe()
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.49", True, "Face width below hard range")
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.5", False, "Face width lower boundary")
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "50.1", True, "Face width above hard range")
_assert_validation_error(probe, _spec(plane_specs, "area"), "0.2", True, "Face area below hard range")
_assert_validation_error(probe, _spec(plane_specs, "area"), "2500", False, "Face area upper boundary")
_assert_validation_error(
probe,
_spec(plane_specs, "face_target_normal_position"),
"-71",
True,
"Face offset below hard range",
)
center_local = _scoped_effective_spec(plane_specs, "face_center_position", "local")
center_owning = _scoped_effective_spec(plane_specs, "face_center_position", "owning")
_assert_vector_distance_limit(center_local, 70.7106781185, "Face center local")
_assert_vector_distance_limit(center_owning, 70.7106781185, "Face center owning")
_assert_validation_error(
probe,
center_local,
"75.7106781185, 5, 0",
False,
"Face center target at distance boundary",
)
_assert_validation_error(
probe,
center_local,
"76, 5, 0",
True,
"Face center target beyond distance limit",
)
if "push_pull_distance" in plane_keys:
raise SystemExit("plane Face should not expose a separate push_pull_distance row")
for key in ("area", "local_face_width", "local_face_height"):
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
key,
mode,
("5%", "5 倍", "会被阻止"),
)
for mode in ("push_pull", "local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
mode,
("会被阻止",),
)
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_center_position",
mode,
("5 倍", "会被阻止"),
)
shell_specs = _specs(
{
**plane_info,
"shell_region_status": "candidate",
"shell_thickness_estimate": 2.0,
"shell_current_thickness": 2.0,
"shell_signed_thickness": 2.0,
"shell_opposite_face_id": 2,
"shell_overlap_ratio_estimate": 1.0,
}
)
_assert_hard_range(shell_specs, "shell_thickness_estimate", 0.1, 10.0)
_assert_validation_error(
probe,
_spec(shell_specs, "shell_thickness_estimate"),
"0.05",
True,
"thin wall thickness below hard range",
)
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
shell_specs,
"shell_thickness_estimate",
mode,
("5%", "5 倍", "会被阻止"),
)
cylinder_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
"feature_bottom_face_ids": (),
}
)
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
slot_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 3.141592653589793,
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
}
)
_assert_no_generic_face_leak(slot_keys, "slot/half-hole feature")
_assert_contains(
slot_keys,
{"slot_chord_width_estimate", "slot_sagitta_depth_estimate", "slot_arc_length_estimate"},
"slot/half-hole feature",
)
boss_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "boss/outer-round candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"height_estimate": 5.0,
"same_domain_height_estimate": 5.0,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
"feature_start_end_face_ids": (1,),
}
)
_assert_no_generic_face_leak(boss_keys, "boss feature")
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
fillet_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"diameter": 2.0,
"radius": 1.0,
"angular_span": 1.5707963267948966,
"existing_fillet_radius": 1.0,
"feature_existing_fillet_support_face_ids": (1, 2),
"area": 3.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
}
)
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature")
analytic_cases = (
(
"cone feature",
{
"surface": "cone",
"reference_radius": 4.0,
"reference_diameter": 8.0,
"semi_angle_degrees": 12.0,
"area": 50.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
},
{"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"},
),
(
"sphere feature",
{
"surface": "sphere",
"radius": 5.0,
"diameter": 10.0,
"area": 100.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"center": (0.0, 0.0, 0.0),
},
{"sphere_radius", "sphere_diameter"},
),
(
"torus feature",
{
"surface": "torus",
"major_radius": 8.0,
"minor_radius": 2.0,
"major_diameter": 16.0,
"minor_diameter": 4.0,
"area": 100.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"center": (0.0, 0.0, 0.0),
},
{"torus_major_radius", "torus_minor_radius"},
),
)
for label, info, required in analytic_cases:
keys = _spec_keys(info)
_assert_no_generic_face_leak(keys, label)
_assert_contains(keys, required, label)
_assert_target_change_detection()
_assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms()
print("property editor specs ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -52,6 +52,75 @@ def _thickness_axis(size: tuple[float, float, float]) -> int:
return min(range(3), key=lambda index: size[index]) return min(range(3), key=lambda index: size[index])
def _face_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} lacks a stable center")
return float(center[0]), float(center[1]), float(center[2])
def _face_area(model: StepModel, face_id: int) -> float:
return float(model.face_info(face_id).get("area") or 0.0)
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _vector(value: object, label: str) -> tuple[float, float, float]:
if not isinstance(value, tuple) or len(value) != 3:
raise SystemExit(f"{label} should be a 3D vector, got {value!r}")
return float(value[0]), float(value[1]), float(value[2])
def _axis_affine_point(
point: tuple[float, float, float],
axis_point: tuple[float, float, float],
axis_direction: tuple[float, float, float],
scale: float,
) -> tuple[float, float, float]:
length = (axis_direction[0] ** 2 + axis_direction[1] ** 2 + axis_direction[2] ** 2) ** 0.5
if length <= 1e-12:
raise SystemExit("owning shell thickness plan produced a zero axis direction")
direction = (axis_direction[0] / length, axis_direction[1] / length, axis_direction[2] / length)
relative = (point[0] - axis_point[0], point[1] - axis_point[1], point[2] - axis_point[2])
along = relative[0] * direction[0] + relative[1] * direction[1] + relative[2] * direction[2]
axial = (direction[0] * along, direction[1] * along, direction[2] * along)
rest = (relative[0] - axial[0], relative[1] - axial[1], relative[2] - axial[2])
return (
axis_point[0] + rest[0] + axial[0] * scale,
axis_point[1] + rest[1] + axial[1] * scale,
axis_point[2] + rest[2] + axial[2] * scale,
)
def _assert_logical_face_retained(
model: StepModel,
logical_id: int,
expected_center: tuple[float, float, float],
expected_area: float,
tolerance: float,
label: str,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"{label} did not retain logical Face {logical_id}")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"{label} logical Face {logical_id} did not resolve into matches {matches}")
info = model.face_info(resolved)
if info.get("surface") != "plane":
raise SystemExit(f"{label} retained logical Face should be planar, got {info.get('surface')}")
center = _face_center(model, resolved)
if _distance(center, expected_center) > tolerance:
raise SystemExit(f"{label} retained Face center should be {expected_center}, got {center}")
area = _face_area(model, resolved)
if abs(area - expected_area) > tolerance:
raise SystemExit(f"{label} retained Face area should be {expected_area:g}, got {area:g}")
return resolved
def _verify_local_bounds( def _verify_local_bounds(
before_min: tuple[float, float, float], before_min: tuple[float, float, float],
before_max: tuple[float, float, float], before_max: tuple[float, float, float],
@@ -101,6 +170,9 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
_write_plate_model(model_path) _write_plate_model(model_path)
model = StepModel.load(model_path) model = StepModel.load(model_path)
face_id = _first_shell_face(model, source_thickness, tolerance) face_id = _first_shell_face(model, source_thickness, tolerance)
logical_id = model.face_region_logical_id(face_id)
before_face_center = _face_center(model, face_id)
before_face_area = _face_area(model, face_id)
before = model.stats() before = model.stats()
before_min, before_max, before_size = _bounds(model) before_min, before_max, before_size = _bounds(model)
axis = _thickness_axis(before_size) axis = _thickness_axis(before_size)
@@ -109,11 +181,24 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
plan = model.shell_thickness_plan(face_id, target_thickness) plan = model.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked": if plan["status"] == "blocked":
raise SystemExit(f"local shell plan was blocked: {plan['message']}") raise SystemExit(f"local shell plan was blocked: {plan['message']}")
outward = _vector(plan.get("outward_direction"), "local shell outward_direction")
distance = float(plan.get("push_pull_distance", 0.0))
expected_logical_center = (
before_face_center[0] + outward[0] * distance,
before_face_center[1] + outward[1] * distance,
before_face_center[2] + outward[2] * distance,
)
result = model.resize_shell_thickness(face_id, target_thickness) result = model.resize_shell_thickness(face_id, target_thickness)
elif mode == "owning": elif mode == "owning":
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness) plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if plan["status"] == "blocked": if plan["status"] == "blocked":
raise SystemExit(f"owning shell plan was blocked: {plan['message']}") raise SystemExit(f"owning shell plan was blocked: {plan['message']}")
expected_logical_center = _axis_affine_point(
before_face_center,
_vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"),
_vector(plan.get("affine_axis_direction"), "owning shell affine_axis_direction"),
float(plan.get("affine_scale", 1.0)),
)
result = model.resize_shell_thickness_owning_scale(face_id, target_thickness) result = model.resize_shell_thickness_owning_scale(face_id, target_thickness)
else: else:
raise SystemExit(f"unsupported mode: {mode}") raise SystemExit(f"unsupported mode: {mode}")
@@ -126,9 +211,19 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
_verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) _verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
else: else:
_verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) _verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
retained_face_id = _assert_logical_face_retained(
model,
logical_id,
expected_logical_center,
before_face_area,
tolerance,
f"{mode} shell thickness",
)
print(f"mode={mode}") print(f"mode={mode}")
print(f"face_id={face_id}") print(f"face_id={face_id}")
print(f"logical_face_id={logical_id}")
print(f"retained_logical_face={retained_face_id}")
print(f"strategy={plan.get('resize_strategy')}") print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}") print(f"before={before}")
print(f"after={after}") print(f"after={after}")
+29 -15
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import faulthandler import faulthandler
import math import math
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -78,6 +79,12 @@ def _enable_crash_log() -> None:
faulthandler.enable(all_threads=True) faulthandler.enable(all_threads=True)
def _crash_log_requested(argv: list[str]) -> bool:
if "--crash-log" in argv:
return True
return os.environ.get("STEP_EDITOR_CRASH_LOG", "").strip().lower() in {"1", "true", "yes", "on"}
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow): class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object) ui_task_requested = Signal(object)
@@ -334,9 +341,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
background: #fff8ef; background: #fff8ef;
border: 1px solid #edc98e; border: 1px solid #edc98e;
border-left: 5px solid #d97706; border-left: 5px solid #d97706;
padding-left: 5px; padding-left: 2px;
padding-right: 3px; padding-right: 1px;
padding-bottom: 7px; padding-bottom: 6px;
} }
QGroupBox#editSection::title { QGroupBox#editSection::title {
background: #fff0d8; background: #fff0d8;
@@ -423,7 +430,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
min-height: 18px; min-height: 18px;
padding: 1px 6px; padding: 1px 3px;
} }
QPushButton#propertyRowEditButton:hover { QPushButton#propertyRowEditButton:hover {
background: #f97316; background: #f97316;
@@ -434,7 +441,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #7c2d12; border-color: #7c2d12;
color: #ffffff; color: #ffffff;
padding-top: 2px; padding-top: 2px;
padding-left: 7px; padding-left: 4px;
} }
QPushButton#propertyRowEditButton[changed="true"] { QPushButton#propertyRowEditButton[changed="true"] {
background: #dcfce7; background: #dcfce7;
@@ -450,13 +457,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border: 1px dashed #bcc7d4; border: 1px dashed #bcc7d4;
color: #8f99a8; color: #8f99a8;
} }
QPushButton#propertyRowEditButton[invalid="true"],
QPushButton#propertyRowEditButton[invalid="true"]:disabled {
background: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
}
QComboBox#propertyScopeCombo { QComboBox#propertyScopeCombo {
background: #ffffff; background: #ffffff;
border: 1px solid #f59e0b; border: 1px solid #f59e0b;
border-radius: 5px; border-radius: 5px;
color: #7c2d12; color: #7c2d12;
min-height: 18px; min-height: 18px;
padding: 0px 4px; padding: 0px 2px;
font-weight: 600; font-weight: 600;
} }
QComboBox#propertyScopeCombo:hover { QComboBox#propertyScopeCombo:hover {
@@ -478,7 +491,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-radius: 5px; border-radius: 5px;
color: #111827; color: #111827;
min-height: 18px; min-height: 18px;
padding: 0px 5px; padding: 0px 2px;
selection-background-color: #bfdbfe; selection-background-color: #bfdbfe;
} }
QLineEdit#propertyTargetEditor:hover { QLineEdit#propertyTargetEditor:hover {
@@ -488,7 +501,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
QLineEdit#propertyTargetEditor:focus { QLineEdit#propertyTargetEditor:focus {
background: #ffffff; background: #ffffff;
border: 2px solid #2563eb; border: 2px solid #2563eb;
padding: 0px 4px; padding: 0px 1px;
} }
QPushButton#propertyExpandBar { QPushButton#propertyExpandBar {
background: #f8fafc; background: #f8fafc;
@@ -568,12 +581,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
padding: 0; padding: 0;
} }
QTableWidget#propertyTable::item { QTableWidget#propertyTable::item {
padding-left: 2px; padding-left: 1px;
padding-right: 2px; padding-right: 1px;
} }
QTableWidget#propertyTable QHeaderView::section { QTableWidget#propertyTable QHeaderView::section {
padding-left: 3px; padding-left: 1px;
padding-right: 3px; padding-right: 1px;
} }
QTabWidget::pane { QTabWidget::pane {
border: 1px solid #d8e0eb; border: 1px solid #d8e0eb;
@@ -822,7 +835,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。", "选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。",
) )
object_edit_layout = QVBoxLayout(self.object_edit_box) object_edit_layout = QVBoxLayout(self.object_edit_box)
object_edit_layout.setContentsMargins(2, 8, 2, 4) object_edit_layout.setContentsMargins(0, 8, 0, 4)
object_edit_layout.setSpacing(4) object_edit_layout.setSpacing(4)
self.property_table = QTableWidget(0, 5) self.property_table = QTableWidget(0, 5)
self.property_table.setObjectName("propertyTable") self.property_table.setObjectName("propertyTable")
@@ -984,10 +997,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
help_tip(self.face_area_input, "目标面面积。当前选中对象表会使用这个隐藏输入执行所属特征或 Solid 的均匀缩放。") help_tip(self.face_area_input, "目标面面积。当前选中对象表会使用这个隐藏输入执行所属特征或 Solid 的均匀缩放。")
self.face_width_input = QLineEdit("", edit_box) self.face_width_input = QLineEdit("", edit_box)
self.face_width_input.setVisible(False) self.face_width_input.setVisible(False)
help_tip(self.face_width_input, "目标面宽。当前选中对象表会使用这个隐藏输入修改当前 Face 平面内宽度") help_tip(self.face_width_input, "目标面宽。当前选中对象表会使用这个隐藏输入修改 Face 平面内第一个方向的尺寸")
self.face_height_input = QLineEdit("", edit_box) self.face_height_input = QLineEdit("", edit_box)
self.face_height_input.setVisible(False) self.face_height_input.setVisible(False)
help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入修改当前 Face 平面内高度") help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸")
self.resize_boss_button = QPushButton("调整圆柱凸台直径") self.resize_boss_button = QPushButton("调整圆柱凸台直径")
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
self.resize_boss_button.clicked.connect(self.resize_boss) self.resize_boss_button.clicked.connect(self.resize_boss)
@@ -1375,6 +1388,7 @@ def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def main() -> int: def main() -> int:
if _crash_log_requested(sys.argv):
_enable_crash_log() _enable_crash_log()
path, smoke_test = _parse_args(sys.argv) path, smoke_test = _parse_args(sys.argv)
app = QApplication(sys.argv) app = QApplication(sys.argv)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import argparse
import json
import traceback
from pathlib import Path
from .model import StepModel
def _execute(model: StepModel, operation: str, args: list[object]) -> str:
if operation == "push_pull_face":
return model.push_pull_face(int(args[0]), float(args[1]))
if operation == "move_face_plane_offset_local":
return model.move_face_plane_offset_local(int(args[0]), float(args[1]))
if operation == "resize_face_area_local":
return model.resize_face_area_local(int(args[0]), float(args[1]))
if operation == "resize_face_area":
return model.resize_face_area(int(args[0]), float(args[1]))
if operation == "resize_face_size_local":
return model.resize_face_size_local(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_face_size_owning_scale":
return model.resize_face_size_owning_scale(int(args[0]), float(args[1]), str(args[2]))
if operation == "move_face_center_local":
center = list(args[1])
if len(center) != 3:
raise ValueError("move_face_center_local requires a 3D target center.")
return model.move_face_center_local(
int(args[0]),
(float(center[0]), float(center[1]), float(center[2])),
)
if operation == "resize_shell_thickness":
return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale":
return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1]))
raise ValueError(f"Unsupported isolated edit operation: {operation}")
def main() -> int:
parser = argparse.ArgumentParser(description="Run one high-risk geometry edit in an isolated process.")
parser.add_argument("request", help="JSON request file.")
parsed = parser.parse_args()
request_path = Path(parsed.request)
response_path = request_path.with_suffix(".response.json")
try:
request = json.loads(request_path.read_text(encoding="utf-8-sig"))
input_path = Path(str(request["input_path"]))
output_path = Path(str(request["output_path"]))
operation = str(request["operation"])
args = list(request.get("args") or [])
model = StepModel.load(input_path)
message = _execute(model, operation, args)
model.export_all(output_path)
response_path.write_text(
json.dumps(
{
"ok": True,
"message": message,
"stats": model.stats().__dict__,
"output_path": str(output_path),
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
return 0
except Exception as exc:
response_path.write_text(
json.dumps(
{
"ok": False,
"error": str(exc),
"traceback": traceback.format_exc(),
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+156 -2
View File
@@ -9,7 +9,12 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_Transform from OCC.Core.BRepBuilderAPI import (
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeVertex,
BRepBuilderAPI_MakeWire,
BRepBuilderAPI_Transform,
)
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
@@ -58,6 +63,7 @@ from OCC.Core.TopAbs import (
TopAbs_OUT, TopAbs_OUT,
TopAbs_REVERSED, TopAbs_REVERSED,
TopAbs_SOLID, TopAbs_SOLID,
TopAbs_WIRE,
) )
from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopExp import TopExp_Explorer, topexp
from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopLoc import TopLoc_Location
@@ -104,6 +110,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache: dict[int, list[int]] = {} self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {} self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {} self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None self._same_domain_internal_edge_ids_cache: set[int] | None = None
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
@@ -184,6 +191,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache.clear() self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear() self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None self._same_domain_internal_edge_ids_cache = None
self._same_domain_duplicate_edge_ids_cache = None self._same_domain_duplicate_edge_ids_cache = None
@@ -275,6 +283,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_info_cache.clear() self._face_info_cache.clear()
self._feature_info_cache.clear() self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._local_face_deform_readiness_cache.clear()
def quick_face_info(self, face_id: int) -> dict[str, object]: def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces): if face_id < 0 or face_id >= len(self.faces):
@@ -310,6 +319,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。", "selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。",
} }
info.update(_shape_bounds_info(face)) info.update(_shape_bounds_info(face))
info.update(self._face_boundary_wire_info(face))
if surface_type == GeomAbs_Plane: if surface_type == GeomAbs_Plane:
plane = surf.Plane() plane = surf.Plane()
direction = plane.Axis().Direction() direction = plane.Axis().Direction()
@@ -328,6 +338,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["feature_source_face_id"] = face_id info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,) info["feature_highlight_face_ids"] = (face_id,)
info["feature_edit_actions"] = "推拉平面" info["feature_edit_actions"] = "推拉平面"
info.update(self._local_face_deform_readiness(face_id))
info.update( info.update(
self._local_face_plane_size_info( self._local_face_plane_size_info(
face_id, face_id,
@@ -420,6 +431,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"boundary_edges": boundary_edges, "boundary_edges": boundary_edges,
} }
info.update(_shape_bounds_info(face)) info.update(_shape_bounds_info(face))
info.update(self._face_boundary_wire_info(face))
if surface_type == GeomAbs_Plane: if surface_type == GeomAbs_Plane:
plane = surf.Plane() plane = surf.Plane()
direction = plane.Axis().Direction() direction = plane.Axis().Direction()
@@ -433,6 +445,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"] info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"] info["push_pull_note"] = push_pull_direction["note"]
info.update(self._local_face_deform_readiness(face_id))
info.update( info.update(
self._local_face_plane_size_info( self._local_face_plane_size_info(
face_id, face_id,
@@ -1128,6 +1141,112 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache[face_id] = list(edge_ids) self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids return edge_ids
def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try:
boundary_wires = len(_explore(face, TopAbs_WIRE))
except Exception:
boundary_wires = 0
inner_boundary_wires = max(boundary_wires - 1, 0)
return {
"boundary_wires": boundary_wires,
"inner_boundary_wires": inner_boundary_wires,
"has_inner_boundaries": inner_boundary_wires > 0,
}
def _local_face_deform_readiness(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {
"local_face_deform_ready": False,
"local_face_deform_blocker": "Face ID 不存在,不能做局部 Face 变形。",
}
solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
cache_key = solid_id if solid_id >= 0 else -(face_id + 1)
cached = self._local_face_deform_readiness_cache.get(cache_key)
if cached is not None:
return dict(cached)
def remember(info: dict[str, object]) -> dict[str, object]:
self._local_face_deform_readiness_cache[cache_key] = dict(info)
return dict(info)
if solid_id < 0 or solid_id >= len(self.solids):
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": 1,
"local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做“只改当前面”的局部重建。",
}
)
solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id]
if not solid_face_ids:
solid_face_ids = [face_id]
face_count = len(solid_face_ids)
if face_count > 128:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放“只改当前面”的局部重建。",
}
)
source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.faces[face_id]
tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6)
for item in solid_face_ids:
face = self.faces[item]
try:
surface = BRepAdaptor_Surface(face)
except Exception:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 不能稳定读取曲面类型,不能做局部 Face 变形。",
}
)
if surface.GetType() != GeomAbs_Plane:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放“只改当前面”。",
}
)
wire_info = self._face_boundary_wire_info(face)
if bool(wire_info.get("has_inner_boundaries")):
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或推拉等专门修改方式。",
}
)
try:
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环不能稳定读取,不能做局部 Face 变形。",
}
)
except Exception:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环读取失败,不能做局部 Face 变形。",
}
)
return remember(
{
"local_face_deform_ready": True,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "",
}
)
def face_boundary_edge_ids(self, face_id: int) -> list[int]: def face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces): if face_id < 0 or face_id >= len(self.faces):
return [] return []
@@ -1166,6 +1285,22 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_info_cache.pop(face_id, None) self._face_info_cache.pop(face_id, None)
self._feature_info_cache.pop(face_id, None) self._feature_info_cache.pop(face_id, None)
def assign_logical_face_region_exclusive(self, logical_id: int, face_ids: Iterable[int]) -> None:
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
if not valid_face_ids:
return
logical_id = int(logical_id)
replacement_id = max([logical_id, len(self.faces), *[int(item) for item in self.face_logical_ids]], default=logical_id) + 1
for face_id, current_logical_id in enumerate(list(self.face_logical_ids)):
if int(current_logical_id) != logical_id or face_id in valid_face_ids:
continue
self.face_logical_ids[face_id] = replacement_id
replacement_id += 1
self._quick_face_info_cache.pop(face_id, None)
self._face_info_cache.pop(face_id, None)
self._feature_info_cache.pop(face_id, None)
self.assign_logical_face_region(logical_id, valid_face_ids)
def nearest_edge_id_to_point( def nearest_edge_id_to_point(
self, self,
edge_ids: Iterable[int], edge_ids: Iterable[int],
@@ -1487,7 +1622,26 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return sorted(edge_id for edge_id, count in counts.items() if count == 1) return sorted(edge_id for edge_id, count in counts.items() if count == 1)
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape: def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)] valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
if not valid_face_ids:
raise ValueError("No planar faces were found for push/pull.")
if len(valid_face_ids) > 1:
boundary_edge_ids = self._region_boundary_edge_ids(valid_face_ids)
if boundary_edge_ids:
try:
wire = BRepBuilderAPI_MakeWire()
for edge_id in boundary_edge_ids:
wire.Add(topods.Edge(self.edges[edge_id]))
if not hasattr(wire, "IsDone") or wire.IsDone():
face_builder = BRepBuilderAPI_MakeFace(wire.Wire())
if not hasattr(face_builder, "IsDone") or face_builder.IsDone():
profile = face_builder.Face()
if not profile.IsNull():
_ensure_valid_shape(profile)
return profile
except Exception:
pass
profile_faces = [self.faces[face_id] for face_id in valid_face_ids]
if not profile_faces: if not profile_faces:
raise ValueError("No planar faces were found for push/pull.") raise ValueError("No planar faces were found for push/pull.")
return _unify_same_domain_shape(_compound_from_shapes(profile_faces)) return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
+1013 -6
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -483,22 +483,22 @@ INFO_LABELS = {
"area_center": "面积中心", "area_center": "面积中心",
"local_face_width": "当前面宽", "local_face_width": "当前面宽",
"local_face_height": "当前面高", "local_face_height": "当前面高",
"local_face_width_direction": "面宽方向", "local_face_width_direction": "面宽 方向",
"local_face_height_direction": "面高方向", "local_face_height_direction": "面高 方向",
"local_face_size_center": "面宽/面高中心", "local_face_size_center": "面宽/面高 中心",
"face_size_axis": "Face尺寸方向", "face_size_axis": "面宽/面高 方向",
"face_size_label": "Face尺寸名称", "face_size_label": "面宽/面高 名称",
"current_face_width": "当前面宽", "current_face_width": "当前面宽",
"target_face_width": "目标面宽", "target_face_width": "目标面宽",
"current_face_height": "当前面高", "current_face_height": "当前面高",
"target_face_height": "目标面高", "target_face_height": "目标面高",
"current_face_size": "当前Face尺寸", "current_face_size": "当前面宽/面高",
"target_face_size": "目标Face尺寸", "target_face_size": "目标面宽/面高",
"face_size_delta": "Face尺寸变化量", "face_size_delta": "面宽/面高 变化量",
"face_size_delta_ratio": "Face尺寸变化比例", "face_size_delta_ratio": "面宽/面高 变化比例",
"face_size_scale": "Face尺寸缩放比例", "face_size_scale": "面宽/面高 缩放比例",
"face_size_center": "Face尺寸缩放中心", "face_size_center": "面宽/面高 缩放中心",
"face_size_axis_direction": "Face尺寸缩放方向", "face_size_axis_direction": "面宽/面高 缩放方向",
"owning_face_size_rebuild_mode": "所属对象尺寸重建模式", "owning_face_size_rebuild_mode": "所属对象尺寸重建模式",
"length_center": "长度中心", "length_center": "长度中心",
"bbox_min": "包围盒最小点", "bbox_min": "包围盒最小点",
+360 -80
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import json
import math import math
from pathlib import Path from pathlib import Path
import subprocess
import sys
import tempfile
import vtk import vtk
from PySide6.QtCore import Qt, QThread, Slot from PySide6.QtCore import Qt, QThread, Slot
@@ -336,6 +340,7 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
plan = self._quick_push_pull_plan(face_id, distance) plan = self._quick_push_pull_plan(face_id, distance)
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能推拉平面", str(plan["message"])) QMessageBox.information(self, "不能推拉平面", str(plan["message"]))
self.statusBar().showMessage("推拉平面已阻止") self.statusBar().showMessage("推拉平面已阻止")
@@ -343,6 +348,11 @@ class WindowActionMixin:
if plan["risk"] != "low": if plan["risk"] != "low":
warnings = str(plan.get("warnings", "")) warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else "" warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if str(plan.get("risk")) == "high"
else ""
)
result = QMessageBox.question( result = QMessageBox.question(
self, self,
"确认推拉平面", "确认推拉平面",
@@ -356,6 +366,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -393,10 +404,13 @@ class WindowActionMixin:
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"), "push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"), "push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
"push_pull_scope_note": plan.get("push_pull_scope_note"), "push_pull_scope_note": plan.get("push_pull_scope_note"),
"push_pull_inward_material_depth": plan.get("push_pull_inward_material_depth"),
"push_pull_inward_cut_ratio": plan.get("push_pull_inward_cut_ratio"),
"bbox_diagonal": plan.get("bbox_diagonal"), "bbox_diagonal": plan.get("bbox_diagonal"),
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]),
) )
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -432,6 +446,7 @@ class WindowActionMixin:
risk = "low" risk = "low"
status = "ready" status = "ready"
warnings: list[str] = [] warnings: list[str] = []
blockers: list[str] = []
if distance_abs <= 1e-9: if distance_abs <= 1e-9:
return { return {
"status": "blocked", "status": "blocked",
@@ -451,6 +466,31 @@ class WindowActionMixin:
status = "caution" status = "caution"
warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。") warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。")
outward_tuple = tuple(float(item) for item in outward)
inward_material_depth = None
inward_cut_ratio = None
if distance < 0 and self.model is not None:
inward_material_depth = self.model._push_pull_inward_material_depth(face_id, outward_tuple)
if inward_material_depth is not None and inward_material_depth > 1e-9:
inward_cut_ratio = distance_abs / inward_material_depth
depth_tolerance = max(
inward_material_depth * 1e-5,
(bbox_diagonal or 0.0) * 1e-7,
1e-6,
)
if distance_abs >= inward_material_depth - depth_tolerance:
status = "blocked"
risk = "blocked"
blockers.append("向内切削距离达到或超过当前面背后的材料厚度;继续执行很可能把实体切空或生成无效几何。")
elif inward_cut_ratio >= 0.85:
risk = "high"
warnings.append("向内切削距离已经接近当前面背后的材料厚度,剩余壁厚很薄,请谨慎确认。")
elif inward_cut_ratio >= 0.6 and risk != "high":
risk = "medium"
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
if status != "blocked" and risk in {"medium", "high"}:
status = "caution"
scope_face_ids = ( scope_face_ids = (
_int_values(info.get("push_pull_scope_face_ids")) _int_values(info.get("push_pull_scope_face_ids"))
or _int_values(info.get("feature_highlight_face_ids")) or _int_values(info.get("feature_highlight_face_ids"))
@@ -476,12 +516,14 @@ class WindowActionMixin:
if solid_id is None and self.model is not None and 0 <= face_id < len(self.model.face_solid_ids): if solid_id is None and self.model is not None and 0 <= face_id < len(self.model.face_solid_ids):
solid_id = self.model.face_solid_ids[face_id] solid_id = self.model.face_solid_ids[face_id]
message = " ".join(warnings) if warnings else "可以尝试偏移该平面;完整几何检查会在后台执行。" message = " ".join(warnings) if warnings else "可以尝试偏移该平面;完整几何检查会在后台执行。"
if blockers:
message = " ".join(blockers + warnings)
return { return {
"status": status, "status": status,
"risk": risk, "risk": risk,
"message": message, "message": message,
"warnings": "".join(warnings), "warnings": "".join(warnings),
"blockers": "", "blockers": "".join(blockers),
"face_id": face_id, "face_id": face_id,
"part_id": part_id, "part_id": part_id,
"solid_id": solid_id, "solid_id": solid_id,
@@ -489,7 +531,9 @@ class WindowActionMixin:
"surface": surface, "surface": surface,
"area": info.get("area"), "area": info.get("area"),
"bbox_diagonal": bbox_diagonal, "bbox_diagonal": bbox_diagonal,
"outward_direction": tuple(float(item) for item in outward), "push_pull_inward_material_depth": inward_material_depth,
"push_pull_inward_cut_ratio": inward_cut_ratio,
"outward_direction": outward_tuple,
"current_plane_position": current_plane_position, "current_plane_position": current_plane_position,
"target_plane_position": target_plane_position, "target_plane_position": target_plane_position,
"resize_strategy": "push-pull-planar-face-region", "resize_strategy": "push-pull-planar-face-region",
@@ -547,7 +591,12 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"当前版本会移动当前平面区域,让它和相对平面的距离接近目标厚度;这不是 CAD 壳命令参数编辑。确定继续吗?" + (
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if str(plan.get("risk")) == "high"
else ""
)
+ "当前版本会移动当前平面区域,让它和相对平面的距离接近目标厚度;这不是 CAD 壳命令参数编辑。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
@@ -556,6 +605,9 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消薄壁厚度调整") self.statusBar().showMessage("已取消薄壁厚度调整")
return return
if str(plan.get("risk")) == "high":
self.clear_edit_preview(render=False)
else:
self._show_shell_thickness_preview(face_id, target_thickness) self._show_shell_thickness_preview(face_id, target_thickness)
def action(): def action():
@@ -595,6 +647,7 @@ class WindowActionMixin:
}, },
target_kind="feature" if self.selected_kind == "feature" else "face", target_kind="feature" if self.selected_kind == "feature" else "face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness]),
) )
def resize_shell_thickness_owning_scale(self) -> None: def resize_shell_thickness_owning_scale(self) -> None:
@@ -627,6 +680,9 @@ class WindowActionMixin:
f"缩放比例: {_format_value(plan.get('affine_scale'))}", f"缩放比例: {_format_value(plan.get('affine_scale'))}",
f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", f"缩放目标: {_format_value(plan.get('affine_target_kind'))}",
f"厚度方向: {_format_value(plan.get('affine_axis_direction'))}", f"厚度方向: {_format_value(plan.get('affine_axis_direction'))}",
f"重建模式: {_format_value(plan.get('owning_shell_thickness_rebuild_mode'))}",
f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}",
f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}",
f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}", f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}",
"当前语义: 沿薄壁/壳体厚度方向整体缩放所属特征或 Solid;不是推拉当前平面区域。", "当前语义: 沿薄壁/壳体厚度方向整体缩放所属特征或 Solid;不是推拉当前平面区域。",
] ]
@@ -668,6 +724,9 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"), "affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"), "affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"), "affine_target_kind": plan.get("affine_target_kind"),
"owning_shell_thickness_rebuild_mode": plan.get("owning_shell_thickness_rebuild_mode"),
"local_face_deform_face_count": plan.get("local_face_deform_face_count"),
"local_face_deform_moved_point_count": plan.get("local_face_deform_moved_point_count"),
"part_solid_count": plan.get("part_solid_count"), "part_solid_count": plan.get("part_solid_count"),
"resize_strategy": plan.get("resize_strategy"), "resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"), "edit_strategy_label": plan.get("edit_strategy_label"),
@@ -681,6 +740,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_shell_thickness_owning_scale", [face_id, target_thickness]),
) )
@staticmethod @staticmethod
@@ -860,7 +920,10 @@ class WindowActionMixin:
QMessageBox.information(self, f"不能{title}", str(plan.get("message", ""))) QMessageBox.information(self, f"不能{title}", str(plan.get("message", "")))
self.statusBar().showMessage(blocked_status) self.statusBar().showMessage(blocked_status)
return False return False
if self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan): if (
not self._quick_edit_title_supports_isolation(title)
and self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan)
):
return False return False
if plan.get("risk") == "low": if plan.get("risk") == "low":
return True return True
@@ -883,7 +946,12 @@ class WindowActionMixin:
+ f"\n风险: {plan.get('risk')}\n" + f"\n风险: {plan.get('risk')}\n"
+ warnings_line + warnings_line
+ f"\n{plan.get('message', '')}\n\n" + f"\n{plan.get('message', '')}\n\n"
"为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;" + (
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if str(plan.get("risk")) == "high" and self._quick_edit_title_supports_isolation(title)
else ""
)
+ "为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;"
"详细几何方案会进入后台计算,完成后自动刷新模型。\n\n" "详细几何方案会进入后台计算,完成后自动刷新模型。\n\n"
"确定继续吗?" "确定继续吗?"
), ),
@@ -895,6 +963,36 @@ class WindowActionMixin:
return False return False
return True return True
def _quick_edit_title_supports_isolation(self, title: str) -> bool:
return title in {
"薄壁厚度(整体)缩放所属对象",
"只缩放当前Face面积",
"面面积(整体)",
"面宽(当前面)",
"面高(当前面)",
"面宽(整体)",
"面高(整体)",
"面偏移(当前面)",
"只移动当前Face",
}
def _isolation_for_plan(
self,
plan: dict[str, object],
operation: str,
args: list[object],
*,
timeout_seconds: float = 180.0,
) -> dict[str, object] | None:
if str(plan.get("risk", "")) != "high":
return None
return {
"operation": operation,
"args": args,
"timeout_seconds": timeout_seconds,
"reason": "high-risk-occ-edit",
}
def resize_hole(self) -> None: def resize_hole(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -3776,6 +3874,90 @@ class WindowActionMixin:
def move_edge_end_point(self) -> None: def move_edge_end_point(self) -> None:
self._move_edge_endpoint("end") self._move_edge_endpoint("end")
def move_circular_edge_axis_center(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再移动圆Edge相邻轴心。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择圆Edge", "请先选择一条圆形或圆弧 Edge。")
return
try:
target_center = (
float(self.edge_center_x_input.text()),
float(self.edge_center_y_input.text()),
float(self.edge_center_z_input.text()),
)
except (AttributeError, ValueError):
QMessageBox.critical(self, "圆心坐标无效", "请输入 X, Y, Z 三个数字形式的目标圆心坐标。")
return
edge_id = self.selected_edge_id
plan = self.model.circular_edge_axis_move_plan(edge_id, target_center)
lines = [
f"Edge: {edge_id}",
f"当前圆心: {_format_value(plan.get('current_edge_center'))}",
f"目标圆心: {_format_value(plan.get('target_edge_center'))}",
f"圆心移动: {_format_value(plan.get('circular_edge_center_move_vector'))}",
f"相邻Face: {_format_value(plan.get('circular_edge_cylinder_face_id'))}",
f"执行路径: {_format_value(plan.get('circular_edge_cylinder_mode_label'))}",
f"当前轴心: {_format_value(plan.get('current_axis_center'))}",
f"目标轴心: {_format_value(plan.get('target_axis_center'))}",
f"圆边半径: {_format_value(plan.get('circular_edge_current_radius'))}",
"当前版本会按圆Edge圆心的移动量,移动相邻孔/槽/凸台的圆柱轴心。",
]
if not self._confirm_quick_edit_plan(
"圆Edge圆心/轴心移动",
plan,
lines,
blocked_status="圆Edge圆心/轴心移动已阻止",
cancelled_status="已取消圆Edge圆心/轴心移动",
):
return
self.clear_edit_preview(render=False)
def action():
return self.model.move_circular_edge_axis_center(edge_id, target_center)
self._run_edit_action(
action,
operation_name="移动圆Edge相邻轴心",
target=f"Edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"curve": plan.get("curve"),
"current_edge_center": plan.get("current_edge_center"),
"target_edge_center": plan.get("target_edge_center"),
"circular_edge_center_move_vector": plan.get("circular_edge_center_move_vector"),
"circular_edge_center_move_distance": plan.get("circular_edge_center_move_distance"),
"circular_edge_current_radius": plan.get("circular_edge_current_radius"),
"circular_edge_current_diameter": plan.get("circular_edge_current_diameter"),
"circular_edge_cylinder_face_id": plan.get("circular_edge_cylinder_face_id"),
"circular_edge_cylinder_mode": plan.get("circular_edge_cylinder_mode"),
"circular_edge_cylinder_mode_label": plan.get("circular_edge_cylinder_mode_label"),
"current_axis_center": plan.get("current_axis_center"),
"target_axis_center": plan.get("target_axis_center"),
"axis_move_vector": plan.get("axis_move_vector"),
"axis_move_distance": plan.get("axis_move_distance"),
"axis_move_radial_distance": plan.get("axis_move_radial_distance"),
"axis_move_axial_delta": plan.get("axis_move_axial_delta"),
"target_diameter": plan.get("target_diameter"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"move_axis_status": plan.get("status"),
"move_axis_risk": plan.get("risk"),
"move_axis_message": plan.get("message"),
"move_axis_warnings": plan.get("warnings"),
"move_axis_blockers": plan.get("blockers"),
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind="edge",
target_id=edge_id,
)
def move_edge_center_point(self) -> None: def move_edge_center_point(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -3996,6 +4178,9 @@ class WindowActionMixin:
"delta_reference_radius": plan.get("delta_reference_radius"), "delta_reference_radius": plan.get("delta_reference_radius"),
"reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"), "reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"),
"semi_angle": plan.get("semi_angle"), "semi_angle": plan.get("semi_angle"),
"semi_angle_degrees": plan.get("semi_angle_degrees"),
"target_semi_angle": plan.get("target_semi_angle"),
"target_semi_angle_degrees": plan.get("target_semi_angle_degrees"),
"affine_scale": plan.get("affine_scale"), "affine_scale": plan.get("affine_scale"),
"affine_transform_kind": plan.get("affine_transform_kind"), "affine_transform_kind": plan.get("affine_transform_kind"),
"affine_transform_label": plan.get("affine_transform_label"), "affine_transform_label": plan.get("affine_transform_label"),
@@ -4245,7 +4430,12 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"这不是只改变一个面的 CAD 历史面积参数;继续操作会缩放所属对象并刷新 B-Rep 结果几何,支持失败回滚/撤销。确定继续吗?" + (
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if str(plan.get("risk")) == "high"
else ""
)
+ "这不是只改变一个面的 CAD 历史面积参数;继续操作会缩放所属对象并刷新 B-Rep 结果几何,支持失败回滚/撤销。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
@@ -4288,6 +4478,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_face_area", [face_id, target_area]),
) )
def resize_face_area_local(self) -> None: def resize_face_area_local(self) -> None:
@@ -4363,6 +4554,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_face_area_local", [face_id, target_area]),
) )
def resize_face_width_local(self) -> None: def resize_face_width_local(self) -> None:
@@ -4466,6 +4658,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_face_size_local", [face_id, target_size, axis_key]),
) )
def _resize_face_size_owning_scale(self, axis: str) -> None: def _resize_face_size_owning_scale(self, axis: str) -> None:
@@ -4569,6 +4762,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_face_size_owning_scale", [face_id, target_size, axis_key]),
) )
def move_selected_face_plane_position_by_translation(self) -> None: def move_selected_face_plane_position_by_translation(self) -> None:
@@ -4585,42 +4779,15 @@ class WindowActionMixin:
QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。") QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。")
return return
face_id = self.selected_face_id face_id = self.selected_face_id
frame = self._selected_plane_offset_frame(face_id) plan = self.model.face_plane_offset_owning_translation_plan(face_id, distance)
if frame is None:
QMessageBox.information(self, "不能按面偏移平移", "当前 Face 缺少稳定平面方向或基准点。")
return
plane_origin, plane_direction, current_position = frame
target_position = current_position + distance
vector = (
plane_direction[0] * distance,
plane_direction[1] * distance,
plane_direction[2] * distance,
)
if self.selected_solid_id is not None:
moved_kind = "solid"
moved_id = self.selected_solid_id
plan = self.model.translate_solid_plan(moved_id, vector)
def action():
return self.model.translate_solid(moved_id, vector)
elif self.selected_part_id is not None:
moved_kind = "part"
moved_id = self.selected_part_id
plan = self.model.translate_part_plan(moved_id, vector)
def action():
return self.model.translate_part(moved_id, vector)
else:
QMessageBox.information(self, "不能按面偏移平移", "当前 Face 没有关联到稳定的所属对象。")
return
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能按面偏移平移", str(plan["message"])) QMessageBox.information(self, "不能按面偏移平移", str(plan["message"]))
self.statusBar().showMessage("面偏移整体平移已阻止") self.statusBar().showMessage("面偏移整体平移已阻止")
return return
current_position = float(plan.get("current_plane_position", 0.0) or 0.0)
target_position = float(plan.get("target_plane_position", current_position) or current_position)
plane_direction = tuple(plan.get("plane_direction") or (0.0, 0.0, 0.0))
if not self._confirm_face_plane_position_translation_plan( if not self._confirm_face_plane_position_translation_plan(
plan, plan,
face_id, face_id,
@@ -4631,6 +4798,12 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消面偏移整体平移") self.statusBar().showMessage("已取消面偏移整体平移")
return return
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
def action():
return self.model.translate_face_plane_offset_owning(face_id, distance)
moved_kind = str(plan.get("target_kind", ""))
moved_id = plan.get("solid_id") if moved_kind == "solid" else plan.get("part_id")
self._run_edit_action( self._run_edit_action(
action, action,
operation_name="面偏移(整体)", operation_name="面偏移(整体)",
@@ -4643,9 +4816,9 @@ class WindowActionMixin:
"moved_target_id": moved_id, "moved_target_id": moved_id,
"current_plane_position": current_position, "current_plane_position": current_position,
"target_plane_position": target_position, "target_plane_position": target_position,
"plane_origin": plane_origin, "plane_origin": plan.get("plane_origin"),
"plane_direction": plane_direction, "plane_direction": plane_direction,
"translation_vector": vector, "translation_vector": plan.get("translation_vector"),
"translation_distance": plan.get("translation_distance"), "translation_distance": plan.get("translation_distance"),
"bbox_diagonal": plan.get("bbox_diagonal"), "bbox_diagonal": plan.get("bbox_diagonal"),
"translate_status": plan.get("status"), "translate_status": plan.get("status"),
@@ -4655,7 +4828,7 @@ class WindowActionMixin:
"translate_blockers": plan.get("blockers"), "translate_blockers": plan.get("blockers"),
"resize_strategy": "translate-owning-shape-from-plane-offset", "resize_strategy": "translate-owning-shape-from-plane-offset",
"edit_strategy_label": "按面偏移平移所属对象", "edit_strategy_label": "按面偏移平移所属对象",
"edit_semantics": "把目标面偏移换算成沿当前面方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。", "edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。",
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
@@ -4676,33 +4849,12 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
frame = self._selected_plane_offset_frame(face_id) plan = self.model.face_plane_offset_local_plan(face_id, distance)
if frame is None:
QMessageBox.information(self, "不能只移动当前Face", "当前 Face 缺少稳定平面方向或基准点。")
return
plane_origin, plane_direction, current_position = frame
current_center = self._selected_face_center_for_translation(face_id)
if current_center is None:
QMessageBox.information(self, "不能只移动当前Face", "当前 Face 缺少稳定中心坐标。")
return
target_position = current_position + distance
vector = (
plane_direction[0] * distance,
plane_direction[1] * distance,
plane_direction[2] * distance,
)
target_center = (
current_center[0] + vector[0],
current_center[1] + vector[1],
current_center[2] + vector[2],
)
plan = self.model.face_center_local_move_plan(face_id, target_center)
lines = [ lines = [
f"Face: {face_id}", f"Face: {face_id}",
f"当前面偏移: {_format_value(current_position)}", f"当前面偏移: {_format_value(plan.get('current_plane_position'))}",
f"目标面偏移: {_format_value(target_position)}", f"目标面偏移: {_format_value(plan.get('target_plane_position'))}",
f"平面方向: {_format_value(plane_direction)}", f"平面方向: {_format_value(plan.get('plane_direction'))}",
f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", f"移动向量: {_format_value(plan.get('face_center_move_vector'))}",
f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", f"移动距离: {_format_value(plan.get('face_center_move_distance'))}",
f"移动/所属对象尺寸: {_format_percent(plan.get('face_center_move_ratio'))}", f"移动/所属对象尺寸: {_format_percent(plan.get('face_center_move_ratio'))}",
@@ -4721,7 +4873,7 @@ class WindowActionMixin:
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
def action(): def action():
return self.model.move_face_center_local(face_id, target_center) return self.model.move_face_plane_offset_local(face_id, distance)
self._run_edit_action( self._run_edit_action(
action, action,
@@ -4732,10 +4884,10 @@ class WindowActionMixin:
"part_id": plan.get("part_id"), "part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"), "solid_id": plan.get("solid_id"),
"surface": plan.get("surface"), "surface": plan.get("surface"),
"current_plane_position": current_position, "current_plane_position": plan.get("current_plane_position"),
"target_plane_position": target_position, "target_plane_position": plan.get("target_plane_position"),
"plane_origin": plane_origin, "plane_origin": plan.get("plane_origin"),
"plane_direction": plane_direction, "plane_direction": plan.get("plane_direction"),
"plane_offset_distance": distance, "plane_offset_distance": distance,
"current_face_center": plan.get("current_face_center"), "current_face_center": plan.get("current_face_center"),
"target_face_center": plan.get("target_face_center"), "target_face_center": plan.get("target_face_center"),
@@ -4749,7 +4901,7 @@ class WindowActionMixin:
"resize_strategy": "local-face-plane-offset-deform", "resize_strategy": "local-face-plane-offset-deform",
"edit_strategy_label": "按面偏移只移动当前Face", "edit_strategy_label": "按面偏移只移动当前Face",
"edit_semantics": ( "edit_semantics": (
"把目标面偏移换算成沿当前面方向的移动量,只移动当前 Face 的顶点并重建相邻平面;" "把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 的顶点并重建相邻平面;"
"不推拉加料/切削,也不平移所属对象。" "不推拉加料/切削,也不平移所属对象。"
), ),
"resize_status": plan.get("status"), "resize_status": plan.get("status"),
@@ -4761,6 +4913,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "move_face_plane_offset_local", [face_id, distance]),
) )
def move_selected_face_center(self) -> None: def move_selected_face_center(self) -> None:
@@ -4787,18 +4940,18 @@ class WindowActionMixin:
if self.selected_solid_id is not None: if self.selected_solid_id is not None:
moved_kind = "solid" moved_kind = "solid"
moved_id = self.selected_solid_id moved_id = self.selected_solid_id
plan = self.model.translate_solid_plan(moved_id, vector) plan = self.model.face_center_owning_translation_plan(face_id, target_center)
def action(): def action():
return self.model.translate_solid(moved_id, vector) return self.model.move_face_center_owning(face_id, target_center)
elif self.selected_part_id is not None: elif self.selected_part_id is not None:
moved_kind = "part" moved_kind = "part"
moved_id = self.selected_part_id moved_id = self.selected_part_id
plan = self.model.translate_part_plan(moved_id, vector) plan = self.model.face_center_owning_translation_plan(face_id, target_center)
def action(): def action():
return self.model.translate_part(moved_id, vector) return self.model.move_face_center_owning(face_id, target_center)
else: else:
QMessageBox.information(self, "不能移动Face中心", "当前 Face 没有关联到稳定的所属对象。") QMessageBox.information(self, "不能移动Face中心", "当前 Face 没有关联到稳定的所属对象。")
@@ -4918,6 +5071,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "move_face_center_local", [face_id, list(target_center)]),
) )
def move_selected_axis_center_by_translation(self) -> None: def move_selected_axis_center_by_translation(self) -> None:
@@ -5181,11 +5335,11 @@ class WindowActionMixin:
f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n"
f"平移距离: {_format_value(plan['translation_distance'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n"
f"编辑策略: 按面偏移平移所属对象\n" f"编辑策略: 按面偏移平移所属对象\n"
f"编辑方式: 沿当前面方向平移所属特征或 Solid;不推拉当前面,不切削,也不补料。\n" f"编辑方式: 沿当前面垂直方向平移所属特征或 Solid;不推拉当前面,不切削,也不补料。\n"
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"如果你想改变厚度或把这个面推出/切入,请使用不带“整体”的面偏移或偏移距离行。确定继续吗?" "如果你想改变厚度或把这个面推出/切入,请在“面偏移”这一行把影响范围选为“推拉当前面”。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
@@ -6023,7 +6177,7 @@ class WindowActionMixin:
"该操作被稳定性保护阻止。\n\n" "该操作被稳定性保护阻止。\n\n"
"当前计划被判定为 high risk;这类 OCCT 布尔、倒圆或局部重建在复杂 STEP 上" "当前计划被判定为 high risk;这类 OCCT 布尔、倒圆或局部重建在复杂 STEP 上"
"可能不是普通失败,而是让进程卡死或直接退出。请先尝试更小的参数、修复模型、" "可能不是普通失败,而是让进程卡死或直接退出。请先尝试更小的参数、修复模型、"
"选择更明确的面/边,或等后续子进程隔离执行通道实现后再开放" "选择更明确的面/边;当前这类操作还没有接入隔离子进程执行通道。"
), ),
) )
self.statusBar().showMessage("高风险操作已被稳定性保护阻止") self.statusBar().showMessage("高风险操作已被稳定性保护阻止")
@@ -6037,6 +6191,7 @@ class WindowActionMixin:
parameters: dict[str, object], parameters: dict[str, object],
target_kind: str | None = None, target_kind: str | None = None,
target_id: int | None = None, target_id: int | None = None,
isolation: dict[str, object] | None = None,
) -> None: ) -> None:
if self.model is None: if self.model is None:
return return
@@ -6057,6 +6212,7 @@ class WindowActionMixin:
"pick_position": self.selected_pick_position, "pick_position": self.selected_pick_position,
"show_same_domain_internal_edges": self._show_same_domain_internal_edges(), "show_same_domain_internal_edges": self._show_same_domain_internal_edges(),
"edit_result_deflection": result_deflection, "edit_result_deflection": result_deflection,
"isolation": dict(isolation or {}),
} }
self._begin_edit_task(operation_name) self._begin_edit_task(operation_name)
@@ -6097,6 +6253,16 @@ class WindowActionMixin:
before_stats = self.model.stats() before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id) before_part_stats = self._part_stats_or_none(target_part_id)
before_geometry = {} before_geometry = {}
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation:
return self._run_isolated_edit_job(
context=context,
isolation=isolation,
snapshot=snapshot,
before_stats=before_stats,
before_part_stats=before_part_stats,
before_geometry=before_geometry,
)
try: try:
result = action() result = action()
after_snapshot = self.model.snapshot() after_snapshot = self.model.snapshot()
@@ -6140,6 +6306,120 @@ class WindowActionMixin:
return job return job
def _run_isolated_edit_job(
self,
*,
context: dict[str, object],
isolation: dict[str, object],
snapshot: dict[object, object],
before_stats,
before_part_stats,
before_geometry: dict[str, object],
) -> dict[str, object]:
if self.model is None:
raise RuntimeError("Model is not loaded.")
target_part_id = self._edit_context_part_id(context)
timeout_seconds = float(isolation.get("timeout_seconds") or 180.0)
operation = str(isolation.get("operation") or "").strip()
args = list(isolation.get("args") or [])
if not operation:
raise RuntimeError("隔离执行缺少操作名称。")
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_edit_") as temp_dir:
temp_root = Path(temp_dir)
input_path = temp_root / "input.step"
output_path = temp_root / "output.step"
request_path = temp_root / "request.json"
self.model.export_all(input_path)
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"operation": operation,
"args": args,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
command = [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
try:
completed = subprocess.run(
command,
cwd=project_root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"隔离子进程执行超时,已终止危险计算;主程序和原模型保持不变。"
f" 超时时间: {timeout_seconds:g}s"
) from exc
response_path = request_path.with_suffix(".response.json")
response: dict[str, object] = {}
if response_path.exists():
try:
response = json.loads(response_path.read_text(encoding="utf-8"))
except Exception:
response = {}
if completed.returncode != 0 or not bool(response.get("ok", False)):
error = str(response.get("error") or completed.stderr or completed.stdout or "未知错误").strip()
raise RuntimeError(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。"
f" 子进程返回码: {completed.returncode}. 错误: {error}"
)
if not output_path.exists():
raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。")
new_model = StepModel.load(output_path)
try:
new_model.filename = self.step_path
except Exception:
pass
self.model = new_model
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_geometry: dict[str, object] = {}
model_polydata = None
edge_polydata = None
try:
deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection)
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
except Exception:
model_polydata = None
edge_polydata = None
child_message = str(response.get("message") or "隔离子进程编辑完成。")
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
"snapshot": snapshot,
"before_stats": before_stats,
"before_part_stats": before_part_stats,
"before_geometry": before_geometry,
"after_snapshot": after_snapshot,
"after_stats": after_stats,
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
}
def _edit_context_part_id(self, context: dict[str, object]) -> int | None: def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None: if self.model is None:
return None return None
@@ -6218,9 +6498,9 @@ class WindowActionMixin:
elif "面宽(当前面)" in operation_name or "面高(当前面)" in operation_name: elif "面宽(当前面)" in operation_name or "面高(当前面)" in operation_name:
progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。" progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。"
elif "面偏移(当前面)" in operation_name: elif "面偏移(当前面)" in operation_name:
progress_text = f"{operation_name} 正在后台计算;当前会沿平面方向移动所选 Face 顶点并重建相邻平面。" progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向移动所选 Face 顶点并重建相邻平面。"
elif "面偏移(整体)" in operation_name: elif "面偏移(整体)" in operation_name:
progress_text = f"{operation_name} 正在后台计算;当前会沿平面方向平移所属特征或 Solid,不推拉当前面。" progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向平移所属特征或 Solid,不推拉当前面。"
elif "Face中心" in operation_name: elif "Face中心" in operation_name:
progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不做单面局部扭曲。" progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不做单面局部扭曲。"
elif "只移动当前Face" in operation_name: elif "只移动当前Face" in operation_name:
+227 -62
View File
@@ -1313,6 +1313,9 @@ class WindowStateMixin:
_int_values(action_info.get("feature_start_end_face_ids")) _int_values(action_info.get("feature_start_end_face_ids"))
or _int_values(action_info.get("feature_end_end_face_ids")) or _int_values(action_info.get("feature_end_end_face_ids"))
) )
show_generic_face_edit_specs = bool(has_face and (is_plane or is_shell_candidate))
local_face_deform_ready = bool(action_info.get("local_face_deform_ready", True))
local_face_deform_blocker = str(action_info.get("local_face_deform_blocker") or "").strip()
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2 has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line" is_line_edge = has_edge and curve == "line"
specs: list[dict[str, object]] = [] specs: list[dict[str, object]] = []
@@ -1327,28 +1330,65 @@ class WindowStateMixin:
return "" return ""
return ", ".join(_format_float(item) for item in value) return ", ".join(_format_float(item) for item in value)
def relative_range_hint(current: object, caution_ratio: float, high_ratio: float) -> str: def relative_range_hint(
current: object,
caution_ratio: float,
high_ratio: float,
hard_limits: str = "",
) -> str:
number = _float_or_none(current) number = _float_or_none(current)
if number is None or number <= 0: if number is None or number <= 0:
return "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。" base = "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
return f"{base} {hard_limits}".strip()
lower = max(number * (1.0 - caution_ratio), 0.0) lower = max(number * (1.0 - caution_ratio), 0.0)
upper = number * (1.0 + caution_ratio) upper = number * (1.0 + caution_ratio)
return ( base = (
f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改" f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改"
f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);" f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);"
f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。" f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。"
) )
return f"{base} {hard_limits}".strip()
def positive_minimum() -> dict[str, object]: def positive_minimum() -> dict[str, object]:
return {"min_value": 0.0, "min_exclusive": True} return {"min_value": 0.0, "min_exclusive": True}
def face_scale_limits(current: object, *, squared: bool = False) -> dict[str, object]:
number = _float_or_none(current)
if number is None or number <= 0:
return positive_minimum()
power = 2 if squared else 1
return {
"min_value": number * (0.05 ** power),
"max_value": number * (5.0 ** power),
}
def face_offset_target_limits(current_position: object) -> dict[str, object]:
position = _float_or_none(current_position)
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if position is None or diagonal is None or diagonal <= 0:
return {}
span = diagonal * 5.0
return {"min_value": position - span, "max_value": position + span}
def face_vector_move_limit(reference: object) -> dict[str, object]:
center = _triple_or_none(reference)
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if center is None or diagonal is None or diagonal <= 0:
return {}
return {
"vector_distance_reference": center,
"max_vector_distance": diagonal * 5.0,
"vector_distance_label": "中心移动距离",
}
def push_pull_hint() -> str: def push_pull_hint() -> str:
diagonal = _float_or_none(action_info.get("bbox_diagonal")) diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if diagonal is not None and diagonal > 0: if diagonal is not None and diagonal > 0:
return ( return (
"可输入正数或负数,单位同模型;" "可输入正数或负数,单位同模型;"
f"建议单次绝对值不超过 {_format_float(diagonal * 0.08)}" f"建议单次绝对值不超过 {_format_float(diagonal * 0.08)}"
f"超过 {_format_float(diagonal * 0.2)} 时风险较高" f"超过 {_format_float(diagonal * 0.2)} 时风险较高"
f"超过 {_format_float(diagonal * 5.0)} 会被阻止。"
) )
return "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。" return "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。"
@@ -1362,6 +1402,11 @@ class WindowStateMixin:
) )
return "格式为 X, Y, Z;建议先小幅试改并查看预览。" return "格式为 X, Y, Z;建议先小幅试改并查看预览。"
face_linear_hard_limit = "目标值低于当前值的 5% 或高于当前值的 5 倍会被阻止。"
face_area_hard_limit = "如果目标面积会让面宽/面高缩到当前 5% 以下或放大到 5 倍以上,会被阻止。"
face_offset_hard_limit = "如果目标位置与当前位置差距远超当前模型尺寸,会被阻止。"
face_center_hard_limit = "如果目标中心与当前中心距离超过所属对象尺寸的 5 倍,会被阻止。"
def hole_diameter_upper_limit(current: object) -> float | None: def hole_diameter_upper_limit(current: object) -> float | None:
current_number = _float_or_none(current) current_number = _float_or_none(current)
height = _float_or_none(action_info.get("height_estimate")) height = _float_or_none(action_info.get("height_estimate"))
@@ -1406,6 +1451,11 @@ class WindowStateMixin:
"高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通半径行。" "高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通半径行。"
) )
def face_local_disabled_tip(base: str) -> str:
if local_face_deform_ready or not local_face_deform_blocker:
return base
return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}"
def add_spec( def add_spec(
*, *,
key: str, key: str,
@@ -1591,14 +1641,24 @@ class WindowStateMixin:
), ),
) )
elif is_plane or is_shell_candidate: elif is_plane or is_shell_candidate:
face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。"
face_semantics_tip = (
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
"面偏移是沿当前面垂直方向测到的位置。影响范围决定这次修改只作用在当前 Face,"
"还是推拉加料/切削,或带动所属特征或 Solid。"
)
if is_plane and not local_face_deform_ready:
face_semantics_text = "Face:当前面暂不能只改当前面,可用推拉或整体策略。"
face_semantics_tip = (
"此 Face 不适合做“只改当前面”的局部顶点重建;"
f"原因:{local_face_deform_blocker or '当前拓扑不满足局部重建条件。'}"
"可优先使用“推拉当前面”、孔/槽专门入口,或选择移动/调整整个特征。"
)
add_readonly_spec( add_readonly_spec(
key="face_edit_semantics", key="face_edit_semantics",
label="编辑方式", label="编辑方式",
text="Face:先改目标值,再用“影响范围”选择只改当前面或调整整个特征。", text=face_semantics_text,
tip=( tip=face_semantics_tip,
"面积、面宽、面高、中心和面偏移会尽量合并成一行参数;"
"影响范围下拉框决定这次修改是只作用在当前 Face,还是带动所属特征或 Solid。"
),
) )
if has_edge: if has_edge:
@@ -1612,7 +1672,7 @@ class WindowStateMixin:
), ),
) )
if has_face: if show_generic_face_edit_specs:
current_face_area = _float_or_none(action_info.get("area")) current_face_area = _float_or_none(action_info.get("area"))
current_face_center = ( current_face_center = (
_triple_or_none(action_info.get("area_center")) _triple_or_none(action_info.get("area_center"))
@@ -1631,6 +1691,7 @@ class WindowStateMixin:
"target_attr": "face_area_input", "target_attr": "face_area_input",
"enabled": bool( "enabled": bool(
is_plane is_plane
and local_face_deform_ready
and current_face_area is not None and current_face_area is not None
and current_face_area > 0 and current_face_area > 0
and current_face_center is not None and current_face_center is not None
@@ -1639,10 +1700,12 @@ class WindowStateMixin:
"输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点," "输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点,"
"相邻面会按新边界重建。" "相邻面会按新边界重建。"
), ),
"disabled_tip": "只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。", "disabled_tip": face_local_disabled_tip(
"只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。"
),
"range_hint": ( "range_hint": (
"只改当前面时,相邻面可能自然变斜。当前只对简单全平面实体开放," "只改当前面时,相邻面可能自然变斜。当前只对简单全平面实体开放,"
f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8)}" f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}"
), ),
}, },
"owning": { "owning": {
@@ -1657,24 +1720,24 @@ class WindowStateMixin:
"disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。", "disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。",
"range_hint": ( "range_hint": (
"调整整个特征会影响同一对象上的其它尺寸。" "调整整个特征会影响同一对象上的其它尺寸。"
f" {relative_range_hint(current_face_area, 0.15, 0.8)}" f" {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}"
), ),
}, },
}, },
value_type="positive", value_type="positive",
used=("area", "area_center", "bbox_center"), used=("area", "area_center", "bbox_center"),
**positive_minimum(), **face_scale_limits(current_face_area, squared=True),
) )
if is_plane: if is_plane:
current_face_width = _float_or_none(action_info.get("local_face_width")) current_face_width = _float_or_none(action_info.get("local_face_width"))
current_face_height = _float_or_none(action_info.get("local_face_height")) current_face_height = _float_or_none(action_info.get("local_face_height"))
face_size_tip = ( face_size_tip = (
"这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影尺寸" "这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影长度"
"不是全局 X/Y/Z 包围盒尺寸。只修改当前 Face 顶点,相邻面会按新顶点重建。" "不是面积,也不是模型整体高度。只修改当前 Face 顶点,相邻面会按新顶点重建。"
) )
face_size_owner_tip = ( face_size_owner_tip = (
"这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影尺寸" "这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影长度"
"不是全局 X/Y/Z 包围盒尺寸。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。" "不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
) )
add_scoped_spec( add_scoped_spec(
key="local_face_width", key="local_face_width",
@@ -1688,13 +1751,16 @@ class WindowStateMixin:
"action": "resize_face_width_local", "action": "resize_face_width_local",
"target_attr": "face_width_input", "target_attr": "face_width_input",
"enabled": bool( "enabled": bool(
current_face_width is not None local_face_deform_ready
and current_face_width is not None
and current_face_width > 0 and current_face_width > 0
and current_face_center is not None and current_face_center is not None
), ),
"enabled_tip": f"输入目标面宽;{face_size_tip}", "enabled_tip": f"输入目标面宽;{face_size_tip}",
"disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。", "disabled_tip": face_local_disabled_tip(
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8)}", "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
}, },
"owning": { "owning": {
"label": "调整整个特征", "label": "调整整个特征",
@@ -1707,13 +1773,13 @@ class WindowStateMixin:
and (self.selected_part_id is not None or self.selected_solid_id is not None) and (self.selected_part_id is not None or self.selected_solid_id is not None)
), ),
"enabled_tip": f"输入目标面宽;{face_size_owner_tip}", "enabled_tip": f"输入目标面宽;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按面宽缩放所属对象。", "disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5)}", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}",
}, },
}, },
value_type="positive", value_type="positive",
used=("local_face_width", "local_face_width_direction", "local_face_size_center"), used=("local_face_width", "local_face_width_direction", "local_face_size_center"),
**positive_minimum(), **face_scale_limits(current_face_width),
) )
add_scoped_spec( add_scoped_spec(
key="local_face_height", key="local_face_height",
@@ -1727,13 +1793,16 @@ class WindowStateMixin:
"action": "resize_face_height_local", "action": "resize_face_height_local",
"target_attr": "face_height_input", "target_attr": "face_height_input",
"enabled": bool( "enabled": bool(
current_face_height is not None local_face_deform_ready
and current_face_height is not None
and current_face_height > 0 and current_face_height > 0
and current_face_center is not None and current_face_center is not None
), ),
"enabled_tip": f"输入目标面高;{face_size_tip}", "enabled_tip": f"输入目标面高;{face_size_tip}",
"disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。", "disabled_tip": face_local_disabled_tip(
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8)}", "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
}, },
"owning": { "owning": {
"label": "调整整个特征", "label": "调整整个特征",
@@ -1746,13 +1815,13 @@ class WindowStateMixin:
and (self.selected_part_id is not None or self.selected_solid_id is not None) and (self.selected_part_id is not None or self.selected_solid_id is not None)
), ),
"enabled_tip": f"输入目标面高;{face_size_owner_tip}", "enabled_tip": f"输入目标面高;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按面高缩放所属对象。", "disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5)}", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}",
}, },
}, },
value_type="positive", value_type="positive",
used=("local_face_height", "local_face_height_direction", "local_face_size_center"), used=("local_face_height", "local_face_height_direction", "local_face_size_center"),
**positive_minimum(), **face_scale_limits(current_face_height),
) )
add_scoped_spec( add_scoped_spec(
key="face_center_position", key="face_center_position",
@@ -1765,18 +1834,21 @@ class WindowStateMixin:
"label": "只改当前面", "label": "只改当前面",
"action": "move_selected_face_center_local", "action": "move_selected_face_center_local",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(is_plane and current_face_center is not None), "enabled": bool(is_plane and local_face_deform_ready and current_face_center is not None),
"enabled_tip": ( "enabled_tip": (
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;" "输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;"
"程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。" "程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。"
), ),
"disabled_tip": "只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。", "disabled_tip": face_local_disabled_tip(
"只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。"
),
"range_hint": ( "range_hint": (
"只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。" "只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。"
f"{translation_hint()}" f"{translation_hint()} {face_center_hard_limit}"
), ),
"target_transform": "target_center_to_translation", "target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_face_center}, "transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
}, },
"owning": { "owning": {
"label": "移动整个特征", "label": "移动整个特征",
@@ -1791,10 +1863,11 @@ class WindowStateMixin:
"disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。", "disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。",
"range_hint": ( "range_hint": (
"移动整个特征不会改变当前对象形状,但同一对象会整体搬动。" "移动整个特征不会改变当前对象形状,但同一对象会整体搬动。"
f"{translation_hint()}" f"{translation_hint()} {face_center_hard_limit}"
), ),
"target_transform": "target_center_to_translation", "target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_face_center}, "transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
}, },
}, },
value_type="vector3", value_type="vector3",
@@ -1837,11 +1910,12 @@ class WindowStateMixin:
"action": "push_pull_face", "action": "push_pull_face",
"target_attr": "offset_input", "target_attr": "offset_input",
"enabled": current_plane_position is not None, "enabled": current_plane_position is not None,
"enabled_tip": "输入目标面偏移;程序会沿当前推拉方向移动面,自动加料或切削。", "enabled_tip": "输入目标面偏移;程序会沿当前面的垂直方向移动面,自动加料或切削。",
"disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。", "disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。",
"range_hint": ( "range_hint": (
"是沿当前推拉方向测量的位置值,不是 X/Y/Z 坐标;单位同模型。" "面偏移是沿当前面垂直方向测量的目标值,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。"
"目标值大于当前值通常向外移,小于当前值通常向内移" "程序会把目标面偏移自动换算成本次需要移动的距离"
f"{face_offset_hard_limit}"
), ),
"target_transform": "plane_target_position_to_offset", "target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position}, "transform_context": {"current_plane_position": current_plane_position},
@@ -1852,18 +1926,21 @@ class WindowStateMixin:
"target_attr": "offset_input", "target_attr": "offset_input",
"enabled": bool( "enabled": bool(
current_plane_position is not None current_plane_position is not None
and local_face_deform_ready
and plane_direction is not None and plane_direction is not None
and current_face_center is not None and current_face_center is not None
), ),
"enabled_tip": ( "enabled_tip": (
"输入目标面偏移;程序只把当前 Face 沿平面方向移动到该位置" "输入目标面偏移;程序只把当前 Face 沿当前面的垂直方向移动到该目标值"
"并让相邻平面按新顶点重建。" "并让相邻平面按新顶点重建。"
), ),
"disabled_tip": "当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。", "disabled_tip": face_local_disabled_tip(
"当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。"
),
"range_hint": ( "range_hint": (
"只改当前面不会自动加料/切削,相邻面可能自然变斜," "只改当前面不会自动加料/切削,相邻面可能自然变斜,"
"非共面面可能被拆成三角面。" "非共面面可能被拆成三角面。"
f"{translation_hint()}" f"{translation_hint()} {face_offset_hard_limit}"
), ),
"target_transform": "plane_target_position_to_offset", "target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position}, "transform_context": {"current_plane_position": current_plane_position},
@@ -1878,32 +1955,20 @@ class WindowStateMixin:
and (self.selected_part_id is not None or self.selected_solid_id is not None) and (self.selected_part_id is not None or self.selected_solid_id is not None)
), ),
"enabled_tip": ( "enabled_tip": (
"输入目标面偏移;程序会沿当前面方向平移所属特征或 Solid" "输入目标面偏移;程序会沿当前面的垂直方向平移所属特征或 Solid"
"当前面形状和所属对象内部尺寸不变。" "当前面形状和所属对象内部尺寸不变。"
), ),
"disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。", "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。",
"range_hint": ( "range_hint": (
"这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。" "这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。"
f"{translation_hint()}" f"{translation_hint()} {face_offset_hard_limit}"
), ),
"target_transform": "plane_target_position_to_offset", "target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position}, "transform_context": {"current_plane_position": current_plane_position},
}, },
}, },
used=("plane_origin", "push_pull_outward_direction", "normal"), used=("plane_origin", "push_pull_outward_direction", "normal"),
) **face_offset_target_limits(current_plane_position),
add_spec(
key="push_pull_distance",
label="偏移距离",
current_raw=0.0,
target_text="0",
action="push_pull_face",
target_attr="offset_input",
enabled=True,
enabled_tip="输入本次要让这个面沿当前推拉方向移动多远;正负表示两个相反方向,执行后这里会重新从 0 开始。",
disabled_tip="当前对象不是可移动的平面。",
range_hint=push_pull_hint(),
current_text="0(本次未移动)",
) )
if is_shell_candidate: if is_shell_candidate:
current = _float_or_none(action_info.get("shell_thickness_estimate")) current = _float_or_none(action_info.get("shell_thickness_estimate"))
@@ -1924,7 +1989,7 @@ class WindowStateMixin:
"enabled": current is not None, "enabled": current is not None,
"enabled_tip": "输入薄壁/壳体局部区域的目标厚度;程序会推拉当前平面来改变与相对面的距离。", "enabled_tip": "输入薄壁/壳体局部区域的目标厚度;程序会推拉当前平面来改变与相对面的距离。",
"disabled_tip": "当前平面没有稳定识别到可修改的薄壁厚度。", "disabled_tip": "当前平面没有稳定识别到可修改的薄壁厚度。",
"range_hint": relative_range_hint(current, 0.35, 0.8), "range_hint": relative_range_hint(current, 0.35, 0.8, face_linear_hard_limit),
}, },
"owning": { "owning": {
"label": "调整整个特征", "label": "调整整个特征",
@@ -1946,13 +2011,13 @@ class WindowStateMixin:
"disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。", "disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。",
"range_hint": ( "range_hint": (
"这是整体缩放所属对象,不是推拉当前平面;" "这是整体缩放所属对象,不是推拉当前平面;"
f"如果只想移动当前薄壁平面区域,请把影响范围设为“推拉当前面”。 {relative_range_hint(current, 0.2, 0.5)}" f"如果只想移动当前薄壁平面区域,请把影响范围设为“推拉当前面”。 {relative_range_hint(current, 0.2, 0.5, face_linear_hard_limit)}"
), ),
}, },
}, },
value_type="positive", value_type="positive",
used=("shell_thickness_estimate", "shell_current_thickness", "shell_signed_thickness", "normal", "plane_origin"), used=("shell_thickness_estimate", "shell_current_thickness", "shell_signed_thickness", "normal", "plane_origin"),
**positive_minimum(), **face_scale_limits(current),
) )
if is_hole_or_groove: if is_hole_or_groove:
current_diameter = _float_or_none(action_info.get("diameter")) current_diameter = _float_or_none(action_info.get("diameter"))
@@ -3180,7 +3245,28 @@ class WindowStateMixin:
if is_circle_edge: if is_circle_edge:
current_radius = _float_or_none(action_info.get("radius")) current_radius = _float_or_none(action_info.get("radius"))
current_diameter = _float_or_none(action_info.get("diameter")) current_diameter = _float_or_none(action_info.get("diameter"))
circle_edge_center = _triple_or_none(action_info.get("center"))
can_resize_circle = bool(current_length is not None and current_length > 0 and current_radius is not None and current_radius > 0) can_resize_circle = bool(current_length is not None and current_length > 0 and current_radius is not None and current_radius > 0)
add_spec(
key="circle_edge_axis_center",
label="圆心/轴心",
current_raw=circle_edge_center if circle_edge_center is not None else "",
target_text=vector_text(circle_edge_center),
action="move_circular_edge_axis_center",
target_attrs=("edge_center_x_input", "edge_center_y_input", "edge_center_z_input"),
enabled=bool(circle_edge_center is not None and current_radius is not None and current_radius > 0),
enabled_tip=(
"输入目标 X, Y, Z 坐标;程序会用圆Edge圆心的移动量,"
"移动相邻孔、槽或凸台的圆柱轴心。"
),
disabled_tip="当前圆Edge缺少稳定圆心/半径,或没有可识别的相邻圆柱特征。",
value_type="vector3",
range_hint=(
"格式为 X, Y, Z。这个修改只在点击“修改”时才识别相邻圆柱;"
"优先移动局部孔/槽/凸台,不会整体平移零件。建议先小幅移动。"
),
used=("center", "adjacent_face_ids"),
)
circle_context = { circle_context = {
"edge_current_length": current_length, "edge_current_length": current_length,
"edge_current_radius": current_radius, "edge_current_radius": current_radius,
@@ -3614,6 +3700,7 @@ class WindowStateMixin:
target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN) widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN)
if isinstance(widget, QPushButton): if isinstance(widget, QPushButton):
validation_error = ""
if str(effective_spec.get("value_type", "number")) == "command": if str(effective_spec.get("value_type", "number")) == "command":
row_changed = True row_changed = True
widget.setText(str(effective_spec.get("button_text") or "执行")) widget.setText(str(effective_spec.get("button_text") or "执行"))
@@ -3623,14 +3710,28 @@ class WindowStateMixin:
text = self._property_target_text(row) text = self._property_target_text(row)
row_changed = self._property_target_changed(effective_spec, text) row_changed = self._property_target_changed(effective_spec, text)
empty_target = not bool(text.strip()) empty_target = not bool(text.strip())
widget.setText("未输入" if empty_target else ("应用" if row_changed else "未改动")) validation_error = "" if empty_target else self._property_target_validation_error(effective_spec, text)
enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action") and row_changed) if empty_target:
widget.setText("未输入")
elif validation_error:
widget.setText("无效")
else:
widget.setText("应用" if row_changed else "未改动")
enabled = bool(
has_model
and effective_spec.get("enabled")
and effective_spec.get("action")
and row_changed
and not validation_error
)
if empty_target: if empty_target:
tooltip = ( tooltip = (
f"{effective_spec.get('label', '当前属性')}”的目标值为空。" f"{effective_spec.get('label', '当前属性')}”的目标值为空。"
"这不会删除模型里的点、边或面,只是暂时没有要应用的目标值;" "这不会删除模型里的点、边或面,只是暂时没有要应用的目标值;"
"重新选择对象会按当前模型值重新填入。" "重新选择对象会按当前模型值重新填入。"
) )
elif validation_error:
tooltip = f"{validation_error} 请调整目标值后再应用。"
elif row_changed: elif row_changed:
scope_label = str(effective_spec.get("scope_label") or "").strip() scope_label = str(effective_spec.get("scope_label") or "").strip()
suffix = f"{scope_label}" if scope_label else "" suffix = f"{scope_label}" if scope_label else ""
@@ -3640,7 +3741,8 @@ class WindowStateMixin:
range_hint = self._property_range_hint(effective_spec) range_hint = self._property_range_hint(effective_spec)
if range_hint: if range_hint:
tooltip = f"{tooltip}\n\n{range_hint}" tooltip = f"{tooltip}\n\n{range_hint}"
widget.setProperty("changed", bool(row_changed)) widget.setProperty("changed", bool(row_changed and not validation_error))
widget.setProperty("invalid", bool(validation_error))
widget.setToolTip(tooltip) widget.setToolTip(tooltip)
widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor) widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor)
widget.style().unpolish(widget) widget.style().unpolish(widget)
@@ -3679,7 +3781,7 @@ class WindowStateMixin:
}: }:
continue continue
text = self._property_target_text(row) text = self._property_target_text(row)
if self._property_target_changed(effective_spec, text): if self._property_target_changed(effective_spec, text) and not self._property_target_validation_error(effective_spec, text):
changed.append((row, effective_spec, text)) changed.append((row, effective_spec, text))
return changed return changed
@@ -3728,6 +3830,65 @@ class WindowStateMixin:
return abs(value - current) > PROPERTY_VALUE_TOLERANCE return abs(value - current) > PROPERTY_VALUE_TOLERANCE
return None return None
def _property_target_validation_error(self, spec: dict[str, object], text: str) -> str:
value_type = str(spec.get("value_type", "number"))
if value_type == "command":
return ""
if not text.strip():
return ""
try:
if value_type == "vector3":
values = self._parse_property_vector3(text)
return self._property_vector_distance_error(spec, values)
if value_type == "choice":
self._property_choice_value(spec, text)
return ""
if value_type == "number_pair":
values = self._parse_property_number_pair(text)
if spec.get("positive_pair") and any(value <= 0 for value in values):
return "请输入两个大于 0 的目标值。"
for value in values:
range_error = self._property_scalar_range_error(spec, value)
if range_error:
return range_error
return ""
if value_type in {"integer", "integer_or_empty"}:
if not text and value_type == "integer_or_empty":
return ""
try:
value = int(text)
except ValueError:
return "请输入整数形式的目标值。"
return self._property_scalar_range_error(spec, float(value))
try:
value = float(text)
except ValueError:
return "请输入数字形式的目标值。"
if value_type == "positive" and value <= 0:
return "请输入大于 0 的目标值。"
return self._property_scalar_range_error(spec, value)
except ValueError as exc:
return str(exc)
def _property_vector_distance_error(
self,
spec: dict[str, object],
values: tuple[float, float, float],
) -> str:
max_distance = _float_or_none(spec.get("max_vector_distance"))
reference = _triple_or_none(spec.get("vector_distance_reference"))
if max_distance is None or max_distance <= 0 or reference is None:
return ""
distance = math.sqrt(
(values[0] - reference[0]) * (values[0] - reference[0])
+ (values[1] - reference[1]) * (values[1] - reference[1])
+ (values[2] - reference[2]) * (values[2] - reference[2])
)
if distance > max_distance + PROPERTY_VALUE_TOLERANCE:
label = str(spec.get("vector_distance_label") or spec.get("label") or "目标距离")
return f"{label} 必须小于或等于 {_format_float(max_distance)}"
return ""
def _property_target_changed(self, spec: dict[str, object], text: str) -> bool: def _property_target_changed(self, spec: dict[str, object], text: str) -> bool:
value_type = str(spec.get("value_type", "number")) value_type = str(spec.get("value_type", "number"))
if not text: if not text:
@@ -3824,6 +3985,10 @@ class WindowStateMixin:
if not is_command and not self._property_target_changed(spec, text): if not is_command and not self._property_target_changed(spec, text):
self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。") self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。")
return return
validation_error = "" if is_command else self._property_target_validation_error(spec, text)
if validation_error:
QMessageBox.information(self, "目标值无效", validation_error)
return
try: try:
if not is_command: if not is_command:
self._sync_property_edit_target(spec, text) self._sync_property_edit_target(spec, text)