feat: 完善 Face 一级关系编辑和稳定性

This commit is contained in:
2026-08-04 09:35:39 +08:00
parent bb44e3920d
commit 5799d5d813
29 changed files with 6295 additions and 189 deletions
+38 -14
View File
@@ -79,6 +79,7 @@ python scripts\verify_analytic_surface_resize.py
python scripts\verify_boss_resize.py python scripts\verify_boss_resize.py
python scripts\verify_hole_resize.py python scripts\verify_hole_resize.py
python scripts\verify_slot_resize.py python scripts\verify_slot_resize.py
python scripts\verify_cylindrical_first_level_topology.py
``` ```
11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行: 11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行:
@@ -123,7 +124,7 @@ git diff --check
- 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。 - 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
@@ -136,7 +137,7 @@ git diff --check
所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。
当前 Face 的 high-risk 修改已经开始接入隔离子进程执行,包括 `推拉当前面``面偏移(当前面)``面积(当前面)``面宽/面高(当前面)``中心(当前面)``薄壁厚度(当前面)`,以及 `面积(整体)``面宽/面高(整体)``薄壁厚度(整体)`:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。 当前 Face 参数化修改已经接入隔离子进程执行,包括 `推拉当前面``面偏移(当前面)``面积(当前面)``面宽/面高(当前面)``中心(当前面)``薄壁厚度(当前面)``面积(整体)``面宽/面高(整体)``薄壁厚度(整体)`,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。
产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。
@@ -193,7 +194,7 @@ git diff --check
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | | 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)``参考直径(整体)``半角(整体)`球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 | | 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径``参考直径``半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
@@ -512,19 +513,23 @@ vertices: 3262
建议下一步按这个顺序推进: 建议下一步按这个顺序推进:
1. 先集中完成 Face 修改能力:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面``推拉当前面``移动整个特征``调整整个特征` 等语义,并保证 `python scripts\verify_face_edit_suite.py` 能集中覆盖 1. 先集中完成 Face 一级拓扑关系编辑:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面``推拉当前面``移动整个特征``调整整个特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系
2. Face 闭环稳定后,再集中做 Edge:先把直线 Edge 长度、起点、中心、终点和倒圆/倒角语义做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback 2. Face 一级关系闭环稳定后,再集中做槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证
3. Edge 闭环稳定后,再集中做槽/半孔:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度和中心距要统一验证 3. 槽/孔一级关系稳定后,再集中做 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback
4. 闭环稳定后,再集中做孔/圆柱、盲孔/盲槽、凸台、已有圆角、圆锥/球/环面等对象;每类都先补能力和验证,再进入下一类。 4. Edge 闭环稳定后,再集中做凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。
5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。 5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
Face 阶段的当前验收口径: Face 阶段的当前验收口径:
- 当前阶段的 `一级关系` 定义为:选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为当前阶段的自动传播范围。
- 选中平面 Face 时,`当前选中对象` 的参数表会显示 `一级关系` 说明行,直接列出当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并提示二级、三级关系暂不自动传播。
- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。 - `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积``面宽``面高``中心``面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积``面宽``面高``中心``面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。
- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动只带动一级侧面,二级底面不跟随移动。
- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。后续继续把这些一级关系用于更细的孔/槽实际重建策略。
- 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 - 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。
- Face 阶段闭环后,再进入 Edge 阶段;不要在 Face 还没有稳定解释和验证时提前分散到槽、孔或圆锥/球/环面。 - Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象
## 怎么使用 ## 怎么使用
@@ -924,9 +929,11 @@ pythonocc-step-editor/
GeometryParametric.spec # PyInstaller 文件夹版打包配置 GeometryParametric.spec # PyInstaller 文件夹版打包配置
package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip
generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具
verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证圆锥半角、解析曲面整体缩放 verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证简单圆锥解析重建和曲面整体缩放
verify_cone_semi_angle_isolation.py # 验证圆锥半角/参考半径隔离执行和嵌入式锥孔局部重切
verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改 verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改
verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证 verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证
verify_edge_coordinate_edit.py # 验证直线 Edge 起点/中心/终点坐标局部修改
verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放
verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线
verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改
@@ -947,6 +954,7 @@ pythonocc-step-editor/
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改
verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改
verify_cylindrical_first_level_topology.py # 临时生成孔/槽 STEP 并验证圆柱孔、盲孔、半圆槽、长圆槽的一级共边拓扑字段
step_editor/ step_editor/
__init__.py # 包导出 __init__.py # 包导出
app.py # 主窗口初始化、UI 搭建和程序启动 app.py # 主窗口初始化、UI 搭建和程序启动
@@ -1041,12 +1049,15 @@ git diff --check
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step` - `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step` - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`
- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证圆锥参考半径、圆锥半角、球面半径、环面主半径和环面小半径整体缩放。 - `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。
- `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。
- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 - `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。
- `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 圆角/倒角/已有圆角修改和椭圆 Edge 主/小半径修改。 - `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起直线 Edge 起点/中心/终点坐标修改、Edge 圆角/倒角/已有圆角修改和椭圆 Edge 主/小半径修改。
- `scripts/verify_edge_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。
- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。 - `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。
- `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。 - `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。
- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 - `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。
- `scripts/verify_face_first_level_topology.py`:加载 `cube_10mm.step`,验证正方体上表面 Face 的一级关系只包含当前 Face、4 条边界 Edge、4 个边界 Vertex 和 4 个共享边相邻侧面;局部移动上表面后,4 个一级侧面会跟随重建,二级底面保持不动。
- `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 推拉,并确认推拉后原逻辑 Face ID 会定位到新生成的整片顶面。 - `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_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。
- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。 - `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。
@@ -1064,6 +1075,8 @@ git diff --check
- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、面宽/面高 或面偏移编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `只改当前面` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 - `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_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。
- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
- `scripts/verify_cylindrical_first_level_topology.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,验证孔/槽类圆柱特征的修改计划会暴露一级共边拓扑关系,包括圆柱侧壁、边界 Edge、边界 Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。
- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。 - `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 - `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。 - `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。
@@ -1089,7 +1102,8 @@ git diff --check
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。
- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。
- 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高 和中心统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。 - 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。
- 编辑语义:Face 的面偏移、面积、面宽/面高 和中心通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、薄壁、圆角和 Edge 长度也会通过 `影响范围` 或计划策略说明本次修改会局部重建、推拉、移动还是整体缩放。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
@@ -1121,6 +1135,8 @@ git diff --check
Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。 Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。
Face 一级关系基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face`face_first_level_topology()` 会返回 `topology_relation_depth=1`、4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并明确二级/三级关系暂不递归传播。将上表面中心沿 Z 方向从 `10` 移到 `12` 后,4 个一级侧面中心 Z 从 `5` 变为 `6`,二级底面中心 Z 仍为 `0`,说明当前局部 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,将 `面偏移` 正向或负向改变 `1mm``只改当前面` 会走 `local-face-plane-offset-deform`,只移动当前 Face 顶点并重建相邻面;`移动整个特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`推拉当前面` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“面偏移”操作。
Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。 Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。
@@ -1143,7 +1159,7 @@ Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临
薄壁厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3``2 -> 1``推拉当前面` 会保持相对面不动,`调整整个特征` 会保持厚度中心基本不动。 薄壁厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3``2 -> 1``推拉当前面` 会保持相对面不动,`调整整个特征` 会保持厚度中心基本不动。
解析曲面整体缩放基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过五条路线:圆锥参考半径 `4 -> 5`、圆锥半角 `11.3099° -> 16°`、球面半径 `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`。球面和环面缩放后仍能识别为解析面;简单圆锥优先解析重建,嵌入式锥孔/沉孔的半角和参考半径由 `scripts/verify_cone_semi_angle_isolation.py` 覆盖局部重切与隔离执行;默认模型里的复杂浅锥/拔模面 `1° -> 10°``1° -> 50°` 会被提前禁用或阻断,避免生成无效 B-Rep
椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine``ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。 椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine``ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。
@@ -1165,3 +1181,11 @@ Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临
- 运行导出的 `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json` 默认不提交。 - 运行导出的 `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json` 默认不提交。
- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。 - 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。
- 工作区可能有用户改动,不能随意 `git checkout --``git reset --hard` 或回滚不相关文件。 - 工作区可能有用户改动,不能随意 `git checkout --``git reset --hard` 或回滚不相关文件。
### 2026-08-03 工程师返还代码合并记录
- 已合并特征参数策略:Feature 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。
- 已合并矩形平面轮廓识别:规则四直边平面可以识别为矩形平面、矩形棱柱、矩形口袋或矩形凸台候选,并估算长度、宽度、高度/深度。
- 已合并关联特征扫描:选中一个 Face 时,可在有限共享边拓扑范围内关联附近孔、槽、凸台、圆角、圆锥/球/环面等候选特征,并把关联特征的可改尺寸显示到当前特征参数表中。
- 已合并显示质量修复:精细网格只重点细化解析曲面;法线平滑不再合并不同 B-Rep Face 的边界点,减少平面被相邻曲面污染后发黑的问题。
- 新增验证脚本:`scripts/verify_feature_parameter_policy.py``scripts/verify_prismatic_feature.py``scripts/verify_associated_features.py`
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+3
View File
@@ -4,9 +4,11 @@ import os
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
project_root = os.path.abspath(os.path.join(SPECPATH, '..')) project_root = os.path.abspath(os.path.join(SPECPATH, '..'))
icon_path = os.path.join(project_root, 'assets', 'ico', 'logo_new.ico')
datas = [(os.path.join(project_root, 'assets'), 'assets')] datas = [(os.path.join(project_root, 'assets'), 'assets')]
binaries = [] binaries = []
hiddenimports = [ hiddenimports = [
'step_editor.isolated_edit_worker',
'PySide6.QtCore', 'PySide6.QtCore',
'PySide6.QtGui', 'PySide6.QtGui',
'PySide6.QtWidgets', 'PySide6.QtWidgets',
@@ -65,6 +67,7 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=icon_path,
) )
coll = COLLECT( coll = COLLECT(
exe, exe,
+97
View File
@@ -0,0 +1,97 @@
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
from step_editor.ui_helpers import _smooth_surface_polydata
from step_editor.window_state import WindowStateMixin
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
SOURCE_FACE_ID = 398
def main() -> int:
model = StepModel.load(MODEL_PATH)
root = model.feature_info(SOURCE_FACE_ID)
if root.get("shell_region_status") == "candidate":
raise AssertionError("Face 398 remote opposite plane was incorrectly exposed as wall thickness")
associated = model.associated_feature_infos(SOURCE_FACE_ID)
by_type = {str(info.get("feature_type")): info for info in associated}
for feature_type in ("凸台/外圆候选", "圆柱孔候选"):
if feature_type not in by_type:
raise AssertionError(f"missing associated {feature_type}: {tuple(by_type)}")
if any(info.get("feature_type") == "规则矩形平面候选" for info in associated):
raise AssertionError("unstable 2D-only rectangular candidates should not be associated")
boss = by_type["凸台/外圆候选"]
hole = by_type["圆柱孔候选"]
if int(boss.get("association_source_face_id", -1)) != 394:
raise AssertionError(f"unexpected boss source: {boss.get('association_source_face_id')}")
if int(hole.get("association_source_face_id", -1)) != 1591:
raise AssertionError(f"unexpected hole source: {hole.get('association_source_face_id')}")
if not bool(boss.get("is_full_cylinder")) or not bool(hole.get("is_full_cylinder")):
raise AssertionError("split half-cylinder faces were not combined into full cylinders")
state = object.__new__(WindowStateMixin)
state.model = model
state.operation_in_progress = False
state.scan_in_progress = False
state.load_in_progress = False
state.selected_face_id = SOURCE_FACE_ID
state.selected_edge_id = None
state.selected_kind = "feature"
state.selected_part_id = int(root["part_id"])
state.selected_solid_id = int(root["solid_id"])
context = state._feature_context_info(SOURCE_FACE_ID)
specs, _used = state._editable_property_specs(context)
rows = state._feature_context_property_specs(specs, context)
editable = {
(str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action")))
for row in rows
if row.get("parameter_role") == "dimension" and row.get("source_face_id") is not None
}
expected = {
("凸台/外圆候选 · 直径", 394, "resize_boss"),
("凸台/外圆候选 · 高度", 394, "resize_boss_height"),
("圆柱孔候选 · 直径", 1591, "resize_hole"),
("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"),
}
if not expected.issubset(editable):
raise AssertionError(f"missing associated editable rows: {expected - editable}")
display = _smooth_surface_polydata(model.build_face_polydata(deflection=0.035))
face_ids = display.GetCellData().GetArray("face_id")
normals = display.GetPointData().GetNormals()
boss_cells = [
index for index in range(display.GetNumberOfCells())
if int(face_ids.GetTuple1(index)) == 394
]
if len(boss_cells) < 400:
raise AssertionError(f"cylindrical display tessellation is too coarse: {len(boss_cells)}")
plane_cells = [
index for index in range(display.GetNumberOfCells())
if int(face_ids.GetTuple1(index)) == SOURCE_FACE_ID
]
plane_points = sorted({
display.GetCell(cell_id).GetPointId(point_index)
for cell_id in plane_cells
for point_index in range(display.GetCell(cell_id).GetNumberOfPoints())
})
plane_normals = [normals.GetTuple(point_id) for point_id in plane_points]
reference = plane_normals[0]
if min(sum(a * b for a, b in zip(reference, normal)) for normal in plane_normals) < 0.9999:
raise AssertionError("planar B-Rep Face normals were polluted by adjacent faces")
print("associated feature and display quality ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+758
View File
@@ -0,0 +1,758 @@
from __future__ import annotations
import math
from pathlib import Path
import sys
import tempfile
import time
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, BRepPrimAPI_MakeCylinder
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, 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.geometry_utils import _finalize_boolean_result
from step_editor.step_io import _write_step
from step_editor.window_actions import WindowActionMixin
from step_editor.window_state import WindowStateMixin
class _ActionProbe(WindowActionMixin):
def __init__(self, model: StepModel, path: Path) -> None:
self.model = model
self.step_path = path
class _PropertyProbe(WindowStateMixin):
def __init__(self, model: object | None = None, face_id: int = 0) -> None:
self.model = object() if model is None else model
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 = "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 _write_one_degree_cone(path: Path) -> None:
height = 10.0
top_radius = 1.0
bottom_radius = top_radius + height * math.tan(math.radians(1.0))
_write_step(BRepPrimAPI_MakeCone(bottom_radius, top_radius, height).Shape(), path)
def _write_one_degree_tip_cone(path: Path) -> None:
height = 10.0
bottom_radius = height * math.tan(math.radians(1.0))
_write_step(BRepPrimAPI_MakeCone(bottom_radius, 0.0, height).Shape(), path)
def _write_rotated_translated_cone(path: Path) -> None:
shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape()
rotate = gp_Trsf()
rotate.SetRotation(gp_Ax1(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 1.0, 0.0)), math.radians(37.0))
rotated = BRepBuilderAPI_Transform(shape, rotate, True).Shape()
translate = gp_Trsf()
translate.SetTranslation(gp_Vec(13.0, -4.0, 7.0))
moved = BRepBuilderAPI_Transform(rotated, translate, True).Shape()
_write_step(moved, path)
def _write_embedded_countersink(path: Path) -> None:
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
cutter = BRepPrimAPI_MakeCone(
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
2.0,
5.0,
5.0,
).Shape()
cut = BRepAlgoAPI_Cut(block, cutter)
_write_step(_finalize_boolean_result(cut, "verify embedded countersink cone cut"), path)
def _write_countersunk_through_hole(path: Path) -> None:
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
through_hole = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(15.0, 15.0, -0.5), gp_Dir(0.0, 0.0, 1.0)),
2.0,
11.0,
).Shape()
cut_hole = BRepAlgoAPI_Cut(block, through_hole)
shape = _finalize_boolean_result(cut_hole, "verify countersunk through-hole cylinder cut")
countersink = BRepPrimAPI_MakeCone(
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
2.0,
5.0,
5.0,
).Shape()
cut_sink = BRepAlgoAPI_Cut(shape, countersink)
_write_step(_finalize_boolean_result(cut_sink, "verify countersunk through-hole cone cut"), path)
def _first_cone_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
if model.face_info(face_id).get("surface") == "cone":
return face_id
raise SystemExit("test model did not expose a cone Face")
def _best_cone_face_by_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, dict[str, object], float]:
best: tuple[int, dict[str, object], 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, info, score)
if best is None:
raise SystemExit("no analytic cone Face was found")
return best
def _has_cylinder_diameter(model: StepModel, target_diameter: float, tolerance: float = 1e-4) -> bool:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
diameter = info.get("diameter")
if isinstance(diameter, (int, float)) and abs(float(diameter) - target_diameter) <= tolerance:
return True
return False
def _best_cone_angle_degrees(model: StepModel) -> float | None:
best_angle: float | None = None
for edited_face_id in range(len(model.faces)):
info = model.face_info(edited_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)))
if best_angle is None or abs(angle_degrees - 50.0) < abs(best_angle - 50.0):
best_angle = angle_degrees
return best_angle
def _unit(values: tuple[float, float, float]) -> tuple[float, float, float]:
length = math.sqrt(sum(float(value) ** 2 for value in values))
if length <= 1e-12:
raise SystemExit(f"cannot normalize vector {values}")
return tuple(float(value) / length for value in values)
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return sum(float(left[index]) * float(right[index]) for index in range(3))
def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return math.sqrt(sum((float(left[index]) - float(right[index])) ** 2 for index in range(3)))
def _cone_semi_angle_spec_action() -> str:
probe = _PropertyProbe()
specs, _used = probe._editable_property_specs(
{
"surface": "cone",
"reference_radius": 1.0,
"reference_diameter": 2.0,
"semi_angle": math.radians(1.0),
"area": 10.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),
}
)
for spec in specs:
if spec.get("key") == "cone_semi_angle_degrees":
if spec.get("target_transform"):
raise SystemExit(f"cone semi-angle should not be converted through reference radius: {spec}")
return str(spec.get("action") or "")
raise SystemExit("cone semi-angle property spec was not found")
def _cone_property_spec_for_model(model: StepModel, face_id: int, key: str) -> dict[str, object]:
probe = _PropertyProbe(model, face_id)
specs, _used = probe._editable_property_specs(model.face_info(face_id))
for spec in specs:
if spec.get("key") == key:
return spec
raise SystemExit(f"{key} property spec was not found for Face {face_id}")
def _cone_semi_angle_spec_for_model(model: StepModel, face_id: int) -> dict[str, object]:
return _cone_property_spec_for_model(model, face_id, "cone_semi_angle_degrees")
def _verify_non_cap_reference_radius_rebuild(model: StepModel, plan: dict[str, object]) -> None:
fake_plan = dict(plan)
current_reference_radius = float(plan.get("current_reference_radius") or 0.0)
scale = float(plan.get("affine_scale") or 0.0)
if current_reference_radius <= 0 or scale <= 0:
raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}")
fake_current_reference_radius = current_reference_radius * 0.95
fake_plan.update(
{
"current_reference_radius": fake_current_reference_radius,
"target_reference_radius": fake_current_reference_radius * scale,
"current_reference_diameter": fake_current_reference_radius * 2.0,
"target_reference_diameter": fake_current_reference_radius * scale * 2.0,
"resize_strategy": "radial-affine-scale-cone-semi-angle",
"analytic_rebuild_available": False,
}
)
model._annotate_simple_conical_rebuild_plan(fake_plan, "semi-angle")
if not fake_plan.get("analytic_rebuild_available"):
raise SystemExit(f"non-cap cone reference radius should still allow semi-angle rebuild: {fake_plan}")
if fake_plan.get("analytic_cone_reference_radius_matches_current"):
raise SystemExit(f"test setup should simulate a reference radius that is not a cap radius: {fake_plan}")
if fake_plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
raise SystemExit(f"non-cap cone reference rebuild chose wrong strategy: {fake_plan}")
model._apply_conical_analytic_rebuild_if_simple(fake_plan)
best_angle = _best_cone_angle_degrees(model)
if best_angle is None:
raise SystemExit("non-cap reference rebuild should keep an analytic cone Face")
if abs(best_angle - 50.0) > 0.5:
raise SystemExit(f"non-cap reference rebuild produced {best_angle:g}deg instead of 50deg")
def _verify_non_cap_reference_radius_direct_edit_is_blocked(model: StepModel, plan: dict[str, object]) -> None:
fake_plan = dict(plan)
current_reference_radius = float(plan.get("current_reference_radius") or 0.0)
scale = float(plan.get("affine_scale") or 0.0)
if current_reference_radius <= 0 or scale <= 0:
raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}")
fake_current_reference_radius = current_reference_radius * 0.95
fake_plan.update(
{
"status": "caution",
"risk": "medium",
"message": "",
"warnings": "",
"blockers": "",
"current_reference_radius": fake_current_reference_radius,
"target_reference_radius": fake_current_reference_radius * scale,
"current_reference_diameter": fake_current_reference_radius * 2.0,
"target_reference_diameter": fake_current_reference_radius * scale * 2.0,
"resize_strategy": "radial-affine-scale-cone-reference-radius",
"analytic_rebuild_available": False,
}
)
model._annotate_simple_conical_rebuild_plan(fake_plan, "reference-radius")
model._block_unstable_conical_reference_radius_plan(fake_plan)
if fake_plan.get("status") != "blocked":
raise SystemExit(f"non-cap direct reference-radius edit should be blocked: {fake_plan}")
if fake_plan.get("resize_strategy") != "blocked-cone-reference-radius-non-cap":
raise SystemExit(f"non-cap direct reference-radius edit chose wrong strategy: {fake_plan}")
message = str(fake_plan.get("message") or "")
if "简单圆锥" not in message or "锥孔/沉孔" not in message:
raise SystemExit(f"non-cap direct reference-radius blocker should explain supported paths: {fake_plan}")
def _verify_tip_cone_rebuild(path: Path) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
for key in ("cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"):
spec = _cone_property_spec_for_model(model, face_id, key)
if not spec.get("enabled"):
raise SystemExit(f"simple cone {key} property should stay editable: {spec}")
before = model.stats()
plan = model.conical_semi_angle_plan(face_id, 50.0)
if plan["status"] == "blocked":
raise SystemExit(f"tip cone semi-angle plan was blocked: {plan}")
if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
raise SystemExit(f"tip cone should use analytic rebuild, got {plan}")
result = model.resize_conical_semi_angle(face_id, 50.0)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"tip cone edit changed solid count: before={before.solids}, after={after.solids}")
best_angle = _best_cone_angle_degrees(model)
if best_angle is None:
raise SystemExit(f"tip cone edit should keep an analytic cone Face: {result}")
if abs(best_angle - 50.0) > 0.5:
raise SystemExit(f"tip cone edit produced {best_angle:g}deg instead of 50deg")
def _verify_rotated_cone_rebuild(path: Path) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
before_info = model.face_info(face_id)
before_axis = _unit(before_info["axis"])
before_axis_point = tuple(float(value) for value in before_info["axis_point"])
before = model.stats()
plan = model.conical_semi_angle_plan(face_id, 50.0)
if plan["status"] == "blocked":
raise SystemExit(f"rotated cone semi-angle plan was blocked: {plan}")
if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
raise SystemExit(f"rotated cone should use analytic rebuild, got {plan}")
result = model.resize_conical_semi_angle(face_id, 50.0)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"rotated cone edit changed solid count: before={before.solids}, after={after.solids}")
best_face: tuple[int, dict[str, object], float] | None = None
for edited_face_id in range(len(model.faces)):
info = model.face_info(edited_face_id)
if info.get("surface") != "cone":
continue
angle = info.get("semi_angle")
if not isinstance(angle, (int, float)):
continue
score = abs(abs(math.degrees(float(angle))) - 50.0)
if best_face is None or score < best_face[2]:
best_face = (edited_face_id, info, score)
if best_face is None:
raise SystemExit(f"rotated cone edit should keep an analytic cone Face: {result}")
edited_info = best_face[1]
after_axis = _unit(edited_info["axis"])
if abs(_dot(before_axis, after_axis)) < 0.999:
raise SystemExit(f"rotated cone edit changed the cone axis direction: before={before_axis}, after={after_axis}")
after_axis_point = tuple(float(value) for value in edited_info["axis_point"])
if _distance(before_axis_point, after_axis_point) > 1e-4:
raise SystemExit(
f"rotated cone edit moved the cone axis point unexpectedly: before={before_axis_point}, after={after_axis_point}"
)
def _verify_embedded_countersink_recut(path: Path, target_angle_degrees: float) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
before = model.stats()
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
if plan["status"] == "blocked":
raise SystemExit(f"embedded countersink cone semi-angle plan was blocked: {plan}")
if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle":
raise SystemExit(f"embedded countersink cone should use bounded local recut, got {plan}")
expected_mode = "enlarge" if target_angle_degrees > 30.963756532 else "shrink"
if plan.get("embedded_cone_recut_mode") != expected_mode:
raise SystemExit(f"embedded countersink cone recut mode mismatch: expected={expected_mode}, plan={plan}")
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"embedded countersink edit changed solid count: before={before.solids}, after={after.solids}")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, target_angle_degrees)
angle_degrees = abs(math.degrees(float(edited_info["semi_angle"])))
if abs(angle_degrees - target_angle_degrees) > 0.5:
raise SystemExit(
f"embedded countersink edit produced {angle_degrees:g}deg instead of {target_angle_degrees:g}deg"
)
circles = model._conical_face_circle_boundaries(
edited_face_id,
tuple(float(value) for value in edited_info["axis_point"]),
_unit(tuple(float(value) for value in edited_info["axis"])),
)
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
raise SystemExit(f"embedded countersink edited cone should keep two circular boundaries: {circles}")
target_large_radius = 2.0 + 5.0 * math.tan(math.radians(target_angle_degrees))
if abs(radii[0] - 2.0) > 1e-4:
raise SystemExit(f"embedded countersink should keep the small radius fixed at 2, got {radii}")
if abs(radii[1] - target_large_radius) > 1e-3:
raise SystemExit(f"embedded countersink large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}")
if "embedded local cone recut" not in result:
raise SystemExit(f"embedded countersink edit should report local cone recut: {result}")
def _verify_countersunk_through_hole_recut(path: Path, target_angle_degrees: float) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
if not _has_cylinder_diameter(model, 4.0):
raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole")
before = model.stats()
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
if plan["status"] == "blocked":
raise SystemExit(f"countersunk through-hole cone semi-angle plan was blocked: {plan}")
if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle":
raise SystemExit(f"countersunk through-hole cone should use bounded local recut, got {plan}")
if "径向缩放" in str(plan.get("message") or ""):
raise SystemExit(f"embedded local recut plan should not show radial-scaling copy: {plan}")
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"countersunk through-hole edit changed solid count: before={before.solids}, after={after.solids}")
_best_cone_face_by_angle(model, target_angle_degrees)
if not _has_cylinder_diameter(model, 4.0):
raise SystemExit(f"countersunk through-hole edit should keep the lower 4mm cylindrical hole: {result}")
def _verify_embedded_cone_reference_radius_recut(
path: Path,
target_reference_radius: float,
*,
expect_lower_cylinder: bool = False,
) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0):
raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole")
before = model.stats()
before_info = model.face_info(face_id)
current_angle_degrees = abs(math.degrees(float(before_info["semi_angle"])))
plan = model.conical_reference_radius_plan(face_id, target_reference_radius)
if plan["status"] == "blocked":
raise SystemExit(f"embedded cone reference-radius plan was blocked: {plan}")
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
raise SystemExit(f"embedded cone reference radius should use bounded local recut, got {plan}")
expected_mode = "enlarge" if target_reference_radius > 2.0 else "shrink"
if plan.get("embedded_cone_recut_mode") != expected_mode:
raise SystemExit(f"embedded cone reference recut mode mismatch: expected={expected_mode}, plan={plan}")
if "径向缩放" in str(plan.get("message") or ""):
raise SystemExit(f"embedded reference recut plan should not show radial-scaling copy: {plan}")
result = model.resize_conical_reference_radius(face_id, target_reference_radius)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"embedded cone reference edit changed solid count: before={before.solids}, after={after.solids}")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, current_angle_degrees)
edited_angle = abs(math.degrees(float(edited_info["semi_angle"])))
if abs(edited_angle - current_angle_degrees) > 0.5:
raise SystemExit(
f"embedded cone reference edit should preserve angle {current_angle_degrees:g}deg, got {edited_angle:g}deg"
)
circles = model._conical_face_circle_boundaries(
edited_face_id,
tuple(float(value) for value in edited_info["axis_point"]),
_unit(tuple(float(value) for value in edited_info["axis"])),
)
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
raise SystemExit(f"embedded cone reference edit should keep two circular boundaries: {circles}")
target_large_radius = target_reference_radius + 3.0
if abs(radii[0] - target_reference_radius) > 1e-3:
raise SystemExit(
f"embedded cone reference edit small radius mismatch: got {radii[0]:g}, target={target_reference_radius:g}"
)
if abs(radii[1] - target_large_radius) > 1e-3:
raise SystemExit(f"embedded cone reference edit large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}")
if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0):
raise SystemExit(f"embedded cone reference edit should keep the lower 4mm cylindrical hole: {result}")
if "reference radius resize completed by embedded local cone recut" not in result:
raise SystemExit(f"embedded cone reference edit should report local cone recut: {result}")
def _verify_embedded_cone_non_cap_reference_radius_recut(path: Path) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
info = model.face_info(face_id)
current_reference_radius = 3.5
target_reference_radius = 4.0
plan = {
"status": "caution",
"risk": "medium",
"message": "",
"warnings": "",
"blockers": "",
"face_id": face_id,
"part_id": int(model.face_part_ids[face_id]),
"solid_id": int(model.face_solid_ids[face_id]),
"surface": "cone",
"current_reference_radius": current_reference_radius,
"target_reference_radius": target_reference_radius,
"axis_point": info.get("axis_point"),
"axis": info.get("axis"),
"resize_strategy": "radial-affine-scale-cone-reference-radius",
"affine_scale": target_reference_radius / current_reference_radius,
"affine_target_kind": "part",
}
model._annotate_embedded_conical_recut_plan(plan, "reference-radius")
if not plan.get("embedded_cone_recut_available"):
raise SystemExit(f"non-cap embedded reference radius should allow local recut: {plan}")
if plan.get("embedded_cone_reference_matches_small") or plan.get("embedded_cone_reference_matches_large"):
raise SystemExit(f"test setup should use a non-cap reference section: {plan}")
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
raise SystemExit(f"non-cap embedded reference should use bounded local recut, got {plan}")
expected_small_radius = 2.5
expected_large_radius = 5.5
if abs(float(plan.get("embedded_cone_target_small_radius") or 0.0) - expected_small_radius) > 1e-6:
raise SystemExit(f"non-cap embedded target small radius mismatch: {plan}")
if abs(float(plan.get("embedded_cone_target_large_radius") or 0.0) - expected_large_radius) > 1e-6:
raise SystemExit(f"non-cap embedded target large radius mismatch: {plan}")
before = model.stats()
if not model._apply_embedded_conical_recut_if_available(plan):
raise SystemExit(f"non-cap embedded reference recut was not applied: {plan}")
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"non-cap embedded reference edit changed solid count: before={before.solids}, after={after.solids}")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, 30.963756532)
circles = model._conical_face_circle_boundaries(
edited_face_id,
tuple(float(value) for value in edited_info["axis_point"]),
_unit(tuple(float(value) for value in edited_info["axis"])),
)
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
raise SystemExit(f"non-cap embedded reference edit should keep two circular boundaries: {circles}")
if abs(radii[0] - expected_small_radius) > 1e-3 or abs(radii[1] - expected_large_radius) > 1e-3:
raise SystemExit(
f"non-cap embedded reference edit boundary mismatch: got={radii}, "
f"target={[expected_small_radius, expected_large_radius]}"
)
def _verify_embedded_cone_feature_info_boundary_metrics(path: Path) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
info = model.feature_info(face_id)
expected = {
"feature_cone_small_radius": 2.0,
"feature_cone_large_radius": 5.0,
"feature_cone_height": 5.0,
}
for key, target in expected.items():
value = info.get(key)
if not isinstance(value, (int, float)) or abs(float(value) - target) > 1e-4:
raise SystemExit(f"embedded cone feature info {key} mismatch: got={value}, target={target}")
angle = info.get("feature_cone_boundary_half_angle_degrees")
if not isinstance(angle, (int, float)) or abs(float(angle) - 30.963756532) > 1e-4:
raise SystemExit(f"embedded cone feature info half-angle mismatch: {info}")
if "半角" not in str(info.get("feature_edit_actions") or ""):
raise SystemExit(f"cone feature edit actions should mention half-angle: {info}")
def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None:
model = StepModel.load(path)
face_id = _first_cone_face(model)
part_id = int(model.face_part_ids[face_id])
plan = model.conical_reference_radius_plan(face_id, 3.0)
if plan["status"] == "blocked":
raise SystemExit(f"embedded reference-radius isolation plan was blocked: {plan}")
if str(plan.get("risk")) != "high":
raise SystemExit(f"embedded reference-radius isolation case should be high risk, got {plan}")
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
raise SystemExit(f"embedded reference-radius isolation should use local recut, got {plan}")
probe = _ActionProbe(model, path)
isolation = probe._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, 3.0])
if isolation is None:
raise SystemExit(f"embedded reference-radius edit should request isolated execution: {plan}")
context = {
"operation_name": "resize embedded cone reference radius",
"target": f"Face {face_id}",
"parameters": {
"part_id": part_id,
"surface": "cone",
"target_reference_radius": plan.get("target_reference_radius"),
"resize_strategy": plan.get("resize_strategy"),
},
"target_kind": "face",
"target_id": face_id,
"target_logical_id": model.face_region_logical_id(face_id),
"pick_position": None,
"show_same_domain_internal_edges": False,
"edit_result_deflection": 2.4,
}
result = probe._run_isolated_edit_job(
context=context,
isolation=isolation,
snapshot=model.snapshot(),
before_stats=model.stats(),
before_part_stats=model.part_topology_stats(part_id),
before_quality=probe._edit_quality_info_or_none(model, context, part_id),
before_geometry={},
)
if not result.get("message"):
raise SystemExit("isolated embedded reference-radius edit did not return a completion message")
if probe.model is model:
raise SystemExit("successful isolated embedded reference-radius edit should load the edited model")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(probe.model, 30.963756532)
circles = probe.model._conical_face_circle_boundaries(
edited_face_id,
tuple(float(value) for value in edited_info["axis_point"]),
_unit(tuple(float(value) for value in edited_info["axis"])),
)
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-3 or abs(radii[1] - 6.0) > 1e-3:
raise SystemExit(f"isolated embedded reference-radius edit produced wrong cone boundaries: {radii}")
def _verify_complex_shallow_cone_half_angle_is_blocked() -> None:
model_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
model = StepModel.load(model_path)
checked_face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.quick_face_info(face_id)
if info.get("surface") != "cone":
continue
angle = info.get("semi_angle")
reference_radius = info.get("reference_radius")
if not isinstance(angle, (int, float)) or not isinstance(reference_radius, (int, float)):
continue
if abs(math.degrees(float(angle))) > 1.5 or float(reference_radius) < 1000.0:
continue
for target_angle in (10.0, 50.0):
plan = model.conical_semi_angle_plan(face_id, target_angle)
if plan.get("status") != "blocked" or plan.get("risk") != "blocked":
raise SystemExit(
f"complex shallow cone half-angle edit should be blocked for Face {face_id}, "
f"target={target_angle:g}: {plan}"
)
if plan.get("resize_strategy") not in {
"blocked-complex-cone-semi-angle-extreme-scale",
"blocked-complex-cone-semi-angle-shallow-far-axis",
}:
raise SystemExit(f"complex shallow cone blocker chose wrong strategy: {plan}")
message = str(plan.get("message") or "")
if "复杂浅锥/拔模面" not in message or "无效 B-Rep" not in message:
raise SystemExit(f"complex shallow cone blocker should explain the real risk: {plan}")
spec = _cone_semi_angle_spec_for_model(model, face_id)
if spec.get("enabled"):
raise SystemExit(f"complex shallow cone semi-angle property should be disabled: {spec}")
disabled_tip = str(spec.get("disabled_tip") or "")
if (
"复杂浅锥/拔模面" not in disabled_tip
and "圆锥面/拔模面" not in disabled_tip
) or "无效 B-Rep" not in disabled_tip:
raise SystemExit(f"complex shallow cone disabled tip should explain the risk: {spec}")
for key in ("cone_reference_radius", "cone_reference_diameter"):
ref_spec = _cone_property_spec_for_model(model, face_id, key)
if ref_spec.get("enabled"):
raise SystemExit(f"complex shallow cone {key} property should be disabled: {ref_spec}")
ref_tip = str(ref_spec.get("disabled_tip") or "")
if (
"复杂圆锥面/拔模面" not in ref_tip
and "复杂浅锥/拔模面" not in ref_tip
and "圆锥面/拔模面" not in ref_tip
) or "无效 B-Rep" not in ref_tip:
raise SystemExit(f"complex shallow cone {key} disabled tip should explain the risk: {ref_spec}")
reference_plan = model.conical_reference_radius_plan(face_id, float(reference_radius) * 1.05)
if reference_plan.get("status") != "blocked" or reference_plan.get("risk") != "blocked":
raise SystemExit(f"complex shallow cone reference-radius edit should be blocked: {reference_plan}")
if reference_plan.get("resize_strategy") not in {
"blocked-complex-cone-reference-radius-unsupported-fallback",
"blocked-complex-cone-reference-radius-shallow-far-axis",
}:
raise SystemExit(f"complex shallow cone reference-radius blocker chose wrong strategy: {reference_plan}")
if 1490.0 <= float(reference_radius) <= 1510.0:
start = time.perf_counter()
user_plan = model.conical_reference_radius_plan(face_id, 1700.0)
elapsed = time.perf_counter() - start
if elapsed > 1.0:
raise SystemExit(
f"complex shallow cone reference-radius 1700 plan should be blocked quickly; "
f"face={face_id}, elapsed={elapsed:.3f}s, plan={user_plan}"
)
if user_plan.get("status") != "blocked" or user_plan.get("risk") != "blocked":
raise SystemExit(f"complex shallow cone reference-radius 1700 edit should be blocked: {user_plan}")
checked_face_ids.append(face_id)
if len(checked_face_ids) >= 3:
break
if len(checked_face_ids) < 3:
raise SystemExit(f"complex shallow cone half-angle guard found too few real-model cases: {checked_face_ids}")
def main() -> int:
action = _cone_semi_angle_spec_action()
if action != "resize_cone_semi_angle":
raise SystemExit(f"cone semi-angle property should call resize_cone_semi_angle, got {action!r}")
with tempfile.TemporaryDirectory(prefix="geom_param_cone_semi_angle_isolation_") as temp_dir:
cone_path = Path(temp_dir) / "cone_1deg.step"
tip_cone_path = Path(temp_dir) / "tip_cone_1deg.step"
rotated_cone_path = Path(temp_dir) / "rotated_cone.step"
countersink_path = Path(temp_dir) / "embedded_countersink.step"
through_countersink_path = Path(temp_dir) / "countersunk_through_hole.step"
_write_one_degree_cone(cone_path)
_write_one_degree_tip_cone(tip_cone_path)
_write_rotated_translated_cone(rotated_cone_path)
_write_embedded_countersink(countersink_path)
_write_countersunk_through_hole(through_countersink_path)
_verify_tip_cone_rebuild(tip_cone_path)
_verify_rotated_cone_rebuild(rotated_cone_path)
_verify_embedded_countersink_recut(countersink_path, 50.0)
_verify_embedded_countersink_recut(countersink_path, 20.0)
_verify_embedded_cone_feature_info_boundary_metrics(countersink_path)
_verify_countersunk_through_hole_recut(through_countersink_path, 50.0)
_verify_countersunk_through_hole_recut(through_countersink_path, 20.0)
_verify_embedded_cone_reference_radius_recut(countersink_path, 3.0)
_verify_embedded_cone_reference_radius_recut(countersink_path, 1.5)
_verify_embedded_cone_reference_radius_recut(through_countersink_path, 3.0, expect_lower_cylinder=True)
_verify_embedded_cone_reference_radius_recut(through_countersink_path, 1.5, expect_lower_cylinder=True)
_verify_embedded_cone_non_cap_reference_radius_recut(countersink_path)
_verify_embedded_reference_radius_isolated_window_job(countersink_path)
model = StepModel.load(cone_path)
face_id = _first_cone_face(model)
part_id = int(model.face_part_ids[face_id])
solid_id = int(model.face_solid_ids[face_id])
plan = model.conical_semi_angle_plan(face_id, 50.0)
if str(plan.get("risk")) != "high":
raise SystemExit(f"1deg -> 50deg cone semi-angle should be high risk, got {plan}")
fake_reference_model = StepModel.load(cone_path)
_verify_non_cap_reference_radius_rebuild(fake_reference_model, dict(plan))
fake_direct_reference_model = StepModel.load(cone_path)
_verify_non_cap_reference_radius_direct_edit_is_blocked(fake_direct_reference_model, dict(plan))
probe = _ActionProbe(model, cone_path)
isolation = probe._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, 50.0])
if isolation is None:
raise SystemExit(f"high-risk cone semi-angle edit should request isolated execution: {plan}")
context = {
"operation_name": "修改圆锥半角(整体)",
"target": f"Face {face_id}",
"parameters": {
"part_id": part_id,
"solid_id": solid_id,
"surface": "cone",
"target_reference_radius": plan.get("target_reference_radius"),
"target_semi_angle_degrees": plan.get("target_semi_angle_degrees"),
"resize_strategy": plan.get("resize_strategy"),
},
"target_kind": "face",
"target_id": face_id,
"target_logical_id": model.face_region_logical_id(face_id),
"pick_position": None,
"show_same_domain_internal_edges": False,
"edit_result_deflection": 2.4,
}
snapshot = model.snapshot()
result = probe._run_isolated_edit_job(
context=context,
isolation=isolation,
snapshot=snapshot,
before_stats=model.stats(),
before_part_stats=model.part_topology_stats(part_id),
before_quality=probe._edit_quality_info_or_none(model, context, part_id),
before_geometry={},
)
if not result.get("message"):
raise SystemExit("isolated cone semi-angle edit did not return a completion message")
if probe.model is model:
raise SystemExit("successful isolated cone semi-angle edit should load the edited model")
edited_model = probe.model
best_angle = _best_cone_angle_degrees(edited_model)
if best_angle is None:
raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face")
if abs(best_angle - 50.0) > 0.5:
raise SystemExit(f"isolated cone semi-angle edit produced {best_angle:g}deg instead of 50deg")
_verify_complex_shallow_cone_half_angle_is_blocked()
print("cone semi-angle isolated analytic rebuild ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,234 @@
from __future__ import annotations
from pathlib import Path
import math
import sys
import tempfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = Path(__file__).resolve().parent
for path in (PROJECT_ROOT, SCRIPTS_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from step_editor.model import StepModel
from verify_hole_resize import ( # noqa: E402
_axis_center,
_first_hole_face,
_write_blind_hole_model,
_write_through_hole_model,
)
from verify_slot_resize import ( # noqa: E402
_first_slot_face,
_slot_axis_center,
_slot_metric,
_write_half_round_slot_model,
_write_obround_slot_model,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _assert_common_topology(topology: dict[str, object], label: str) -> None:
_assert(topology.get("topology_relation_depth") == 1, f"{label}: topology depth should be 1")
_assert(
topology.get("topology_relation_boundary") == "shared-edge",
f"{label}: topology boundary should be shared-edge",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored, f"{label}: second-level propagation should be explicitly ignored")
_assert("third-level" in ignored, f"{label}: third-level propagation should be explicitly ignored")
_assert(
int(topology.get("cylindrical_feature_side_face_count", 0) or 0) >= 1,
f"{label}: selected cylindrical side region is missing",
)
_assert(
int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) >= 1,
f"{label}: cylindrical boundary edges are missing",
)
_assert(
int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 1,
f"{label}: direct adjacent Faces are missing",
)
def _assert_plan_topology(plan: dict[str, object], label: str) -> None:
_assert(plan.get("topology_relation_depth") == 1, f"{label}: plan topology depth should be 1")
_assert(
plan.get("topology_relation_status") == "ready",
f"{label}: plan topology should be ready, got {plan.get('topology_relation_status')!r}",
)
_assert(
int(plan.get("first_level_boundary_edge_count", 0) or 0) >= 1,
f"{label}: plan boundary edges are missing",
)
_assert(
int(plan.get("first_level_adjacent_face_count", 0) or 0) >= 1,
f"{label}: plan adjacent Faces are missing",
)
_assert(
"second-level" in tuple(plan.get("topology_ignored_relation_depths", ()) or ()),
f"{label}: plan should document ignored deeper topology",
)
_assert(
plan.get("first_level_topology_status") == "ready",
f"{label}: first-level topology guard should be ready",
)
_assert(
plan.get("first_level_topology_guard_note"),
f"{label}: first-level topology guard note is missing",
)
def _verify_through_hole() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_hole_") 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)
topology = model.cylindrical_feature_first_level_topology(face_id)
_assert_common_topology(topology, "through hole")
_assert(
int(topology.get("cylindrical_feature_end_face_count", 0) or 0) >= 1,
"through hole: opening/end Faces should be part of first-level topology",
)
center = _axis_center(model, face_id)
diameter_plan = model.cylindrical_resize_plan(face_id, 8.0)
axis_plan = model.cylindrical_axis_move_plan(face_id, (center[0] + 1.0, center[1], center[2]))
suppress_plan = model.cylindrical_suppress_plan(face_id)
_assert_plan_topology(diameter_plan, "through hole diameter plan")
_assert_plan_topology(axis_plan, "through hole axis plan")
_assert_plan_topology(suppress_plan, "through hole suppress plan")
def _verify_blind_hole() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_blind_") as temp_dir:
model_path = Path(temp_dir) / "blind_hole.step"
_write_blind_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=True)
topology = model.cylindrical_feature_first_level_topology(face_id)
_assert_common_topology(topology, "blind hole")
_assert(
int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0) >= 1,
"blind hole: bottom Face should be part of first-level topology",
)
feature = model.feature_info(face_id)
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()) or ())
bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None
depth_plan = model.cylindrical_depth_plan(face_id, 8.0, bottom_face_id=bottom_face_id)
_assert_plan_topology(depth_plan, "blind hole depth plan")
_assert(
depth_plan.get("feature_bottom_face_ids"),
"blind hole depth plan should keep bottom Face context",
)
def _verify_half_round_slot() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_slot_") as temp_dir:
model_path = Path(temp_dir) / "half_round_slot.step"
_write_half_round_slot_model(model_path)
model = StepModel.load(model_path)
face_id = _first_slot_face(model)
topology = model.cylindrical_feature_first_level_topology(face_id)
_assert_common_topology(topology, "half-round slot")
_assert(
topology.get("slot_kind") == "partial-cylindrical-groove",
"half-round slot: slot kind should be preserved",
)
_assert(
int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0) >= 1,
"half-round slot: direct slot boundary Faces are missing",
)
center = _slot_axis_center(model, face_id)
width = _slot_metric(model, face_id, "slot_chord_width_estimate")
depth = _slot_metric(model, face_id, "slot_sagitta_depth_estimate")
arc_length = _slot_metric(model, face_id, "slot_arc_length_estimate")
angular_span = _slot_metric(model, face_id, "slot_angular_span")
plans = (
("slot width plan", model.cylindrical_slot_resize_plan(face_id, width + 1.0, "width")),
("slot depth plan", model.cylindrical_slot_resize_plan(face_id, depth + 0.5, "depth")),
("slot arc plan", model.cylindrical_slot_resize_plan(face_id, arc_length + 0.5, "arc_length")),
("slot angular plan", model.cylindrical_slot_angular_span_plan(face_id, max(0.25, angular_span * 0.8))),
("slot axis plan", model.cylindrical_slot_axis_move_plan(face_id, (center[0], center[1] + 0.5, center[2]))),
)
for label, plan in plans:
_assert_plan_topology(plan, label)
def _verify_obround_slot_length_plan() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_obround_") as temp_dir:
model_path = Path(temp_dir) / "obround_slot.step"
_write_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_id = _first_slot_face(model)
topology = model.cylindrical_feature_first_level_topology(face_id)
_assert_common_topology(topology, "obround slot end")
_assert(
int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 2,
"obround slot end: direct side-wall neighbors are missing",
)
length_plan = model.cylindrical_slot_total_length_plan(face_id, 20.0)
center_plan = model.cylindrical_slot_center_distance_plan(face_id, 14.0)
_assert_plan_topology(length_plan, "obround slot total length plan")
_assert_plan_topology(center_plan, "obround slot center distance plan")
_assert(
length_plan.get("slot_pair_face_id") not in {None, ""},
"obround slot length plan should still expose the paired slot end",
)
def _verify_missing_first_level_neighbor_blocks_plan() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_guard_") 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)
original_topology = model.cylindrical_feature_first_level_topology
def broken_topology(target_face_id: int) -> dict[str, object]:
topology = dict(original_topology(target_face_id))
topology["cylindrical_feature_adjacent_face_ids"] = ()
topology["cylindrical_feature_adjacent_face_count"] = 0
topology["cylindrical_feature_first_level_face_ids"] = topology.get(
"cylindrical_feature_side_face_ids",
(target_face_id,),
)
topology["cylindrical_feature_first_level_face_count"] = int(
topology.get("cylindrical_feature_side_face_count", 1) or 1
)
return topology
model.cylindrical_feature_first_level_topology = broken_topology # type: ignore[method-assign]
plan = model.cylindrical_resize_plan(face_id, 8.0)
_assert(plan.get("status") == "blocked", "missing first-level adjacent Faces should block diameter plan")
_assert(
plan.get("first_level_topology_status") == "blocked",
"missing first-level adjacent Faces should mark topology guard blocked",
)
_assert(
"相邻 Face" in str(plan.get("blockers") or plan.get("message") or ""),
"blocked plan should explain the missing direct adjacent Faces",
)
def main() -> int:
_verify_through_hole()
_verify_blind_hole()
_verify_half_round_slot()
_verify_obround_slot_length_plan()
_verify_missing_first_level_neighbor_blocks_plan()
print("cylindrical first-level topology verification passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
import argparse
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 _tuple3(value: object, label: str) -> tuple[float, float, float]:
if not isinstance(value, (tuple, list)) or len(value) != 3:
raise SystemExit(f"{label} is missing or invalid: {value!r}")
return float(value[0]), float(value[1]), float(value[2])
def _sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return a[0] - b[0], a[1] - b[1], a[2] - b[2]
def _add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return a[0] + b[0], a[1] + b[1], a[2] + b[2]
def _scale(a: tuple[float, float, float], factor: float) -> tuple[float, float, float]:
return a[0] * factor, a[1] * factor, a[2] * factor
def _length(a: tuple[float, float, float]) -> float:
return (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) ** 0.5
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return _length(_sub(a, b))
def _first_line_edge_near_length(model: StepModel, length: float, tolerance: float) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
if abs(float(info.get("length") or 0.0) - length) <= tolerance:
return edge_id
raise SystemExit(f"no line Edge near length {length:g}")
def _endpoint_pair_error(
model: StepModel,
edge_id: int,
expected_start: tuple[float, float, float],
expected_end: tuple[float, float, float],
) -> float:
info = model.edge_info(edge_id)
start = _tuple3(info.get("start_point"), f"Edge {edge_id} start")
end = _tuple3(info.get("end_point"), f"Edge {edge_id} end")
direct = max(_distance(start, expected_start), _distance(end, expected_end))
reversed_order = max(_distance(start, expected_end), _distance(end, expected_start))
return min(direct, reversed_order)
def _nearest_expected_edge(
model: StepModel,
expected_start: tuple[float, float, float],
expected_end: tuple[float, float, float],
) -> tuple[int, float, float]:
best: tuple[int, float, float] | None = None
target_length = _distance(expected_start, expected_end)
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
endpoint_error = _endpoint_pair_error(model, edge_id, expected_start, expected_end)
length_error = abs(float(info.get("length") or 0.0) - target_length)
if best is None or (endpoint_error, length_error, edge_id) < (best[1], best[2], best[0]):
best = (edge_id, endpoint_error, length_error)
if best is None:
raise SystemExit("edited model has no line Edge for endpoint verification")
return best
def _source_edge_frame(
model: StepModel,
source_length: float,
tolerance: float,
) -> tuple[int, tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], float]:
edge_id = _first_line_edge_near_length(model, source_length, tolerance)
info = model.edge_info(edge_id)
start = _tuple3(info.get("start_point"), "source start")
end = _tuple3(info.get("end_point"), "source end")
vector = _sub(end, start)
length = _length(vector)
if length <= 1e-9:
raise SystemExit(f"source Edge {edge_id} has zero length")
direction = _scale(vector, 1.0 / length)
return edge_id, start, end, direction, length
def _run_endpoint_case(role: str, target_delta: float, tolerance: float) -> None:
model = StepModel.load(DEFAULT_MODEL)
edge_id, start, end, direction, source_length = _source_edge_frame(model, 10.0, tolerance)
if role == "start":
target_start = _add(start, _scale(direction, -target_delta))
target_end = end
target_point = target_start
elif role == "end":
target_start = start
target_end = _add(end, _scale(direction, target_delta))
target_point = target_end
else:
raise SystemExit(f"unsupported endpoint role: {role}")
before = model.stats()
plan = model.edge_endpoint_move_plan(edge_id, role, target_point)
if plan["status"] == "blocked":
raise SystemExit(f"{role} endpoint plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
raise SystemExit(f"{role} endpoint should use local-edge-endpoint-deform, got {plan.get('resize_strategy')}")
result = model.move_edge_endpoint(edge_id, role, target_point)
after = model.stats()
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
if after.solids != before.solids:
raise SystemExit(f"{role} endpoint changed solid count: before={before.solids}, after={after.solids}")
if endpoint_error > tolerance or length_error > tolerance:
raise SystemExit(
f"{role} endpoint verification failed: matched_edge={matched_edge}, "
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}"
)
print(f"mode={role}_endpoint")
print(f"source_edge={edge_id}")
print(f"matched_edge={matched_edge}")
print(f"source_length={source_length:.6f}")
print(f"target_point={target_point}")
print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_center_case(center_delta: tuple[float, float, float], tolerance: float) -> None:
model = StepModel.load(DEFAULT_MODEL)
edge_id, start, end, _direction, source_length = _source_edge_frame(model, 10.0, tolerance)
current_center = _tuple3(model.edge_info(edge_id).get("length_center"), "source center")
target_center = _add(current_center, center_delta)
target_start = _add(start, center_delta)
target_end = _add(end, center_delta)
before = model.stats()
plan = model.edge_center_move_plan(edge_id, target_center)
if plan["status"] == "blocked":
raise SystemExit(f"center plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "local-edge-center-deform":
raise SystemExit(f"center move should use local-edge-center-deform, got {plan.get('resize_strategy')}")
result = model.move_edge_center(edge_id, target_center)
after = model.stats()
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
if after.solids != before.solids:
raise SystemExit(f"center move changed solid count: before={before.solids}, after={after.solids}")
if endpoint_error > tolerance or length_error > tolerance:
raise SystemExit(
f"center move verification failed: matched_edge={matched_edge}, "
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}"
)
print("mode=center")
print(f"source_edge={edge_id}")
print(f"matched_edge={matched_edge}")
print(f"source_length={source_length:.6f}")
print(f"target_center={target_center}")
print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify straight Edge start, center and end coordinate edits.")
parser.add_argument("--mode", default="all", choices=["all", "start", "center", "end"])
parser.add_argument("--endpoint-delta", type=float, default=3.0)
parser.add_argument("--center-delta", default="0,0,2")
parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args()
center_delta = _tuple3([part.strip() for part in str(args.center_delta).split(",")], "center delta")
if args.mode in {"all", "start"}:
_run_endpoint_case("start", args.endpoint_delta, args.tolerance)
if args.mode in {"all", "center"}:
_run_center_case(center_delta, args.tolerance)
if args.mode in {"all", "end"}:
_run_endpoint_case("end", args.endpoint_delta, args.tolerance)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -54,6 +54,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Edge length scale owning object, fixed center", "Edge length scale owning object, fixed center",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "center"), ("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "center"),
), ),
(
"Edge start, center and end coordinate edits",
("verify_edge_coordinate_edit.py",),
),
( (
"Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize", "Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize",
("verify_edge_round_chamfer.py",), ("verify_edge_round_chamfer.py",),
@@ -0,0 +1,328 @@
from __future__ import annotations
import sys
from pathlib import Path
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, BRepPrimAPI_MakeSphere
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.model_types import TopologyStats
from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_actions import WindowActionMixin
class _Probe(WindowActionMixin):
pass
def _face_context(strategy: str = "local-face-area-only-deform") -> dict[str, object]:
return {
"operation_name": "Face integrity guard probe",
"target_kind": "face",
"target_id": 0,
"parameters": {"resize_strategy": strategy},
}
def _edge_context() -> dict[str, object]:
return {
"operation_name": "Edge integrity guard probe",
"target_kind": "edge",
"target_id": 0,
"parameters": {"resize_strategy": "local-edge-length-deform"},
}
def _expect_blocked(label: str, callback) -> None:
try:
callback()
except RuntimeError as exc:
print(f"{label}: blocked as expected: {exc}")
return
raise SystemExit(f"{label}: expected a RuntimeError")
def _expect_allowed(label: str, callback) -> list[str]:
try:
warnings = callback()
except RuntimeError as exc:
raise SystemExit(f"{label}: should have been allowed, got {exc}") from exc
print(f"{label}: allowed, warnings={len(warnings)}")
return warnings
def _write_embedded_countersink(path: Path) -> None:
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
cutter = BRepPrimAPI_MakeCone(
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
2.0,
5.0,
5.0,
).Shape()
cut = BRepAlgoAPI_Cut(block, cutter)
_write_step(_finalize_boolean_result(cut, "verify Face cone integrity countersink cut"), path)
def _first_face_by_surface(model: StepModel, surface: str) -> int:
for face_id in range(len(model.faces)):
if model.face_info(face_id).get("surface") == surface:
return face_id
raise SystemExit(f"no {surface} Face was recognized")
def main() -> int:
probe = _Probe()
before = TopologyStats(parts=1, solids=1, faces=6, edges=12, vertices=8)
after_ok = TopologyStats(parts=1, solids=1, faces=6, edges=12, vertices=8)
after_no_solid = TopologyStats(parts=1, solids=0, faces=0, edges=0, vertices=0)
after_two_solids = TopologyStats(parts=1, solids=2, faces=8, edges=16, vertices=12)
before_quality_ok = {"quality_status": "ok", "brep_valid": True, "quality_warnings": ""}
after_quality_ok = {"quality_status": "ok", "brep_valid": True, "quality_warnings": ""}
after_quality_bad = {
"quality_status": "warning",
"brep_valid": False,
"quality_warnings": "B-Rep invalid after edit",
}
before_quality_bad = {
"quality_status": "warning",
"brep_valid": False,
"quality_warnings": "B-Rep invalid before edit",
}
_expect_blocked(
"Face edit losing solids",
lambda: probe._verified_edit_quality_warnings(
_face_context(),
before,
after_no_solid,
before,
after_no_solid,
before_quality_ok,
after_quality_ok,
),
)
_expect_blocked(
"Face edit changing target solid count",
lambda: probe._verified_edit_quality_warnings(
_face_context(),
before,
after_two_solids,
before,
after_two_solids,
before_quality_ok,
after_quality_ok,
),
)
_expect_blocked(
"New invalid B-Rep",
lambda: probe._verified_edit_quality_warnings(
_face_context(),
before,
after_ok,
before,
after_ok,
before_quality_ok,
after_quality_bad,
),
)
warnings = _expect_allowed(
"Already-warning B-Rep stays warning",
lambda: probe._verified_edit_quality_warnings(
_face_context(),
before,
after_ok,
before,
after_ok,
before_quality_bad,
after_quality_bad,
),
)
if not warnings:
raise SystemExit("existing quality warning should still be reported")
cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
cube_stats = cube.stats()
cube_part_id = int(cube.face_part_ids[0])
cube_solid_id = int(cube.face_solid_ids[0])
cube_context = {
"operation_name": "Face target area integrity probe",
"target_kind": "face",
"target_id": 0,
"target_logical_id": cube.face_region_logical_id(0),
"parameters": {
"resize_strategy": "local-face-area-only-deform",
"part_id": cube_part_id,
"solid_id": cube_solid_id,
"surface": "plane",
"target_area": 144.0,
},
}
_expect_blocked(
"Face target area mismatch",
lambda: probe._verified_edit_quality_warnings(
cube_context,
cube_stats,
cube_stats,
cube.part_topology_stats(cube_part_id),
cube.part_topology_stats(cube_part_id),
before_quality_ok,
after_quality_ok,
after_model=cube,
),
)
edited_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
edited_before_stats = edited_cube.stats()
edited_before_part_stats = edited_cube.part_topology_stats(cube_part_id)
edited_cube.resize_face_area_local(0, 144.0)
_expect_allowed(
"Face target area reached",
lambda: probe._verified_edit_quality_warnings(
cube_context,
edited_before_stats,
edited_cube.stats(),
edited_before_part_stats,
edited_cube.part_topology_stats(cube_part_id),
before_quality_ok,
after_quality_ok,
after_model=edited_cube,
),
)
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_sphere_") as temp_dir:
sphere_path = Path(temp_dir) / "sphere.step"
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path)
sphere = StepModel.load(sphere_path)
sphere_face_id = next(
face_id for face_id in range(len(sphere.faces)) if sphere.face_info(face_id).get("surface") == "sphere"
)
sphere_part_id = int(sphere.face_part_ids[sphere_face_id])
sphere_solid_id = int(sphere.face_solid_ids[sphere_face_id])
sphere_context = {
"operation_name": "Face target sphere radius integrity probe",
"target_kind": "face",
"target_id": sphere_face_id,
"target_logical_id": sphere.face_region_logical_id(sphere_face_id),
"parameters": {
"resize_strategy": "sphere-radius-owning-scale",
"part_id": sphere_part_id,
"solid_id": sphere_solid_id,
"surface": "sphere",
"target_radius": 6.25,
},
}
_expect_blocked(
"Face target sphere radius mismatch",
lambda: probe._verified_edit_quality_warnings(
sphere_context,
sphere.stats(),
sphere.stats(),
sphere.part_topology_stats(sphere_part_id),
sphere.part_topology_stats(sphere_part_id),
before_quality_ok,
after_quality_ok,
after_model=sphere,
),
)
edited_sphere = StepModel.load(sphere_path)
edited_sphere.resize_spherical_radius(sphere_face_id, 6.25)
_expect_allowed(
"Face target sphere radius reached",
lambda: probe._verified_edit_quality_warnings(
sphere_context,
sphere.stats(),
edited_sphere.stats(),
sphere.part_topology_stats(sphere_part_id),
edited_sphere.part_topology_stats(sphere_part_id),
before_quality_ok,
after_quality_ok,
after_model=edited_sphere,
),
)
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_cone_") as temp_dir:
cone_path = Path(temp_dir) / "embedded_countersink.step"
_write_embedded_countersink(cone_path)
cone = StepModel.load(cone_path)
cone_face_id = _first_face_by_surface(cone, "cone")
cone_part_id = int(cone.face_part_ids[cone_face_id])
cone_solid_id = int(cone.face_solid_ids[cone_face_id])
stale_plane_id = 0
if cone.face_info(stale_plane_id).get("surface") == "cone":
raise SystemExit("cone integrity test expected Face 0 to be a stale non-cone candidate")
cone_context = {
"operation_name": "Face target cone boundary integrity probe",
"target_kind": "face",
"target_id": stale_plane_id,
"target_logical_id": stale_plane_id,
"parameters": {
"resize_strategy": "bounded-cone-recut-preserve-angle-reference-radius",
"part_id": cone_part_id,
"solid_id": cone_solid_id,
"surface": "cone",
"target_reference_radius": 3.0,
"embedded_cone_target_small_radius": 3.0,
"embedded_cone_target_large_radius": 6.0,
},
}
_expect_blocked(
"Face target cone boundary mismatch",
lambda: probe._verified_edit_quality_warnings(
cone_context,
cone.stats(),
cone.stats(),
cone.part_topology_stats(cone_part_id),
cone.part_topology_stats(cone_part_id),
before_quality_ok,
after_quality_ok,
after_model=cone,
),
)
edited_cone = StepModel.load(cone_path)
edited_before_stats = edited_cone.stats()
edited_before_part_stats = edited_cone.part_topology_stats(cone_part_id)
edited_cone.resize_conical_reference_radius(cone_face_id, 3.0)
_expect_allowed(
"Face target cone boundary reached despite stale id",
lambda: probe._verified_edit_quality_warnings(
cone_context,
edited_before_stats,
edited_cone.stats(),
edited_before_part_stats,
edited_cone.part_topology_stats(cone_part_id),
before_quality_ok,
after_quality_ok,
after_model=edited_cone,
),
)
_expect_allowed(
"Non-Face edit solid-count change only warns",
lambda: probe._verified_edit_quality_warnings(
_edge_context(),
before,
after_two_solids,
before,
after_two_solids,
before_quality_ok,
after_quality_ok,
),
)
if probe._isolation_for_plan({"risk": "medium"}, "resize_face_area_local", [0, 125.0]) is None:
raise SystemExit("medium-risk Face edits should use isolated execution")
if probe._isolation_for_plan({"risk": "medium"}, "resize_edge_length", [0, 12.0]) is not None:
raise SystemExit("unsupported medium-risk Edge edits should not use Face isolation")
if probe._isolation_for_plan({"risk": "low"}, "resize_face_area_local", [0, 125.0]) is None:
raise SystemExit("low-risk Face parameter edits should still use isolated execution")
if probe._isolation_for_plan({"risk": "high"}, "resize_edge_length", [0, 12.0]) is not None:
raise SystemExit("unsupported high-risk Edge edits should not enter the Face isolation worker")
print("Face edit integrity guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -14,6 +14,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face width, current face only", "face width, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"), ("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
), ),
(
"Face first-level shared-edge topology",
("verify_face_first_level_topology.py",),
),
( (
"face width, owning feature", "face width, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"), ("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"),
@@ -118,6 +122,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Face no-op plans are blocked", "Face no-op plans are blocked",
("verify_face_noop_guards.py",), ("verify_face_noop_guards.py",),
), ),
(
"Face edit integrity guards reject broken results",
("verify_face_edit_integrity_guards.py",),
),
(
"cone semi-angle high-risk edit is isolated and rebuilt analytically",
("verify_cone_semi_angle_isolation.py",),
),
) )
+161
View File
@@ -0,0 +1,161 @@
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
from scripts.verify_property_editor_specs import _PropertySpecProbe, _spec
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
TOLERANCE = 1e-5
def _center(info: dict[str, object]) -> tuple[float, float, float]:
value = info.get("area_center") or info.get("bbox_center")
if not isinstance(value, tuple) or len(value) != 3:
raise SystemExit(f"Face has no stable center: {info}")
return float(value[0]), float(value[1]), float(value[2])
def _top_plane_face(model: StepModel) -> int:
best: tuple[float, int] | None = None
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = _center(info)
if best is None or center[2] > best[0]:
best = (center[2], face_id)
if best is None:
raise SystemExit("No planar Face found.")
return best[1]
def _plane_center_z_values(model: StepModel) -> list[float]:
values: list[float] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
values.append(round(_center(info)[2], 6))
return sorted(values)
def _assert_close(actual: float, expected: float, label: str) -> None:
if abs(float(actual) - float(expected)) > TOLERANCE:
raise SystemExit(f"{label}: expected {expected:g}, got {actual:g}")
def _assert_first_level_topology(model: StepModel, face_id: int) -> dict[str, object]:
topology = model.face_first_level_topology(face_id)
if topology.get("topology_relation_depth") != 1:
raise SystemExit(f"Face topology depth should be 1, got {topology}")
if topology.get("topology_relation_boundary") != "shared-edge":
raise SystemExit(f"Face topology should use shared-edge boundary, got {topology}")
if int(topology.get("same_domain_face_count", 0)) != 1:
raise SystemExit(f"Cube top Face should be a single same-domain region, got {topology}")
if int(topology.get("first_level_boundary_edge_count", 0)) != 4:
raise SystemExit(f"Cube top Face should expose 4 first-level boundary Edges, got {topology}")
if int(topology.get("first_level_boundary_vertex_count", 0)) != 4:
raise SystemExit(f"Cube top Face should expose 4 first-level boundary Vertices, got {topology}")
adjacent = tuple(topology.get("first_level_adjacent_face_ids", ()))
if len(adjacent) != 4:
raise SystemExit(f"Cube top Face should have 4 shared-edge adjacent Faces, got {topology}")
first_level = set(int(item) for item in topology.get("first_level_face_ids", ()))
if len(first_level) != 5 or face_id not in first_level:
raise SystemExit(f"Cube top Face first-level set should contain selected Face + 4 side Faces, got {topology}")
ignored = tuple(topology.get("topology_ignored_relation_depths", ()))
if "second-level" not in ignored or "third-level" not in ignored:
raise SystemExit(f"Face topology should explicitly leave deeper relations for later, got {topology}")
return topology
def _assert_plan_exposes_first_level(plan: dict[str, object], label: str) -> None:
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"{label} should expose topology_relation_depth=1, got {plan}")
if int(plan.get("first_level_adjacent_face_count", 0)) != 4:
raise SystemExit(f"{label} should expose 4 first-level adjacent Faces, got {plan}")
if int(plan.get("first_level_boundary_edge_count", 0)) != 4:
raise SystemExit(f"{label} should expose 4 first-level boundary Edges, got {plan}")
if "二级" not in str(plan.get("topology_ignored_relation_note", "")):
raise SystemExit(f"{label} should explain that second/third-level relations are not propagated yet, got {plan}")
def _assert_selection_exposes_first_level(model: StepModel, face_id: int) -> None:
probe = _PropertySpecProbe()
probe.model = model
probe.selected_face_id = face_id
probe.selected_kind = "feature"
quick_info = model.quick_face_info(face_id)
feature_info = probe._feature_info_for_selected_face(face_id, quick_info)
probe.selected_part_id = int(feature_info.get("part_id", 1))
solid_id = int(feature_info.get("solid_id", -1))
probe.selected_solid_id = solid_id if solid_id >= 0 else None
if int(feature_info.get("topology_relation_depth", 0) or 0) != 1:
raise SystemExit(f"Selected Face feature info should expose first-level topology, got {feature_info}")
if int(feature_info.get("first_level_adjacent_face_count", 0) or 0) != 4:
raise SystemExit(f"Selected Face should expose 4 first-level adjacent Faces, got {feature_info}")
editable_specs, _used = probe._editable_property_specs(feature_info)
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
topology_row = _spec(feature_rows, "face_first_level_topology")
topology_text = str(topology_row.get("current_text") or "")
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
if fragment not in topology_text:
raise SystemExit(f"Selected Face topology row is not clear enough: {topology_row}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
face_id = _top_plane_face(model)
topology = _assert_first_level_topology(model, face_id)
_assert_selection_exposes_first_level(model, face_id)
info = model.face_info(face_id)
current_center = _center(info)
target_center = (current_center[0], current_center[1], current_center[2] + 2.0)
plans = (
("center local", model.face_center_local_move_plan(face_id, target_center)),
("area local", model.face_area_local_resize_plan(face_id, 144.0)),
("width local", model.face_size_local_resize_plan(face_id, 15.0, "width")),
("offset local", model.face_plane_offset_local_plan(face_id, 2.0)),
("push pull", model.push_pull_plan(face_id, 2.0)),
)
for label, plan in plans:
if plan.get("status") == "blocked":
raise SystemExit(f"{label} unexpectedly blocked: {plan}")
_assert_plan_exposes_first_level(plan, label)
before = model.stats()
result = model.move_face_center_local(face_id, target_center)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"Local Face move changed solid count: before={before}, after={after}")
if after.faces != before.faces:
raise SystemExit(f"Cube first-level local move should keep face count stable: before={before}, after={after}")
z_values = _plane_center_z_values(model)
if len(z_values) != 6:
raise SystemExit(f"Expected 6 planar Faces after local move, got z values {z_values}")
_assert_close(z_values[0], 0.0, "Second-level bottom Face center Z")
for index, value in enumerate(z_values[1:5], start=1):
_assert_close(value, 6.0, f"First-level side Face {index} center Z")
_assert_close(z_values[-1], 12.0, "Moved source Face center Z")
print(f"model={DEFAULT_MODEL}")
print(f"face_id={face_id}")
print(f"topology={topology}")
print(f"z_values_after_local_move={z_values}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -130,6 +130,17 @@ def main() -> int:
if not bool(push_pull_mode.get("enabled", False)): if not bool(push_pull_mode.get("enabled", False)):
raise SystemExit("planar cylinder cap push/pull scope should remain available") raise SystemExit("planar cylinder cap push/pull scope should remain available")
huge_push_plan = model.push_pull_plan(face_id, 50.0)
if huge_push_plan.get("status") != "blocked":
raise SystemExit(f"huge planar cylinder cap push/pull should be blocked early: {huge_push_plan}")
old_height = float(huge_push_plan.get("cylindrical_cap_extension_old_height") or 0.0)
new_height = float(huge_push_plan.get("cylindrical_cap_extension_new_height") or 0.0)
if old_height <= 0.0 or new_height <= old_height * 3.0:
raise SystemExit(f"huge cap push/pull should expose the blocked height growth: {huge_push_plan}")
huge_message = str(huge_push_plan.get("message") or "")
if "圆柱高度" not in huge_message and "cylindrical" not in huge_message.lower():
raise SystemExit(f"huge cap push/pull should explain the cylinder-height risk: {huge_push_plan}")
before_height = _bbox_height(model) before_height = _bbox_height(model)
push_plan = model.push_pull_plan(face_id, 1.0) push_plan = model.push_pull_plan(face_id, 1.0)
if push_plan.get("status") == "blocked": if push_plan.get("status") == "blocked":
+2
View File
@@ -281,6 +281,8 @@ def main() -> int:
resolved_strategy = "push-pull-planar-face" resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy: if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}") raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
if "Face result check:" not in result:
raise SystemExit(f"Face edit result message should include a result check, got: {result}")
after = model.stats() after = model.stats()
if after.solids != before.solids: if after.solids != before.solids:
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import math
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
def assert_keys(info: dict[str, object], expected: tuple[str, ...]) -> None:
actual = _feature_dimension_keys(info)
if actual != expected:
raise AssertionError(f"expected {expected}, got {actual} for {info}")
def main() -> int:
assert_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"angular_span": math.tau,
},
("diameter", "hole_depth_estimate"),
)
assert_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"angular_span": math.pi,
},
(
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_total_length_estimate",
),
)
assert_keys(
{
"surface": "cylinder",
"feature_guess": "boss/outer-round candidate",
"angular_span": math.tau,
},
("boss_diameter", "boss_height"),
)
assert_keys(
{
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"angular_span": math.pi / 2.0,
},
("existing_fillet_radius_estimate",),
)
assert_keys(
{"surface": "plane", "shell_region_status": "candidate"},
("shell_thickness_estimate",),
)
assert_keys(
{
"surface": "plane",
"prismatic_profile_status": "candidate",
"prismatic_extrusion_status": "candidate",
},
("local_face_width", "local_face_height", "shell_thickness_estimate"),
)
assert_keys(
{"surface": "torus"},
("torus_major_radius", "torus_minor_radius"),
)
specs = [
{
"key": "area",
"editable": True,
"enabled": True,
"label": "area",
},
{
"key": "diameter",
"editable": True,
"enabled": True,
"label": "diameter",
},
{
"key": "hole_depth_estimate",
"editable": True,
"enabled": False,
"label": "depth",
},
{
"key": "hole_edit_semantics",
"editable": False,
"enabled": False,
"label": "semantics",
},
]
filtered = WindowStateMixin._feature_property_specs(
object(),
specs,
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"angular_span": math.tau,
},
)
filtered_keys = tuple(str(spec.get("key")) for spec in filtered)
if filtered_keys != ("diameter", "hole_edit_semantics"):
raise AssertionError(f"unexpected filtered feature parameters: {filtered_keys}")
print("feature parameter policy ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+157 -1
View File
@@ -6,7 +6,7 @@ import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -73,6 +73,46 @@ def _run_worker_case(
return model return model
def _run_main_worker_entry_case() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_main_worker_entry_") 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(DEFAULT_MODEL),
"output_path": str(output_path),
"operation": "resize_face_area_local",
"args": [0, 144.0],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
completed = subprocess.run(
[sys.executable, "main.py", "--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"main worker entry failed with code {completed.returncode}: {detail}")
response = json.loads(response_path.read_text(encoding="utf-8"))
if not response.get("ok") or not output_path.exists():
raise SystemExit(f"main worker entry did not produce an edited STEP: {response}")
model = StepModel.load(output_path)
_assert_face_area("main worker entry", model, 144.0)
print("main worker entry ok")
def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool: def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
try: try:
return abs(float(value) - target) <= tolerance return abs(float(value) - target) <= tolerance
@@ -80,6 +120,15 @@ def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
return False return False
def _logical_region_has_width(model: StepModel, logical_id: int, target_width: float) -> bool:
for face_id in model.face_ids_for_logical_id(logical_id):
info = model.face_info(face_id)
width = info.get("local_face_width")
if _float_close(width, target_width, tolerance=1e-4):
return True
return False
def _triple_close(value: object, target: tuple[float, float, float], tolerance: float = 1e-5) -> bool: 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: if not isinstance(value, (list, tuple)) or len(value) != 3:
return False return False
@@ -150,6 +199,21 @@ def _write_shell_plate(path: Path) -> None:
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), path) _write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), path)
def _write_sphere_model(path: Path) -> None:
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), path)
def _write_torus_model(path: Path) -> None:
_write_step(BRepPrimAPI_MakeTorus(8.0, 2.0).Shape(), path)
def _first_face_by_surface(model: StepModel, surface: str) -> int:
for face_id in range(len(model.faces)):
if model.face_info(face_id).get("surface") == surface:
return face_id
raise SystemExit(f"no {surface} Face was recognized")
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int: def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
candidates: list[tuple[int, int]] = [] candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
@@ -177,6 +241,35 @@ def _assert_shell_thickness(label: str, model: StepModel, target_thickness: floa
print(f"matched_shell_thickness={thickness:g}, bbox_size={size}") print(f"matched_shell_thickness={thickness:g}, bbox_size={size}")
def _assert_sphere_radius(label: str, model: StepModel, target_radius: float = 10.0) -> None:
matches = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "sphere":
continue
radius = info.get("radius")
if _float_close(radius, target_radius, tolerance=1e-4):
matches.append((face_id, float(radius)))
if not matches:
raise SystemExit(f"{label}: output does not contain a sphere Face radius {target_radius:g}")
print(f"matched_sphere_radii={matches}")
def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float = 4.0) -> None:
matches = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "torus":
continue
major = info.get("major_radius")
minor = info.get("minor_radius")
if _float_close(minor, target_minor, tolerance=1e-4):
matches.append((face_id, float(major), float(minor)))
if not matches:
raise SystemExit(f"{label}: output does not contain a torus Face minor radius {target_minor:g}")
print(f"matched_torus_radii={matches}")
def main() -> int: def main() -> int:
_run_worker_case( _run_worker_case(
label="面偏移(当前面)", label="面偏移(当前面)",
@@ -252,6 +345,39 @@ def main() -> int:
input_path=shell_path, input_path=shell_path,
) )
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_curved_face_verify_") as temp_dir:
temp_root = Path(temp_dir)
sphere_path = temp_root / "sphere.step"
_write_sphere_model(sphere_path)
sphere_probe = StepModel.load(sphere_path)
sphere_face_id = _first_face_by_surface(sphere_probe, "sphere")
sphere_plan = sphere_probe.spherical_radius_plan(sphere_face_id, 10.0)
if str(sphere_plan.get("risk")) != "high":
raise SystemExit(f"sphere radius isolation case should be high risk, got {sphere_plan}")
_run_worker_case(
label="sphere radius",
operation="resize_sphere_radius",
args=[sphere_face_id, 10.0],
validator=_assert_sphere_radius,
input_path=sphere_path,
)
torus_path = temp_root / "torus.step"
_write_torus_model(torus_path)
torus_probe = StepModel.load(torus_path)
torus_face_id = _first_face_by_surface(torus_probe, "torus")
torus_plan = torus_probe.toroidal_radius_plan(torus_face_id, 4.0, "minor")
if str(torus_plan.get("risk")) != "high":
raise SystemExit(f"torus minor-radius isolation case should be high risk, got {torus_plan}")
_run_worker_case(
label="torus minor radius",
operation="resize_torus_radius",
args=[torus_face_id, 4.0, "minor"],
validator=_assert_torus_minor_radius,
input_path=torus_path,
)
from step_editor.window_actions import WindowActionMixin from step_editor.window_actions import WindowActionMixin
class _IsolatedJobProbe(WindowActionMixin): class _IsolatedJobProbe(WindowActionMixin):
@@ -260,6 +386,29 @@ def main() -> int:
self.step_path = DEFAULT_MODEL self.step_path = DEFAULT_MODEL
probe = _IsolatedJobProbe() probe = _IsolatedJobProbe()
if probe._isolated_edit_command(Path("request.json")) != [
sys.executable,
"-m",
"step_editor.isolated_edit_worker",
"request.json",
]:
raise SystemExit("source isolation command should use python -m step_editor.isolated_edit_worker")
had_frozen_attr = hasattr(sys, "frozen")
previous_frozen = getattr(sys, "frozen", None)
try:
setattr(sys, "frozen", True)
if probe._isolated_edit_command(Path("request.json")) != [
sys.executable,
"--isolated-edit-worker",
"request.json",
]:
raise SystemExit("frozen isolation command should call main.exe --isolated-edit-worker")
finally:
if had_frozen_attr:
setattr(sys, "frozen", previous_frozen)
else:
delattr(sys, "frozen")
for title in ( for title in (
"面宽(当前面)", "面宽(当前面)",
"面高(当前面)", "面高(当前面)",
@@ -291,12 +440,15 @@ def main() -> int:
snapshot=snapshot, snapshot=snapshot,
before_stats=probe.model.stats(), before_stats=probe.model.stats(),
before_part_stats=probe.model.part_topology_stats(1), before_part_stats=probe.model.part_topology_stats(1),
before_quality=probe._edit_quality_info_or_none(probe.model, context, 1),
before_geometry={}, before_geometry={},
) )
if "隔离子进程" not in str(result.get("message", "")): if "隔离子进程" not in str(result.get("message", "")):
raise SystemExit(f"isolated window job did not report isolated execution: {result}") raise SystemExit(f"isolated window job did not report isolated execution: {result}")
if probe.model.stats().solids != 1: if probe.model.stats().solids != 1:
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}") raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
if not _logical_region_has_width(probe.model, 0, 25.0):
raise SystemExit("isolated window job did not preserve the original logical Face ID on the edited width")
print("isolated window job ok") print("isolated window job ok")
owning_probe = _IsolatedJobProbe() owning_probe = _IsolatedJobProbe()
@@ -317,12 +469,16 @@ def main() -> int:
snapshot=owning_probe.model.snapshot(), snapshot=owning_probe.model.snapshot(),
before_stats=owning_probe.model.stats(), before_stats=owning_probe.model.stats(),
before_part_stats=owning_probe.model.part_topology_stats(1), before_part_stats=owning_probe.model.part_topology_stats(1),
before_quality=owning_probe._edit_quality_info_or_none(owning_probe.model, context, 1),
before_geometry={}, before_geometry={},
) )
if "隔离子进程" not in str(owning_result.get("message", "")): if "隔离子进程" not in str(owning_result.get("message", "")):
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}") raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0) _assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
if not _logical_region_has_width(owning_probe.model, 0, 25.0):
raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
print("isolated owning window job ok") print("isolated owning window job ok")
_run_main_worker_entry_case()
return 0 return 0
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, 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.window_state import WindowStateMixin, _feature_dimension_keys
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def close_to(value: object, expected: float, tolerance: float = 1e-6) -> bool:
try:
return abs(float(value) - expected) <= tolerance
except (TypeError, ValueError):
return False
def find_feature(model: StepModel, feature_type: str) -> dict[str, object]:
matches = [model.feature_info(face_id) for face_id in range(len(model.faces))]
matches = [info for info in matches if info.get("feature_type") == feature_type]
if not matches:
observed = sorted({str(model.feature_info(face_id).get("feature_type")) for face_id in range(len(model.faces))})
raise AssertionError(f"expected {feature_type}, observed {observed}")
return matches[0]
def assert_prismatic_sizes(
info: dict[str, object], length: float, width: float, depth: float
) -> None:
expected = {
"prismatic_length": length,
"prismatic_width": width,
"prismatic_extrusion_estimate": depth,
}
for key, value in expected.items():
if not close_to(info.get(key), value):
raise AssertionError(f"{key}={info.get(key)!r}, expected {value}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
if len(model.faces) != 6:
raise AssertionError(f"expected 6 cube faces, got {len(model.faces)}")
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("prismatic_profile_status") != "candidate":
raise AssertionError(f"Face {face_id} was not recognized as a rectangular profile")
if info.get("prismatic_extrusion_status") != "candidate":
raise AssertionError(f"Face {face_id} was not recognized as a connected extrusion")
for key in ("prismatic_length", "prismatic_width", "prismatic_extrusion_estimate"):
if not close_to(info.get(key), 10.0):
raise AssertionError(f"Face {face_id} {key}={info.get(key)!r}, expected 10")
if len(tuple(info.get("prismatic_connected_side_face_ids", ()))) != 4:
raise AssertionError(f"Face {face_id} does not have four connected side faces")
info = model.feature_info(0)
expected_keys = ("local_face_width", "local_face_height", "shell_thickness_estimate")
if _feature_dimension_keys(info) != expected_keys:
raise AssertionError(f"unexpected cube feature dimensions: {_feature_dimension_keys(info)}")
state = object.__new__(WindowStateMixin)
state.model = model
state.operation_in_progress = False
state.scan_in_progress = False
state.load_in_progress = False
state.selected_face_id = 0
state.selected_edge_id = None
state.selected_kind = "feature"
state.selected_part_id = int(info["part_id"])
state.selected_solid_id = int(info["solid_id"])
specs, _used = state._editable_property_specs(info)
filtered = state._feature_property_specs(specs, info)
dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"]
actual = tuple(
(
str(spec.get("key")),
str(spec.get("label")),
str(spec.get("current_text")),
)
for spec in dimensions
)
expected = (
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "10"),
("shell_thickness_estimate", "高度/深度", "10"),
)
if actual != expected:
raise AssertionError(f"unexpected prismatic UI parameters: {actual}")
with tempfile.TemporaryDirectory(prefix="step-editor-prismatic-") as temp_dir:
temp_path = Path(temp_dir)
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape()
pocket_path = temp_path / "rectangular-pocket.step"
_write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), pocket_path)
pocket_info = find_feature(StepModel.load(pocket_path), "矩形口袋候选")
assert_prismatic_sizes(pocket_info, 10.0, 8.0, 3.0)
if pocket_info.get("prismatic_reference_source") != "side-wall-topology":
raise AssertionError(f"unexpected pocket reference: {pocket_info.get('prismatic_reference_source')}")
boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape()
boss_path = temp_path / "rectangular-boss.step"
_write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), boss_path)
boss_info = find_feature(StepModel.load(boss_path), "矩形凸台候选")
assert_prismatic_sizes(boss_info, 10.0, 8.0, 3.0)
if boss_info.get("prismatic_reference_source") not in {
"side-wall-topology", "overlapping-plane"
}:
raise AssertionError(f"unexpected boss reference: {boss_info.get('prismatic_reference_source')}")
print("prismatic feature recognition ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80 -1
View File
@@ -36,6 +36,13 @@ def _specs(info: dict[str, object]) -> list[dict[str, object]]:
return specs return specs
def _display_specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
probe.selected_kind = "face"
specs = probe._property_editor_specs(info, info)
return probe._sort_property_specs_for_display(specs)
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]: def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
for spec in specs: for spec in specs:
if spec.get("key") != key: if spec.get("key") != key:
@@ -145,6 +152,33 @@ def _assert_no_generic_face_leak(keys: set[str], label: str) -> None:
raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}") raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}")
def _is_actionable_edit_spec(spec: dict[str, object]) -> bool:
return (
bool(spec.get("editable"))
and bool(spec.get("enabled"))
and bool(spec.get("action"))
and str(spec.get("value_type", "number")) != "command"
)
def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[str, ...], label: str) -> None:
display_specs = _display_specs({**info, "readonly_probe_for_ordering": "readonly"})
seen_non_actionable = False
front_keys: list[str] = []
for spec in display_specs:
key = str(spec.get("key", ""))
if _is_actionable_edit_spec(spec):
if seen_non_actionable:
raise SystemExit(f"{label}: actionable row {key!r} appeared after a read-only/non-action row")
front_keys.append(key)
else:
seen_non_actionable = True
missing = [key for key in expected_keys if key not in front_keys]
if missing:
raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}")
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]: def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离") legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
hits: list[str] = [] hits: list[str] = []
@@ -292,6 +326,14 @@ def main() -> int:
"plane_origin": (0.0, 0.0, 0.0), "plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0), "push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0), "normal": (0.0, 0.0, 1.0),
"topology_relation_depth": 1,
"topology_relation_status": "ready",
"same_domain_face_count": 1,
"first_level_boundary_edge_count": 4,
"first_level_boundary_vertex_count": 4,
"first_level_adjacent_face_count": 4,
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
} }
plane_specs = _specs(plane_info) plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs} plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
@@ -309,7 +351,35 @@ def main() -> int:
_assert_label(plane_specs, "local_face_width", "面宽") _assert_label(plane_specs, "local_face_width", "面宽")
_assert_label(plane_specs, "local_face_height", "面高") _assert_label(plane_specs, "local_face_height", "面高")
_assert_label(plane_specs, "face_target_normal_position", "面偏移") _assert_label(plane_specs, "face_target_normal_position", "面偏移")
_assert_actionable_rows_first(
plane_info,
(
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
),
"plane Face display order",
)
_assert_no_legacy_face_terms(plane_specs) _assert_no_legacy_face_terms(plane_specs)
plane_feature_probe = _PropertySpecProbe()
plane_feature_probe.selected_kind = "feature"
plane_feature_specs, _used = plane_feature_probe._editable_property_specs(plane_info)
plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info)
plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows}
topology_spec = _spec(plane_feature_rows, "face_first_level_topology")
topology_text = str(topology_spec.get("current_text") or "")
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
if fragment not in topology_text:
raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}")
if "face_target_normal_position" not in plane_feature_keys:
raise SystemExit(
"plane feature mode should expose the current Face offset parameter; "
f"got {sorted(plane_feature_keys)}"
)
if "no_editable_feature_dimensions" in plane_feature_keys:
raise SystemExit("plane feature mode should not fall back to no editable dimensions")
_assert_hard_range(plane_specs, "area", 0.25, 2500.0) _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_width", 0.5, 50.0)
_assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0) _assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0)
@@ -526,9 +596,18 @@ def main() -> int:
), ),
) )
for label, info, required in analytic_cases: for label, info, required in analytic_cases:
keys = _spec_keys(info) specs = _specs(info)
keys = {str(spec.get("key", "")) for spec in specs}
_assert_no_generic_face_leak(keys, label) _assert_no_generic_face_leak(keys, label)
_assert_contains(keys, required, label) _assert_contains(keys, required, label)
if label == "cone feature":
_assert_label(specs, "cone_reference_radius", "参考半径")
_assert_label(specs, "cone_reference_diameter", "参考直径")
_assert_label(specs, "cone_semi_angle_degrees", "圆锥半角")
display_specs = _display_specs(info)
surface_spec = _spec(display_specs, "surface")
if surface_spec.get("current_text") != "圆锥面 / 拔模面":
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}")
_assert_target_change_detection() _assert_target_change_detection()
_assert_holed_plane_local_scopes_disabled() _assert_holed_plane_local_scopes_disabled()
+130 -29
View File
@@ -8,7 +8,11 @@ import sys
from pathlib import Path from pathlib import Path
import vtk import vtk
import vtkmodules.vtkInteractionStyle # noqa: F401
import vtkmodules.vtkRenderingFreeType # noqa: F401
import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
@@ -54,7 +58,19 @@ from .window_state import WindowStateMixin
_CRASH_LOG_HANDLE = None _CRASH_LOG_HANDLE = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" APP_USER_MODEL_ID = "GeometryParametric.StepEditor"
ISOLATED_EDIT_WORKER_ARG = "--isolated-edit-worker"
def _resource_path(*relative_parts: str) -> Path:
frozen_root = getattr(sys, "_MEIPASS", None)
if frozen_root:
return Path(frozen_root).joinpath(*relative_parts)
return PROJECT_ROOT.joinpath(*relative_parts)
APP_ICON_PATH = _resource_path("assets", "ico", "logo_new.ico")
DEFAULT_MODEL_PATH = _resource_path("assets", "models", "geom_extract.step")
# Panel build switches. These work like a Python-side "#if 0": code in a # Panel build switches. These work like a Python-side "#if 0": code in a
# disabled branch is kept for later, but the widgets are not constructed. # disabled branch is kept for later, but the widgets are not constructed.
@@ -85,13 +101,41 @@ def _crash_log_requested(argv: list[str]) -> bool:
return os.environ.get("STEP_EDITOR_CRASH_LOG", "").strip().lower() in {"1", "true", "yes", "on"} return os.environ.get("STEP_EDITOR_CRASH_LOG", "").strip().lower() in {"1", "true", "yes", "on"}
def _application_icon() -> QIcon:
icon = QIcon(str(APP_ICON_PATH))
if icon.isNull():
icon = QIcon(str(PROJECT_ROOT / "assets" / "ico" / "logo_new.ico"))
return icon
def _set_windows_taskbar_identity() -> None:
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID)
except Exception:
pass
def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
if ISOLATED_EDIT_WORKER_ARG not in argv:
return None
index = argv.index(ISOLATED_EDIT_WORKER_ARG)
if index + 1 >= len(argv):
raise ValueError(f"{ISOLATED_EDIT_WORKER_ARG} requires a request JSON path.")
return Path(argv[index + 1])
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow): class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object) ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = True): def __init__(self, step_path: str | Path, *, background_load: bool = False):
super().__init__() super().__init__()
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection) self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
self.setWindowTitle("几何参数化") self.setWindowTitle("几何参数化")
self.setWindowIcon(_application_icon())
self.resize(1280, 820) self.resize(1280, 820)
self.model: StepModel | None = None self.model: StepModel | None = None
@@ -110,11 +154,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None self.hover_face_actor = None
self.hover_edge_actor = None self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None self.hover_signature: tuple[str, int] | None = None
self.hover_interval_ms = 140 self.hover_interval_ms = 260
self.hover_move_threshold_px = 8 self.hover_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None self.pending_hover_position: tuple[int, int] | None = None
self.last_hover_pick_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None
self.camera_interaction_active = False self.camera_interaction_active = False
self.last_camera_interaction_ended_at: datetime | None = None
self.hover_after_camera_cooldown_ms = 420
self.pointer_button_down = False self.pointer_button_down = False
self.left_button_press_position: tuple[int, int] | None = None self.left_button_press_position: tuple[int, int] | None = None
self.left_button_dragged = False self.left_button_dragged = False
@@ -180,9 +226,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.load_refine_thread: QThread | None = None self.load_refine_thread: QThread | None = None
self.load_refine_worker: LoadWorker | None = None self.load_refine_worker: LoadWorker | None = None
self.pending_load_path: Path | None = None self.pending_load_path: Path | None = None
self.initial_load_deflection = 2.0 self.auto_load_on_show = bool(background_load)
self.edit_result_deflection = 1.6 self.vtk_interactor_started = False
self.last_id_kind = "Face" self.first_show_handled = False
self.preview_load_deflection = 1.0
self.initial_load_deflection = 0.035
self.edit_result_deflection = 0.035
self.last_id_kind = "Feature"
self.feature_detection_level = "current-only"
self.property_editor_updating = False self.property_editor_updating = False
self.property_editor_specs: list[dict[str, object]] = [] self.property_editor_specs: list[dict[str, object]] = []
self.property_table_expanded = False self.property_table_expanded = False
@@ -190,7 +241,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self._build_ui() self._build_ui()
self._build_vtk() self._build_vtk()
self.load_step(self.step_path, background=background_load)
def _build_ui(self) -> None: def _build_ui(self) -> None:
help_tip = self._set_help_tip help_tip = self._set_help_tip
@@ -303,7 +353,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
min-width: 8px; min-width: 8px;
} }
QLabel#mouseModeLabel, QLabel#mouseModeLabel,
QLabel#idSelectLabel { QLabel#idSelectLabel,
QLabel#featureDetectionLabel {
color: #4736b3; color: #4736b3;
font-weight: 700; font-weight: 700;
} }
@@ -681,9 +732,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mouse_mode_label.setMaximumWidth(62) self.mouse_mode_label.setMaximumWidth(62)
help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。") help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。")
self.mode_combo = NoWheelComboBox() self.mode_combo = NoWheelComboBox()
for mode in ("Part", "Solid", "Face", "Edge", "Feature"): for mode in ("Feature", "Face", "Edge", "Solid", "Part"):
self.mode_combo.addItem(_selection_mode_label(mode), mode) self.mode_combo.addItem(_selection_mode_label(mode), mode)
self._set_selection_mode("Face") self._set_selection_mode("Feature")
self.mode_combo.setMinimumWidth(94) self.mode_combo.setMinimumWidth(94)
self.mode_combo.setMaximumWidth(112) self.mode_combo.setMaximumWidth(112)
help_tip( help_tip(
@@ -703,8 +754,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_id_separator = mode_separator self.mode_id_separator = mode_separator
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。") help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。")
mode_layout.addWidget(mode_separator) mode_layout.addWidget(mode_separator)
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是特征探测级别。")
detection_panel = QWidget()
detection_layout = QHBoxLayout(detection_panel)
detection_layout.setContentsMargins(8, 10, 8, 6)
detection_layout.setSpacing(6)
self.feature_detection_label = QLabel("特征探测级别")
self.feature_detection_label.setObjectName("featureDetectionLabel")
self.feature_detection_label.setMinimumWidth(88)
help_tip(self.feature_detection_label, "控制点击特征时探测多深。默认只探测当前特征的一层局部关联。")
self.feature_detection_combo = NoWheelComboBox()
self.feature_detection_combo.addItem("只识别当前特征", "current-only")
self.feature_detection_combo.addItem("探测相邻特征", "associated-only")
self.feature_detection_combo.addItem("探测二级特征", "secondary")
self.feature_detection_combo.setCurrentIndex(0)
self.feature_detection_combo.setMinimumWidth(128)
help_tip(
self.feature_detection_combo,
"默认只读取当前点击的特征,点击最流畅;需要查找周边孔、槽、凸台等关联时,再切换到相邻或二级探测。",
)
self.feature_detection_combo.currentIndexChanged.connect(
lambda _index: self._on_feature_detection_level_changed()
)
detection_layout.addWidget(self.feature_detection_label)
detection_layout.addWidget(self.feature_detection_combo, stretch=1)
mode_layout.addWidget(detection_panel, stretch=1)
id_select_panel = QWidget() id_select_panel = QWidget()
id_select_panel.setVisible(False)
select_layout = QHBoxLayout(id_select_panel) select_layout = QHBoxLayout(id_select_panel)
select_layout.setContentsMargins(8, 10, 8, 6) select_layout.setContentsMargins(8, 10, 8, 6)
select_layout.setSpacing(6) select_layout.setSpacing(6)
@@ -723,6 +801,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self._current_selection_mode())) self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self._current_selection_mode()))
self.id_input.textChanged.connect(lambda _text: self._update_action_states()) self.id_input.textChanged.connect(lambda _text: self._update_action_states())
self.id_input.returnPressed.connect(lambda: self.select_by_id(self._current_selection_mode())) self.id_input.returnPressed.connect(lambda: self.select_by_id(self._current_selection_mode()))
self.id_select_label.setVisible(False)
self.id_input.setVisible(False)
self.select_id_button.setVisible(False)
select_layout.addWidget(self.id_select_label) select_layout.addWidget(self.id_select_label)
select_layout.addWidget(self.id_input) select_layout.addWidget(self.id_input)
select_layout.addWidget(self.select_id_button) select_layout.addWidget(self.select_id_button)
@@ -828,18 +909,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_layout.addWidget(self.repair_model_button) export_layout.addWidget(self.repair_model_button)
export_layout.addWidget(self.repair_selected_button) export_layout.addWidget(self.repair_selected_button)
self.object_edit_box = QGroupBox("当前选中对象:未选择") self.object_edit_box = QGroupBox("特征参数:未选择")
self.object_edit_box.setObjectName("editSection") self.object_edit_box.setObjectName("editSection")
help_tip( help_tip(
self.object_edit_box, self.object_edit_box,
"选择模型对象后,这里列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读", "选择特征后,这里列出可可靠修改的语义尺寸;面积、中心和拓扑数据位于下方诊断信息",
) )
object_edit_layout = QVBoxLayout(self.object_edit_box) object_edit_layout = QVBoxLayout(self.object_edit_box)
object_edit_layout.setContentsMargins(0, 8, 0, 4) object_edit_layout.setContentsMargins(0, 8, 0, 4)
object_edit_layout.setSpacing(4) object_edit_layout.setSpacing(4)
self.property_table = QTableWidget(0, 5) self.property_table = QTableWidget(0, 5)
self.property_table.setObjectName("propertyTable") self.property_table.setObjectName("propertyTable")
self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "影响范围", "目标值", "操作"]) self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "修改方式", "目标值", "操作"])
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True) self.property_table.setAlternatingRowColors(True)
@@ -859,20 +940,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 82) self.property_table.setColumnWidth(0, 148)
self.property_table.setColumnWidth(1, 78) self.property_table.setColumnWidth(1, 68)
self.property_table.setColumnWidth(2, 104) self.property_table.setColumnWidth(2, 96)
self.property_table.setColumnWidth(3, 82) self.property_table.setColumnWidth(3, 72)
self.property_table.setColumnWidth(4, 58) self.property_table.setColumnWidth(4, 54)
help_tip( help_tip(
self.property_table, self.property_table,
"当前选中对象的属性表。可修改的行可以选择影响范围、输入目标值,并点击操作列里的按钮应用这一行", "特征模式显示当前特征及局部关联特征的可变尺寸;输入目标值后,可按行应用或使用下方按钮应用当前修改",
) )
self.property_table.itemChanged.connect(self._on_property_table_item_changed) self.property_table.itemChanged.connect(self._on_property_table_item_changed)
object_edit_layout.addWidget(self.property_table) object_edit_layout.addWidget(self.property_table)
self.property_expand_button = QPushButton("展开全部属性") self.property_expand_button = QPushButton("展开全部参数")
self.property_expand_button.setObjectName("propertyExpandBar") self.property_expand_button.setObjectName("propertyExpandBar")
help_tip(self.property_expand_button, "当前选中对象属性较多时默认只显示前 6 行;点击这里展开或收起属性表。") help_tip(self.property_expand_button, "参数较多时默认只显示前 6 行;点击这里展开或收起完整参数列表。")
self.property_expand_button.clicked.connect(self.toggle_property_table_expanded) self.property_expand_button.clicked.connect(self.toggle_property_table_expanded)
self.property_expand_button.setVisible(False) self.property_expand_button.setVisible(False)
self.property_expand_button.setMaximumHeight(22) self.property_expand_button.setMaximumHeight(22)
@@ -1326,10 +1407,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
history_layout.addLayout(history_buttons) history_layout.addLayout(history_buttons)
panel_layout.addWidget(history_box) panel_layout.addWidget(history_box)
info_box = QGroupBox("选中对象信息") panel_layout.addStretch(1)
info_box = QGroupBox("诊断信息(高级)")
info_box.setObjectName("infoSection") info_box.setObjectName("infoSection")
help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。") info_box.setCheckable(True)
info_box.setChecked(False)
help_tip(info_box, "展开查看面积、中心、Face ID、拓扑关系和识别依据;这些内容不属于主特征尺寸。")
info_layout = QVBoxLayout(info_box) info_layout = QVBoxLayout(info_box)
self.diagnostic_info_content = QWidget()
diagnostic_info_layout = QVBoxLayout(self.diagnostic_info_content)
diagnostic_info_layout.setContentsMargins(0, 0, 0, 0)
info_buttons = QHBoxLayout() info_buttons = QHBoxLayout()
self.copy_id_button = QPushButton("复制 ID") self.copy_id_button = QPushButton("复制 ID")
help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/特征会优先复制逻辑 Face ID。") help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/特征会优先复制逻辑 Face ID。")
@@ -1343,7 +1431,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
info_buttons.addWidget(self.copy_id_button) info_buttons.addWidget(self.copy_id_button)
info_buttons.addWidget(self.copy_pick_button) info_buttons.addWidget(self.copy_pick_button)
info_buttons.addWidget(self.copy_info_button) info_buttons.addWidget(self.copy_info_button)
info_layout.addLayout(info_buttons) diagnostic_info_layout.addLayout(info_buttons)
self.info_tabs = QTabWidget() self.info_tabs = QTabWidget()
self.info_tree = QTreeWidget() self.info_tree = QTreeWidget()
@@ -1358,8 +1446,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.info_tabs.addTab(self.info_tree, "属性表") self.info_tabs.addTab(self.info_tree, "属性表")
self.info_tabs.addTab(self.info_text, "原始文本") self.info_tabs.addTab(self.info_text, "原始文本")
help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。") help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。")
info_layout.addWidget(self.info_tabs) diagnostic_info_layout.addWidget(self.info_tabs)
panel_layout.addWidget(info_box, stretch=1) info_layout.addWidget(self.diagnostic_info_content)
info_box.toggled.connect(self._on_diagnostic_info_toggled)
self.diagnostic_info_content.setVisible(False)
panel_layout.addWidget(info_box)
self._update_action_states() self._update_action_states()
@@ -1368,7 +1459,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.vtk_widget.installEventFilter(self) self.vtk_widget.installEventFilter(self)
root_layout.addWidget(self.vtk_widget, stretch=1) root_layout.addWidget(self.vtk_widget, stretch=1)
self.statusBar().showMessage("Ready") self.statusBar().showMessage("请选择 STEP 文件,或点击“读取模型”加载到可视化区域。")
def _set_help_tip(self, widget, text: str) -> None: def _set_help_tip(self, widget, text: str) -> None:
widget.setToolTip(text) widget.setToolTip(text)
@@ -1388,10 +1479,20 @@ def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def main() -> int: def main() -> int:
worker_request = _isolated_edit_worker_request(sys.argv)
if worker_request is not None:
from .isolated_edit_worker import run_request
return run_request(worker_request)
if _crash_log_requested(sys.argv): if _crash_log_requested(sys.argv):
_enable_crash_log() _enable_crash_log()
path, smoke_test = _parse_args(sys.argv) path, smoke_test = _parse_args(sys.argv)
_set_windows_taskbar_identity()
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("几何参数化")
app.setApplicationDisplayName("几何参数化")
app.setOrganizationName("GeometryParametric")
app.setWindowIcon(_application_icon())
window = StepEditorWindow(path, background_load=not smoke_test) window = StepEditorWindow(path, background_load=not smoke_test)
if smoke_test: if smoke_test:
print("smoke test ok") print("smoke test ok")
@@ -1399,7 +1500,7 @@ def main() -> int:
app.quit() app.quit()
return 0 return 0
window.show() window.show()
window.vtk_widget.Start() window._ensure_vtk_interactor_started()
return app.exec() return app.exec()
+252
View File
@@ -64,6 +64,185 @@ from .geometry_utils import * # noqa: F403
class FeatureMixin: class FeatureMixin:
def _cylindrical_feature_first_level_plan_fields(self, face_id: int) -> dict[str, object]:
try:
topology = self.cylindrical_feature_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"first_level_boundary_edge_ids": (),
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_ids": (),
"first_level_adjacent_face_count": 0,
"cylindrical_feature_side_face_ids": (face_id,),
"cylindrical_feature_side_face_count": 1,
}
fields = {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"first_level_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("cylindrical_feature_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"first_level_adjacent_surface_types": topology.get("cylindrical_feature_adjacent_surface_types", ()),
"first_level_shared_edges_by_face": topology.get("cylindrical_feature_shared_edges_by_face", ()),
"first_level_face_ids": topology.get("cylindrical_feature_first_level_face_ids", ()),
"first_level_face_count": topology.get("cylindrical_feature_first_level_face_count", 0),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
"cylindrical_feature_side_face_ids": topology.get("cylindrical_feature_side_face_ids", (face_id,)),
"cylindrical_feature_side_face_count": topology.get("cylindrical_feature_side_face_count", 1),
"cylindrical_feature_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"cylindrical_feature_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"cylindrical_feature_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"cylindrical_feature_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"cylindrical_feature_end_face_ids": topology.get("cylindrical_feature_end_face_ids", ()),
"cylindrical_feature_end_face_count": topology.get("cylindrical_feature_end_face_count", 0),
"cylindrical_feature_bottom_face_ids": topology.get("cylindrical_feature_bottom_face_ids", ()),
"cylindrical_feature_bottom_face_count": topology.get("cylindrical_feature_bottom_face_count", 0),
"cylindrical_feature_opening_face_ids": topology.get("cylindrical_feature_opening_face_ids", ()),
"cylindrical_feature_opening_face_count": topology.get("cylindrical_feature_opening_face_count", 0),
"cylindrical_feature_slot_boundary_face_ids": topology.get(
"cylindrical_feature_slot_boundary_face_ids",
(),
),
"cylindrical_feature_slot_boundary_face_count": topology.get(
"cylindrical_feature_slot_boundary_face_count",
0,
),
}
fields["first_level_edit_semantics"] = (
"Cylindrical feature edits currently use only first-level shared-edge topology: "
"the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; "
"second-level and deeper propagation is not automatic yet."
)
return fields
def _cylindrical_first_level_guard_fields(
self,
topology_fields: dict[str, object],
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
blockers: list[str] = []
warnings: list[str] = []
risk = "low"
status = str(topology_fields.get("topology_relation_status") or "")
if status != "ready":
blockers.append(
"当前孔/槽的一级关系拓扑无法确认,已阻止局部重建;请换一个更明确的孔壁/槽壁 Face。"
)
side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0)
edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0)
vertex_count = int(topology_fields.get("first_level_boundary_vertex_count", 0) or 0)
adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0)
slot_boundary_count = int(topology_fields.get("cylindrical_feature_slot_boundary_face_count", 0) or 0)
bottom_count = int(topology_fields.get("cylindrical_feature_bottom_face_count", 0) or 0)
if side_count <= 0:
blockers.append("没有识别到当前孔/槽的圆柱侧壁区域,不能稳定局部修改。")
if edge_count <= 0:
blockers.append("没有识别到当前孔/槽侧壁的边界 Edge,不能确定一级联动范围。")
if adjacent_count <= 0:
blockers.append("没有识别到与当前孔/槽直接共边的相邻 Face,不能保证修改后拓扑闭合。")
if require_slot_boundary and slot_boundary_count <= 0:
blockers.append("当前槽/半孔缺少直接槽边界 Face,不能稳定执行槽的局部重建。")
if require_bottom and bottom_count <= 0:
blockers.append("当前盲孔/盲槽缺少可靠底面 Face,不能稳定执行局部深度修改。")
if not blockers:
if side_count > 4 or edge_count > 20 or adjacent_count > 14:
risk = _max_risk(risk, "high")
warnings.append(
"当前孔/槽的一级关系邻域较复杂,局部布尔重建可能影响多个直接相邻面。"
)
elif side_count > 1 or edge_count > 10 or adjacent_count > 8:
risk = _max_risk(risk, "medium")
warnings.append("当前孔/槽由多个侧壁或较多相邻面组成,修改后请重点检查一级邻域。")
note = (
f"一级关系拓扑检查:侧壁 Face {side_count} 个,边界 Edge {edge_count} 条,"
f"边界 Vertex {vertex_count} 个,直接相邻 Face {adjacent_count} 个。"
)
return {
"first_level_topology_status": "blocked" if blockers else "ready",
"first_level_topology_risk": "blocked" if blockers else risk,
"first_level_topology_blockers": "".join(blockers),
"first_level_topology_warnings": "".join(warnings),
"first_level_topology_guard_note": note,
}
def _apply_cylindrical_first_level_guard(
self,
topology_fields: dict[str, object],
blockers: list[str],
warnings: list[str],
risk: str,
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> str:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
blockers.append(blocker_text)
if warning_text:
warnings.append(warning_text)
return _max_risk(risk, str(guard.get("first_level_topology_risk") or "low"))
def _apply_cylindrical_first_level_guard_to_readiness(
self,
readiness: dict[str, object],
topology_fields: dict[str, object],
*,
status_key: str,
risk_key: str,
note_key: str,
warnings_key: str,
blockers_key: str,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
result = dict(readiness)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
result[status_key] = "blocked"
result[risk_key] = "blocked"
result[blockers_key] = _join_nonempty(result.get(blockers_key), blocker_text)
result[note_key] = _join_nonempty(result.get(note_key), blocker_text)
else:
result[risk_key] = _max_risk(str(result.get(risk_key) or "low"), str(guard["first_level_topology_risk"]))
if warning_text:
result[warnings_key] = _join_nonempty(result.get(warnings_key), warning_text)
result[note_key] = _join_nonempty(result.get(note_key), warning_text)
return result
def editable_feature_candidates( def editable_feature_candidates(
self, self,
limit: int = 160, limit: int = 160,
@@ -727,6 +906,7 @@ class FeatureMixin:
} }
current_diameter = float(info["diameter"]) current_diameter = float(info["diameter"])
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range( axis_range = self._cylindrical_axis_range(
face_id, face_id,
BRepAdaptor_Surface(self.faces[face_id]), BRepAdaptor_Surface(self.faces[face_id]),
@@ -737,6 +917,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter) readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="resize_status",
risk_key="resize_risk",
note_key="resize_note",
warnings_key="resize_warnings",
blockers_key="resize_blockers",
)
resize_mode = _resize_mode(current_diameter, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter)
delta_diameter = new_diameter - current_diameter delta_diameter = new_diameter - current_diameter
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
@@ -781,6 +970,7 @@ class FeatureMixin:
"resize_strategy": "same-axis-bounded-cylinder-recut", "resize_strategy": "same-axis-bounded-cylinder-recut",
"edit_strategy_label": "同轴圆柱重切", "edit_strategy_label": "同轴圆柱重切",
"edit_semantics": edit_semantics, "edit_semantics": edit_semantics,
**topology_fields,
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
} }
@@ -799,12 +989,14 @@ class FeatureMixin:
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
except Exception: except Exception:
feature = {} feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [ warnings: list[str] = [
"Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis." "Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis."
] ]
risk = "medium" risk = "medium"
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
try: try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -897,6 +1089,7 @@ class FeatureMixin:
return { return {
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
**topology_fields,
"status": status, "status": status,
"risk": risk, "risk": risk,
"message": message, "message": message,
@@ -965,6 +1158,7 @@ class FeatureMixin:
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
except Exception: except Exception:
feature = {} feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter")) current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None: if current_diameter is None:
@@ -982,6 +1176,13 @@ class FeatureMixin:
"Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume." "Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume."
] ]
risk = "medium" risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try: try:
target_value = float(target_value) target_value = float(target_value)
@@ -1067,6 +1268,7 @@ class FeatureMixin:
"feature_slot_face_ids": feature.get("feature_slot_face_ids"), "feature_slot_face_ids": feature.get("feature_slot_face_ids"),
"feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"), "feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"),
"slot_note": feature.get("slot_note"), "slot_note": feature.get("slot_note"),
**topology_fields,
} }
if blockers: if blockers:
return { return {
@@ -1141,6 +1343,7 @@ class FeatureMixin:
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
except Exception: except Exception:
feature = {} feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter")) current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None: if current_diameter is None:
@@ -1153,6 +1356,13 @@ class FeatureMixin:
"Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center." "Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center."
] ]
risk = "medium" risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try: try:
target_angular_span = float(target_angular_span) target_angular_span = float(target_angular_span)
@@ -1241,6 +1451,7 @@ class FeatureMixin:
return { return {
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
**topology_fields,
"status": status, "status": status,
"risk": "blocked" if blockers else risk, "risk": "blocked" if blockers else risk,
"message": message, "message": message,
@@ -1303,12 +1514,20 @@ class FeatureMixin:
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
except Exception: except Exception:
feature = {} feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [ warnings: list[str] = [
"Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis." "Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis."
] ]
risk = "medium" risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try: try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -1473,6 +1692,7 @@ class FeatureMixin:
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
**pair_plan, **pair_plan,
**topology_fields,
"status": status, "status": status,
"risk": "blocked" if blockers else risk, "risk": "blocked" if blockers else risk,
"message": message, "message": message,
@@ -1723,6 +1943,7 @@ class FeatureMixin:
raise ValueError(f"Unknown face id {face_id}") raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id) info = self.face_info(face_id)
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter")) current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None or current_diameter <= 1e-9: if current_diameter is None or current_diameter <= 1e-9:
return { return {
@@ -1749,6 +1970,13 @@ class FeatureMixin:
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
risk = "medium" risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
if not paired_plan: if not paired_plan:
if pair_face_id is None: if pair_face_id is None:
@@ -1815,6 +2043,7 @@ class FeatureMixin:
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
**paired_plan, **paired_plan,
**topology_fields,
"status": status, "status": status,
"risk": risk, "risk": risk,
"message": message, "message": message,
@@ -1918,6 +2147,7 @@ class FeatureMixin:
current_diameter = float(info["diameter"]) current_diameter = float(info["diameter"])
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range( axis_range = self._cylindrical_axis_range(
face_id, face_id,
BRepAdaptor_Surface(self.faces[face_id]), BRepAdaptor_Surface(self.faces[face_id]),
@@ -2130,6 +2360,7 @@ class FeatureMixin:
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。", "message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
} }
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range( axis_range = self._cylindrical_axis_range(
face_id, face_id,
BRepAdaptor_Surface(self.faces[face_id]), BRepAdaptor_Surface(self.faces[face_id]),
@@ -2140,6 +2371,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_suppress_readiness(scoped_info) readiness = _cylinder_suppress_readiness(scoped_info)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="suppress_status",
risk_key="suppress_risk",
note_key="suppress_note",
warnings_key="suppress_warnings",
blockers_key="suppress_blockers",
)
fill_plan = self._bounded_cylinder_fill_plan(face_id) fill_plan = self._bounded_cylinder_fill_plan(face_id)
fill_plan["fill_strategy"] = "bounded-hole-suppress-fill" fill_plan["fill_strategy"] = "bounded-hole-suppress-fill"
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。" fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
@@ -2169,6 +2409,7 @@ class FeatureMixin:
"feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"), "feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "fill-cylindrical-hole-volume", "resize_strategy": "fill-cylindrical-hole-volume",
**topology_fields,
"edit_strategy_label": "圆柱补料封堵", "edit_strategy_label": "圆柱补料封堵",
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。", "edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
**fill_plan, **fill_plan,
@@ -2191,6 +2432,7 @@ class FeatureMixin:
} }
feature = self.feature_info(face_id) feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
context = self._blind_cylindrical_depth_context( context = self._blind_cylindrical_depth_context(
face_id, face_id,
info, info,
@@ -2212,6 +2454,15 @@ class FeatureMixin:
context.get("context_message"), context.get("context_message"),
) )
readiness["depth_note"] = readiness["depth_blockers"] readiness["depth_note"] = readiness["depth_blockers"]
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="depth_status",
risk_key="depth_risk",
note_key="depth_note",
warnings_key="depth_warnings",
blockers_key="depth_blockers",
)
current_depth = float(depth_info.get("hole_depth_estimate", 0.0)) current_depth = float(depth_info.get("hole_depth_estimate", 0.0))
delta_depth = target_depth - current_depth delta_depth = target_depth - current_depth
@@ -2249,6 +2500,7 @@ class FeatureMixin:
"feature_bottom_detection": feature.get("feature_bottom_detection"), "feature_bottom_detection": feature.get("feature_bottom_detection"),
"feature_bottom_note": feature.get("feature_bottom_note"), "feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "bounded-blind-depth-cut-or-fill", "resize_strategy": "bounded-blind-depth-cut-or-fill",
**topology_fields,
"edit_strategy_label": "盲孔/盲槽深度切削或补料", "edit_strategy_label": "盲孔/盲槽深度切削或补料",
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。", "edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
} }
+21 -3
View File
@@ -25,8 +25,17 @@ class InfoPanelMixin:
self.current_info_values = dict(info) self.current_info_values = dict(info)
self.current_info_text = _info_to_text(info) self.current_info_text = _info_to_text(info)
self.info_text.setPlainText(self.current_info_text) self.info_text.setPlainText(self.current_info_text)
self._populate_info_tree(info) diagnostic_content = getattr(self, "diagnostic_info_content", None)
self.info_tabs.setCurrentWidget(self.info_tree) diagnostics_visible = bool(
diagnostic_content is None
or not hasattr(diagnostic_content, "isVisible")
or diagnostic_content.isVisible()
)
if diagnostics_visible:
self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree)
else:
self.info_tree.clear()
if hasattr(self, "_refresh_property_editor"): if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor() self._refresh_property_editor()
if hasattr(self, "_update_selected_object_title"): if hasattr(self, "_update_selected_object_title"):
@@ -45,6 +54,15 @@ class InfoPanelMixin:
if hasattr(self, "mode_combo"): if hasattr(self, "mode_combo"):
self._update_action_states() self._update_action_states()
def _on_diagnostic_info_toggled(self, checked: bool) -> None:
if hasattr(self, "diagnostic_info_content"):
self.diagnostic_info_content.setVisible(bool(checked))
if checked and self.current_info_values:
self._populate_info_tree(self.current_info_values)
self.info_tabs.setCurrentWidget(self.info_tree)
elif not checked and hasattr(self, "info_tree"):
self.info_tree.clear()
def _populate_info_tree(self, info: dict[str, object]) -> None: def _populate_info_tree(self, info: dict[str, object]) -> None:
self.info_tree.clear() self.info_tree.clear()
emitted: set[str] = set() emitted: set[str] = set()
@@ -67,7 +85,7 @@ class InfoPanelMixin:
group.setFirstColumnSpanned(True) group.setFirstColumnSpanned(True)
self.info_tree.addTopLevelItem(group) self.info_tree.addTopLevelItem(group)
for key, value in items: for key, value in items:
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)]) child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_info_value(key, value)])
child.setData(0, Qt.UserRole, key) child.setData(0, Qt.UserRole, key)
child.setToolTip(0, key) child.setToolTip(0, key)
child.setToolTip(1, _format_value(value)) child.setToolTip(1, _format_value(value))
+17 -6
View File
@@ -33,15 +33,19 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_shell_thickness(int(args[0]), float(args[1])) return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale": if operation == "resize_shell_thickness_owning_scale":
return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1])) return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_cone_reference_radius":
return model.resize_conical_reference_radius(int(args[0]), float(args[1]))
if operation == "resize_cone_semi_angle":
return model.resize_conical_semi_angle(int(args[0]), float(args[1]))
if operation == "resize_sphere_radius":
return model.resize_spherical_radius(int(args[0]), float(args[1]))
if operation == "resize_torus_radius":
return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2]))
raise ValueError(f"Unsupported isolated edit operation: {operation}") raise ValueError(f"Unsupported isolated edit operation: {operation}")
def main() -> int: def run_request(request: str | Path) -> int:
parser = argparse.ArgumentParser(description="Run one high-risk geometry edit in an isolated process.") request_path = Path(request)
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") response_path = request_path.with_suffix(".response.json")
try: try:
request = json.loads(request_path.read_text(encoding="utf-8-sig")) request = json.loads(request_path.read_text(encoding="utf-8-sig"))
@@ -83,5 +87,12 @@ def main() -> int:
return 2 return 2
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()
return run_request(parsed.request)
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+702 -19
View File
@@ -63,6 +63,7 @@ from OCC.Core.TopAbs import (
TopAbs_OUT, TopAbs_OUT,
TopAbs_REVERSED, TopAbs_REVERSED,
TopAbs_SOLID, TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE, TopAbs_WIRE,
) )
from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopExp import TopExp_Explorer, topexp
@@ -110,6 +111,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache: dict[int, list[int]] = {} self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {} self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {} self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {} self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -191,6 +194,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache.clear() self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear() self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear() self._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None self._same_domain_internal_edge_ids_cache = None
@@ -283,8 +288,24 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_info_cache.clear() self._face_info_cache.clear()
self._feature_info_cache.clear() self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear() self._local_face_deform_readiness_cache.clear()
def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool:
previous = tuple(int(item) for item in logical_ids)
if len(previous) != len(self.faces):
return False
self.face_logical_ids = list(previous)
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear()
return True
def quick_face_info(self, face_id: int) -> dict[str, object]: def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces): if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}") raise ValueError(f"Unknown face id {face_id}")
@@ -323,16 +344,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if surface_type == GeomAbs_Plane: if surface_type == GeomAbs_Plane:
plane = surf.Plane() plane = surf.Plane()
direction = plane.Axis().Direction() direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location()) info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction) info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"] info["push_pull_confidence"] = "unchecked"
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"] info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。"
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
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["push_pull_status"] = "candidate" info["push_pull_status"] = "candidate"
info["feature_type"] = "可推拉平面候选" info["feature_type"] = "可推拉平面候选"
info["feature_source_face_id"] = face_id info["feature_source_face_id"] = face_id
@@ -353,7 +369,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
radius = cyl.Radius() radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9) swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=False)
info["radius"] = radius info["radius"] = radius
info["diameter"] = radius * 2.0 info["diameter"] = radius * 2.0
info["axis_point"] = _point_tuple(axis.Location()) info["axis_point"] = _point_tuple(axis.Location())
@@ -361,13 +376,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["angular_span"] = u_span info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98 info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"] info["feature_guess"] = "cylindrical face"
info["confidence"] = classification["confidence"] info["confidence"] = "unchecked"
info["material_toward_axis"] = classification["toward_axis"] info["material_toward_axis"] = "not sampled"
info["material_away_axis"] = classification["away_axis"] info["material_away_axis"] = "not sampled"
info["material_vote_summary"] = classification["vote_summary"] info["material_vote_summary"] = "quick selection skips material sampling"
info["material_sample_count"] = classification["sample_count"] info["material_sample_count"] = 0
info["note"] = classification["note"] info["note"] = "快速选择阶段不判断孔/槽/凸台;需要语义识别时切换特征探测级别。"
info["feature_source_face_id"] = face_id info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,) info["feature_highlight_face_ids"] = (face_id,)
if u_span < math.tau * 0.92: if u_span < math.tau * 0.92:
@@ -572,6 +587,112 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._feature_info_cache[face_id] = dict(result) self._feature_info_cache[face_id] = dict(result)
return dict(result) return dict(result)
def associated_feature_infos(
self,
face_id: int,
*,
max_depth: int = 3,
max_scan_faces: int = 72,
max_features: int = 10,
) -> list[dict[str, object]]:
"""Detect editable feature candidates near the selected face.
STEP does not store a dependable CAD feature-history graph, so this
uses a shallow shared-edge walk. It can cross small cap/support faces to
reach a nearby hole, slot, boss, or analytic surface, but it avoids
scanning an entire solid through large carrier planes.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
source_info = self.feature_info(face_id)
source_area = max(float(source_info.get("area", 0.0) or 0.0), 1e-12)
source_feature_faces = set(_int_values(source_info.get("feature_face_ids"))) or {face_id}
visited = {face_id}
frontier: list[tuple[int, int]] = [(face_id, 0)]
candidate_hops: dict[int, int] = {}
while frontier and len(visited) < max_scan_faces:
current_id, depth = frontier.pop(0)
if depth >= max_depth:
continue
edge_ids = self._face_boundary_edge_ids(current_id)
neighbors = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, current_id)) - {current_id})
for neighbor_id in neighbors:
candidate_hops[neighbor_id] = min(candidate_hops.get(neighbor_id, depth + 1), depth + 1)
if neighbor_id in visited or len(visited) >= max_scan_faces:
continue
visited.add(neighbor_id)
expand = True
if neighbor_id != face_id and depth >= 1:
quick = self.quick_face_info(neighbor_id)
neighbor_area = float(quick.get("area", 0.0) or 0.0)
if str(quick.get("surface", "")) == "plane" and neighbor_area > source_area * 8.0:
expand = False
if expand:
frontier.append((neighbor_id, depth + 1))
results: list[dict[str, object]] = []
seen_features: set[tuple[str, frozenset[int]]] = set()
for candidate_id, hop_count in sorted(candidate_hops.items(), key=lambda item: (item[1], item[0])):
if candidate_id in source_feature_faces:
continue
try:
info = self.feature_info(candidate_id)
except Exception:
continue
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
feature_type = str(info.get("feature_type", "") or "")
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"cone", "sphere", "torus"}
or (
surface == "cylinder"
and feature_guess
in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
)
)
if not is_semantic:
continue
identity_face_ids = _int_values(info.get("feature_face_ids"))
if info.get("prismatic_profile_status") == "candidate":
identity_face_ids = (
_int_values(info.get("prismatic_highlight_face_ids"))
or _int_values(info.get("feature_highlight_face_ids"))
)
feature_faces = frozenset(identity_face_ids or [candidate_id])
identity = (feature_type or feature_guess or surface, feature_faces)
if identity in seen_features:
continue
seen_features.add(identity)
related = dict(info)
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_priority": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
}
)
results.append(related)
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:max_features]
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id) boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0) major_radius = float(info.get("major_radius", 0.0) or 0.0)
@@ -581,9 +702,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
{ {
"kind": "feature", "kind": "feature",
"feature_type": "环面候选", "feature_type": "环面候选",
"feature_type": prismatic_info.get("feature_type", "可推拉平面候选"),
"feature_source_face_id": face_id, "feature_source_face_id": face_id,
"feature_face_ids": (face_id,), "feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,),
"feature_highlight_face_ids": tuple(sorted(highlight_face_ids)),
"feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径", "feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": ( "feature_mode": (
@@ -622,6 +745,36 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id) boundary_edge_ids = self._face_boundary_edge_ids(face_id)
reference_radius = float(info.get("reference_radius", 0.0) or 0.0) reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
boundary_info: dict[str, object] = {}
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_point is not None and axis_direction is not None:
try:
circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
circles = []
if len(circles) == 2:
sorted_circles = sorted(circles, key=lambda item: float(item["radius"]))
small = sorted_circles[0]
large = sorted_circles[1]
small_center = tuple(float(value) for value in small["center"])
large_center = tuple(float(value) for value in large["center"])
height = _vector_length(_tuple_sub(large_center, small_center))
small_radius = float(small["radius"])
large_radius = float(large["radius"])
if height > 1e-9 and large_radius > small_radius > 1e-9:
boundary_info.update(
{
"feature_cone_small_radius": small_radius,
"feature_cone_small_diameter": small_radius * 2.0,
"feature_cone_large_radius": large_radius,
"feature_cone_large_diameter": large_radius * 2.0,
"feature_cone_height": height,
"feature_cone_boundary_half_angle_degrees": math.degrees(
math.atan((large_radius - small_radius) / height)
),
}
)
result = dict(info) result = dict(info)
result.update( result.update(
{ {
@@ -633,11 +786,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_reference_radius": reference_radius, "feature_reference_radius": reference_radius,
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "", "feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
"feature_edit_actions": "修改圆锥参考半径/直径", "feature_edit_actions": "修改圆锥参考半径/直径/半角",
"feature_mode": ( "feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放" "这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;简单圆锥会解析重建"
"不是 CAD 历史里的锥孔或倒角参数。" "嵌入式锥孔/沉孔会优先局部重切,不是 CAD 历史里的锥孔或倒角参数。"
), ),
**boundary_info,
} }
) )
return result return result
@@ -646,6 +800,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id) coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids) boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info) shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info)
if len(coplanar_face_ids) > 1: if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。" scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
else: else:
@@ -653,6 +808,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions = "推拉平面" edit_actions = "推拉平面"
if shell_info.get("shell_region_status") == "candidate": if shell_info.get("shell_region_status") == "candidate":
edit_actions += ";调整薄壁/壳体厚度" edit_actions += ";调整薄壁/壳体厚度"
if prismatic_info.get("prismatic_profile_status") == "candidate":
edit_actions = "调整规则矩形轮廓长度/宽度"
if prismatic_info.get("prismatic_extrusion_status") == "candidate":
edit_actions += ";调整棱柱高度/凹槽深度"
else:
edit_actions += ";沿法向推拉"
highlight_face_ids = set(coplanar_face_ids)
highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids")))
result = dict(info) result = dict(info)
result.update( result.update(
{ {
@@ -671,10 +834,247 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_edit_actions": edit_actions, "feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
**shell_info, **shell_info,
**prismatic_info,
} }
) )
return result return result
def _planar_rectangular_profile_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
shell_info: dict[str, object],
) -> dict[str, object]:
region_ids = sorted({int(item) for item in coplanar_face_ids})
if region_ids != [face_id]:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "共面区域包含多个 Face,暂不把整体包围盒当作规则矩形特征尺寸。",
}
edge_ids = self._face_boundary_edge_ids(face_id)
if len(edge_ids) != 4:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "规则矩形轮廓需要恰好四条边。",
}
direction_groups: list[dict[str, object]] = []
for edge_id in edge_ids:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() != GeomAbs_Line:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓含非直线边,不按规则矩形特征处理。",
}
start = _point_tuple(curve.Value(curve.FirstParameter()))
end = _point_tuple(curve.Value(curve.LastParameter()))
vector = _tuple_sub(end, start)
length = math.sqrt(_tuple_dot(vector, vector))
direction = _tuple_normalized(vector)
except Exception:
direction = None
length = 0.0
if direction is None or length <= 1e-9:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形轮廓存在退化边或无法读取的直线边。",
}
matched_group = None
for group in direction_groups:
group_direction = _tuple_or_none(group.get("direction"))
if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999:
matched_group = group
break
if matched_group is None:
matched_group = {"direction": direction, "lengths": [], "edge_ids": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
matched_group["edge_ids"].append(edge_id)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "四条边没有形成两组稳定的平行对边。",
}
first_direction = _tuple_or_none(direction_groups[0].get("direction"))
second_direction = _tuple_or_none(direction_groups[1].get("direction"))
if first_direction is None or second_direction is None or abs(_tuple_dot(first_direction, second_direction)) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "两组对边不垂直,不按规则矩形特征处理。",
}
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / len(lengths)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形候选的相对边长度不一致。",
}
group["average_length"] = average
direction_groups.sort(key=lambda group: float(group["average_length"]), reverse=True)
length = float(direction_groups[0]["average_length"])
width = float(direction_groups[1]["average_length"])
area = _float_or_none(info.get("area"))
area_ratio = area / max(length * width, 1e-12) if area is not None else 0.0
if area is None or abs(area_ratio - 1.0) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓面积与长乘宽不一致,可能存在内孔或非矩形裁剪。",
"prismatic_profile_area_ratio": area_ratio,
}
adjacent_side_ids = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, face_id)) - {face_id})
try:
opposite_face_id = int(shell_info["shell_opposite_face_id"])
except (KeyError, TypeError, ValueError):
opposite_face_id = None
connected_side_ids: list[int] = []
if opposite_face_id is not None:
for side_id in adjacent_side_ids:
side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
if opposite_face_id in side_neighbors:
connected_side_ids.append(side_id)
reference_face_ids = [opposite_face_id] if opposite_face_id is not None else []
topology_reference = False
signed_extrusion = _float_or_none(shell_info.get("shell_signed_thickness"))
if len(connected_side_ids) < 2:
source_plane = BRepAdaptor_Surface(self.faces[face_id]).Plane()
source_normal = source_plane.Axis().Direction()
solid_id = self.face_solid_ids[face_id]
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6)
groups: list[dict[str, object]] = []
for side_id in adjacent_side_ids:
neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
for candidate_id in neighbors:
if candidate_id == face_id or candidate_id in region_ids:
continue
if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id:
continue
try:
candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id])
if candidate_surface.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surface.Plane()
normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.995:
continue
signed_distance = _axis_parameter(
source_plane.Location(),
source_normal,
candidate_plane.Location(),
)
except Exception:
continue
if abs(signed_distance) <= tolerance:
continue
matched_group = None
for group in groups:
if abs(float(group["signed_distance"]) - signed_distance) <= tolerance * 20.0:
matched_group = group
break
if matched_group is None:
matched_group = {
"signed_distance": signed_distance,
"face_ids": set(),
"side_ids": set(),
"normal_dot": normal_dot,
}
groups.append(matched_group)
matched_group["face_ids"].add(candidate_id)
matched_group["side_ids"].add(side_id)
eligible_groups = [group for group in groups if len(group["side_ids"]) >= 2]
if eligible_groups:
eligible_groups.sort(key=lambda group: (-len(group["side_ids"]), abs(float(group["signed_distance"]))))
best_group = eligible_groups[0]
reference_face_ids = sorted(int(item) for item in best_group["face_ids"])
connected_side_ids = sorted(int(item) for item in best_group["side_ids"])
opposite_face_id = reference_face_ids[0]
signed_extrusion = float(best_group["signed_distance"])
topology_reference = True
support_ratio = len(connected_side_ids) / max(len(adjacent_side_ids), 1)
shell_info.update(
{
"shell_region_status": "candidate",
"shell_region_kind": "prismatic-topology-reference",
"shell_source_face_ids": tuple(region_ids),
"shell_opposite_face_id": opposite_face_id,
"shell_thickness_estimate": abs(signed_extrusion),
"shell_signed_thickness": signed_extrusion,
"shell_overlap_ratio_estimate": support_ratio,
"shell_opposite_normal_dot": best_group["normal_dot"],
"shell_confidence": "high" if support_ratio >= 0.99 else "medium",
"shell_note": "通过矩形轮廓的相邻侧壁找到高度/深度基准。",
}
)
extrusion_candidate = opposite_face_id is not None and len(connected_side_ids) >= 2
profile_confidence = "high" if len(adjacent_side_ids) == 4 and area_ratio >= 0.999 else "medium"
feature_type = "规则矩形棱柱候选" if extrusion_candidate else "规则矩形平面候选"
feature_semantics = "generic-prismatic"
if extrusion_candidate and reference_face_ids:
reference_area = 0.0
for reference_id in reference_face_ids:
props = GProp_GProps()
brepgprop.SurfaceProperties(self.faces[reference_id], props)
reference_area += float(props.Mass())
oriented_normal = _tuple_normalized(_tuple_or_none(info.get("oriented_normal")))
source_normal_tuple = _tuple_normalized(
_dir_tuple(BRepAdaptor_Surface(self.faces[face_id]).Plane().Axis().Direction())
)
if reference_area > area * 1.2 and oriented_normal is not None and source_normal_tuple is not None:
outward_offset = float(signed_extrusion or 0.0) * _tuple_dot(source_normal_tuple, oriented_normal)
if outward_offset > 0:
feature_type = "矩形口袋候选"
feature_semantics = "subtractive-pocket"
else:
feature_type = "矩形凸台候选"
feature_semantics = "additive-boss"
result: dict[str, object] = {
"prismatic_profile_status": "candidate",
"prismatic_profile_kind": "rectangular-planar-profile",
"prismatic_profile_confidence": profile_confidence,
"confidence": profile_confidence,
"prismatic_length": length,
"prismatic_width": width,
"prismatic_length_direction": direction_groups[0]["direction"],
"prismatic_width_direction": direction_groups[1]["direction"],
"prismatic_profile_area_ratio": area_ratio,
"prismatic_side_face_ids": tuple(adjacent_side_ids),
"prismatic_connected_side_face_ids": tuple(connected_side_ids),
"prismatic_reference_face_ids": tuple(reference_face_ids),
"prismatic_feature_semantics": feature_semantics,
"prismatic_profile_note": "四条直线边形成两组等长平行对边,面积与长乘宽一致。",
"feature_type": feature_type,
"local_face_width": length,
"local_face_height": width,
"local_face_width_direction": direction_groups[0]["direction"],
"local_face_height_direction": direction_groups[1]["direction"],
}
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": opposite_face_id,
"prismatic_extrusion_confidence": extrusion_confidence,
"confidence": extrusion_confidence,
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
}
)
return result
def _planar_shell_region_info( def _planar_shell_region_info(
self, self,
face_id: int, face_id: int,
@@ -768,6 +1168,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
thickness = float(best["shell_thickness_estimate"]) thickness = float(best["shell_thickness_estimate"])
overlap_ratio = float(best["shell_overlap_ratio_estimate"]) overlap_ratio = float(best["shell_overlap_ratio_estimate"])
local_width = _float_or_none(info.get("local_face_width"))
local_height = _float_or_none(info.get("local_face_height"))
local_spans = [value for value in (local_width, local_height) if value is not None and value > tolerance]
if local_spans and thickness > min(local_spans) * 1.5:
return {
"shell_region_status": "not-detected",
"shell_opposite_face_id": best["shell_opposite_face_id"],
"shell_thickness_estimate": thickness,
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_region_note": "相对平面距离明显大于当前面的局部短边,不按薄壁厚度处理。",
}
thin_ratio = thickness / diagonal thin_ratio = thickness / diagonal
if overlap_ratio >= 0.55 and thin_ratio <= 0.08: if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
confidence = "high" confidence = "high"
@@ -798,15 +1209,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
domain_info = dict(info) domain_info = dict(info)
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
domain_info["height_estimate"] = axis_range["span"] domain_info["height_estimate"] = axis_range["span"]
angular_spans: list[float] = []
for side_id in side_face_ids:
try:
side_surface = BRepAdaptor_Surface(self.faces[side_id])
angular_spans.append(abs(side_surface.LastUParameter() - side_surface.FirstUParameter()))
except Exception:
continue
combined_angular_span = min(sum(angular_spans), math.tau) if angular_spans else float(info.get("angular_span", 0.0))
domain_info["angular_span"] = combined_angular_span
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info) end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info)
end_face_ids = end_faces["end_face_ids"] end_face_ids = end_faces["end_face_ids"]
bottom_face_ids = end_faces["bottom_face_ids"] bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"] opening_face_ids = end_faces["opening_face_ids"]
guess = str(info.get("feature_guess", "cylindrical face"))
has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"])
material_toward = str(info.get("material_toward_axis", "") or "")
material_away = str(info.get("material_away_axis", "") or "")
if (
guess == "round/fillet candidate"
and has_two_axial_caps
and material_toward == "inside"
and "outside" in material_away
):
info = dict(info)
info["feature_guess"] = "boss/outer-round candidate"
info["confidence"] = "medium"
info["note"] = "partial cylinder has material inside its axis and explicit planar caps at both ends"
domain_info["feature_guess"] = info["feature_guess"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info) slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
guess = str(info.get("feature_guess", "cylindrical face")) guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0)) angular_span = combined_angular_span
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92: if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选" feature_type = "槽/半孔候选"
@@ -856,6 +1291,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_adjacent_face_ids": tuple(adjacent_face_ids), "feature_adjacent_face_ids": tuple(adjacent_face_ids),
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
"same_domain_height_estimate": axis_range["span"], "same_domain_height_estimate": axis_range["span"],
"same_domain_angular_span": combined_angular_span,
"angular_span": combined_angular_span,
"is_full_cylinder": combined_angular_span >= math.tau * 0.92,
"same_domain_range_source": axis_range["range_source"], "same_domain_range_source": axis_range["range_source"],
"same_domain_face_ids": tuple(side_face_ids), "same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids), "same_domain_face_count": len(side_face_ids),
@@ -1141,6 +1579,251 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache[face_id] = list(edge_ids) self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids return edge_ids
def face_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for a Face.
The current project defines first-level Face topology as the selected
Face region plus Faces that share a boundary Edge with that region.
Vertex-only contacts are reported through boundary vertex counts, but
they are not used as propagation edges at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._face_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
try:
same_domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
except Exception:
same_domain_face_ids = [face_id]
same_domain_face_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)}))
if not same_domain_face_ids:
same_domain_face_ids = (face_id,)
same_domain_set = set(same_domain_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(same_domain_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in same_domain_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
first_level_face_ids = tuple(sorted({*same_domain_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for item in same_domain_face_ids:
explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types: list[tuple[int, str]] = []
for adjacent_id in adjacent_face_ids:
adjacent_surface_types.append((adjacent_id, self.face_surface_kind(adjacent_id)))
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
topology = {
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"当前阶段只传播一级关系;相邻 Face 再连接出去的二级、三级拓扑只作为后续目标,不自动递归编辑。"
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"same_domain_face_ids": same_domain_face_ids,
"same_domain_face_count": len(same_domain_face_ids),
"same_domain_region_kind": "same-domain-region" if len(same_domain_face_ids) > 1 else "single-face",
"selected_boundary_edge_ids": selected_boundary_edge_ids,
"selected_boundary_edge_count": len(selected_boundary_edge_ids),
"first_level_boundary_edge_ids": region_boundary_edge_ids,
"first_level_boundary_edge_count": len(region_boundary_edge_ids),
"first_level_boundary_vertex_points": boundary_vertex_points,
"first_level_boundary_vertex_count": len(boundary_vertex_points),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": tuple(adjacent_surface_types),
"first_level_shared_edges_by_face": shared_edge_refs,
"first_level_face_ids": first_level_face_ids,
"first_level_face_count": len(first_level_face_ids),
"first_level_topology_note": (
f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、"
f"边界 Edge {len(region_boundary_edge_ids)} 条、"
f"边界 Vertex {len(boundary_vertex_points)} 个、"
f"共享边一级相邻 Face {len(adjacent_face_ids)} 个;"
"当前编辑计划只处理这些一级关系。"
),
}
for item in same_domain_face_ids:
self._face_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def cylindrical_feature_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the first-level B-Rep neighborhood for a cylindrical feature.
For holes and slots, first-level topology means the selected cylindrical
side region and Faces that directly share one of its boundary Edges.
Paired slot ends reached through another planar wall are deliberately
not promoted to first-level topology at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._cylindrical_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
info = self.face_info(face_id)
if info.get("surface") != "cylinder":
raise ValueError(f"Face {face_id} is not a cylindrical feature face")
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
feature = self.feature_info(face_id)
def valid_face_ids(values: object) -> tuple[int, ...]:
result: list[int] = []
for item in _int_values(values):
if item < 0 or item >= len(self.faces):
continue
if source_part_id >= 0 and self.face_part_ids[item] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[item] != source_solid_id:
continue
result.append(item)
return tuple(sorted(set(result)))
side_face_ids = valid_face_ids(feature.get("feature_side_face_ids"))
if not side_face_ids:
side_face_ids = valid_face_ids(feature.get("feature_face_ids"))
if not side_face_ids:
try:
side_face_ids = valid_face_ids(self.connected_same_domain_face_ids(face_id))
except Exception:
side_face_ids = ()
if not side_face_ids:
side_face_ids = (face_id,)
side_face_set = set(side_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(side_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in side_face_set:
continue
if source_part_id >= 0 and self.face_part_ids[candidate_id] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
end_face_ids = valid_face_ids(feature.get("feature_end_face_ids"))
bottom_face_ids = valid_face_ids(feature.get("feature_bottom_face_ids"))
opening_face_ids = valid_face_ids(feature.get("feature_opening_face_ids"))
slot_boundary_face_ids = valid_face_ids(feature.get("feature_slot_boundary_face_ids"))
first_level_face_ids = tuple(sorted({*side_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for edge_id in region_boundary_edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types = tuple((item, self.face_surface_kind(item)) for item in adjacent_face_ids)
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
topology = {
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. "
"Faces reached through those neighbors are recorded later as second-level or deeper relationships."
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess", info.get("feature_guess")),
"slot_kind": feature.get("slot_kind", ""),
"cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")),
"is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))),
"angular_span": angular_span,
"cylindrical_feature_side_face_ids": side_face_ids,
"cylindrical_feature_side_face_count": len(side_face_ids),
"cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids,
"cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids),
"cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids,
"cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids),
"cylindrical_feature_boundary_vertex_points": boundary_vertex_points,
"cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points),
"cylindrical_feature_adjacent_face_ids": adjacent_face_ids,
"cylindrical_feature_adjacent_face_count": len(adjacent_face_ids),
"cylindrical_feature_adjacent_surface_types": adjacent_surface_types,
"cylindrical_feature_shared_edges_by_face": shared_edge_refs,
"cylindrical_feature_first_level_face_ids": first_level_face_ids,
"cylindrical_feature_first_level_face_count": len(first_level_face_ids),
"cylindrical_feature_end_face_ids": end_face_ids,
"cylindrical_feature_end_face_count": len(end_face_ids),
"cylindrical_feature_bottom_face_ids": bottom_face_ids,
"cylindrical_feature_bottom_face_count": len(bottom_face_ids),
"cylindrical_feature_opening_face_ids": opening_face_ids,
"cylindrical_feature_opening_face_count": len(opening_face_ids),
"cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids,
"cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids),
"first_level_topology_note": (
f"Cylindrical side Faces={len(side_face_ids)}, boundary Edges={len(region_boundary_edge_ids)}, "
f"boundary Vertices={len(boundary_vertex_points)}, direct adjacent Faces={len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
for item in side_face_ids:
self._cylindrical_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]: def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try: try:
boundary_wires = len(_explore(face, TopAbs_WIRE)) boundary_wires = len(_explore(face, TopAbs_WIRE))
+1377 -27
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -140,7 +140,26 @@ class PolydataMixin:
def _ensure_mesh(self, deflection: float) -> None: def _ensure_mesh(self, deflection: float) -> None:
requested = max(float(deflection), 1e-9) requested = max(float(deflection), 1e-9)
if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999: if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999:
BRepMesh_IncrementalMesh(self.shape, requested) if requested <= 0.04:
BRepMesh_IncrementalMesh(
self.shape,
0.35,
False,
math.radians(12.0),
True,
)
for face in self.faces:
try:
surface_type = BRepAdaptor_Surface(face).GetType()
if surface_type in {GeomAbs_Cylinder, GeomAbs_Cone}:
BRepMesh_IncrementalMesh(face, requested, False, math.radians(1.2), True)
elif surface_type in {GeomAbs_Sphere, GeomAbs_Torus}:
BRepMesh_IncrementalMesh(face, max(requested, 0.1), False, math.radians(4.0), True)
except Exception:
continue
else:
angular_degrees = min(24.0, max(12.0, 12.0 * requested / 0.35))
BRepMesh_IncrementalMesh(self.shape, requested, False, math.radians(angular_degrees), True)
self._mesh_deflection = requested self._mesh_deflection = requested
def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8): def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8):
+4
View File
@@ -120,9 +120,11 @@ class TransformMixin:
part = self.part_by_id(part_id) part = self.part_by_id(part_id)
if part is None: if part is None:
raise ValueError(f"未知零件 ID {part_id}") raise ValueError(f"未知零件 ID {part_id}")
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
part.shape = _translated_shape_by_vector(part.shape, vector) part.shape = _translated_shape_by_vector(part.shape, vector)
_ensure_valid_shape(part.shape) _ensure_valid_shape(part.shape)
self.refresh_topology() self.refresh_topology()
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
return ( return (
f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, " f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
@@ -137,6 +139,7 @@ class TransformMixin:
if part is None: if part is None:
raise ValueError(f"Unknown part id {part_id}") raise ValueError(f"Unknown part id {part_id}")
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
part_solids = _explore(part.shape, TopAbs_SOLID) part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1: if len(part_solids) <= 1:
part.shape = _translated_shape_by_vector(part.shape, vector) part.shape = _translated_shape_by_vector(part.shape, vector)
@@ -156,6 +159,7 @@ class TransformMixin:
_ensure_valid_shape(part.shape) _ensure_valid_shape(part.shape)
self.refresh_topology() self.refresh_topology()
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
return ( return (
f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, " f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
+53 -14
View File
@@ -462,7 +462,7 @@ INFO_LABELS = {
"ellipse_edge_major_radius": "椭圆Edge主半径", "ellipse_edge_major_radius": "椭圆Edge主半径",
"ellipse_edge_minor_radius": "椭圆Edge小半径", "ellipse_edge_minor_radius": "椭圆Edge小半径",
"reference_radius": "参考半径", "reference_radius": "参考半径",
"semi_angle": "半角", "semi_angle": "圆锥半角",
"angular_span": "角度跨度", "angular_span": "角度跨度",
"height_estimate": "估算高度", "height_estimate": "估算高度",
"hole_depth_estimate": "孔/槽深度估算", "hole_depth_estimate": "孔/槽深度估算",
@@ -606,6 +606,12 @@ INFO_LABELS = {
"existing_fillet_radius_estimate": "已有圆角半径估算", "existing_fillet_radius_estimate": "已有圆角半径估算",
"feature_reference_radius": "特征参考半径", "feature_reference_radius": "特征参考半径",
"feature_reference_diameter": "特征参考直径", "feature_reference_diameter": "特征参考直径",
"feature_cone_small_radius": "锥孔小端半径",
"feature_cone_small_diameter": "锥孔小端直径",
"feature_cone_large_radius": "锥孔大端半径",
"feature_cone_large_diameter": "锥孔大端直径",
"feature_cone_height": "锥孔高度",
"feature_cone_boundary_half_angle_degrees": "锥孔半角",
"feature_sphere_radius": "球面半径", "feature_sphere_radius": "球面半径",
"feature_sphere_diameter": "球面直径", "feature_sphere_diameter": "球面直径",
"feature_torus_major_radius": "环面主半径", "feature_torus_major_radius": "环面主半径",
@@ -768,6 +774,32 @@ SELECTION_MODE_LABELS = {
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()} SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
SURFACE_VALUE_LABELS = {
"plane": "平面",
"cylinder": "圆柱面",
"cone": "圆锥面 / 拔模面",
"sphere": "球面",
"torus": "环面",
"bezier surface": "Bezier 曲面",
"b-spline surface": "B-spline 曲面",
"surface of revolution": "旋转曲面",
"surface of extrusion": "拉伸曲面",
"offset surface": "偏移曲面",
"other surface": "其它曲面",
}
CURVE_VALUE_LABELS = {
"line": "直线",
"circle": "圆 / 圆弧",
"ellipse": "椭圆 / 椭圆弧",
"hyperbola": "双曲线",
"parabola": "抛物线",
"bezier curve": "Bezier 曲线",
"b-spline curve": "B-spline 曲线",
"other curve": "其它曲线",
}
def _selection_mode_label(mode: object) -> str: def _selection_mode_label(mode: object) -> str:
mode_text = str(mode or "") mode_text = str(mode or "")
return SELECTION_MODE_LABELS.get(mode_text, mode_text or "Face") return SELECTION_MODE_LABELS.get(mode_text, mode_text or "Face")
@@ -783,7 +815,7 @@ def _format_float(value: float) -> str:
def _info_to_text(info: dict[str, object]) -> str: def _info_to_text(info: dict[str, object]) -> str:
return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items()) return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items())
def _part_tree_kind_label(kind: str) -> str: def _part_tree_kind_label(kind: str) -> str:
@@ -807,25 +839,21 @@ def _enable_overlay_depth_offset(mapper) -> None:
def _smooth_surface_polydata(polydata): def _smooth_surface_polydata(polydata):
clean = vtk.vtkCleanPolyData()
clean.SetInputData(polydata)
clean.PointMergingOn()
clean.SetTolerance(1e-7)
clean.Update()
normals = vtk.vtkPolyDataNormals() normals = vtk.vtkPolyDataNormals()
normals.SetInputConnection(clean.GetOutputPort()) # build_face_polydata owns a separate point set for each B-Rep Face.
# Keep those boundary points separate: averaging a side-wall normal into a
# planar cap creates the dark triangular gradients seen in the UI.
normals.SetInputData(polydata)
normals.ComputePointNormalsOn() normals.ComputePointNormalsOn()
normals.ComputeCellNormalsOff() normals.ComputeCellNormalsOff()
normals.ConsistencyOn() normals.ConsistencyOn()
normals.SplittingOn() normals.SplittingOff()
normals.SetFeatureAngle(35.0) if hasattr(normals, "NonManifoldTraversalOff"):
if hasattr(normals, "AutoOrientNormalsOn"): normals.NonManifoldTraversalOff()
normals.AutoOrientNormalsOn()
normals.Update() normals.Update()
smoothed = vtk.vtkPolyData() smoothed = vtk.vtkPolyData()
smoothed.DeepCopy(normals.GetOutput()) smoothed.ShallowCopy(normals.GetOutput())
return smoothed return smoothed
@@ -882,6 +910,17 @@ def _format_value(value: object) -> str:
return str(value) return str(value)
def _format_info_value(key: object, value: object) -> str:
key_text = str(key or "")
if key_text == "surface":
value_text = str(value or "")
return SURFACE_VALUE_LABELS.get(value_text, value_text)
if key_text == "curve":
value_text = str(value or "")
return CURVE_VALUE_LABELS.get(value_text, value_text)
return _format_value(value)
def _int_values(value: object) -> list[int]: def _int_values(value: object) -> list[int]:
if value is None or value == "": if value is None or value == "":
return [] return []
+742 -26
View File
@@ -375,7 +375,11 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes: if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消推拉平面") self.statusBar().showMessage("已取消推拉平面")
return return
self._show_push_pull_preview(face_id, distance, plan=plan) isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan)
else:
self.clear_edit_preview(render=False)
def action(): def action():
return self.model.push_pull_face(face_id, distance) return self.model.push_pull_face(face_id, distance)
@@ -410,7 +414,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]), isolation=isolation,
) )
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -467,10 +471,9 @@ class WindowActionMixin:
warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。") warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。")
outward_tuple = tuple(float(item) for item in outward) outward_tuple = tuple(float(item) for item in outward)
inward_material_depth = None inward_material_depth = _float_or_none(info.get("push_pull_inward_material_depth"))
inward_cut_ratio = None inward_cut_ratio = None
if distance < 0 and self.model is not None: if distance < 0:
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: if inward_material_depth is not None and inward_material_depth > 1e-9:
inward_cut_ratio = distance_abs / inward_material_depth inward_cut_ratio = distance_abs / inward_material_depth
depth_tolerance = max( depth_tolerance = max(
@@ -488,6 +491,10 @@ class WindowActionMixin:
elif inward_cut_ratio >= 0.6 and risk != "high": elif inward_cut_ratio >= 0.6 and risk != "high":
risk = "medium" risk = "medium"
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。") warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
else:
if risk == "low":
risk = "medium"
warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。")
if status != "blocked" and risk in {"medium", "high"}: if status != "blocked" and risk in {"medium", "high"}:
status = "caution" status = "caution"
@@ -605,10 +612,11 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消薄壁厚度调整") self.statusBar().showMessage("已取消薄壁厚度调整")
return return
if str(plan.get("risk")) == "high": isolation = self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness])
if isolation is not None:
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
else: else:
self._show_shell_thickness_preview(face_id, target_thickness) self._show_shell_thickness_preview(face_id, target_thickness, plan=plan)
def action(): def action():
return self.model.resize_shell_thickness(face_id, target_thickness) return self.model.resize_shell_thickness(face_id, target_thickness)
@@ -647,7 +655,7 @@ class WindowActionMixin:
}, },
target_kind="feature" if self.selected_kind == "feature" else "face", target_kind="feature" if self.selected_kind == "feature" else "face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness]), isolation=isolation,
) )
def resize_shell_thickness_owning_scale(self) -> None: def resize_shell_thickness_owning_scale(self) -> None:
@@ -984,13 +992,31 @@ class WindowActionMixin:
*, *,
timeout_seconds: float = 180.0, timeout_seconds: float = 180.0,
) -> dict[str, object] | None: ) -> dict[str, object] | None:
if str(plan.get("risk", "")) != "high": risk = str(plan.get("risk", ""))
isolated_face_operations = {
"push_pull_face",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_owning_scale",
"move_face_center_local",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
"resize_sphere_radius",
"resize_torus_radius",
}
if operation not in isolated_face_operations:
return None
if risk not in {"low", "medium", "high"}:
return None return None
return { return {
"operation": operation, "operation": operation,
"args": args, "args": args,
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"reason": "high-risk-occ-edit", "reason": f"{risk}-risk-face-occ-edit",
} }
def resize_hole(self) -> None: def resize_hole(self) -> None:
@@ -1067,6 +1093,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=isolation,
) )
def resize_cylindrical_owning_scale(self) -> None: def resize_cylindrical_owning_scale(self) -> None:
@@ -1130,6 +1157,12 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"), "affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"), "affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"), "affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"), "resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"), "edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"), "edit_semantics": plan.get("edit_semantics"),
@@ -2564,6 +2597,13 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"), "affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"), "affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"), "affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_fixed_small_radius": plan.get("embedded_cone_fixed_small_radius"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"), "resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"), "edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"), "edit_semantics": plan.get("edit_semantics"),
@@ -4112,7 +4152,7 @@ class WindowActionMixin:
def resize_cone_reference_radius(self) -> None: def resize_cone_reference_radius(self) -> None:
if self.model is None: if self.model is None:
return return
if self._edit_busy("请等待当前编辑完成后再修改圆锥面(整体)"): if self._edit_busy("请等待当前编辑完成后再修改圆锥面参数"):
return return
if self.selected_face_id is None: if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。") QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。")
@@ -4127,16 +4167,22 @@ class WindowActionMixin:
face_id = self.selected_face_id face_id = self.selected_face_id
plan = self.model.conical_reference_radius_plan(face_id, target_radius) plan = self.model.conical_reference_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, target_radius])
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥面(整体)", str(plan["message"])) QMessageBox.information(self, "不能修改圆锥面参数", str(plan["message"]))
self.statusBar().showMessage("圆锥面(整体)修改已阻止") self.statusBar().showMessage("圆锥面参数修改已阻止")
return return
if plan["risk"] != "low": if plan["risk"] != "low":
warnings = str(plan.get("warnings", "")) warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else "" warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question( result = QMessageBox.question(
self, self,
"确认修改圆锥面(整体)", "确认修改圆锥面参数",
( (
f"Face: {face_id}\n" f"Face: {face_id}\n"
f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n" f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n"
@@ -4150,13 +4196,14 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
) )
if result != QMessageBox.StandardButton.Yes: if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥面(整体)修改") self.statusBar().showMessage("已取消圆锥面参数修改")
return return
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
@@ -4165,7 +4212,7 @@ class WindowActionMixin:
self._run_edit_action( self._run_edit_action(
action, action,
operation_name="修改圆锥面参数(整体)", operation_name="修改圆锥面参数",
target=f"Face {face_id}", target=f"Face {face_id}",
parameters={ parameters={
"part_id": plan.get("part_id"), "part_id": plan.get("part_id"),
@@ -4198,6 +4245,104 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=isolation,
)
def resize_cone_semi_angle(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改圆锥半角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥 Face 或圆锥特征。")
return
if not hasattr(self, "cone_reference_radius_input"):
return
try:
target_angle_degrees = float(self.cone_reference_radius_input.text())
except ValueError:
QMessageBox.critical(self, "圆锥半角无效", "请输入数字形式的目标半角,单位是度。")
return
face_id = self.selected_face_id
plan = self.model.conical_semi_angle_plan(face_id, target_angle_degrees)
isolation = self._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, target_angle_degrees])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥半角", str(plan["message"]))
self.statusBar().showMessage("圆锥半角修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改圆锥半角",
(
f"Face: {face_id}\n"
f"当前半角: {_format_value(plan.get('semi_angle_degrees'))}\n"
f"目标半角: {_format_value(plan.get('target_semi_angle_degrees'))}\n"
f"目标参考半径: {_format_value(plan.get('target_reference_radius'))}\n"
f"变化比例: {_format_percent(plan.get('reference_radius_delta_ratio'))}\n"
f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n"
f"影响范围: {_format_value(plan.get('edit_semantics'))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥半角修改")
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_conical_semi_angle(face_id, target_angle_degrees)
self._run_edit_action(
action,
operation_name="修改圆锥半角(整体)",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"surface": "cone",
"current_reference_radius": plan.get("current_reference_radius"),
"target_reference_radius": plan.get("target_reference_radius"),
"current_reference_diameter": plan.get("current_reference_diameter"),
"target_reference_diameter": plan.get("target_reference_diameter"),
"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"),
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
},
target_kind="face",
target_id=face_id,
isolation=isolation,
) )
def resize_sphere_radius(self) -> None: def resize_sphere_radius(self) -> None:
@@ -4218,6 +4363,7 @@ class WindowActionMixin:
face_id = self.selected_face_id face_id = self.selected_face_id
plan = self.model.spherical_radius_plan(face_id, target_radius) plan = self.model.spherical_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_sphere_radius", [face_id, target_radius])
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"])) QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"]))
self.statusBar().showMessage("球面(整体)修改已阻止") self.statusBar().showMessage("球面(整体)修改已阻止")
@@ -4225,6 +4371,11 @@ class WindowActionMixin:
if plan["risk"] != "low": if plan["risk"] != "low":
warnings = str(plan.get("warnings", "")) warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else "" warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question( result = QMessageBox.question(
self, self,
"确认修改球面(整体)", "确认修改球面(整体)",
@@ -4241,6 +4392,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4285,6 +4437,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=isolation,
) )
def resize_torus_major_radius(self) -> None: def resize_torus_major_radius(self) -> None:
@@ -4313,6 +4466,7 @@ class WindowActionMixin:
mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)" mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)"
face_id = self.selected_face_id face_id = self.selected_face_id
plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key) plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key)
isolation = self._isolation_for_plan(plan, "resize_torus_radius", [face_id, target_radius, mode_key])
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"])) QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"]))
self.statusBar().showMessage("环面(整体)修改已阻止") self.statusBar().showMessage("环面(整体)修改已阻止")
@@ -4320,6 +4474,11 @@ class WindowActionMixin:
if plan["risk"] != "low": if plan["risk"] != "low":
warnings = str(plan.get("warnings", "")) warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else "" warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question( result = QMessageBox.question(
self, self,
f"确认修改环面{mode_label}", f"确认修改环面{mode_label}",
@@ -4338,6 +4497,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4386,6 +4546,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=isolation,
) )
def resize_face_area(self) -> None: def resize_face_area(self) -> None:
@@ -6252,6 +6413,7 @@ class WindowActionMixin:
target_part_id = self._edit_context_part_id(context) target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats() before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id) before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
before_geometry = {} before_geometry = {}
isolation = context.get("isolation") isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation: if isinstance(isolation, dict) and isolation:
@@ -6261,6 +6423,7 @@ class WindowActionMixin:
snapshot=snapshot, snapshot=snapshot,
before_stats=before_stats, before_stats=before_stats,
before_part_stats=before_part_stats, before_part_stats=before_part_stats,
before_quality=before_quality,
before_geometry=before_geometry, before_geometry=before_geometry,
) )
try: try:
@@ -6268,6 +6431,17 @@ class WindowActionMixin:
after_snapshot = self.model.snapshot() after_snapshot = self.model.snapshot()
after_stats = self.model.stats() after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id) after_part_stats = self._part_stats_or_none(target_part_id)
after_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=self.model,
)
after_geometry = {} after_geometry = {}
except Exception as exc: except Exception as exc:
try: try:
@@ -6298,7 +6472,7 @@ class WindowActionMixin:
"after_snapshot": after_snapshot, "after_snapshot": after_snapshot,
"after_stats": after_stats, "after_stats": after_stats,
"after_part_stats": after_part_stats, "after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats), "quality_warnings": quality_warnings,
"after_geometry": after_geometry, "after_geometry": after_geometry,
"model_polydata": model_polydata, "model_polydata": model_polydata,
"edge_polydata": edge_polydata, "edge_polydata": edge_polydata,
@@ -6314,6 +6488,7 @@ class WindowActionMixin:
snapshot: dict[object, object], snapshot: dict[object, object],
before_stats, before_stats,
before_part_stats, before_part_stats,
before_quality: dict[str, object] | None,
before_geometry: dict[str, object], before_geometry: dict[str, object],
) -> dict[str, object]: ) -> dict[str, object]:
if self.model is None: if self.model is None:
@@ -6346,7 +6521,7 @@ class WindowActionMixin:
encoding="utf-8", encoding="utf-8",
) )
command = [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)] command = self._isolated_edit_command(request_path)
try: try:
completed = subprocess.run( completed = subprocess.run(
command, command,
@@ -6385,11 +6560,23 @@ class WindowActionMixin:
new_model.filename = self.step_path new_model.filename = self.step_path
except Exception: except Exception:
pass pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
self._preserve_isolated_face_logical_id(new_model, context, child_message)
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
after_part_stats = self._part_stats_or_none_for_model(new_model, target_part_id)
after_quality = self._edit_quality_info_or_none(new_model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=new_model,
)
self.model = new_model 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] = {} after_geometry: dict[str, object] = {}
model_polydata = None model_polydata = None
edge_polydata = None edge_polydata = None
@@ -6404,7 +6591,6 @@ class WindowActionMixin:
model_polydata = None model_polydata = None
edge_polydata = None edge_polydata = None
child_message = str(response.get("message") or "隔离子进程编辑完成。")
return { return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。", "message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
"snapshot": snapshot, "snapshot": snapshot,
@@ -6414,12 +6600,47 @@ class WindowActionMixin:
"after_snapshot": after_snapshot, "after_snapshot": after_snapshot,
"after_stats": after_stats, "after_stats": after_stats,
"after_part_stats": after_part_stats, "after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats), "quality_warnings": quality_warnings,
"after_geometry": after_geometry, "after_geometry": after_geometry,
"model_polydata": model_polydata, "model_polydata": model_polydata,
"edge_polydata": edge_polydata, "edge_polydata": edge_polydata,
} }
def _isolated_edit_command(self, request_path: Path) -> list[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--isolated-edit-worker", str(request_path)]
return [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
def _preserve_isolated_face_logical_id(
self,
model: StepModel,
context: dict[str, object],
result_message: str,
) -> None:
if not self._context_is_face_parameter_edit(context):
return
target_logical_id = context.get("target_logical_id")
if target_logical_id is None:
return
located_text = _message_field(result_message, "nearest_face") or _message_field(result_message, "verified_face")
if located_text is None:
return
try:
face_id = int(float(located_text))
logical_id = int(target_logical_id)
except (TypeError, ValueError):
return
if not (0 <= face_id < len(model.faces)):
return
try:
face_ids = model.face_region_ids(face_id) or [face_id]
except Exception:
face_ids = [face_id]
try:
model.assign_logical_face_region_exclusive(logical_id, face_ids)
except Exception:
return
def _edit_context_part_id(self, context: dict[str, object]) -> int | None: def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None: if self.model is None:
return None return None
@@ -6452,13 +6673,508 @@ class WindowActionMixin:
return None return None
def _part_stats_or_none(self, part_id: int | None): def _part_stats_or_none(self, part_id: int | None):
if self.model is None or part_id is None: return self._part_stats_or_none_for_model(self.model, part_id)
def _part_stats_or_none_for_model(self, model: StepModel | None, part_id: int | None):
if model is None or part_id is None:
return None return None
try: try:
return self.model.part_topology_stats(part_id) return model.part_topology_stats(part_id)
except Exception: except Exception:
return None return None
def _edit_quality_info_or_none(
self,
model: StepModel | None,
context: dict[str, object],
target_part_id: int | None,
) -> dict[str, object] | None:
if model is None:
return None
try:
if target_part_id is not None:
return model.export_quality_info("part", target_part_id)
return model.export_quality_info("all", None)
except Exception:
try:
return model.export_quality_info("all", None)
except Exception:
return None
def _verified_edit_quality_warnings(
self,
context: dict[str, object],
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality: dict[str, object] | None,
after_quality: dict[str, object] | None,
*,
after_model: StepModel | None = None,
) -> list[str]:
warnings = _edit_quality_warnings(before_part_stats, after_part_stats)
blockers: list[str] = []
if getattr(after_stats, "solids", 0) <= 0:
blockers.append("编辑结果里没有检测到 Solid,模型已保持在修改前状态。")
if getattr(after_stats, "faces", 0) <= 0:
blockers.append("编辑结果里没有检测到 Face,模型已保持在修改前状态。")
if getattr(after_stats, "edges", 0) <= 0:
blockers.append("编辑结果里没有检测到 Edge,模型已保持在修改前状态。")
if after_quality is not None:
quality_status = str(after_quality.get("quality_status", ""))
after_brep_valid = after_quality.get("brep_valid")
before_brep_valid = before_quality.get("brep_valid") if before_quality is not None else None
quality_warnings = str(after_quality.get("quality_warnings", "")).strip()
if quality_status == "blocked":
blockers.append(quality_warnings or "编辑结果质量检查被判定为不可用。")
elif after_brep_valid is False and before_brep_valid is not False:
blockers.append(quality_warnings or "编辑结果没有通过 B-Rep 校验。")
elif quality_warnings:
warnings.append(quality_warnings)
if self._context_is_face_parameter_edit(context):
if (
before_part_stats is not None
and after_part_stats is not None
and getattr(before_part_stats, "solids", 0) > 0
and getattr(after_part_stats, "solids", 0) != getattr(before_part_stats, "solids", 0)
):
blockers.append(
"Face 参数化编辑后目标特征的 Solid 数量发生变化,已阻止接受该结果。"
f" Solid: {before_part_stats.solids} -> {after_part_stats.solids}"
)
target_blocker = self._face_target_integrity_blocker(after_model, context)
if target_blocker:
blockers.append(target_blocker)
if blockers:
raise RuntimeError(" ".join(blockers))
return warnings
def _face_target_integrity_blocker(
self,
model: StepModel | None,
context: dict[str, object],
) -> str:
if model is None:
return ""
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return ""
surface = str(parameters.get("surface") or "")
face_ids = self._face_target_candidate_ids(model, context)
if not face_ids:
if surface in {"cone", "sphere", "torus"} and any(
parameters.get(key) not in {"", None}
for key in (
"target_reference_radius",
"target_semi_angle_degrees",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
):
return f"Face 编辑结果里没有找到目标 {surface} 面,模型已恢复到修改前状态。"
return ""
checks: list[tuple[str, bool]] = []
target_area = _float_or_none(parameters.get("target_area"))
if target_area is not None and target_area > 0:
checks.append(("面积", self._face_target_area_matches(model, face_ids, target_area)))
target_width = _float_or_none(parameters.get("target_face_width"))
target_height = _float_or_none(parameters.get("target_face_height"))
if target_width is not None or target_height is not None:
checks.append(("面宽/面高", self._face_target_size_matches(model, face_ids, target_width, target_height)))
target_center = _triple_or_none(parameters.get("target_face_center"))
if target_center is not None:
checks.append(("中心", self._face_target_center_matches(model, face_ids, target_center)))
target_position = _float_or_none(parameters.get("target_plane_position"))
plane_direction = (
_unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if target_position is not None and plane_direction is not None:
checks.append(
(
"面偏移",
self._face_target_plane_position_matches(
model,
face_ids,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
),
)
)
target_thickness = _float_or_none(parameters.get("shell_target_thickness"))
if target_thickness is not None and target_thickness > 0:
checks.append(
(
"薄壁厚度",
self._face_target_shell_thickness_matches(model, face_ids, target_thickness),
)
)
if surface == "sphere":
target_radius = _float_or_none(parameters.get("target_radius"))
if target_radius is not None and target_radius > 0:
checks.append(("半径", self._face_target_numeric_info_matches(model, face_ids, "radius", target_radius)))
elif surface == "cone":
resize_strategy = str(parameters.get("resize_strategy") or "")
embedded_target_small_radius = _float_or_none(parameters.get("embedded_cone_target_small_radius"))
embedded_target_large_radius = _float_or_none(parameters.get("embedded_cone_target_large_radius"))
if (
embedded_target_small_radius is not None
and embedded_target_small_radius > 0
and embedded_target_large_radius is not None
and embedded_target_large_radius > embedded_target_small_radius
):
checks.append(
(
"锥孔边界半径",
self._face_target_cone_boundary_radii_match(
model,
face_ids,
embedded_target_small_radius,
embedded_target_large_radius,
),
)
)
target_reference_radius = _float_or_none(parameters.get("target_reference_radius"))
if (
target_reference_radius is not None
and target_reference_radius > 0
and "semi-angle" not in resize_strategy
and "bounded-cone-recut" not in resize_strategy
):
checks.append(
(
"参考半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"reference_radius",
target_reference_radius,
),
)
)
target_semi_angle_degrees = _float_or_none(parameters.get("target_semi_angle_degrees"))
if target_semi_angle_degrees is not None and target_semi_angle_degrees > 0:
checks.append(
(
"半角",
self._face_target_angle_degrees_matches(model, face_ids, target_semi_angle_degrees),
)
)
elif surface == "torus":
target_major_radius = _float_or_none(parameters.get("target_major_radius"))
if target_major_radius is not None and target_major_radius > 0:
checks.append(
(
"主半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"major_radius",
target_major_radius,
),
)
)
target_minor_radius = _float_or_none(parameters.get("target_minor_radius"))
if target_minor_radius is not None and target_minor_radius > 0:
checks.append(
(
"小半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"minor_radius",
target_minor_radius,
),
)
)
failed = [name for name, ok in checks if not ok]
if not failed:
return ""
return (
"Face 编辑结果没有达到目标值;"
f"未通过的检查: {', '.join(failed)}"
"模型已恢复到修改前状态。"
)
def _face_target_candidate_ids(self, model: StepModel, context: dict[str, object]) -> list[int]:
parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
candidates: list[int] = []
logical_id = context.get("target_logical_id")
if logical_id is not None:
try:
candidates.extend(model.face_ids_for_logical_id(int(logical_id)))
except Exception:
pass
target_id = context.get("target_id")
try:
numeric_target_id = int(target_id) if target_id is not None else -1
except (TypeError, ValueError):
numeric_target_id = -1
if 0 <= numeric_target_id < len(model.faces):
candidates.append(numeric_target_id)
if not candidates:
candidates = list(range(len(model.faces)))
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
surface = str(parameters.get("surface") or "")
target_keys = (
"target_area",
"target_face_width",
"target_face_height",
"target_face_center",
"target_plane_position",
"shell_target_thickness",
"target_reference_radius",
"target_semi_angle_degrees",
"embedded_cone_target_small_radius",
"embedded_cone_target_large_radius",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
has_target_value = any(parameters.get(key) not in {"", None} for key in target_keys)
def filtered(source_ids, *, require_solid: bool = True) -> list[int]:
result: list[int] = []
for face_id in source_ids:
if not (0 <= int(face_id) < len(model.faces)):
continue
if part_id is not None and int(model.face_part_ids[int(face_id)]) != part_id:
continue
if (
require_solid
and solid_id is not None
and solid_id >= 0
and int(model.face_solid_ids[int(face_id)]) != solid_id
):
continue
if surface in {"plane", "cylinder", "cone", "sphere", "torus"}:
try:
if model.face_surface_kind(int(face_id)) != surface:
continue
except Exception:
continue
if int(face_id) not in result:
result.append(int(face_id))
return result
result = filtered(candidates)
if result:
return result
if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value:
result = filtered(range(len(model.faces)))
if result:
return result
if solid_id is not None and solid_id >= 0:
result = filtered(range(len(model.faces)), require_solid=False)
return result
def _edit_integrity_int_or_none(self, value: object) -> int | None:
if value in {"", None}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _face_target_area_matches(self, model: StepModel, face_ids: list[int], target_area: float) -> bool:
tolerance = max(abs(target_area) * 0.02, 1e-4)
for face_id in face_ids:
try:
area = _float_or_none(model.face_info(face_id).get("area"))
except Exception:
area = None
if area is not None and abs(area - target_area) <= tolerance:
return True
return False
def _face_target_size_matches(
self,
model: StepModel,
face_ids: list[int],
target_width: float | None,
target_height: float | None,
) -> bool:
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
width = _float_or_none(info.get("local_face_width"))
height = _float_or_none(info.get("local_face_height"))
if target_width is not None:
if width is None or abs(width - target_width) > max(abs(target_width) * 0.02, 1e-4):
continue
if target_height is not None:
if height is None or abs(height - target_height) > max(abs(target_height) * 0.02, 1e-4):
continue
return True
return False
def _face_target_center_matches(
self,
model: StepModel,
face_ids: list[int],
target_center: tuple[float, float, float],
) -> bool:
reference = max(max(abs(item) for item in target_center), 1.0)
tolerance = max(reference * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
center = _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center"))
if center is None:
continue
distance = math.sqrt(sum((center[index] - target_center[index]) ** 2 for index in range(3)))
if distance <= tolerance:
return True
return False
def _face_target_plane_position_matches(
self,
model: StepModel,
face_ids: list[int],
target_position: float,
direction: tuple[float, float, float],
bbox_diagonal: float | None,
) -> bool:
tolerance = max((bbox_diagonal or 1.0) * 1e-4, abs(target_position) * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
origin = _triple_or_none(info.get("plane_origin"))
if origin is None:
continue
position = sum(origin[index] * direction[index] for index in range(3))
if abs(position - target_position) <= tolerance:
return True
return False
def _face_target_shell_thickness_matches(
self,
model: StepModel,
face_ids: list[int],
target_thickness: float,
) -> bool:
tolerance = max(abs(target_thickness) * 0.03, 1e-4)
for face_id in face_ids:
try:
info = model.feature_info(face_id)
except Exception:
continue
current = _float_or_none(info.get("shell_thickness_estimate"))
if current is not None and abs(current - target_thickness) <= tolerance:
return True
return False
def _face_target_numeric_info_matches(
self,
model: StepModel,
face_ids: list[int],
info_key: str,
target_value: float,
) -> bool:
tolerance = max(abs(target_value) * 0.01, 1e-4)
for face_id in face_ids:
try:
value = _float_or_none(model.face_info(face_id).get(info_key))
except Exception:
value = None
if value is not None and abs(value - target_value) <= tolerance:
return True
return False
def _face_target_cone_boundary_radii_match(
self,
model: StepModel,
face_ids: list[int],
target_small_radius: float,
target_large_radius: float,
) -> bool:
tolerance = max(abs(target_large_radius) * 0.01, abs(target_small_radius) * 0.01, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
axis_point = _triple_or_none(info.get("axis_point"))
axis_direction = _unit_triple_or_none(info.get("axis"))
if axis_point is None or axis_direction is None:
continue
circles = model._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
continue
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
continue
if abs(radii[0] - target_small_radius) <= tolerance and abs(radii[1] - target_large_radius) <= tolerance:
return True
return False
def _face_target_angle_degrees_matches(
self,
model: StepModel,
face_ids: list[int],
target_degrees: float,
) -> bool:
tolerance = max(abs(target_degrees) * 0.01, 0.05)
for face_id in face_ids:
try:
angle = _float_or_none(model.face_info(face_id).get("semi_angle"))
except Exception:
angle = None
if angle is None:
continue
degrees = abs(math.degrees(angle))
if abs(degrees - target_degrees) <= tolerance:
return True
return False
def _context_is_face_parameter_edit(self, context: dict[str, object]) -> bool:
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return False
strategy = str(parameters.get("resize_strategy") or parameters.get("translate_strategy") or "")
if any(
token in strategy
for token in (
"face",
"plane-offset",
"push-pull-planar",
"push-pull-shell",
"shell-thickness",
"cone",
"sphere",
"torus",
"radial-affine-scale",
)
):
return True
operation = str(context.get("operation_name") or "")
target_kind = str(context.get("target_kind") or "")
return target_kind in {"face", "feature"} and any(
token in operation for token in ("Face", "", "薄壁", "推拉", "圆锥", "球面", "环面")
)
def _begin_edit_task(self, operation_name: str) -> None: def _begin_edit_task(self, operation_name: str) -> None:
self.operation_in_progress = True self.operation_in_progress = True
self._update_action_states() self._update_action_states()
+168 -20
View File
@@ -46,6 +46,40 @@ class WindowCoreMixin:
def _run_ui_task(self, callback) -> None: def _run_ui_task(self, callback) -> None:
callback() callback()
def showEvent(self, event) -> None:
super().showEvent(event)
if getattr(self, "first_show_handled", False):
return
self.first_show_handled = True
QTimer.singleShot(0, self._after_first_show)
@Slot()
def _after_first_show(self) -> None:
self._ensure_vtk_interactor_started()
if getattr(self, "auto_load_on_show", False):
self.auto_load_on_show = False
self.load_step(self.step_path, background=True)
def _ensure_vtk_interactor_started(self) -> None:
if getattr(self, "vtk_interactor_started", False):
return
self.vtk_interactor_started = True
try:
self.vtk_widget.Initialize()
except Exception:
try:
self.interactor.Initialize()
except Exception:
pass
try:
self.vtk_widget.Start()
except Exception:
pass
try:
self.render_window.Render()
except Exception:
pass
def _is_ui_thread(self) -> bool: def _is_ui_thread(self) -> bool:
return QThread.currentThread() == self.thread() return QThread.currentThread() == self.thread()
@@ -93,7 +127,11 @@ class WindowCoreMixin:
self.renderer = vtk.vtkRenderer() self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self.render_window = self.vtk_widget.GetRenderWindow() self.render_window = self.vtk_widget.GetRenderWindow()
if hasattr(self.render_window, "SetMultiSamples"):
self.render_window.SetMultiSamples(0)
self.render_window.AddRenderer(self.renderer) self.render_window.AddRenderer(self.renderer)
if hasattr(self.renderer, "UseFXAAOff"):
self.renderer.UseFXAAOff()
self.interactor = self.render_window.GetInteractor() self.interactor = self.render_window.GetInteractor()
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera()) self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
@@ -105,7 +143,9 @@ class WindowCoreMixin:
self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release) self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press) self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press)
self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release) self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move) # Qt mouse tracking already drives hover. Avoid routing every VTK
# camera-move event through Python; that made rotation feel sticky on
# large STEP meshes.
self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start) self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start)
self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end) self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end)
if hasattr(self.interactor, "SetDesiredUpdateRate"): if hasattr(self.interactor, "SetDesiredUpdateRate"):
@@ -119,6 +159,12 @@ class WindowCoreMixin:
light.SetIntensity(0.9) light.SetIntensity(0.9)
self.renderer.AddLight(light) self.renderer.AddLight(light)
fill_light = vtk.vtkLight()
fill_light.SetLightTypeToCameraLight()
fill_light.SetPosition(-1, -1, 1)
fill_light.SetIntensity(0.28)
self.renderer.AddLight(fill_light)
self.interactor.Initialize() self.interactor.Initialize()
def on_camera_interaction_start(self, _obj, _event) -> None: def on_camera_interaction_start(self, _obj, _event) -> None:
@@ -143,6 +189,19 @@ class WindowCoreMixin:
if not getattr(self, "camera_interaction_active", False): if not getattr(self, "camera_interaction_active", False):
return return
self.camera_interaction_active = False self.camera_interaction_active = False
self.last_camera_interaction_ended_at = datetime.now()
self.pending_hover_position = None
self.last_hover_pick_position = None
def _hover_suppressed_after_camera(self) -> bool:
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
if ended_at is None:
return False
cooldown_ms = int(getattr(self, "hover_after_camera_cooldown_ms", 0) or 0)
if cooldown_ms <= 0:
return False
elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0
return elapsed_ms < cooldown_ms
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
load_thread_running = bool( load_thread_running = bool(
@@ -213,11 +272,32 @@ class WindowCoreMixin:
self.load_in_progress = True self.load_in_progress = True
self.pending_load_path = new_path self.pending_load_path = new_path
self.statusBar().showMessage(f"Preparing fast load for {new_path.name}...") self.statusBar().showMessage(f"正在读取 STEP 可视化网格:{new_path.name}...")
self._clear_hover(render=True) self._clear_hover(render=True)
self._update_action_states() self._update_action_states()
QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path)) QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path))
@Slot(object)
def _run_packaged_initial_load(self, expected_path: Path) -> None:
if self.pending_load_path != expected_path:
return
try:
self._load_step_sync(
expected_path,
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
)
finally:
self._end_load_task()
@Slot(object)
def _start_initial_load_worker(self, expected_path: Path) -> None:
# Qt/VTK/OCCT visualization objects are not safe to construct from this
# worker path in the current PySide build. Keep the old entry point as a
# main-thread fallback so stale signal paths cannot crash the process.
self._run_deferred_initial_load(expected_path)
def _load_step_sync( def _load_step_sync(
self, self,
new_path: Path, new_path: Path,
@@ -253,13 +333,29 @@ class WindowCoreMixin:
try: try:
self._load_step_sync( self._load_step_sync(
expected_path, expected_path,
deflection=self.initial_load_deflection, deflection=self.preview_load_deflection,
show_internal_edges=True, show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="Fast loading", status_prefix="读取 STEP 可视化网格",
) )
finally: finally:
self._end_load_task() self._end_load_task()
@staticmethod
def _copy_polydata_for_ui_thread(polydata: object) -> object:
if polydata is None:
return None
copied = vtk.vtkPolyData()
copied.DeepCopy(polydata)
return copied
def _detach_worker_polydata_result(self, result: dict[str, object]) -> dict[str, object]:
detached = dict(result)
if detached.get("model_polydata") is not None:
detached["model_polydata"] = self._copy_polydata_for_ui_thread(detached["model_polydata"])
if detached.get("edge_polydata") is not None:
detached["edge_polydata"] = self._copy_polydata_for_ui_thread(detached["edge_polydata"])
return detached
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None: def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
new_path = Path(result["path"]) new_path = Path(result["path"])
stats = result["stats"] stats = result["stats"]
@@ -309,9 +405,18 @@ class WindowCoreMixin:
try: try:
if not isinstance(result, dict): if not isinstance(result, dict):
raise RuntimeError("Load task returned an unexpected result.") raise RuntimeError("Load task returned an unexpected result.")
result = self._detach_worker_polydata_result(result)
self._apply_loaded_model_result(result, reset_camera=True) self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}") self.load_in_progress = False
self._end_load_task() self.pending_load_path = None
self._update_action_states()
if float(getattr(self, "preview_load_deflection", 0.0) or 0.0) > float(
getattr(self, "initial_load_deflection", 0.0) or 0.0
):
self.statusBar().showMessage(f"已快速显示模型:{self.step_path.name},正在后台细化显示...")
self._start_load_refine(result)
else:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
except Exception as exc: except Exception as exc:
self._end_load_task() self._end_load_task()
QMessageBox.critical(self, "Load failed", str(exc)) QMessageBox.critical(self, "Load failed", str(exc))
@@ -326,14 +431,18 @@ class WindowCoreMixin:
self.statusBar().showMessage("STEP load failed; current model was left unchanged.") self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
def _start_load_refine(self, initial_result: dict[str, object]) -> None: def _start_load_refine(self, initial_result: dict[str, object]) -> None:
self._end_load_task() self.statusBar().showMessage(f"Loaded {self.step_path.name}")
if self.model is not None:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
@Slot(object) @Slot(object)
def _finish_load_refine(self, result: object) -> None: def _finish_load_refine(self, result: object) -> None:
try: try:
if isinstance(result, dict) and result.get("model") is self.model: if (
isinstance(result, dict)
and Path(result.get("path", "")) == self.step_path
and not getattr(self, "operation_history", [])
):
self.model = result["model"]
result = self._detach_worker_polydata_result(result)
self._rebuild_scene_from_polydata( self._rebuild_scene_from_polydata(
result["model_polydata"], result["model_polydata"],
result["edge_polydata"], result["edge_polydata"],
@@ -351,7 +460,7 @@ class WindowCoreMixin:
"display": "ready", "display": "ready",
} }
) )
self.statusBar().showMessage(f"Loaded {self.step_path.name}") self.statusBar().showMessage(f"精细显示已完成:{self.step_path.name}")
finally: finally:
self._end_load_task() self._end_load_task()
@@ -385,12 +494,24 @@ class WindowCoreMixin:
"STEP 文件 (*.step *.stp);;所有文件 (*.*)", "STEP 文件 (*.step *.stp);;所有文件 (*.*)",
) )
if path: if path:
self.load_step(path) self.step_path = Path(path)
if hasattr(self, "path_label"):
path_text = str(self.step_path)
self.path_label.setText(path_text)
self.path_label.setToolTip(path_text)
self.path_label.setCursorPosition(0)
self.statusBar().showMessage(f"已选择 STEP 文件:{self.step_path.name},正在读取模型...")
self._update_action_states()
self._ensure_vtk_interactor_started()
self.load_step(self.step_path, background=True)
def reload_step(self) -> None: def reload_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再重新加载。"): if self._edit_busy("请等待当前编辑完成后再重新加载。"):
return return
self.load_step(self.step_path) path_text = self.path_label.text().strip() if hasattr(self, "path_label") else ""
path = Path(path_text) if path_text else self.step_path
self._ensure_vtk_interactor_started()
self.load_step(path)
def _clear_history(self) -> None: def _clear_history(self) -> None:
self.undo_stack.clear() self.undo_stack.clear()
@@ -1107,6 +1228,7 @@ class WindowCoreMixin:
or self.load_in_progress or self.load_in_progress
or self.model is None or self.model is None
or self.model_actor is None or self.model_actor is None
or self._hover_suppressed_after_camera()
): ):
self._clear_hover(render=True) self._clear_hover(render=True)
return return
@@ -1129,7 +1251,11 @@ class WindowCoreMixin:
if not self._is_ui_thread(): if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y)) self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y))
return return
if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False): if (
getattr(self, "pointer_button_down", False)
or getattr(self, "camera_interaction_active", False)
or self._hover_suppressed_after_camera()
):
return return
position = (int(x), int(y)) position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
@@ -1156,6 +1282,7 @@ class WindowCoreMixin:
or self.model is None or self.model is None
or self.model_actor is None or self.model_actor is None
or self.pending_hover_position is None or self.pending_hover_position is None
or self._hover_suppressed_after_camera()
): ):
self._clear_hover(render=True) self._clear_hover(render=True)
return return
@@ -1576,7 +1703,7 @@ class WindowCoreMixin:
self.selected_pick_position = pick_position self.selected_pick_position = pick_position
info = self.model.quick_face_info(face_id) info = self.model.quick_face_info(face_id)
if feature_mode: if feature_mode:
info = self._feature_info_for_selected_face(face_id, info) info = self._feature_context_info(face_id)
info["kind"] = "feature" info["kind"] = "feature"
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征") info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id] highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
@@ -1601,7 +1728,7 @@ class WindowCoreMixin:
return return
if self._edit_busy("编辑计算中,暂时不能选择特征。"): if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return return
info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id)) info = self._feature_context_info(face_id)
info["kind"] = "feature" info["kind"] = "feature"
self._reset_selection(clear_highlight=False, clear_info=False) self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature" self.selected_kind = "feature"
@@ -2026,16 +2153,37 @@ class WindowCoreMixin:
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self.render_window.Render()
def _show_shell_thickness_preview(self, face_id: int, target_thickness: float) -> None: def _show_shell_thickness_preview(
self,
face_id: int,
target_thickness: float,
plan: dict[str, object] | None = None,
) -> None:
if self.model is None: if self.model is None:
return return
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
try: try:
polydata = self.model.shell_thickness_preview_polydata(face_id, target_thickness) plan = dict(plan or self.model.shell_thickness_plan(face_id, target_thickness))
scope_face_ids = _int_values(plan.get("shell_source_face_ids")) or [face_id]
polydata = self._cached_face_overlay_polydata(face_ids=scope_face_ids, smooth=False)
if polydata is None:
polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False)
if polydata is None:
raise RuntimeError("当前显示网格里没有可复用的薄壁预览数据")
except Exception as exc: except Exception as exc:
self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}") self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}")
return return
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) movement = _triple_or_none(plan.get("shell_desired_movement_vector")) if isinstance(plan, dict) else None
if movement is None and isinstance(plan, dict):
outward = _triple_or_none(plan.get("outward_direction"))
distance = _float_or_none(plan.get("push_pull_distance"))
if outward is not None and distance is not None:
movement = (
float(outward[0]) * float(distance),
float(outward[1]) * float(distance),
float(outward[2]) * float(distance),
)
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34, position=movement)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self.render_window.Render()
+481 -28
View File
@@ -28,6 +28,57 @@ PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3 PROPERTY_TARGET_COLUMN = 3
PROPERTY_ACTION_COLUMN = 4 PROPERTY_ACTION_COLUMN = 4
FEATURE_EDIT_SEMANTICS_KEYS = {
"face_first_level_topology",
"slot_edit_semantics",
"hole_edit_semantics",
"boss_edit_semantics",
"existing_fillet_edit_semantics",
"analytic_surface_edit_semantics",
"face_edit_semantics",
}
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
"""Return the independent, user-facing dimensions for a feature candidate."""
surface = str(action_info.get("surface", "") or "")
feature_guess = str(action_info.get("feature_guess", "") or "")
angular_span = _float_or_none(action_info.get("angular_span"))
if surface == "plane":
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return ("shell_thickness_estimate",)
return ("face_target_normal_position",)
if surface == "cylinder":
is_partial = angular_span is not None and angular_span < math.tau * 0.92
if feature_guess == "hole/groove candidate":
if is_partial:
return (
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_total_length_estimate",
)
return ("diameter", "hole_depth_estimate")
if feature_guess == "boss/outer-round candidate":
return ("boss_diameter", "boss_height")
if feature_guess == "round/fillet candidate":
return ("existing_fillet_radius_estimate",)
return ("generic_cylinder_diameter", "cylinder_height")
if surface == "cone":
return ("cone_reference_radius", "cone_semi_angle_degrees")
if surface == "sphere":
return ("sphere_radius",)
if surface == "torus":
return ("torus_major_radius", "torus_minor_radius")
return ()
def _record_message_field(message: str | None, key: str) -> str | None: def _record_message_field(message: str | None, key: str) -> str | None:
if not message: if not message:
@@ -109,9 +160,51 @@ class WindowStateMixin:
enriched["pick_position"] = pick_position enriched["pick_position"] = pick_position
return enriched return enriched
def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.face_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"same_domain_face_count": 1,
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_count": 0,
"first_level_topology_note": (
"当前 Face 的一级拓扑关系暂时无法确认;只改当前面的局部重建会在计划阶段再次检查。"
),
}
return {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)),
"same_domain_face_count": topology.get("same_domain_face_count", 1),
"same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"),
"first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0),
"first_level_face_ids": topology.get("first_level_face_ids", (face_id,)),
"first_level_face_count": topology.get("first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
}
def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]: def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]:
if self.model is None: if self.model is None:
return dict(fallback_info) return dict(fallback_info)
if "associated_feature_infos" in fallback_info:
return dict(fallback_info)
surface = str(fallback_info.get("surface", "") or "") surface = str(fallback_info.get("surface", "") or "")
if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES: if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES:
return dict(fallback_info) return dict(fallback_info)
@@ -153,7 +246,7 @@ class WindowStateMixin:
info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认") info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认")
elif surface == "cone": elif surface == "cone":
info.setdefault("feature_type", "圆锥面候选") info.setdefault("feature_type", "圆锥面候选")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径(整体缩放)") info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径/半角;程序会按几何选择重建或局部重切")
elif surface == "sphere": elif surface == "sphere":
info.setdefault("feature_type", "球面候选") info.setdefault("feature_type", "球面候选")
info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)") info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)")
@@ -168,8 +261,139 @@ class WindowStateMixin:
): ):
if key in fallback_info: if key in fallback_info:
info.setdefault(key, fallback_info[key]) info.setdefault(key, fallback_info[key])
if surface == "plane":
info.update(self._face_first_level_selection_fields(face_id))
return info return info
def _feature_context_info(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
detection_level = self._current_feature_detection_level()
if detection_level == "current-only":
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
else:
root_info = self.model.feature_info(face_id)
if str(root_info.get("surface", "") or "") == "plane":
root_info.update(self._face_first_level_selection_fields(face_id))
associated: list[dict[str, object]] = []
if detection_level in {"associated-only", "secondary"}:
try:
associated = self.model.associated_feature_infos(face_id)
except Exception:
associated = []
if detection_level == "secondary":
associated = self._secondary_associated_feature_infos(face_id, associated)
highlight_ids = set(_int_values(root_info.get("feature_highlight_face_ids")) or [face_id])
for item in associated:
highlight_ids.update(_int_values(item.get("feature_highlight_face_ids")))
source_id = _int_or_none(item.get("association_source_face_id"))
if source_id is not None:
highlight_ids.add(source_id)
level_label = (
"二级特征"
if detection_level == "secondary"
else ("相邻特征" if detection_level == "associated-only" else "当前特征")
)
info = dict(root_info)
info.update(
{
"associated_feature_infos": associated,
"associated_feature_count": len(associated),
"feature_detection_level": level_label,
"associated_feature_face_ids": tuple(
sorted(
{
int(item.get("association_source_face_id", -1))
for item in associated
if _int_or_none(item.get("association_source_face_id")) is not None
}
)
),
"feature_highlight_face_ids": tuple(sorted(highlight_ids)),
"feature_context_note": (
f"已按“{level_label}”沿共享边拓扑探测当前特征及 {len(associated)} 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
if associated
else (
"当前为轻量识别:只读取被点击对象本身,不自动扫描周边拓扑;需要更多关联时可切换到“探测相邻特征”。"
if detection_level == "current-only"
else f"已按“{level_label}”沿共享边拓扑探测局部邻域,未发现额外的可参数化关联特征。"
)
),
}
)
return info
def _current_feature_detection_level(self) -> str:
combo = getattr(self, "feature_detection_combo", None)
if isinstance(combo, NoWheelComboBox):
value = combo.currentData()
if isinstance(value, str) and value:
return value
return str(getattr(self, "feature_detection_level", "current-only") or "current-only")
def _on_feature_detection_level_changed(self) -> None:
self.feature_detection_level = self._current_feature_detection_level()
if self.model is None or self.selected_kind != "feature" or self.selected_face_id is None:
return
try:
info = self._feature_context_info(self.selected_face_id)
except Exception:
return
self.current_info_values = dict(info)
self.current_info_text = "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
self._refresh_property_editor()
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id])
self._update_selected_object_title()
def _secondary_associated_feature_infos(
self,
source_face_id: int,
direct_infos: list[dict[str, object]],
) -> list[dict[str, object]]:
if self.model is None:
return list(direct_infos)
results: list[dict[str, object]] = []
seen: set[tuple[str, tuple[int, ...]]] = set()
def add(info: dict[str, object]) -> None:
source_id = _int_or_none(info.get("association_source_face_id"))
if source_id is None or source_id == source_face_id:
return
face_ids = tuple(sorted(_int_values(info.get("feature_highlight_face_ids")) or [source_id]))
identity = (str(info.get("feature_type", "") or info.get("feature_guess", "")), face_ids)
if identity in seen:
return
seen.add(identity)
results.append(dict(info))
for info in direct_infos:
add(info)
for info in list(results):
parent_id = _int_or_none(info.get("association_source_face_id"))
if parent_id is None:
continue
try:
for nested in self.model.associated_feature_infos(
parent_id,
max_depth=2,
max_scan_faces=48,
max_features=6,
):
add(nested)
except Exception:
continue
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:14]
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str: def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
if pick_position is None: if pick_position is None:
return message return message
@@ -238,7 +462,7 @@ class WindowStateMixin:
) )
self._set_control_state( self._set_control_state(
self.reload_button, self.reload_button,
has_loaded_model and not busy, not busy and bool(getattr(self, "step_path", None)),
"按当前显示路径读取 STEP 模型。", "按当前显示路径读取 STEP 模型。",
wait_or_load_tip, wait_or_load_tip,
) )
@@ -788,13 +1012,22 @@ class WindowStateMixin:
def _update_selected_object_title(self) -> None: def _update_selected_object_title(self) -> None:
if not hasattr(self, "object_edit_box"): if not hasattr(self, "object_edit_box"):
return return
self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}") section_label = "几何对象(高级)" if self.selected_kind in {"face", "edge", "solid", "part"} else "特征参数"
self.object_edit_box.setTitle(f"{section_label}{self._selected_object_title_suffix()}")
def _selected_object_title_suffix(self) -> str: def _selected_object_title_suffix(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None: if self.selected_kind == "part" and self.selected_part_id is not None:
return f"零件 {self.selected_part_id}" return f"零件 {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None: if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"Solid {self.selected_solid_id}" return f"Solid {self.selected_solid_id}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label() or "特征"
confidence = str(self.current_info_values.get("confidence", "") or "")
confidence_label = {"high": "", "medium": "", "low": ""}.get(confidence, "")
confidence_suffix = f"(置信度:{confidence_label}" if confidence_label else ""
related_count = int(self.current_info_values.get("associated_feature_count", 0) or 0)
related_suffix = f" · 关联 {related_count}" if related_count else ""
return f"{feature_label}{confidence_suffix} · 来源 Face {self.selected_face_id}{related_suffix}"
if self.selected_kind == "feature" and self.selected_face_id is not None: if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label() feature_label = self._selected_feature_label()
if feature_label: if feature_label:
@@ -1147,10 +1380,10 @@ class WindowStateMixin:
self.property_expand_button.setVisible(has_hidden_rows) self.property_expand_button.setVisible(has_hidden_rows)
if expanded: if expanded:
self.property_expand_button.setText(f"收起到前 {collapsed_rows}") self.property_expand_button.setText(f"收起到前 {collapsed_rows}")
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几") self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几")
else: else:
self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)") self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。") self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
def toggle_property_table_expanded(self) -> None: def toggle_property_table_expanded(self) -> None:
if not hasattr(self, "property_table"): if not hasattr(self, "property_table"):
@@ -1172,6 +1405,8 @@ class WindowStateMixin:
action_info: dict[str, object], action_info: dict[str, object],
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
editable_specs, used_keys = self._editable_property_specs(action_info) editable_specs, used_keys = self._editable_property_specs(action_info)
if self.selected_kind == "feature":
return self._feature_context_property_specs(editable_specs, action_info)
specs = list(editable_specs) specs = list(editable_specs)
for key, value in self._ordered_property_info_items(info): for key, value in self._ordered_property_info_items(info):
if key in used_keys: if key in used_keys:
@@ -1180,7 +1415,7 @@ class WindowStateMixin:
{ {
"key": key, "key": key,
"label": INFO_LABELS.get(key, key), "label": INFO_LABELS.get(key, key),
"current_text": _format_value(value), "current_text": _format_info_value(key, value),
"current_raw": value, "current_raw": value,
"target_text": "", "target_text": "",
"editable": False, "editable": False,
@@ -1204,6 +1439,86 @@ class WindowStateMixin:
) )
return specs return specs
def _feature_property_specs(
self,
specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
allowed_keys = _feature_dimension_keys(action_info)
spec_by_key = {str(spec.get("key", "")): spec for spec in specs}
dimensions: list[dict[str, object]] = []
for key in allowed_keys:
spec = spec_by_key.get(key)
if spec is None or not bool(spec.get("editable")) or not bool(spec.get("enabled")):
continue
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
label_overrides = {
"local_face_width": "长度",
"local_face_height": "宽度",
"shell_thickness_estimate": "高度/深度",
}
if key in label_overrides:
dimension["label"] = label_overrides[key]
dimensions.append(dimension)
explanations = [
dict(spec)
for spec in specs
if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS
]
if not dimensions:
dimensions.append(
{
"key": "no_editable_feature_dimensions",
"label": "可变尺寸",
"current_text": "未识别到可靠的独立尺寸",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"disabled_tip": (
"当前几何仍可在诊断信息中查看,但不会把面积、中心、包围盒或底层曲面参数"
"伪装成特征设计尺寸。"
),
}
)
return dimensions + explanations
def _feature_context_property_specs(
self,
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
root_rows = self._feature_property_specs(root_specs, action_info)
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_rows
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"]
related_rows: list[dict[str, object]] = []
for index, related_info in enumerate(associated, start=1):
if not isinstance(related_info, dict):
continue
related_specs, _used = self._editable_property_specs(related_info)
feature_label = str(related_info.get("feature_type") or related_info.get("feature_guess") or "关联特征")
source_face_id = _int_or_none(related_info.get("association_source_face_id"))
for spec in self._feature_property_specs(related_specs, related_info):
if spec.get("parameter_role") != "dimension":
continue
related = dict(spec)
related["label"] = f"{feature_label} · {spec.get('label', '')}"
related["scope_text"] = f"关联 Face {source_face_id}" if source_face_id is not None else f"关联特征 {index}"
related["source_face_id"] = source_face_id
related["source_feature_info"] = dict(related_info)
related["association_index"] = index
related_rows.append(related)
return root_dimensions + related_rows + root_explanations
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info) items = self._ordered_info_items(info)
if not self._is_feature_like_info(info): if not self._is_feature_like_info(info):
@@ -1456,6 +1771,75 @@ class WindowStateMixin:
return base return base
return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}" return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}"
def cone_semi_angle_capability(current_angle_degrees: float | None) -> tuple[bool, str]:
if current_angle_degrees is None:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_semi_angle_plan")
):
return True, ""
delta = max(1.0, min(5.0, abs(current_angle_degrees) * 0.25))
probe_target = current_angle_degrees + delta
if probe_target >= 89.0:
probe_target = max(0.1, current_angle_degrees - delta)
if abs(probe_target - current_angle_degrees) <= 1e-7:
return False, "当前圆锥半角太接近允许范围边界,不能稳定探测可编辑性。"
try:
plan = self.model.conical_semi_angle_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥半角是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-semi-angle") or strategy == "radial-affine-scale-cone-semi-angle":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的圆锥半角参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥半角不能稳定修改。")
return True, ""
def cone_reference_radius_capability(current_radius: float | None) -> tuple[bool, str]:
if current_radius is None or current_radius <= 0:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_reference_radius_plan")
):
return True, ""
probe_target = current_radius * 1.05
try:
plan = self.model.conical_reference_radius_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥参考半径是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-reference-radius") or strategy.startswith(
"blocked-cone-reference-radius"
) or strategy == "radial-affine-scale-cone-reference-radius":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的参考半径/直径参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥参考半径不能稳定修改。")
return True, ""
def add_spec( def add_spec(
*, *,
key: str, key: str,
@@ -1641,6 +2025,26 @@ class WindowStateMixin:
), ),
) )
elif is_plane or is_shell_candidate: elif is_plane or is_shell_candidate:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1:
same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("first_level_boundary_edge_count")) or 0
boundary_vertex_count = _int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
topology_tip = "\n".join(item for item in (topology_note, ignored_note) if item) or (
"当前阶段只处理当前 Face、同域碎片、边界 Edge/Vertex 和共享边相邻 Face;二级、三级关系暂不自动传播。"
)
add_readonly_spec(
key="face_first_level_topology",
label="一级关系",
text=(
f"Face 区域 {same_domain_count} 个;边界 Edge {boundary_edge_count} 条;"
f"边界 Vertex {boundary_vertex_count} 个;共享边相邻 Face {adjacent_face_count} 个。"
),
tip=topology_tip,
)
face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。" face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。"
face_semantics_tip = ( face_semantics_tip = (
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;" "面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
@@ -2896,33 +3300,49 @@ class WindowStateMixin:
current_reference_diameter = ( current_reference_diameter = (
current_reference_radius * 2.0 if current_reference_radius is not None else None current_reference_radius * 2.0 if current_reference_radius is not None else None
) )
reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability(
current_reference_radius
)
reference_radius_enabled = current_reference_radius is not None and reference_radius_supported
add_spec( add_spec(
key="cone_reference_radius", key="cone_reference_radius",
label="参考半径(整体)", label="参考半径",
current_raw=current_reference_radius if current_reference_radius is not None else "", current_raw=current_reference_radius if current_reference_radius is not None else "",
target_text=numeric_text(current_reference_radius), target_text=numeric_text(current_reference_radius),
action="resize_cone_reference_radius", action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None, enabled=reference_radius_enabled,
enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴径向缩放所属对象", enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。", disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考半径,不能直接修改。"
),
value_type="positive", value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}", range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}"
),
used=("reference_radius",), used=("reference_radius",),
**positive_minimum(), **positive_minimum(),
) )
add_spec( add_spec(
key="cone_reference_diameter", key="cone_reference_diameter",
label="参考直径(整体)", label="参考直径",
current_raw=current_reference_diameter if current_reference_diameter is not None else "", current_raw=current_reference_diameter if current_reference_diameter is not None else "",
target_text=numeric_text(current_reference_diameter), target_text=numeric_text(current_reference_diameter),
action="resize_cone_reference_radius", action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=current_reference_diameter is not None, enabled=current_reference_diameter is not None and reference_radius_supported,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放所属对象", enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切",
disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。", disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考直径,不能直接修改。"
),
value_type="positive", value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}", range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}"
),
target_transform="diameter_to_radius", target_transform="diameter_to_radius",
used=("feature_reference_diameter",), used=("feature_reference_diameter",),
**positive_minimum(), **positive_minimum(),
@@ -2931,25 +3351,32 @@ class WindowStateMixin:
current_semi_angle_degrees = ( current_semi_angle_degrees = (
abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None
) )
semi_angle_supported, semi_angle_disabled_reason = cone_semi_angle_capability(current_semi_angle_degrees)
semi_angle_enabled = (
current_reference_radius is not None
and current_semi_angle is not None
and semi_angle_supported
)
add_spec( add_spec(
key="cone_semi_angle_degrees", key="cone_semi_angle_degrees",
label="半角(整体)", label="圆锥半角",
current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "", current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "",
target_text=numeric_text(current_semi_angle_degrees), target_text=numeric_text(current_semi_angle_degrees),
action="resize_cone_reference_radius", action="resize_cone_semi_angle",
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None and current_semi_angle is not None, enabled=semi_angle_enabled,
enabled_tip="输入圆锥面的目标半角,单位是度;程序会换算为目标参考半径后围绕圆锥轴径向缩放所属对象", enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。", disabled_tip=(
semi_angle_disabled_reason
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
),
value_type="positive", value_type="positive",
range_hint="这是整体径向缩放所属对象,不是只替换单个圆锥面的历史半角参数;建议先小幅修改,当前版本要求半角大于 0 且小于 89 度。", range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先保持小端半径和深度,只改变锥孔开口。"
"复杂圆锥/拔模面暂不使用整体缩放兜底。当前版本要求半角大于 0 且小于 89 度。"
),
max_value=89.0, max_value=89.0,
max_exclusive=True, max_exclusive=True,
target_transform="cone_semi_angle_degrees_to_reference_radius",
transform_context={
"current_reference_radius": current_reference_radius,
"current_semi_angle": current_semi_angle,
},
used=("semi_angle",), used=("semi_angle",),
**positive_minimum(), **positive_minimum(),
) )
@@ -3971,6 +4398,31 @@ class WindowStateMixin:
row, _spec, _text = changed[0] row, _spec, _text = changed[0]
self.apply_property_row_edit(row) self.apply_property_row_edit(row)
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None or source_face_id == self.selected_face_id:
return
if self.model is None or source_face_id < 0 or source_face_id >= len(self.model.faces):
raise ValueError("关联特征已经失效,请重新选择模型对象。")
info = spec.get("source_feature_info")
if not isinstance(info, dict):
info = self._feature_context_info(source_face_id)
else:
info = dict(info)
self.selected_kind = "feature"
self.selected_face_id = source_face_id
self.selected_edge_id = None
self.selected_part_id = int(info.get("part_id", self.model.face_part_ids[source_face_id]))
self.selected_solid_id = int(info.get("solid_id", self.model.face_solid_ids[source_face_id]))
self.current_info_values = dict(info)
self.current_info_text = "\n".join(
f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items()
)
self._sync_id_picker("Feature", source_face_id)
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [source_face_id])
self._update_selected_object_title()
def apply_property_row_edit(self, row: int) -> None: def apply_property_row_edit(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", []) specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs): if row < 0 or row >= len(specs):
@@ -3990,6 +4442,7 @@ class WindowStateMixin:
QMessageBox.information(self, "目标值无效", validation_error) QMessageBox.information(self, "目标值无效", validation_error)
return return
try: try:
self._activate_property_source_feature(spec)
if not is_command: if not is_command:
self._sync_property_edit_target(spec, text) self._sync_property_edit_target(spec, text)
self._sync_property_preselects(spec) self._sync_property_preselects(spec)