feat: 完善 Face 参数化编辑和隔离执行
This commit is contained in:
+2
-1
@@ -19,10 +19,11 @@ dist/
|
||||
*_diff_*.txt
|
||||
*_operation_history.json
|
||||
*.log
|
||||
*.zip
|
||||
|
||||
assets/screenshots/
|
||||
|
||||
教学.txt
|
||||
local/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。
|
||||
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。
|
||||
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
|
||||
- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入 Edge、槽、孔、凸台、圆角等特征;不要每类只做一点。
|
||||
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
|
||||
|
||||
## 怎么运行
|
||||
@@ -67,15 +68,14 @@ python main.py assets\models\cube_10mm.step
|
||||
python scripts\generate_cube_step.py
|
||||
```
|
||||
|
||||
10. 验证立方体上的“只改一条 Edge 长度”基线:
|
||||
10. 验证几何修改基线。当前推进和测试优先级是先 Face,再 Edge、槽、孔等其它特征:
|
||||
|
||||
```powershell
|
||||
python scripts\verify_edge_length_resize.py
|
||||
python scripts\verify_edge_round_chamfer.py
|
||||
python scripts\verify_face_edit_suite.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_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_hole_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
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -104,7 +123,7 @@ git diff --check
|
||||
- 用户可以选择零件、Solid、Face、Edge 和几何特征。
|
||||
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
|
||||
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
|
||||
- 用户可以修改可控的特征,比如面偏移、偏移距离、面积、面宽/面高、中心,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
|
||||
- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
|
||||
- 用户可以撤销/重做修改,避免一步编辑做坏。
|
||||
- 用户可以导出修改后的完整模型。
|
||||
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
|
||||
@@ -117,6 +136,8 @@ git diff --check
|
||||
|
||||
所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。
|
||||
|
||||
当前 Face 的 high-risk 修改已经开始接入隔离子进程执行,包括 `推拉当前面`、`面偏移(当前面)`、`面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)`、`薄壁厚度(当前面)`,以及 `面积(整体)`、`面宽/面高(整体)`、`薄壁厚度(整体)`:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。
|
||||
|
||||
产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 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 边、需要复杂约束跟随的边。 |
|
||||
| Face / 面 | 平面偏移、只移动当前面、整体平移所属特征、面积、面宽/面高、中心、薄壁厚度。 | 推拉当前平面;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域。 |
|
||||
| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 |
|
||||
| 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 ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
|
||||
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`推拉端盖`、`移动凸台`、`调整整个特征` 或 `移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
|
||||
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 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 / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
|
||||
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
|
||||
@@ -328,6 +350,7 @@ git diff --check
|
||||
- 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。
|
||||
- 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。
|
||||
- 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。
|
||||
- 对简单全平面薄板/盒体,`调整整个特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。
|
||||
- 当前版本支持已有圆角/倒圆候选结构化识别:
|
||||
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`。
|
||||
- 会估算已有圆角半径、圆弧角度和圆弧长度。
|
||||
@@ -489,13 +512,19 @@ vertices: 3262
|
||||
|
||||
建议下一步按这个顺序推进:
|
||||
|
||||
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。
|
||||
2. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略,优先覆盖 Edge 长度、Face 编辑、孔/槽、凸台和圆角。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。
|
||||
3. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整(含整体缩放)、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
|
||||
4. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。
|
||||
5. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。
|
||||
6. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。
|
||||
7. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
|
||||
1. 先集中完成 Face 修改能力:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面`、`推拉当前面`、`移动整个特征`、`调整整个特征` 等语义,并保证 `python scripts\verify_face_edit_suite.py` 能集中覆盖。
|
||||
2. Face 闭环稳定后,再集中做 Edge:先把直线 Edge 长度、起点、中心、终点和倒圆/倒角语义做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback。
|
||||
3. Edge 闭环稳定后,再集中做槽/半孔:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度和中心距要统一验证。
|
||||
4. 槽闭环稳定后,再集中做孔/圆柱、盲孔/盲槽、凸台、已有圆角、圆锥/球/环面等对象;每类都先补能力和验证,再进入下一类。
|
||||
5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。
|
||||
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 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 时自动扫描;需要编辑时再手动点击扫描。
|
||||
- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
|
||||
- `推拉平面` 行表示这个Face是平面,可以配合 `偏移距离` 和 `推拉平面` 使用。
|
||||
- `推拉平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `面偏移` 行里把 `影响范围` 选为 `推拉当前面` 来执行。
|
||||
- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。
|
||||
- `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。
|
||||
- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。
|
||||
@@ -618,30 +647,20 @@ vertices: 3262
|
||||
编辑:
|
||||
|
||||
- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。
|
||||
- `偏移距离` + `推拉平面`:
|
||||
- 先选择一个平面Face。
|
||||
- 输入偏移距离。
|
||||
- 正数表示沿程序判断的外法向加料。
|
||||
- 负数表示沿内侧方向切削。
|
||||
- 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。
|
||||
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对Face尺寸偏大,会先弹窗确认。
|
||||
- 如果选中Face周围有共面且相接/重叠的碎面,程序会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。
|
||||
- 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。
|
||||
- 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。
|
||||
- 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。
|
||||
- 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
|
||||
- 当前实现仍使用布尔加/减验证推拉路线。
|
||||
- `面偏移`:
|
||||
- 先选择一个平面 Face。
|
||||
- 在当前选中对象表里修改目标面偏移,再用 `影响范围` 选择改法。
|
||||
- `推拉当前面` 会沿当前推拉方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。
|
||||
- 在当前选中对象表里修改 `面偏移`,再用 `影响范围` 选择改法。
|
||||
- `面偏移` 是沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标。
|
||||
- `推拉当前面` 会把目标位置换算成这次要移动的距离,然后沿当前面的垂直方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。
|
||||
- `只改当前面` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。
|
||||
- `移动整个特征` 会沿当前平面方向平移所属特征或 Solid;形状和内部尺寸不变。
|
||||
- `移动整个特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。
|
||||
- 如果选中Face周围有共面且相接/重叠的碎面,`推拉当前面` 会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。
|
||||
- 当前推拉方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
|
||||
- `面积` / `面宽` / `面高`:
|
||||
- 先选择一个 Face,在当前选中对象表里修改目标值,再用 `影响范围` 选择改法。
|
||||
- `只改当前面` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。
|
||||
- `调整整个特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。
|
||||
- `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是全局 X/Y/Z 包围盒尺寸。
|
||||
- `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。
|
||||
- `中心`:
|
||||
- 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `影响范围` 选择改法。
|
||||
- `只改当前面` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。
|
||||
@@ -900,15 +919,32 @@ pythonocc-step-editor/
|
||||
geom_extract.step # 当前默认测试模型
|
||||
cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作
|
||||
screenshots/ # 调试截图和问题截图,默认不提交
|
||||
local/ # 本机自用说明和教学文档,默认不提交
|
||||
scripts/
|
||||
GeometryParametric.spec # PyInstaller 文件夹版打包配置
|
||||
package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip
|
||||
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_edge_edit_suite.py # 一键运行 Edge 专项修改验证
|
||||
verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放
|
||||
verify_edge_length_resize.py # 验证立方体 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_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改
|
||||
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
|
||||
verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改
|
||||
verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改
|
||||
step_editor/
|
||||
@@ -936,7 +972,7 @@ pythonocc-step-editor/
|
||||
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`。
|
||||
|
||||
@@ -948,6 +984,7 @@ pythonocc-step-editor/
|
||||
- `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。
|
||||
- `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略。
|
||||
- `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。
|
||||
- `local/`:本机自用文档目录,例如 `教学.txt` 和 `几何参数化测试说明.txt`,默认不提交;打包脚本会把 `local/几何参数化测试说明.txt` 复制到 `main.exe` 同级目录。
|
||||
|
||||
## 当前版本边界
|
||||
|
||||
@@ -965,6 +1002,8 @@ pythonocc-step-editor/
|
||||
|
||||
最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。
|
||||
|
||||
当前开发节奏必须分阶段闭环:先把 Face 修改能力做完整并集中测试,再做 Edge;Edge 通过后再做槽/半孔、孔/圆柱、凸台、圆角和解析曲面。不要每个对象只做一点就切换方向。
|
||||
|
||||
### 运行方式
|
||||
|
||||
常用命令:
|
||||
@@ -974,8 +1013,9 @@ conda activate pyocc
|
||||
python main.py
|
||||
python main.py assets\models\cube_10mm.step
|
||||
python scripts\generate_cube_step.py
|
||||
python scripts\verify_edge_length_resize.py
|
||||
python scripts\verify_edge_round_chamfer.py
|
||||
python scripts\verify_face_edit_suite.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_analytic_surface_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/screenshots/`:调试截图和问题截图,默认忽略,不提交。
|
||||
- `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_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_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_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,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
|
||||
- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
|
||||
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
|
||||
@@ -1034,7 +1089,7 @@ git diff --check
|
||||
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
|
||||
- 选择:支持零件、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,导出前做基础质量检查。
|
||||
|
||||
@@ -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,将 `面偏移` 的影响范围设为 `只改当前面` 并改变 `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` 上选一个 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,而不是和主体真正合并。
|
||||
|
||||
@@ -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`,候选搜索半径需要覆盖这个距离。
|
||||
|
||||
薄壁厚度基线验证:`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,所以脚本用主/小轴采样半径校验几何结果。
|
||||
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
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 = []
|
||||
hiddenimports = []
|
||||
hiddenimports = [
|
||||
'PySide6.QtCore',
|
||||
'PySide6.QtGui',
|
||||
'PySide6.QtWidgets',
|
||||
'PySide6.QtOpenGL',
|
||||
'PySide6.QtOpenGLWidgets',
|
||||
'vtkmodules.qt.QVTKRenderWindowInteractor',
|
||||
]
|
||||
tmp_ret = collect_all('OCC')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('vtkmodules')
|
||||
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(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
[os.path.join(project_root, 'main.py')],
|
||||
pathex=[project_root],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
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,
|
||||
optimize=0,
|
||||
)
|
||||
@@ -32,7 +54,7 @@ exe = EXE(
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='GeometryParametric',
|
||||
name='main',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -52,6 +53,32 @@ def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int,
|
||||
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(
|
||||
model: StepModel,
|
||||
target_major: float,
|
||||
@@ -73,17 +100,22 @@ def _nearest_torus_radii(
|
||||
|
||||
|
||||
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)):
|
||||
info = model.face_info(face_id)
|
||||
size = info.get("bbox_size")
|
||||
if not isinstance(size, tuple) or len(size) != 3:
|
||||
continue
|
||||
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
|
||||
if abs(dx - target_diameter) <= tolerance and abs(dy - target_diameter) <= tolerance:
|
||||
return True
|
||||
return False
|
||||
if abs(dx - dy) <= max(max(abs(dx), abs(dy)) * 1e-5, 1e-5):
|
||||
diameters.append((dx + dy) * 0.5)
|
||||
return diameters
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
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:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "sphere.step"
|
||||
@@ -199,19 +276,22 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
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-semi-angle-degrees", type=float, default=16.0)
|
||||
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-minor-radius", type=float, default=3.0)
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
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:
|
||||
if mode == "cone":
|
||||
_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":
|
||||
_run_sphere_case(args.sphere_radius, args.tolerance)
|
||||
elif mode == "torus_major":
|
||||
|
||||
@@ -58,6 +58,17 @@ def _first_boss_face(model: StepModel) -> int:
|
||||
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]:
|
||||
info = model.face_info(face_id)
|
||||
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"))
|
||||
|
||||
|
||||
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:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "boss.step"
|
||||
@@ -219,13 +279,14 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
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.",
|
||||
)
|
||||
parser.add_argument("--diameter", type=float, default=8.0)
|
||||
parser.add_argument("--diameter-shrink", type=float, default=4.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("--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)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -235,6 +296,7 @@ def main() -> int:
|
||||
("diameter_shrink", args.diameter_shrink),
|
||||
("height", args.height),
|
||||
("axis_center", args.axis_center),
|
||||
("circle_edge_axis_center", args.circle_edge_axis_center),
|
||||
]
|
||||
if args.mode == "all"
|
||||
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
|
||||
@@ -246,6 +308,8 @@ def main() -> int:
|
||||
_run_height_case(float(target), args.tolerance)
|
||||
elif mode == "axis_center":
|
||||
_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:
|
||||
raise SystemExit(f"unsupported mode: {mode}")
|
||||
return 0
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
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"
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
for _area, face_width, face_height in _plane_sizes(model):
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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("--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("--source-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("--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)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
return _run_all_cases(args)
|
||||
|
||||
model = StepModel.load(Path(args.model))
|
||||
face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance)
|
||||
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)
|
||||
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
|
||||
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)
|
||||
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}"
|
||||
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", ""))
|
||||
if args.property == "offset" and args.strategy == "push_pull":
|
||||
resolved_strategy = "push-pull-planar-face"
|
||||
if resolved_strategy != expected_strategy:
|
||||
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
|
||||
|
||||
@@ -83,13 +288,40 @@ def main() -> int:
|
||||
if after.faces < before.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
|
||||
height = args.other_size if args.axis == "width" else args.target_size
|
||||
if not _has_plane_size(model, width, height, args.tolerance):
|
||||
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"face_id={face_id}")
|
||||
print(f"property={args.property}")
|
||||
print(f"strategy={args.strategy}")
|
||||
print(f"axis={args.axis}")
|
||||
print(f"resolved_strategy={resolved_strategy}")
|
||||
@@ -97,6 +329,8 @@ def main() -> int:
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"plane_sizes={_plane_sizes(model)}")
|
||||
for line in extra_lines:
|
||||
print(line)
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@ def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int:
|
||||
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]:
|
||||
info = model.face_info(face_id)
|
||||
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"))
|
||||
|
||||
|
||||
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:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "through_hole.step"
|
||||
@@ -295,6 +352,7 @@ def main() -> int:
|
||||
"diameter",
|
||||
"diameter_shrink",
|
||||
"axis_center",
|
||||
"circle_edge_axis_center",
|
||||
"suppress",
|
||||
"blind_depth",
|
||||
"blind_depth_shallow",
|
||||
@@ -304,6 +362,7 @@ def main() -> int:
|
||||
parser.add_argument("--diameter", type=float, default=8.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("--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-shallow", type=float, default=4.0)
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
@@ -314,6 +373,7 @@ def main() -> int:
|
||||
("diameter", args.diameter),
|
||||
("diameter_shrink", args.diameter_shrink),
|
||||
("axis_center", args.axis_center),
|
||||
("circle_edge_axis_center", args.circle_edge_axis_center),
|
||||
("suppress", None),
|
||||
("blind_depth", args.blind_depth),
|
||||
("blind_depth_shallow", args.blind_depth_shallow),
|
||||
@@ -326,6 +386,8 @@ def main() -> int:
|
||||
_run_diameter_case(float(target), args.tolerance)
|
||||
elif mode == "axis_center":
|
||||
_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":
|
||||
_run_suppress_case(args.tolerance)
|
||||
elif mode in {"blind_depth", "blind_depth_shallow"}:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -52,6 +52,75 @@ def _thickness_axis(size: tuple[float, float, float]) -> int:
|
||||
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(
|
||||
before_min: 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)
|
||||
model = StepModel.load(model_path)
|
||||
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_min, before_max, before_size = _bounds(model)
|
||||
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)
|
||||
if plan["status"] == "blocked":
|
||||
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)
|
||||
elif mode == "owning":
|
||||
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
|
||||
if plan["status"] == "blocked":
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
else:
|
||||
_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"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"before={before}")
|
||||
print(f"after={after}")
|
||||
|
||||
+29
-15
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
import faulthandler
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -78,6 +79,12 @@ def _enable_crash_log() -> None:
|
||||
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):
|
||||
ui_task_requested = Signal(object)
|
||||
|
||||
@@ -334,9 +341,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
background: #fff8ef;
|
||||
border: 1px solid #edc98e;
|
||||
border-left: 5px solid #d97706;
|
||||
padding-left: 5px;
|
||||
padding-right: 3px;
|
||||
padding-bottom: 7px;
|
||||
padding-left: 2px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
QGroupBox#editSection::title {
|
||||
background: #fff0d8;
|
||||
@@ -423,7 +430,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
QPushButton#propertyRowEditButton:hover {
|
||||
background: #f97316;
|
||||
@@ -434,7 +441,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
border-color: #7c2d12;
|
||||
color: #ffffff;
|
||||
padding-top: 2px;
|
||||
padding-left: 7px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
QPushButton#propertyRowEditButton[changed="true"] {
|
||||
background: #dcfce7;
|
||||
@@ -450,13 +457,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
border: 1px dashed #bcc7d4;
|
||||
color: #8f99a8;
|
||||
}
|
||||
QPushButton#propertyRowEditButton[invalid="true"],
|
||||
QPushButton#propertyRowEditButton[invalid="true"]:disabled {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
QComboBox#propertyScopeCombo {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 5px;
|
||||
color: #7c2d12;
|
||||
min-height: 18px;
|
||||
padding: 0px 4px;
|
||||
padding: 0px 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QComboBox#propertyScopeCombo:hover {
|
||||
@@ -478,7 +491,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
border-radius: 5px;
|
||||
color: #111827;
|
||||
min-height: 18px;
|
||||
padding: 0px 5px;
|
||||
padding: 0px 2px;
|
||||
selection-background-color: #bfdbfe;
|
||||
}
|
||||
QLineEdit#propertyTargetEditor:hover {
|
||||
@@ -488,7 +501,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
QLineEdit#propertyTargetEditor:focus {
|
||||
background: #ffffff;
|
||||
border: 2px solid #2563eb;
|
||||
padding: 0px 4px;
|
||||
padding: 0px 1px;
|
||||
}
|
||||
QPushButton#propertyExpandBar {
|
||||
background: #f8fafc;
|
||||
@@ -568,12 +581,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
padding: 0;
|
||||
}
|
||||
QTableWidget#propertyTable::item {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
}
|
||||
QTableWidget#propertyTable QHeaderView::section {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
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.setContentsMargins(2, 8, 2, 4)
|
||||
object_edit_layout.setContentsMargins(0, 8, 0, 4)
|
||||
object_edit_layout.setSpacing(4)
|
||||
self.property_table = QTableWidget(0, 5)
|
||||
self.property_table.setObjectName("propertyTable")
|
||||
@@ -984,10 +997,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
help_tip(self.face_area_input, "目标面面积。当前选中对象表会使用这个隐藏输入执行所属特征或 Solid 的均匀缩放。")
|
||||
self.face_width_input = QLineEdit("", edit_box)
|
||||
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.setVisible(False)
|
||||
help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入只修改当前 Face 的平面内高度。")
|
||||
help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸。")
|
||||
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
|
||||
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
|
||||
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:
|
||||
if _crash_log_requested(sys.argv):
|
||||
_enable_crash_log()
|
||||
path, smoke_test = _parse_args(sys.argv)
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -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
@@ -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.BRepBndLib import brepbndlib
|
||||
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.BRepCheck import BRepCheck_Analyzer
|
||||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||||
@@ -58,6 +63,7 @@ from OCC.Core.TopAbs import (
|
||||
TopAbs_OUT,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SOLID,
|
||||
TopAbs_WIRE,
|
||||
)
|
||||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||||
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._edge_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._same_domain_internal_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._edge_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._same_domain_internal_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._feature_info_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]:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
@@ -310,6 +319,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
"selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。",
|
||||
}
|
||||
info.update(_shape_bounds_info(face))
|
||||
info.update(self._face_boundary_wire_info(face))
|
||||
if surface_type == GeomAbs_Plane:
|
||||
plane = surf.Plane()
|
||||
direction = plane.Axis().Direction()
|
||||
@@ -328,6 +338,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
info["feature_source_face_id"] = face_id
|
||||
info["feature_highlight_face_ids"] = (face_id,)
|
||||
info["feature_edit_actions"] = "推拉平面"
|
||||
info.update(self._local_face_deform_readiness(face_id))
|
||||
info.update(
|
||||
self._local_face_plane_size_info(
|
||||
face_id,
|
||||
@@ -420,6 +431,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
"boundary_edges": boundary_edges,
|
||||
}
|
||||
info.update(_shape_bounds_info(face))
|
||||
info.update(self._face_boundary_wire_info(face))
|
||||
if surface_type == GeomAbs_Plane:
|
||||
plane = surf.Plane()
|
||||
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_confidence"] = push_pull_direction["confidence"]
|
||||
info["push_pull_note"] = push_pull_direction["note"]
|
||||
info.update(self._local_face_deform_readiness(face_id))
|
||||
info.update(
|
||||
self._local_face_plane_size_info(
|
||||
face_id,
|
||||
@@ -1128,6 +1141,112 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
self._face_edge_ids_cache[face_id] = list(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]:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
return []
|
||||
@@ -1166,6 +1285,22 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
self._face_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(
|
||||
self,
|
||||
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)
|
||||
|
||||
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:
|
||||
raise ValueError("No planar faces were found for push/pull.")
|
||||
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
|
||||
|
||||
+1013
-6
File diff suppressed because it is too large
Load Diff
@@ -486,19 +486,19 @@ INFO_LABELS = {
|
||||
"local_face_width_direction": "面宽 方向",
|
||||
"local_face_height_direction": "面高 方向",
|
||||
"local_face_size_center": "面宽/面高 中心",
|
||||
"face_size_axis": "Face尺寸方向",
|
||||
"face_size_label": "Face尺寸名称",
|
||||
"face_size_axis": "面宽/面高 方向",
|
||||
"face_size_label": "面宽/面高 名称",
|
||||
"current_face_width": "当前面宽",
|
||||
"target_face_width": "目标面宽",
|
||||
"current_face_height": "当前面高",
|
||||
"target_face_height": "目标面高",
|
||||
"current_face_size": "当前Face尺寸",
|
||||
"target_face_size": "目标Face尺寸",
|
||||
"face_size_delta": "Face尺寸变化量",
|
||||
"face_size_delta_ratio": "Face尺寸变化比例",
|
||||
"face_size_scale": "Face尺寸缩放比例",
|
||||
"face_size_center": "Face尺寸缩放中心",
|
||||
"face_size_axis_direction": "Face尺寸缩放方向",
|
||||
"current_face_size": "当前面宽/面高",
|
||||
"target_face_size": "目标面宽/面高",
|
||||
"face_size_delta": "面宽/面高 变化量",
|
||||
"face_size_delta_ratio": "面宽/面高 变化比例",
|
||||
"face_size_scale": "面宽/面高 缩放比例",
|
||||
"face_size_center": "面宽/面高 缩放中心",
|
||||
"face_size_axis_direction": "面宽/面高 缩放方向",
|
||||
"owning_face_size_rebuild_mode": "所属对象尺寸重建模式",
|
||||
"length_center": "长度中心",
|
||||
"bbox_min": "包围盒最小点",
|
||||
|
||||
+360
-80
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import Qt, QThread, Slot
|
||||
@@ -336,6 +340,7 @@ class WindowActionMixin:
|
||||
return
|
||||
face_id = self.selected_face_id
|
||||
plan = self._quick_push_pull_plan(face_id, distance)
|
||||
|
||||
if plan["status"] == "blocked":
|
||||
QMessageBox.information(self, "不能推拉平面", str(plan["message"]))
|
||||
self.statusBar().showMessage("推拉平面已阻止")
|
||||
@@ -343,6 +348,11 @@ class WindowActionMixin:
|
||||
if plan["risk"] != "low":
|
||||
warnings = str(plan.get("warnings", ""))
|
||||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||||
isolation_line = (
|
||||
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
|
||||
if str(plan.get("risk")) == "high"
|
||||
else ""
|
||||
)
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"确认推拉平面",
|
||||
@@ -356,6 +366,7 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
f"{plan['message']}\n\n"
|
||||
f"{isolation_line}"
|
||||
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
|
||||
),
|
||||
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_count": plan.get("push_pull_scope_face_count"),
|
||||
"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"),
|
||||
},
|
||||
target_kind="face",
|
||||
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]:
|
||||
@@ -432,6 +446,7 @@ class WindowActionMixin:
|
||||
risk = "low"
|
||||
status = "ready"
|
||||
warnings: list[str] = []
|
||||
blockers: list[str] = []
|
||||
if distance_abs <= 1e-9:
|
||||
return {
|
||||
"status": "blocked",
|
||||
@@ -451,6 +466,31 @@ class WindowActionMixin:
|
||||
status = "caution"
|
||||
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 = (
|
||||
_int_values(info.get("push_pull_scope_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):
|
||||
solid_id = self.model.face_solid_ids[face_id]
|
||||
message = " ".join(warnings) if warnings else "可以尝试偏移该平面;完整几何检查会在后台执行。"
|
||||
if blockers:
|
||||
message = " ".join(blockers + warnings)
|
||||
return {
|
||||
"status": status,
|
||||
"risk": risk,
|
||||
"message": message,
|
||||
"warnings": ";".join(warnings),
|
||||
"blockers": "",
|
||||
"blockers": ";".join(blockers),
|
||||
"face_id": face_id,
|
||||
"part_id": part_id,
|
||||
"solid_id": solid_id,
|
||||
@@ -489,7 +531,9 @@ class WindowActionMixin:
|
||||
"surface": surface,
|
||||
"area": info.get("area"),
|
||||
"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,
|
||||
"target_plane_position": target_plane_position,
|
||||
"resize_strategy": "push-pull-planar-face-region",
|
||||
@@ -547,7 +591,12 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
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.No,
|
||||
@@ -556,6 +605,9 @@ class WindowActionMixin:
|
||||
self.statusBar().showMessage("已取消薄壁厚度调整")
|
||||
return
|
||||
|
||||
if str(plan.get("risk")) == "high":
|
||||
self.clear_edit_preview(render=False)
|
||||
else:
|
||||
self._show_shell_thickness_preview(face_id, target_thickness)
|
||||
|
||||
def action():
|
||||
@@ -595,6 +647,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="feature" if self.selected_kind == "feature" else "face",
|
||||
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:
|
||||
@@ -627,6 +680,9 @@ class WindowActionMixin:
|
||||
f"缩放比例: {_format_value(plan.get('affine_scale'))}",
|
||||
f"缩放目标: {_format_value(plan.get('affine_target_kind'))}",
|
||||
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', ''))}",
|
||||
"当前语义: 沿薄壁/壳体厚度方向整体缩放所属特征或 Solid;不是推拉当前平面区域。",
|
||||
]
|
||||
@@ -668,6 +724,9 @@ class WindowActionMixin:
|
||||
"affine_axis_point": plan.get("affine_axis_point"),
|
||||
"affine_axis_direction": plan.get("affine_axis_direction"),
|
||||
"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"),
|
||||
"resize_strategy": plan.get("resize_strategy"),
|
||||
"edit_strategy_label": plan.get("edit_strategy_label"),
|
||||
@@ -681,6 +740,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
isolation=self._isolation_for_plan(plan, "resize_shell_thickness_owning_scale", [face_id, target_thickness]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -860,7 +920,10 @@ class WindowActionMixin:
|
||||
QMessageBox.information(self, f"不能{title}", str(plan.get("message", "")))
|
||||
self.statusBar().showMessage(blocked_status)
|
||||
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
|
||||
if plan.get("risk") == "low":
|
||||
return True
|
||||
@@ -883,7 +946,12 @@ class WindowActionMixin:
|
||||
+ f"\n风险: {plan.get('risk')}\n"
|
||||
+ warnings_line
|
||||
+ 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"
|
||||
"确定继续吗?"
|
||||
),
|
||||
@@ -895,6 +963,36 @@ class WindowActionMixin:
|
||||
return False
|
||||
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:
|
||||
if self.model is None:
|
||||
return
|
||||
@@ -3776,6 +3874,90 @@ class WindowActionMixin:
|
||||
def move_edge_end_point(self) -> None:
|
||||
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:
|
||||
if self.model is None:
|
||||
return
|
||||
@@ -3996,6 +4178,9 @@ class WindowActionMixin:
|
||||
"delta_reference_radius": plan.get("delta_reference_radius"),
|
||||
"reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"),
|
||||
"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_transform_kind": plan.get("affine_transform_kind"),
|
||||
"affine_transform_label": plan.get("affine_transform_label"),
|
||||
@@ -4245,7 +4430,12 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
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.No,
|
||||
@@ -4288,6 +4478,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
isolation=self._isolation_for_plan(plan, "resize_face_area", [face_id, target_area]),
|
||||
)
|
||||
|
||||
def resize_face_area_local(self) -> None:
|
||||
@@ -4363,6 +4554,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -4466,6 +4658,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -4569,6 +4762,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -4585,42 +4779,15 @@ class WindowActionMixin:
|
||||
QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。")
|
||||
return
|
||||
face_id = self.selected_face_id
|
||||
frame = self._selected_plane_offset_frame(face_id)
|
||||
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
|
||||
plan = self.model.face_plane_offset_owning_translation_plan(face_id, distance)
|
||||
|
||||
if plan["status"] == "blocked":
|
||||
QMessageBox.information(self, "不能按面偏移平移", str(plan["message"]))
|
||||
self.statusBar().showMessage("面偏移整体平移已阻止")
|
||||
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(
|
||||
plan,
|
||||
face_id,
|
||||
@@ -4631,6 +4798,12 @@ class WindowActionMixin:
|
||||
self.statusBar().showMessage("已取消面偏移整体平移")
|
||||
return
|
||||
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(
|
||||
action,
|
||||
operation_name="面偏移(整体)",
|
||||
@@ -4643,9 +4816,9 @@ class WindowActionMixin:
|
||||
"moved_target_id": moved_id,
|
||||
"current_plane_position": current_position,
|
||||
"target_plane_position": target_position,
|
||||
"plane_origin": plane_origin,
|
||||
"plane_origin": plan.get("plane_origin"),
|
||||
"plane_direction": plane_direction,
|
||||
"translation_vector": vector,
|
||||
"translation_vector": plan.get("translation_vector"),
|
||||
"translation_distance": plan.get("translation_distance"),
|
||||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||||
"translate_status": plan.get("status"),
|
||||
@@ -4655,7 +4828,7 @@ class WindowActionMixin:
|
||||
"translate_blockers": plan.get("blockers"),
|
||||
"resize_strategy": "translate-owning-shape-from-plane-offset",
|
||||
"edit_strategy_label": "按面偏移平移所属对象",
|
||||
"edit_semantics": "把目标面偏移换算成沿当前平面方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。",
|
||||
"edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。",
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
@@ -4676,33 +4849,12 @@ class WindowActionMixin:
|
||||
return
|
||||
|
||||
face_id = self.selected_face_id
|
||||
frame = self._selected_plane_offset_frame(face_id)
|
||||
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)
|
||||
plan = self.model.face_plane_offset_local_plan(face_id, distance)
|
||||
lines = [
|
||||
f"Face: {face_id}",
|
||||
f"当前面偏移: {_format_value(current_position)}",
|
||||
f"目标面偏移: {_format_value(target_position)}",
|
||||
f"平面方向: {_format_value(plane_direction)}",
|
||||
f"当前面偏移: {_format_value(plan.get('current_plane_position'))}",
|
||||
f"目标面偏移: {_format_value(plan.get('target_plane_position'))}",
|
||||
f"平面方向: {_format_value(plan.get('plane_direction'))}",
|
||||
f"移动向量: {_format_value(plan.get('face_center_move_vector'))}",
|
||||
f"移动距离: {_format_value(plan.get('face_center_move_distance'))}",
|
||||
f"移动/所属对象尺寸: {_format_percent(plan.get('face_center_move_ratio'))}",
|
||||
@@ -4721,7 +4873,7 @@ class WindowActionMixin:
|
||||
self.clear_edit_preview(render=False)
|
||||
|
||||
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(
|
||||
action,
|
||||
@@ -4732,10 +4884,10 @@ class WindowActionMixin:
|
||||
"part_id": plan.get("part_id"),
|
||||
"solid_id": plan.get("solid_id"),
|
||||
"surface": plan.get("surface"),
|
||||
"current_plane_position": current_position,
|
||||
"target_plane_position": target_position,
|
||||
"plane_origin": plane_origin,
|
||||
"plane_direction": plane_direction,
|
||||
"current_plane_position": plan.get("current_plane_position"),
|
||||
"target_plane_position": plan.get("target_plane_position"),
|
||||
"plane_origin": plan.get("plane_origin"),
|
||||
"plane_direction": plan.get("plane_direction"),
|
||||
"plane_offset_distance": distance,
|
||||
"current_face_center": plan.get("current_face_center"),
|
||||
"target_face_center": plan.get("target_face_center"),
|
||||
@@ -4749,7 +4901,7 @@ class WindowActionMixin:
|
||||
"resize_strategy": "local-face-plane-offset-deform",
|
||||
"edit_strategy_label": "按面偏移只移动当前Face",
|
||||
"edit_semantics": (
|
||||
"把目标面偏移换算成沿当前平面方向的移动量,只移动当前 Face 的顶点并重建相邻平面;"
|
||||
"把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 的顶点并重建相邻平面;"
|
||||
"不推拉加料/切削,也不平移所属对象。"
|
||||
),
|
||||
"resize_status": plan.get("status"),
|
||||
@@ -4761,6 +4913,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -4787,18 +4940,18 @@ class WindowActionMixin:
|
||||
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)
|
||||
plan = self.model.face_center_owning_translation_plan(face_id, target_center)
|
||||
|
||||
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:
|
||||
moved_kind = "part"
|
||||
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():
|
||||
return self.model.translate_part(moved_id, vector)
|
||||
return self.model.move_face_center_owning(face_id, target_center)
|
||||
|
||||
else:
|
||||
QMessageBox.information(self, "不能移动Face中心", "当前 Face 没有关联到稳定的所属对象。")
|
||||
@@ -4918,6 +5071,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -5181,11 +5335,11 @@ class WindowActionMixin:
|
||||
f"平移向量: {_format_value(plan['translation_vector'])}\n"
|
||||
f"平移距离: {_format_value(plan['translation_distance'])}\n"
|
||||
f"编辑策略: 按面偏移平移所属对象\n"
|
||||
f"编辑方式: 沿当前平面方向平移所属特征或 Solid;不推拉当前面,不切削,也不补料。\n"
|
||||
f"编辑方式: 沿当前面垂直方向平移所属特征或 Solid;不推拉当前面,不切削,也不补料。\n"
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
f"{plan['message']}\n\n"
|
||||
"如果你想改变厚度或把这个面推出/切入,请使用不带“整体”的面偏移或偏移距离行。确定继续吗?"
|
||||
"如果你想改变厚度或把这个面推出/切入,请在“面偏移”这一行把影响范围选为“推拉当前面”。确定继续吗?"
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
@@ -6023,7 +6177,7 @@ class WindowActionMixin:
|
||||
"该操作被稳定性保护阻止。\n\n"
|
||||
"当前计划被判定为 high risk;这类 OCCT 布尔、倒圆或局部重建在复杂 STEP 上"
|
||||
"可能不是普通失败,而是让进程卡死或直接退出。请先尝试更小的参数、修复模型、"
|
||||
"选择更明确的面/边,或等后续子进程隔离执行通道实现后再开放。"
|
||||
"选择更明确的面/边;当前这类操作还没有接入隔离子进程执行通道。"
|
||||
),
|
||||
)
|
||||
self.statusBar().showMessage("高风险操作已被稳定性保护阻止")
|
||||
@@ -6037,6 +6191,7 @@ class WindowActionMixin:
|
||||
parameters: dict[str, object],
|
||||
target_kind: str | None = None,
|
||||
target_id: int | None = None,
|
||||
isolation: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
@@ -6057,6 +6212,7 @@ class WindowActionMixin:
|
||||
"pick_position": self.selected_pick_position,
|
||||
"show_same_domain_internal_edges": self._show_same_domain_internal_edges(),
|
||||
"edit_result_deflection": result_deflection,
|
||||
"isolation": dict(isolation or {}),
|
||||
}
|
||||
|
||||
self._begin_edit_task(operation_name)
|
||||
@@ -6097,6 +6253,16 @@ class WindowActionMixin:
|
||||
before_stats = self.model.stats()
|
||||
before_part_stats = self._part_stats_or_none(target_part_id)
|
||||
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:
|
||||
result = action()
|
||||
after_snapshot = self.model.snapshot()
|
||||
@@ -6140,6 +6306,120 @@ class WindowActionMixin:
|
||||
|
||||
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:
|
||||
if self.model is None:
|
||||
return None
|
||||
@@ -6218,9 +6498,9 @@ class WindowActionMixin:
|
||||
elif "面宽(当前面)" in operation_name or "面高(当前面)" in operation_name:
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。"
|
||||
elif "面偏移(当前面)" in operation_name:
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会沿平面方向移动所选 Face 顶点并重建相邻平面。"
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向移动所选 Face 顶点并重建相邻平面。"
|
||||
elif "面偏移(整体)" in operation_name:
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会沿平面方向平移所属特征或 Solid,不推拉当前面。"
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向平移所属特征或 Solid,不推拉当前面。"
|
||||
elif "Face中心" in operation_name:
|
||||
progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不做单面局部扭曲。"
|
||||
elif "只移动当前Face" in operation_name:
|
||||
|
||||
+227
-62
@@ -1313,6 +1313,9 @@ class WindowStateMixin:
|
||||
_int_values(action_info.get("feature_start_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
|
||||
is_line_edge = has_edge and curve == "line"
|
||||
specs: list[dict[str, object]] = []
|
||||
@@ -1327,28 +1330,65 @@ class WindowStateMixin:
|
||||
return ""
|
||||
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)
|
||||
if number is None or number <= 0:
|
||||
return "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
|
||||
base = "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
|
||||
return f"{base} {hard_limits}".strip()
|
||||
lower = max(number * (1.0 - caution_ratio), 0.0)
|
||||
upper = number * (1.0 + caution_ratio)
|
||||
return (
|
||||
base = (
|
||||
f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改"
|
||||
f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);"
|
||||
f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。"
|
||||
)
|
||||
return f"{base} {hard_limits}".strip()
|
||||
|
||||
def positive_minimum() -> dict[str, object]:
|
||||
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:
|
||||
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
|
||||
if diagonal is not None and diagonal > 0:
|
||||
return (
|
||||
"可输入正数或负数,单位同模型;"
|
||||
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 "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。"
|
||||
|
||||
@@ -1362,6 +1402,11 @@ class WindowStateMixin:
|
||||
)
|
||||
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:
|
||||
current_number = _float_or_none(current)
|
||||
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(
|
||||
*,
|
||||
key: str,
|
||||
@@ -1591,14 +1641,24 @@ class WindowStateMixin:
|
||||
),
|
||||
)
|
||||
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(
|
||||
key="face_edit_semantics",
|
||||
label="编辑方式",
|
||||
text="Face:先改目标值,再用“影响范围”选择只改当前面或调整整个特征。",
|
||||
tip=(
|
||||
"面积、面宽、面高、中心和面偏移会尽量合并成一行参数;"
|
||||
"影响范围下拉框决定这次修改是只作用在当前 Face,还是带动所属特征或 Solid。"
|
||||
),
|
||||
text=face_semantics_text,
|
||||
tip=face_semantics_tip,
|
||||
)
|
||||
|
||||
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_center = (
|
||||
_triple_or_none(action_info.get("area_center"))
|
||||
@@ -1631,6 +1691,7 @@ class WindowStateMixin:
|
||||
"target_attr": "face_area_input",
|
||||
"enabled": bool(
|
||||
is_plane
|
||||
and local_face_deform_ready
|
||||
and current_face_area is not None
|
||||
and current_face_area > 0
|
||||
and current_face_center is not None
|
||||
@@ -1639,10 +1700,12 @@ class WindowStateMixin:
|
||||
"输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点,"
|
||||
"相邻面会按新边界重建。"
|
||||
),
|
||||
"disabled_tip": "只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。",
|
||||
"disabled_tip": face_local_disabled_tip(
|
||||
"只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。"
|
||||
),
|
||||
"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": {
|
||||
@@ -1657,24 +1720,24 @@ class WindowStateMixin:
|
||||
"disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。",
|
||||
"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",
|
||||
used=("area", "area_center", "bbox_center"),
|
||||
**positive_minimum(),
|
||||
**face_scale_limits(current_face_area, squared=True),
|
||||
)
|
||||
if is_plane:
|
||||
current_face_width = _float_or_none(action_info.get("local_face_width"))
|
||||
current_face_height = _float_or_none(action_info.get("local_face_height"))
|
||||
face_size_tip = (
|
||||
"这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影尺寸,"
|
||||
"不是全局 X/Y/Z 包围盒尺寸。只修改当前 Face 顶点,相邻面会按新顶点重建。"
|
||||
"这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度,"
|
||||
"不是面积,也不是模型整体高度。只修改当前 Face 顶点,相邻面会按新顶点重建。"
|
||||
)
|
||||
face_size_owner_tip = (
|
||||
"这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影尺寸,"
|
||||
"不是全局 X/Y/Z 包围盒尺寸。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
|
||||
"这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度,"
|
||||
"不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
|
||||
)
|
||||
add_scoped_spec(
|
||||
key="local_face_width",
|
||||
@@ -1688,13 +1751,16 @@ class WindowStateMixin:
|
||||
"action": "resize_face_width_local",
|
||||
"target_attr": "face_width_input",
|
||||
"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_center is not None
|
||||
),
|
||||
"enabled_tip": f"输入目标面宽;{face_size_tip}",
|
||||
"disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。",
|
||||
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8)}",
|
||||
"disabled_tip": face_local_disabled_tip(
|
||||
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。"
|
||||
),
|
||||
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
|
||||
},
|
||||
"owning": {
|
||||
"label": "调整整个特征",
|
||||
@@ -1707,13 +1773,13 @@ class WindowStateMixin:
|
||||
and (self.selected_part_id is not None or self.selected_solid_id is not None)
|
||||
),
|
||||
"enabled_tip": f"输入目标面宽;{face_size_owner_tip}",
|
||||
"disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按面宽缩放所属对象。",
|
||||
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5)}",
|
||||
"disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按这个方向缩放所属对象。",
|
||||
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}",
|
||||
},
|
||||
},
|
||||
value_type="positive",
|
||||
used=("local_face_width", "local_face_width_direction", "local_face_size_center"),
|
||||
**positive_minimum(),
|
||||
**face_scale_limits(current_face_width),
|
||||
)
|
||||
add_scoped_spec(
|
||||
key="local_face_height",
|
||||
@@ -1727,13 +1793,16 @@ class WindowStateMixin:
|
||||
"action": "resize_face_height_local",
|
||||
"target_attr": "face_height_input",
|
||||
"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_center is not None
|
||||
),
|
||||
"enabled_tip": f"输入目标面高;{face_size_tip}",
|
||||
"disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。",
|
||||
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8)}",
|
||||
"disabled_tip": face_local_disabled_tip(
|
||||
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。"
|
||||
),
|
||||
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
|
||||
},
|
||||
"owning": {
|
||||
"label": "调整整个特征",
|
||||
@@ -1746,13 +1815,13 @@ class WindowStateMixin:
|
||||
and (self.selected_part_id is not None or self.selected_solid_id is not None)
|
||||
),
|
||||
"enabled_tip": f"输入目标面高;{face_size_owner_tip}",
|
||||
"disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按面高缩放所属对象。",
|
||||
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5)}",
|
||||
"disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按这个方向缩放所属对象。",
|
||||
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}",
|
||||
},
|
||||
},
|
||||
value_type="positive",
|
||||
used=("local_face_height", "local_face_height_direction", "local_face_size_center"),
|
||||
**positive_minimum(),
|
||||
**face_scale_limits(current_face_height),
|
||||
)
|
||||
add_scoped_spec(
|
||||
key="face_center_position",
|
||||
@@ -1765,18 +1834,21 @@ class WindowStateMixin:
|
||||
"label": "只改当前面",
|
||||
"action": "move_selected_face_center_local",
|
||||
"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": (
|
||||
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;"
|
||||
"程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。"
|
||||
),
|
||||
"disabled_tip": "只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。",
|
||||
"disabled_tip": face_local_disabled_tip(
|
||||
"只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。"
|
||||
),
|
||||
"range_hint": (
|
||||
"只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。"
|
||||
f"{translation_hint()}"
|
||||
f"{translation_hint()} {face_center_hard_limit}"
|
||||
),
|
||||
"target_transform": "target_center_to_translation",
|
||||
"transform_context": {"current_center": current_face_center},
|
||||
**face_vector_move_limit(current_face_center),
|
||||
},
|
||||
"owning": {
|
||||
"label": "移动整个特征",
|
||||
@@ -1791,10 +1863,11 @@ class WindowStateMixin:
|
||||
"disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。",
|
||||
"range_hint": (
|
||||
"移动整个特征不会改变当前对象形状,但同一对象会整体搬动。"
|
||||
f"{translation_hint()}"
|
||||
f"{translation_hint()} {face_center_hard_limit}"
|
||||
),
|
||||
"target_transform": "target_center_to_translation",
|
||||
"transform_context": {"current_center": current_face_center},
|
||||
**face_vector_move_limit(current_face_center),
|
||||
},
|
||||
},
|
||||
value_type="vector3",
|
||||
@@ -1837,11 +1910,12 @@ class WindowStateMixin:
|
||||
"action": "push_pull_face",
|
||||
"target_attr": "offset_input",
|
||||
"enabled": current_plane_position is not None,
|
||||
"enabled_tip": "输入目标面偏移;程序会沿当前推拉方向移动面,自动加料或切削。",
|
||||
"enabled_tip": "输入目标面偏移;程序会沿当前面的垂直方向移动面,自动加料或切削。",
|
||||
"disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。",
|
||||
"range_hint": (
|
||||
"这是沿当前推拉方向测量的位置值,不是 X/Y/Z 坐标;单位同模型。"
|
||||
"目标值大于当前值通常向外移,小于当前值通常向内移。"
|
||||
"面偏移是沿当前面垂直方向测量的目标值,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。"
|
||||
"程序会把目标面偏移自动换算成本次需要移动的距离。"
|
||||
f"{face_offset_hard_limit}"
|
||||
),
|
||||
"target_transform": "plane_target_position_to_offset",
|
||||
"transform_context": {"current_plane_position": current_plane_position},
|
||||
@@ -1852,18 +1926,21 @@ class WindowStateMixin:
|
||||
"target_attr": "offset_input",
|
||||
"enabled": bool(
|
||||
current_plane_position is not None
|
||||
and local_face_deform_ready
|
||||
and plane_direction is not None
|
||||
and current_face_center is not None
|
||||
),
|
||||
"enabled_tip": (
|
||||
"输入目标面偏移;程序只把当前 Face 沿平面方向移动到该位置,"
|
||||
"输入目标面偏移;程序只把当前 Face 沿当前面的垂直方向移动到该目标值,"
|
||||
"并让相邻平面按新顶点重建。"
|
||||
),
|
||||
"disabled_tip": "当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。",
|
||||
"disabled_tip": face_local_disabled_tip(
|
||||
"当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。"
|
||||
),
|
||||
"range_hint": (
|
||||
"只改当前面不会自动加料/切削,相邻面可能自然变斜,"
|
||||
"非共面面可能被拆成三角面。"
|
||||
f"{translation_hint()}"
|
||||
f"{translation_hint()} {face_offset_hard_limit}"
|
||||
),
|
||||
"target_transform": "plane_target_position_to_offset",
|
||||
"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)
|
||||
),
|
||||
"enabled_tip": (
|
||||
"输入目标面偏移;程序会沿当前平面方向平移所属特征或 Solid,"
|
||||
"输入目标面偏移;程序会沿当前面的垂直方向平移所属特征或 Solid,"
|
||||
"当前面形状和所属对象内部尺寸不变。"
|
||||
),
|
||||
"disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。",
|
||||
"range_hint": (
|
||||
"这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。"
|
||||
f"{translation_hint()}"
|
||||
f"{translation_hint()} {face_offset_hard_limit}"
|
||||
),
|
||||
"target_transform": "plane_target_position_to_offset",
|
||||
"transform_context": {"current_plane_position": current_plane_position},
|
||||
},
|
||||
},
|
||||
used=("plane_origin", "push_pull_outward_direction", "normal"),
|
||||
)
|
||||
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(本次未移动)",
|
||||
**face_offset_target_limits(current_plane_position),
|
||||
)
|
||||
if is_shell_candidate:
|
||||
current = _float_or_none(action_info.get("shell_thickness_estimate"))
|
||||
@@ -1924,7 +1989,7 @@ class WindowStateMixin:
|
||||
"enabled": current is not None,
|
||||
"enabled_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": {
|
||||
"label": "调整整个特征",
|
||||
@@ -1946,13 +2011,13 @@ class WindowStateMixin:
|
||||
"disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。",
|
||||
"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",
|
||||
used=("shell_thickness_estimate", "shell_current_thickness", "shell_signed_thickness", "normal", "plane_origin"),
|
||||
**positive_minimum(),
|
||||
**face_scale_limits(current),
|
||||
)
|
||||
if is_hole_or_groove:
|
||||
current_diameter = _float_or_none(action_info.get("diameter"))
|
||||
@@ -3180,7 +3245,28 @@ class WindowStateMixin:
|
||||
if is_circle_edge:
|
||||
current_radius = _float_or_none(action_info.get("radius"))
|
||||
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)
|
||||
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 = {
|
||||
"edge_current_length": current_length,
|
||||
"edge_current_radius": current_radius,
|
||||
@@ -3614,6 +3700,7 @@ class WindowStateMixin:
|
||||
target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
|
||||
widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN)
|
||||
if isinstance(widget, QPushButton):
|
||||
validation_error = ""
|
||||
if str(effective_spec.get("value_type", "number")) == "command":
|
||||
row_changed = True
|
||||
widget.setText(str(effective_spec.get("button_text") or "执行"))
|
||||
@@ -3623,14 +3710,28 @@ class WindowStateMixin:
|
||||
text = self._property_target_text(row)
|
||||
row_changed = self._property_target_changed(effective_spec, text)
|
||||
empty_target = not bool(text.strip())
|
||||
widget.setText("未输入" if empty_target else ("应用" if row_changed else "未改动"))
|
||||
enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action") and row_changed)
|
||||
validation_error = "" if empty_target else self._property_target_validation_error(effective_spec, text)
|
||||
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:
|
||||
tooltip = (
|
||||
f"“{effective_spec.get('label', '当前属性')}”的目标值为空。"
|
||||
"这不会删除模型里的点、边或面,只是暂时没有要应用的目标值;"
|
||||
"重新选择对象会按当前模型值重新填入。"
|
||||
)
|
||||
elif validation_error:
|
||||
tooltip = f"{validation_error} 请调整目标值后再应用。"
|
||||
elif row_changed:
|
||||
scope_label = str(effective_spec.get("scope_label") or "").strip()
|
||||
suffix = f"({scope_label})" if scope_label else ""
|
||||
@@ -3640,7 +3741,8 @@ class WindowStateMixin:
|
||||
range_hint = self._property_range_hint(effective_spec)
|
||||
if 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.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor)
|
||||
widget.style().unpolish(widget)
|
||||
@@ -3679,7 +3781,7 @@ class WindowStateMixin:
|
||||
}:
|
||||
continue
|
||||
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))
|
||||
return changed
|
||||
|
||||
@@ -3728,6 +3830,65 @@ class WindowStateMixin:
|
||||
return abs(value - current) > PROPERTY_VALUE_TOLERANCE
|
||||
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:
|
||||
value_type = str(spec.get("value_type", "number"))
|
||||
if not text:
|
||||
@@ -3824,6 +3985,10 @@ class WindowStateMixin:
|
||||
if not is_command and not self._property_target_changed(spec, text):
|
||||
self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。")
|
||||
return
|
||||
validation_error = "" if is_command else self._property_target_validation_error(spec, text)
|
||||
if validation_error:
|
||||
QMessageBox.information(self, "目标值无效", validation_error)
|
||||
return
|
||||
try:
|
||||
if not is_command:
|
||||
self._sync_property_edit_target(spec, text)
|
||||
|
||||
Reference in New Issue
Block a user