feat: 完善 Face 一级关系编辑和稳定性
This commit is contained in:
@@ -79,6 +79,7 @@ python scripts\verify_analytic_surface_resize.py
|
||||
python scripts\verify_boss_resize.py
|
||||
python scripts\verify_hole_resize.py
|
||||
python scripts\verify_slot_resize.py
|
||||
python scripts\verify_cylindrical_first_level_topology.py
|
||||
```
|
||||
|
||||
11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行:
|
||||
@@ -123,7 +124,7 @@ git diff --check
|
||||
- 用户可以选择零件、Solid、Face、Edge 和几何特征。
|
||||
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
|
||||
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
|
||||
- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
|
||||
- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
|
||||
- 用户可以撤销/重做修改,避免一步编辑做坏。
|
||||
- 用户可以导出修改后的完整模型。
|
||||
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
|
||||
@@ -136,7 +137,7 @@ git diff --check
|
||||
|
||||
所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 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 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。
|
||||
|
||||
@@ -193,7 +194,7 @@ git diff --check
|
||||
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`推拉端盖`、`移动凸台`、`调整整个特征` 或 `移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
|
||||
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
|
||||
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
|
||||
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)`、`参考直径(整体)` 或 `半角(整体)`;球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 |
|
||||
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 |
|
||||
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
|
||||
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
|
||||
|
||||
@@ -512,19 +513,23 @@ vertices: 3262
|
||||
|
||||
建议下一步按这个顺序推进:
|
||||
|
||||
1. 先集中完成 Face 修改能力:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面`、`推拉当前面`、`移动整个特征`、`调整整个特征` 等语义,并保证 `python scripts\verify_face_edit_suite.py` 能集中覆盖。
|
||||
2. Face 闭环稳定后,再集中做 Edge:先把直线 Edge 长度、起点、中心、终点和倒圆/倒角语义做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback。
|
||||
3. Edge 闭环稳定后,再集中做槽/半孔:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度和中心距要统一验证。
|
||||
4. 槽闭环稳定后,再集中做孔/圆柱、盲孔/盲槽、凸台、已有圆角、圆锥/球/环面等对象;每类都先补能力和验证,再进入下一类。
|
||||
1. 先集中完成 Face 一级拓扑关系编辑:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面`、`推拉当前面`、`移动整个特征`、`调整整个特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。
|
||||
2. Face 一级关系闭环稳定后,再集中做槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证。
|
||||
3. 槽/孔一级关系稳定后,再集中做 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback。
|
||||
4. Edge 闭环稳定后,再集中做凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。
|
||||
5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。
|
||||
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
|
||||
|
||||
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_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 阶段闭环后,再进入 Edge 阶段;不要在 Face 还没有稳定解释和验证时提前分散到槽、孔或圆锥/球/环面。
|
||||
- Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象。
|
||||
|
||||
## 怎么使用
|
||||
|
||||
@@ -924,9 +929,11 @@ pythonocc-step-editor/
|
||||
GeometryParametric.spec # PyInstaller 文件夹版打包配置
|
||||
package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip
|
||||
generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具
|
||||
verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证圆锥半角、解析曲面整体缩放
|
||||
verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证简单圆锥解析重建和曲面整体缩放
|
||||
verify_cone_semi_angle_isolation.py # 验证圆锥半角/参考半径隔离执行和嵌入式锥孔局部重切
|
||||
verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改
|
||||
verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证
|
||||
verify_edge_coordinate_edit.py # 验证直线 Edge 起点/中心/终点坐标局部修改
|
||||
verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放
|
||||
verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线
|
||||
verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改
|
||||
@@ -947,6 +954,7 @@ pythonocc-step-editor/
|
||||
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
|
||||
verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改
|
||||
verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改
|
||||
verify_cylindrical_first_level_topology.py # 临时生成孔/槽 STEP 并验证圆柱孔、盲孔、半圆槽、长圆槽的一级共边拓扑字段
|
||||
step_editor/
|
||||
__init__.py # 包导出
|
||||
app.py # 主窗口初始化、UI 搭建和程序启动
|
||||
@@ -1041,12 +1049,15 @@ git diff --check
|
||||
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`。
|
||||
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
|
||||
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。
|
||||
- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证圆锥参考半径、圆锥半角、球面半径、环面主半径和环面小半径整体缩放。
|
||||
- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。
|
||||
- `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。
|
||||
- `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_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_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 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_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。
|
||||
- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
|
||||
|
||||
- `scripts/verify_cylindrical_first_level_topology.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,验证孔/槽类圆柱特征的修改计划会暴露一级共边拓扑关系,包括圆柱侧壁、边界 Edge、边界 Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。
|
||||
- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
|
||||
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
|
||||
- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。
|
||||
@@ -1089,7 +1102,8 @@ git diff --check
|
||||
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
|
||||
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。
|
||||
- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。
|
||||
- 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高 和中心统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。
|
||||
- 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。
|
||||
- 编辑语义:Face 的面偏移、面积、面宽/面高 和中心通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;圆锥 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,`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,将面积从 `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_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,所以脚本用主/小轴采样半径校验几何结果。
|
||||
|
||||
@@ -1165,3 +1181,11 @@ Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临
|
||||
- 运行导出的 `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json` 默认不提交。
|
||||
- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。
|
||||
- 工作区可能有用户改动,不能随意 `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 |
@@ -4,9 +4,11 @@ import os
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
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')]
|
||||
binaries = []
|
||||
hiddenimports = [
|
||||
'step_editor.isolated_edit_worker',
|
||||
'PySide6.QtCore',
|
||||
'PySide6.QtGui',
|
||||
'PySide6.QtWidgets',
|
||||
@@ -65,6 +67,7 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=icon_path,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -54,6 +54,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"Edge length scale owning object, fixed 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",
|
||||
("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())
|
||||
@@ -14,6 +14,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"face width, current face only",
|
||||
("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",
|
||||
("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",
|
||||
("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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)):
|
||||
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)
|
||||
push_plan = model.push_pull_plan(face_id, 1.0)
|
||||
if push_plan.get("status") == "blocked":
|
||||
|
||||
@@ -281,6 +281,8 @@ def main() -> int:
|
||||
resolved_strategy = "push-pull-planar-face"
|
||||
if resolved_strategy != expected_strategy:
|
||||
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()
|
||||
if after.solids != before.solids:
|
||||
|
||||
@@ -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())
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
import tempfile
|
||||
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
|
||||
@@ -73,6 +73,46 @@ def _run_worker_case(
|
||||
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:
|
||||
try:
|
||||
return abs(float(value) - target) <= tolerance
|
||||
@@ -80,6 +120,15 @@ def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
|
||||
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:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
candidates: list[tuple[int, int]] = []
|
||||
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}")
|
||||
|
||||
|
||||
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:
|
||||
_run_worker_case(
|
||||
label="面偏移(当前面)",
|
||||
@@ -252,6 +345,39 @@ def main() -> int:
|
||||
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
|
||||
|
||||
class _IsolatedJobProbe(WindowActionMixin):
|
||||
@@ -260,6 +386,29 @@ def main() -> int:
|
||||
self.step_path = DEFAULT_MODEL
|
||||
|
||||
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 (
|
||||
"面宽(当前面)",
|
||||
"面高(当前面)",
|
||||
@@ -291,12 +440,15 @@ def main() -> int:
|
||||
snapshot=snapshot,
|
||||
before_stats=probe.model.stats(),
|
||||
before_part_stats=probe.model.part_topology_stats(1),
|
||||
before_quality=probe._edit_quality_info_or_none(probe.model, context, 1),
|
||||
before_geometry={},
|
||||
)
|
||||
if "隔离子进程" not in str(result.get("message", "")):
|
||||
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
|
||||
if probe.model.stats().solids != 1:
|
||||
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
|
||||
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")
|
||||
|
||||
owning_probe = _IsolatedJobProbe()
|
||||
@@ -317,12 +469,16 @@ def main() -> int:
|
||||
snapshot=owning_probe.model.snapshot(),
|
||||
before_stats=owning_probe.model.stats(),
|
||||
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={},
|
||||
)
|
||||
if "隔离子进程" not in str(owning_result.get("message", "")):
|
||||
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
|
||||
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
|
||||
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")
|
||||
_run_main_worker_entry_case()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -36,6 +36,13 @@ def _specs(info: dict[str, object]) -> list[dict[str, object]]:
|
||||
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]:
|
||||
for spec in specs:
|
||||
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}")
|
||||
|
||||
|
||||
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]:
|
||||
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
|
||||
hits: list[str] = []
|
||||
@@ -292,6 +326,14 @@ def main() -> int:
|
||||
"plane_origin": (0.0, 0.0, 0.0),
|
||||
"push_pull_outward_direction": (0.0, 0.0, 1.0),
|
||||
"normal": (0.0, 0.0, 1.0),
|
||||
"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_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_height", "面高")
|
||||
_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)
|
||||
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, "local_face_width", 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:
|
||||
keys = _spec_keys(info)
|
||||
specs = _specs(info)
|
||||
keys = {str(spec.get("key", "")) for spec in specs}
|
||||
_assert_no_generic_face_leak(keys, 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_holed_plane_local_scopes_disabled()
|
||||
|
||||
+130
-29
@@ -8,7 +8,11 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
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.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
@@ -54,7 +58,19 @@ from .window_state import WindowStateMixin
|
||||
|
||||
_CRASH_LOG_HANDLE = None
|
||||
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
|
||||
# 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"}
|
||||
|
||||
|
||||
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):
|
||||
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__()
|
||||
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
|
||||
self.setWindowTitle("几何参数化")
|
||||
self.setWindowIcon(_application_icon())
|
||||
self.resize(1280, 820)
|
||||
|
||||
self.model: StepModel | None = None
|
||||
@@ -110,11 +154,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.hover_face_actor = None
|
||||
self.hover_edge_actor = None
|
||||
self.hover_signature: tuple[str, int] | None = None
|
||||
self.hover_interval_ms = 140
|
||||
self.hover_move_threshold_px = 8
|
||||
self.hover_interval_ms = 260
|
||||
self.hover_move_threshold_px = 10
|
||||
self.pending_hover_position: tuple[int, int] | None = None
|
||||
self.last_hover_pick_position: tuple[int, int] | None = None
|
||||
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.left_button_press_position: tuple[int, int] | None = None
|
||||
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_worker: LoadWorker | None = None
|
||||
self.pending_load_path: Path | None = None
|
||||
self.initial_load_deflection = 2.0
|
||||
self.edit_result_deflection = 1.6
|
||||
self.last_id_kind = "Face"
|
||||
self.auto_load_on_show = bool(background_load)
|
||||
self.vtk_interactor_started = False
|
||||
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_specs: list[dict[str, object]] = []
|
||||
self.property_table_expanded = False
|
||||
@@ -190,7 +241,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
|
||||
self._build_ui()
|
||||
self._build_vtk()
|
||||
self.load_step(self.step_path, background=background_load)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
help_tip = self._set_help_tip
|
||||
@@ -303,7 +353,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
min-width: 8px;
|
||||
}
|
||||
QLabel#mouseModeLabel,
|
||||
QLabel#idSelectLabel {
|
||||
QLabel#idSelectLabel,
|
||||
QLabel#featureDetectionLabel {
|
||||
color: #4736b3;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -681,9 +732,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.mouse_mode_label.setMaximumWidth(62)
|
||||
help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。")
|
||||
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._set_selection_mode("Face")
|
||||
self._set_selection_mode("Feature")
|
||||
self.mode_combo.setMinimumWidth(94)
|
||||
self.mode_combo.setMaximumWidth(112)
|
||||
help_tip(
|
||||
@@ -703,8 +754,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.mode_id_separator = mode_separator
|
||||
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。")
|
||||
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.setVisible(False)
|
||||
select_layout = QHBoxLayout(id_select_panel)
|
||||
select_layout.setContentsMargins(8, 10, 8, 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.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_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_input)
|
||||
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_selected_button)
|
||||
|
||||
self.object_edit_box = QGroupBox("当前选中对象:未选择")
|
||||
self.object_edit_box = QGroupBox("特征参数:未选择")
|
||||
self.object_edit_box.setObjectName("editSection")
|
||||
help_tip(
|
||||
self.object_edit_box,
|
||||
"选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。",
|
||||
"选择特征后,这里只列出可可靠修改的语义尺寸;面积、中心和拓扑数据位于下方诊断信息。",
|
||||
)
|
||||
object_edit_layout = QVBoxLayout(self.object_edit_box)
|
||||
object_edit_layout.setContentsMargins(0, 8, 0, 4)
|
||||
object_edit_layout.setSpacing(4)
|
||||
self.property_table = QTableWidget(0, 5)
|
||||
self.property_table.setObjectName("propertyTable")
|
||||
self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "影响范围", "目标值", "操作"])
|
||||
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "修改方式", "目标值", "操作"])
|
||||
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
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(3, QHeaderView.ResizeMode.Fixed)
|
||||
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||||
self.property_table.setColumnWidth(0, 82)
|
||||
self.property_table.setColumnWidth(1, 78)
|
||||
self.property_table.setColumnWidth(2, 104)
|
||||
self.property_table.setColumnWidth(3, 82)
|
||||
self.property_table.setColumnWidth(4, 58)
|
||||
self.property_table.setColumnWidth(0, 148)
|
||||
self.property_table.setColumnWidth(1, 68)
|
||||
self.property_table.setColumnWidth(2, 96)
|
||||
self.property_table.setColumnWidth(3, 72)
|
||||
self.property_table.setColumnWidth(4, 54)
|
||||
help_tip(
|
||||
self.property_table,
|
||||
"当前选中对象的属性表。可修改的行可以选择影响范围、输入目标值,并点击操作列里的按钮只应用这一行。",
|
||||
"特征模式显示当前特征及局部关联特征的可变尺寸;输入目标值后,可按行应用或使用下方按钮应用当前修改。",
|
||||
)
|
||||
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
|
||||
object_edit_layout.addWidget(self.property_table)
|
||||
self.property_expand_button = QPushButton("展开全部属性")
|
||||
self.property_expand_button = QPushButton("展开全部参数")
|
||||
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.setVisible(False)
|
||||
self.property_expand_button.setMaximumHeight(22)
|
||||
@@ -1326,10 +1407,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
history_layout.addLayout(history_buttons)
|
||||
panel_layout.addWidget(history_box)
|
||||
|
||||
info_box = QGroupBox("选中对象信息")
|
||||
panel_layout.addStretch(1)
|
||||
|
||||
info_box = QGroupBox("诊断信息(高级)")
|
||||
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)
|
||||
self.diagnostic_info_content = QWidget()
|
||||
diagnostic_info_layout = QVBoxLayout(self.diagnostic_info_content)
|
||||
diagnostic_info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
info_buttons = QHBoxLayout()
|
||||
self.copy_id_button = QPushButton("复制 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_pick_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_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_text, "原始文本")
|
||||
help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。")
|
||||
info_layout.addWidget(self.info_tabs)
|
||||
panel_layout.addWidget(info_box, stretch=1)
|
||||
diagnostic_info_layout.addWidget(self.info_tabs)
|
||||
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()
|
||||
|
||||
@@ -1368,7 +1459,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.vtk_widget.installEventFilter(self)
|
||||
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:
|
||||
widget.setToolTip(text)
|
||||
@@ -1388,10 +1479,20 @@ def _parse_args(argv: list[str]) -> tuple[Path, bool]:
|
||||
|
||||
|
||||
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):
|
||||
_enable_crash_log()
|
||||
path, smoke_test = _parse_args(sys.argv)
|
||||
_set_windows_taskbar_identity()
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("几何参数化")
|
||||
app.setApplicationDisplayName("几何参数化")
|
||||
app.setOrganizationName("GeometryParametric")
|
||||
app.setWindowIcon(_application_icon())
|
||||
window = StepEditorWindow(path, background_load=not smoke_test)
|
||||
if smoke_test:
|
||||
print("smoke test ok")
|
||||
@@ -1399,7 +1500,7 @@ def main() -> int:
|
||||
app.quit()
|
||||
return 0
|
||||
window.show()
|
||||
window.vtk_widget.Start()
|
||||
window._ensure_vtk_interactor_started()
|
||||
return app.exec()
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,185 @@ from .geometry_utils import * # noqa: F403
|
||||
|
||||
|
||||
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(
|
||||
self,
|
||||
limit: int = 160,
|
||||
@@ -727,6 +906,7 @@ class FeatureMixin:
|
||||
}
|
||||
current_diameter = float(info["diameter"])
|
||||
feature = self.feature_info(face_id)
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
axis_range = self._cylindrical_axis_range(
|
||||
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.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 = 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)
|
||||
delta_diameter = new_diameter - current_diameter
|
||||
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
|
||||
@@ -781,6 +970,7 @@ class FeatureMixin:
|
||||
"resize_strategy": "same-axis-bounded-cylinder-recut",
|
||||
"edit_strategy_label": "同轴圆柱重切",
|
||||
"edit_semantics": edit_semantics,
|
||||
**topology_fields,
|
||||
**cutter_plan,
|
||||
**fill_plan,
|
||||
}
|
||||
@@ -799,12 +989,14 @@ class FeatureMixin:
|
||||
feature = self.feature_info(face_id)
|
||||
except Exception:
|
||||
feature = {}
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = [
|
||||
"Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis."
|
||||
]
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
|
||||
|
||||
try:
|
||||
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
|
||||
@@ -897,6 +1089,7 @@ class FeatureMixin:
|
||||
return {
|
||||
**cutter_plan,
|
||||
**fill_plan,
|
||||
**topology_fields,
|
||||
"status": status,
|
||||
"risk": risk,
|
||||
"message": message,
|
||||
@@ -965,6 +1158,7 @@ class FeatureMixin:
|
||||
feature = self.feature_info(face_id)
|
||||
except Exception:
|
||||
feature = {}
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
|
||||
current_diameter = _float_or_none(info.get("diameter"))
|
||||
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."
|
||||
]
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(
|
||||
topology_fields,
|
||||
blockers,
|
||||
warnings,
|
||||
risk,
|
||||
require_slot_boundary=True,
|
||||
)
|
||||
|
||||
try:
|
||||
target_value = float(target_value)
|
||||
@@ -1067,6 +1268,7 @@ class FeatureMixin:
|
||||
"feature_slot_face_ids": feature.get("feature_slot_face_ids"),
|
||||
"feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"),
|
||||
"slot_note": feature.get("slot_note"),
|
||||
**topology_fields,
|
||||
}
|
||||
if blockers:
|
||||
return {
|
||||
@@ -1141,6 +1343,7 @@ class FeatureMixin:
|
||||
feature = self.feature_info(face_id)
|
||||
except Exception:
|
||||
feature = {}
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
|
||||
current_diameter = _float_or_none(info.get("diameter"))
|
||||
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."
|
||||
]
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(
|
||||
topology_fields,
|
||||
blockers,
|
||||
warnings,
|
||||
risk,
|
||||
require_slot_boundary=True,
|
||||
)
|
||||
|
||||
try:
|
||||
target_angular_span = float(target_angular_span)
|
||||
@@ -1241,6 +1451,7 @@ class FeatureMixin:
|
||||
return {
|
||||
**cutter_plan,
|
||||
**fill_plan,
|
||||
**topology_fields,
|
||||
"status": status,
|
||||
"risk": "blocked" if blockers else risk,
|
||||
"message": message,
|
||||
@@ -1303,12 +1514,20 @@ class FeatureMixin:
|
||||
feature = self.feature_info(face_id)
|
||||
except Exception:
|
||||
feature = {}
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
|
||||
blockers: 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."
|
||||
]
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(
|
||||
topology_fields,
|
||||
blockers,
|
||||
warnings,
|
||||
risk,
|
||||
require_slot_boundary=True,
|
||||
)
|
||||
|
||||
try:
|
||||
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
|
||||
@@ -1473,6 +1692,7 @@ class FeatureMixin:
|
||||
**cutter_plan,
|
||||
**fill_plan,
|
||||
**pair_plan,
|
||||
**topology_fields,
|
||||
"status": status,
|
||||
"risk": "blocked" if blockers else risk,
|
||||
"message": message,
|
||||
@@ -1723,6 +1943,7 @@ class FeatureMixin:
|
||||
raise ValueError(f"Unknown face id {face_id}")
|
||||
info = self.face_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"))
|
||||
if current_diameter is None or current_diameter <= 1e-9:
|
||||
return {
|
||||
@@ -1749,6 +1970,13 @@ class FeatureMixin:
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = []
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(
|
||||
topology_fields,
|
||||
blockers,
|
||||
warnings,
|
||||
risk,
|
||||
require_slot_boundary=True,
|
||||
)
|
||||
|
||||
if not paired_plan:
|
||||
if pair_face_id is None:
|
||||
@@ -1815,6 +2043,7 @@ class FeatureMixin:
|
||||
**cutter_plan,
|
||||
**fill_plan,
|
||||
**paired_plan,
|
||||
**topology_fields,
|
||||
"status": status,
|
||||
"risk": risk,
|
||||
"message": message,
|
||||
@@ -1918,6 +2147,7 @@ class FeatureMixin:
|
||||
|
||||
current_diameter = float(info["diameter"])
|
||||
feature = self.feature_info(face_id)
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
axis_range = self._cylindrical_axis_range(
|
||||
face_id,
|
||||
BRepAdaptor_Surface(self.faces[face_id]),
|
||||
@@ -2130,6 +2360,7 @@ class FeatureMixin:
|
||||
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
|
||||
}
|
||||
feature = self.feature_info(face_id)
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
axis_range = self._cylindrical_axis_range(
|
||||
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.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
|
||||
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["fill_strategy"] = "bounded-hole-suppress-fill"
|
||||
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
|
||||
@@ -2169,6 +2409,7 @@ class FeatureMixin:
|
||||
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
|
||||
"feature_bottom_note": feature.get("feature_bottom_note"),
|
||||
"resize_strategy": "fill-cylindrical-hole-volume",
|
||||
**topology_fields,
|
||||
"edit_strategy_label": "圆柱补料封堵",
|
||||
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
|
||||
**fill_plan,
|
||||
@@ -2191,6 +2432,7 @@ class FeatureMixin:
|
||||
}
|
||||
|
||||
feature = self.feature_info(face_id)
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
context = self._blind_cylindrical_depth_context(
|
||||
face_id,
|
||||
info,
|
||||
@@ -2212,6 +2454,15 @@ class FeatureMixin:
|
||||
context.get("context_message"),
|
||||
)
|
||||
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))
|
||||
delta_depth = target_depth - current_depth
|
||||
@@ -2249,6 +2500,7 @@ class FeatureMixin:
|
||||
"feature_bottom_detection": feature.get("feature_bottom_detection"),
|
||||
"feature_bottom_note": feature.get("feature_bottom_note"),
|
||||
"resize_strategy": "bounded-blind-depth-cut-or-fill",
|
||||
**topology_fields,
|
||||
"edit_strategy_label": "盲孔/盲槽深度切削或补料",
|
||||
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
|
||||
}
|
||||
|
||||
@@ -25,8 +25,17 @@ class InfoPanelMixin:
|
||||
self.current_info_values = dict(info)
|
||||
self.current_info_text = _info_to_text(info)
|
||||
self.info_text.setPlainText(self.current_info_text)
|
||||
self._populate_info_tree(info)
|
||||
self.info_tabs.setCurrentWidget(self.info_tree)
|
||||
diagnostic_content = getattr(self, "diagnostic_info_content", None)
|
||||
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"):
|
||||
self._refresh_property_editor()
|
||||
if hasattr(self, "_update_selected_object_title"):
|
||||
@@ -45,6 +54,15 @@ class InfoPanelMixin:
|
||||
if hasattr(self, "mode_combo"):
|
||||
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:
|
||||
self.info_tree.clear()
|
||||
emitted: set[str] = set()
|
||||
@@ -67,7 +85,7 @@ class InfoPanelMixin:
|
||||
group.setFirstColumnSpanned(True)
|
||||
self.info_tree.addTopLevelItem(group)
|
||||
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.setToolTip(0, key)
|
||||
child.setToolTip(1, _format_value(value))
|
||||
|
||||
@@ -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]))
|
||||
if operation == "resize_shell_thickness_owning_scale":
|
||||
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}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run one high-risk geometry edit in an isolated process.")
|
||||
parser.add_argument("request", help="JSON request file.")
|
||||
parsed = parser.parse_args()
|
||||
|
||||
request_path = Path(parsed.request)
|
||||
def run_request(request: str | Path) -> int:
|
||||
request_path = Path(request)
|
||||
response_path = request_path.with_suffix(".response.json")
|
||||
try:
|
||||
request = json.loads(request_path.read_text(encoding="utf-8-sig"))
|
||||
@@ -83,5 +87,12 @@ def main() -> int:
|
||||
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__":
|
||||
raise SystemExit(main())
|
||||
|
||||
+702
-19
@@ -63,6 +63,7 @@ from OCC.Core.TopAbs import (
|
||||
TopAbs_OUT,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SOLID,
|
||||
TopAbs_VERTEX,
|
||||
TopAbs_WIRE,
|
||||
)
|
||||
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._edge_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._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[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._edge_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._edge_duplicate_key_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._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()
|
||||
|
||||
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]:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown face id {face_id}")
|
||||
@@ -323,16 +344,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
if surface_type == GeomAbs_Plane:
|
||||
plane = surf.Plane()
|
||||
direction = plane.Axis().Direction()
|
||||
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
|
||||
info["plane_origin"] = _point_tuple(plane.Location())
|
||||
info["normal"] = _dir_tuple(direction)
|
||||
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
|
||||
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
|
||||
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
|
||||
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_confidence"] = "unchecked"
|
||||
info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。"
|
||||
info["push_pull_status"] = "candidate"
|
||||
info["feature_type"] = "可推拉平面候选"
|
||||
info["feature_source_face_id"] = face_id
|
||||
@@ -353,7 +369,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
radius = cyl.Radius()
|
||||
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
|
||||
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["diameter"] = radius * 2.0
|
||||
info["axis_point"] = _point_tuple(axis.Location())
|
||||
@@ -361,13 +376,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
info["angular_span"] = u_span
|
||||
info["is_full_cylinder"] = u_span >= math.tau * 0.98
|
||||
info["height_estimate"] = props.Mass() / swept_area
|
||||
info["feature_guess"] = classification["feature_guess"]
|
||||
info["confidence"] = classification["confidence"]
|
||||
info["material_toward_axis"] = classification["toward_axis"]
|
||||
info["material_away_axis"] = classification["away_axis"]
|
||||
info["material_vote_summary"] = classification["vote_summary"]
|
||||
info["material_sample_count"] = classification["sample_count"]
|
||||
info["note"] = classification["note"]
|
||||
info["feature_guess"] = "cylindrical face"
|
||||
info["confidence"] = "unchecked"
|
||||
info["material_toward_axis"] = "not sampled"
|
||||
info["material_away_axis"] = "not sampled"
|
||||
info["material_vote_summary"] = "quick selection skips material sampling"
|
||||
info["material_sample_count"] = 0
|
||||
info["note"] = "快速选择阶段不判断孔/槽/凸台;需要语义识别时切换特征探测级别。"
|
||||
info["feature_source_face_id"] = face_id
|
||||
info["feature_highlight_face_ids"] = (face_id,)
|
||||
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)
|
||||
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]:
|
||||
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
||||
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",
|
||||
"feature_type": "环面候选",
|
||||
"feature_type": prismatic_info.get("feature_type", "可推拉平面候选"),
|
||||
"feature_source_face_id": face_id,
|
||||
"feature_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_edit_actions": "修改环面主半径/小半径",
|
||||
"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]:
|
||||
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
||||
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.update(
|
||||
{
|
||||
@@ -633,11 +786,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
||||
"feature_reference_radius": reference_radius,
|
||||
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
|
||||
"feature_edit_actions": "修改圆锥参考半径/直径",
|
||||
"feature_edit_actions": "修改圆锥参考半径/直径/半角",
|
||||
"feature_mode": (
|
||||
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放,"
|
||||
"不是 CAD 历史里的锥孔或倒角参数。"
|
||||
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;简单圆锥会解析重建,"
|
||||
"嵌入式锥孔/沉孔会优先局部重切,不是 CAD 历史里的锥孔或倒角参数。"
|
||||
),
|
||||
**boundary_info,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -646,6 +800,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
|
||||
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
|
||||
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:
|
||||
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
|
||||
else:
|
||||
@@ -653,6 +808,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
edit_actions = "推拉平面"
|
||||
if shell_info.get("shell_region_status") == "candidate":
|
||||
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.update(
|
||||
{
|
||||
@@ -671,10 +834,247 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
"feature_edit_actions": edit_actions,
|
||||
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
|
||||
**shell_info,
|
||||
**prismatic_info,
|
||||
}
|
||||
)
|
||||
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(
|
||||
self,
|
||||
face_id: int,
|
||||
@@ -768,6 +1168,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
|
||||
thickness = float(best["shell_thickness_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
|
||||
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
|
||||
confidence = "high"
|
||||
@@ -798,15 +1209,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
domain_info = dict(info)
|
||||
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
||||
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_face_ids = end_faces["end_face_ids"]
|
||||
bottom_face_ids = end_faces["bottom_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)
|
||||
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"))
|
||||
angular_span = float(info.get("angular_span", 0.0))
|
||||
angular_span = combined_angular_span
|
||||
if guess == "hole/groove candidate":
|
||||
if angular_span < math.tau * 0.92:
|
||||
feature_type = "槽/半孔候选"
|
||||
@@ -856,6 +1291,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
"feature_adjacent_face_ids": tuple(adjacent_face_ids),
|
||||
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
||||
"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_face_ids": tuple(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)
|
||||
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]:
|
||||
try:
|
||||
boundary_wires = len(_explore(face, TopAbs_WIRE))
|
||||
|
||||
+1377
-27
File diff suppressed because it is too large
Load Diff
+20
-1
@@ -140,7 +140,26 @@ class PolydataMixin:
|
||||
def _ensure_mesh(self, deflection: float) -> None:
|
||||
requested = max(float(deflection), 1e-9)
|
||||
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
|
||||
|
||||
def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8):
|
||||
|
||||
@@ -120,9 +120,11 @@ class TransformMixin:
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"未知零件 ID {part_id}")
|
||||
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
|
||||
part.shape = _translated_shape_by_vector(part.shape, vector)
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
|
||||
return (
|
||||
f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, "
|
||||
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
|
||||
@@ -137,6 +139,7 @@ class TransformMixin:
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
|
||||
part_solids = _explore(part.shape, TopAbs_SOLID)
|
||||
if len(part_solids) <= 1:
|
||||
part.shape = _translated_shape_by_vector(part.shape, vector)
|
||||
@@ -156,6 +159,7 @@ class TransformMixin:
|
||||
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
|
||||
return (
|
||||
f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, "
|
||||
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
|
||||
|
||||
+53
-14
@@ -462,7 +462,7 @@ INFO_LABELS = {
|
||||
"ellipse_edge_major_radius": "椭圆Edge主半径",
|
||||
"ellipse_edge_minor_radius": "椭圆Edge小半径",
|
||||
"reference_radius": "参考半径",
|
||||
"semi_angle": "半角",
|
||||
"semi_angle": "圆锥半角",
|
||||
"angular_span": "角度跨度",
|
||||
"height_estimate": "估算高度",
|
||||
"hole_depth_estimate": "孔/槽深度估算",
|
||||
@@ -606,6 +606,12 @@ INFO_LABELS = {
|
||||
"existing_fillet_radius_estimate": "已有圆角半径估算",
|
||||
"feature_reference_radius": "特征参考半径",
|
||||
"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_diameter": "球面直径",
|
||||
"feature_torus_major_radius": "环面主半径",
|
||||
@@ -768,6 +774,32 @@ SELECTION_MODE_LABELS = {
|
||||
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:
|
||||
mode_text = str(mode or "")
|
||||
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:
|
||||
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:
|
||||
@@ -807,25 +839,21 @@ def _enable_overlay_depth_offset(mapper) -> None:
|
||||
|
||||
|
||||
def _smooth_surface_polydata(polydata):
|
||||
clean = vtk.vtkCleanPolyData()
|
||||
clean.SetInputData(polydata)
|
||||
clean.PointMergingOn()
|
||||
clean.SetTolerance(1e-7)
|
||||
clean.Update()
|
||||
|
||||
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.ComputeCellNormalsOff()
|
||||
normals.ConsistencyOn()
|
||||
normals.SplittingOn()
|
||||
normals.SetFeatureAngle(35.0)
|
||||
if hasattr(normals, "AutoOrientNormalsOn"):
|
||||
normals.AutoOrientNormalsOn()
|
||||
normals.SplittingOff()
|
||||
if hasattr(normals, "NonManifoldTraversalOff"):
|
||||
normals.NonManifoldTraversalOff()
|
||||
normals.Update()
|
||||
|
||||
smoothed = vtk.vtkPolyData()
|
||||
smoothed.DeepCopy(normals.GetOutput())
|
||||
smoothed.ShallowCopy(normals.GetOutput())
|
||||
return smoothed
|
||||
|
||||
|
||||
@@ -882,6 +910,17 @@ def _format_value(value: object) -> str:
|
||||
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]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
|
||||
+742
-26
@@ -375,7 +375,11 @@ class WindowActionMixin:
|
||||
if result != QMessageBox.StandardButton.Yes:
|
||||
self.statusBar().showMessage("已取消推拉平面")
|
||||
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():
|
||||
return self.model.push_pull_face(face_id, distance)
|
||||
@@ -410,7 +414,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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]:
|
||||
@@ -467,10 +471,9 @@ class WindowActionMixin:
|
||||
warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。")
|
||||
|
||||
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
|
||||
if distance < 0 and self.model is not None:
|
||||
inward_material_depth = self.model._push_pull_inward_material_depth(face_id, outward_tuple)
|
||||
if distance < 0:
|
||||
if inward_material_depth is not None and inward_material_depth > 1e-9:
|
||||
inward_cut_ratio = distance_abs / inward_material_depth
|
||||
depth_tolerance = max(
|
||||
@@ -488,6 +491,10 @@ class WindowActionMixin:
|
||||
elif inward_cut_ratio >= 0.6 and risk != "high":
|
||||
risk = "medium"
|
||||
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
|
||||
else:
|
||||
if risk == "low":
|
||||
risk = "medium"
|
||||
warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。")
|
||||
if status != "blocked" and risk in {"medium", "high"}:
|
||||
status = "caution"
|
||||
|
||||
@@ -605,10 +612,11 @@ class WindowActionMixin:
|
||||
self.statusBar().showMessage("已取消薄壁厚度调整")
|
||||
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)
|
||||
else:
|
||||
self._show_shell_thickness_preview(face_id, target_thickness)
|
||||
self._show_shell_thickness_preview(face_id, target_thickness, plan=plan)
|
||||
|
||||
def action():
|
||||
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_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:
|
||||
@@ -984,13 +992,31 @@ class WindowActionMixin:
|
||||
*,
|
||||
timeout_seconds: float = 180.0,
|
||||
) -> 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 {
|
||||
"operation": operation,
|
||||
"args": args,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"reason": "high-risk-occ-edit",
|
||||
"reason": f"{risk}-risk-face-occ-edit",
|
||||
}
|
||||
|
||||
def resize_hole(self) -> None:
|
||||
@@ -1067,6 +1093,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
isolation=isolation,
|
||||
)
|
||||
|
||||
def resize_cylindrical_owning_scale(self) -> None:
|
||||
@@ -1130,6 +1157,12 @@ class WindowActionMixin:
|
||||
"affine_axis_point": plan.get("affine_axis_point"),
|
||||
"affine_axis_direction": plan.get("affine_axis_direction"),
|
||||
"affine_target_kind": plan.get("affine_target_kind"),
|
||||
"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"),
|
||||
"edit_strategy_label": plan.get("edit_strategy_label"),
|
||||
"edit_semantics": plan.get("edit_semantics"),
|
||||
@@ -2564,6 +2597,13 @@ class WindowActionMixin:
|
||||
"affine_axis_point": plan.get("affine_axis_point"),
|
||||
"affine_axis_direction": plan.get("affine_axis_direction"),
|
||||
"affine_target_kind": plan.get("affine_target_kind"),
|
||||
"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"),
|
||||
"edit_strategy_label": plan.get("edit_strategy_label"),
|
||||
"edit_semantics": plan.get("edit_semantics"),
|
||||
@@ -4112,7 +4152,7 @@ class WindowActionMixin:
|
||||
def resize_cone_reference_radius(self) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
if self._edit_busy("请等待当前编辑完成后再修改圆锥面(整体)。"):
|
||||
if self._edit_busy("请等待当前编辑完成后再修改圆锥面参数。"):
|
||||
return
|
||||
if self.selected_face_id is None:
|
||||
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。")
|
||||
@@ -4127,16 +4167,22 @@ class WindowActionMixin:
|
||||
|
||||
face_id = self.selected_face_id
|
||||
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":
|
||||
QMessageBox.information(self, "不能修改圆锥面(整体)", str(plan["message"]))
|
||||
self.statusBar().showMessage("圆锥面(整体)修改已阻止")
|
||||
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('current_reference_radius'))}\n"
|
||||
@@ -4150,13 +4196,14 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
f"{plan['message']}\n\n"
|
||||
f"{isolation_line}"
|
||||
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if result != QMessageBox.StandardButton.Yes:
|
||||
self.statusBar().showMessage("已取消圆锥面(整体)修改")
|
||||
self.statusBar().showMessage("已取消圆锥面参数修改")
|
||||
return
|
||||
self.clear_edit_preview(render=False)
|
||||
|
||||
@@ -4165,7 +4212,7 @@ class WindowActionMixin:
|
||||
|
||||
self._run_edit_action(
|
||||
action,
|
||||
operation_name="修改圆锥面参数(整体)",
|
||||
operation_name="修改圆锥面参数",
|
||||
target=f"Face {face_id}",
|
||||
parameters={
|
||||
"part_id": plan.get("part_id"),
|
||||
@@ -4198,6 +4245,104 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
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:
|
||||
@@ -4218,6 +4363,7 @@ class WindowActionMixin:
|
||||
|
||||
face_id = self.selected_face_id
|
||||
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":
|
||||
QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"]))
|
||||
self.statusBar().showMessage("球面(整体)修改已阻止")
|
||||
@@ -4225,6 +4371,11 @@ class WindowActionMixin:
|
||||
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,
|
||||
"确认修改球面(整体)",
|
||||
@@ -4241,6 +4392,7 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
f"{plan['message']}\n\n"
|
||||
f"{isolation_line}"
|
||||
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
@@ -4285,6 +4437,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
isolation=isolation,
|
||||
)
|
||||
|
||||
def resize_torus_major_radius(self) -> None:
|
||||
@@ -4313,6 +4466,7 @@ class WindowActionMixin:
|
||||
mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)"
|
||||
face_id = self.selected_face_id
|
||||
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":
|
||||
QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"]))
|
||||
self.statusBar().showMessage("环面(整体)修改已阻止")
|
||||
@@ -4320,6 +4474,11 @@ class WindowActionMixin:
|
||||
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"确认修改环面{mode_label}",
|
||||
@@ -4338,6 +4497,7 @@ class WindowActionMixin:
|
||||
f"风险: {plan['risk']}\n\n"
|
||||
f"{warnings_line}"
|
||||
f"{plan['message']}\n\n"
|
||||
f"{isolation_line}"
|
||||
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
@@ -4386,6 +4546,7 @@ class WindowActionMixin:
|
||||
},
|
||||
target_kind="face",
|
||||
target_id=face_id,
|
||||
isolation=isolation,
|
||||
)
|
||||
|
||||
def resize_face_area(self) -> None:
|
||||
@@ -6252,6 +6413,7 @@ class WindowActionMixin:
|
||||
target_part_id = self._edit_context_part_id(context)
|
||||
before_stats = self.model.stats()
|
||||
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 = {}
|
||||
isolation = context.get("isolation")
|
||||
if isinstance(isolation, dict) and isolation:
|
||||
@@ -6261,6 +6423,7 @@ class WindowActionMixin:
|
||||
snapshot=snapshot,
|
||||
before_stats=before_stats,
|
||||
before_part_stats=before_part_stats,
|
||||
before_quality=before_quality,
|
||||
before_geometry=before_geometry,
|
||||
)
|
||||
try:
|
||||
@@ -6268,6 +6431,17 @@ class WindowActionMixin:
|
||||
after_snapshot = self.model.snapshot()
|
||||
after_stats = self.model.stats()
|
||||
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 = {}
|
||||
except Exception as exc:
|
||||
try:
|
||||
@@ -6298,7 +6472,7 @@ class WindowActionMixin:
|
||||
"after_snapshot": after_snapshot,
|
||||
"after_stats": after_stats,
|
||||
"after_part_stats": after_part_stats,
|
||||
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
|
||||
"quality_warnings": quality_warnings,
|
||||
"after_geometry": after_geometry,
|
||||
"model_polydata": model_polydata,
|
||||
"edge_polydata": edge_polydata,
|
||||
@@ -6314,6 +6488,7 @@ class WindowActionMixin:
|
||||
snapshot: dict[object, object],
|
||||
before_stats,
|
||||
before_part_stats,
|
||||
before_quality: dict[str, object] | None,
|
||||
before_geometry: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
if self.model is None:
|
||||
@@ -6346,7 +6521,7 @@ class WindowActionMixin:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
command = [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
|
||||
command = self._isolated_edit_command(request_path)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
@@ -6385,11 +6560,23 @@ class WindowActionMixin:
|
||||
new_model.filename = self.step_path
|
||||
except Exception:
|
||||
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
|
||||
|
||||
after_snapshot = self.model.snapshot()
|
||||
after_stats = self.model.stats()
|
||||
after_part_stats = self._part_stats_or_none(target_part_id)
|
||||
after_geometry: dict[str, object] = {}
|
||||
model_polydata = None
|
||||
edge_polydata = None
|
||||
@@ -6404,7 +6591,6 @@ class WindowActionMixin:
|
||||
model_polydata = None
|
||||
edge_polydata = None
|
||||
|
||||
child_message = str(response.get("message") or "隔离子进程编辑完成。")
|
||||
return {
|
||||
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
|
||||
"snapshot": snapshot,
|
||||
@@ -6414,12 +6600,47 @@ class WindowActionMixin:
|
||||
"after_snapshot": after_snapshot,
|
||||
"after_stats": after_stats,
|
||||
"after_part_stats": after_part_stats,
|
||||
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
|
||||
"quality_warnings": quality_warnings,
|
||||
"after_geometry": after_geometry,
|
||||
"model_polydata": model_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:
|
||||
if self.model is None:
|
||||
return None
|
||||
@@ -6452,13 +6673,508 @@ class WindowActionMixin:
|
||||
return 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
|
||||
try:
|
||||
return self.model.part_topology_stats(part_id)
|
||||
return model.part_topology_stats(part_id)
|
||||
except Exception:
|
||||
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:
|
||||
self.operation_in_progress = True
|
||||
self._update_action_states()
|
||||
|
||||
+168
-20
@@ -46,6 +46,40 @@ class WindowCoreMixin:
|
||||
def _run_ui_task(self, callback) -> None:
|
||||
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:
|
||||
return QThread.currentThread() == self.thread()
|
||||
|
||||
@@ -93,7 +127,11 @@ class WindowCoreMixin:
|
||||
self.renderer = vtk.vtkRenderer()
|
||||
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
|
||||
self.render_window = self.vtk_widget.GetRenderWindow()
|
||||
if hasattr(self.render_window, "SetMultiSamples"):
|
||||
self.render_window.SetMultiSamples(0)
|
||||
self.render_window.AddRenderer(self.renderer)
|
||||
if hasattr(self.renderer, "UseFXAAOff"):
|
||||
self.renderer.UseFXAAOff()
|
||||
|
||||
self.interactor = self.render_window.GetInteractor()
|
||||
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
|
||||
@@ -105,7 +143,9 @@ class WindowCoreMixin:
|
||||
self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release)
|
||||
self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press)
|
||||
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("EndInteractionEvent", self.on_camera_interaction_end)
|
||||
if hasattr(self.interactor, "SetDesiredUpdateRate"):
|
||||
@@ -119,6 +159,12 @@ class WindowCoreMixin:
|
||||
light.SetIntensity(0.9)
|
||||
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()
|
||||
|
||||
def on_camera_interaction_start(self, _obj, _event) -> None:
|
||||
@@ -143,6 +189,19 @@ class WindowCoreMixin:
|
||||
if not getattr(self, "camera_interaction_active", False):
|
||||
return
|
||||
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:
|
||||
load_thread_running = bool(
|
||||
@@ -213,11 +272,32 @@ class WindowCoreMixin:
|
||||
|
||||
self.load_in_progress = True
|
||||
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._update_action_states()
|
||||
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(
|
||||
self,
|
||||
new_path: Path,
|
||||
@@ -253,13 +333,29 @@ class WindowCoreMixin:
|
||||
try:
|
||||
self._load_step_sync(
|
||||
expected_path,
|
||||
deflection=self.initial_load_deflection,
|
||||
show_internal_edges=True,
|
||||
status_prefix="Fast loading",
|
||||
deflection=self.preview_load_deflection,
|
||||
show_internal_edges=self._show_same_domain_internal_edges(),
|
||||
status_prefix="读取 STEP 可视化网格",
|
||||
)
|
||||
finally:
|
||||
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:
|
||||
new_path = Path(result["path"])
|
||||
stats = result["stats"]
|
||||
@@ -309,9 +405,18 @@ class WindowCoreMixin:
|
||||
try:
|
||||
if not isinstance(result, dict):
|
||||
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.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||||
self._end_load_task()
|
||||
self.load_in_progress = False
|
||||
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:
|
||||
self._end_load_task()
|
||||
QMessageBox.critical(self, "Load failed", str(exc))
|
||||
@@ -326,14 +431,18 @@ class WindowCoreMixin:
|
||||
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
||||
|
||||
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
|
||||
self._end_load_task()
|
||||
if self.model is not None:
|
||||
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||||
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||||
|
||||
@Slot(object)
|
||||
def _finish_load_refine(self, result: object) -> None:
|
||||
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(
|
||||
result["model_polydata"],
|
||||
result["edge_polydata"],
|
||||
@@ -351,7 +460,7 @@ class WindowCoreMixin:
|
||||
"display": "ready",
|
||||
}
|
||||
)
|
||||
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||||
self.statusBar().showMessage(f"精细显示已完成:{self.step_path.name}")
|
||||
finally:
|
||||
self._end_load_task()
|
||||
|
||||
@@ -385,12 +494,24 @@ class WindowCoreMixin:
|
||||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||||
)
|
||||
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:
|
||||
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
|
||||
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:
|
||||
self.undo_stack.clear()
|
||||
@@ -1107,6 +1228,7 @@ class WindowCoreMixin:
|
||||
or self.load_in_progress
|
||||
or self.model is None
|
||||
or self.model_actor is None
|
||||
or self._hover_suppressed_after_camera()
|
||||
):
|
||||
self._clear_hover(render=True)
|
||||
return
|
||||
@@ -1129,7 +1251,11 @@ class WindowCoreMixin:
|
||||
if not self._is_ui_thread():
|
||||
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y))
|
||||
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
|
||||
position = (int(x), int(y))
|
||||
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_actor is None
|
||||
or self.pending_hover_position is None
|
||||
or self._hover_suppressed_after_camera()
|
||||
):
|
||||
self._clear_hover(render=True)
|
||||
return
|
||||
@@ -1576,7 +1703,7 @@ class WindowCoreMixin:
|
||||
self.selected_pick_position = pick_position
|
||||
info = self.model.quick_face_info(face_id)
|
||||
if feature_mode:
|
||||
info = self._feature_info_for_selected_face(face_id, info)
|
||||
info = self._feature_context_info(face_id)
|
||||
info["kind"] = "feature"
|
||||
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
|
||||
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
|
||||
@@ -1601,7 +1728,7 @@ class WindowCoreMixin:
|
||||
return
|
||||
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
|
||||
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"
|
||||
self._reset_selection(clear_highlight=False, clear_info=False)
|
||||
self.selected_kind = "feature"
|
||||
@@ -2026,16 +2153,37 @@ class WindowCoreMixin:
|
||||
self._start_edit_preview_pulse()
|
||||
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:
|
||||
return
|
||||
self.clear_edit_preview(render=False)
|
||||
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:
|
||||
self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}")
|
||||
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.render_window.Render()
|
||||
|
||||
|
||||
+481
-28
@@ -28,6 +28,57 @@ PROPERTY_SCOPE_COLUMN = 2
|
||||
PROPERTY_TARGET_COLUMN = 3
|
||||
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:
|
||||
if not message:
|
||||
@@ -109,9 +160,51 @@ class WindowStateMixin:
|
||||
enriched["pick_position"] = pick_position
|
||||
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]:
|
||||
if self.model is None:
|
||||
return dict(fallback_info)
|
||||
if "associated_feature_infos" in fallback_info:
|
||||
return dict(fallback_info)
|
||||
surface = str(fallback_info.get("surface", "") or "")
|
||||
if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES:
|
||||
return dict(fallback_info)
|
||||
@@ -153,7 +246,7 @@ class WindowStateMixin:
|
||||
info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认")
|
||||
elif surface == "cone":
|
||||
info.setdefault("feature_type", "圆锥面候选")
|
||||
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径(整体缩放)")
|
||||
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径/半角;程序会按几何选择重建或局部重切")
|
||||
elif surface == "sphere":
|
||||
info.setdefault("feature_type", "球面候选")
|
||||
info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)")
|
||||
@@ -168,8 +261,139 @@ class WindowStateMixin:
|
||||
):
|
||||
if key in fallback_info:
|
||||
info.setdefault(key, fallback_info[key])
|
||||
if surface == "plane":
|
||||
info.update(self._face_first_level_selection_fields(face_id))
|
||||
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:
|
||||
if pick_position is None:
|
||||
return message
|
||||
@@ -238,7 +462,7 @@ class WindowStateMixin:
|
||||
)
|
||||
self._set_control_state(
|
||||
self.reload_button,
|
||||
has_loaded_model and not busy,
|
||||
not busy and bool(getattr(self, "step_path", None)),
|
||||
"按当前显示路径读取 STEP 模型。",
|
||||
wait_or_load_tip,
|
||||
)
|
||||
@@ -788,13 +1012,22 @@ class WindowStateMixin:
|
||||
def _update_selected_object_title(self) -> None:
|
||||
if not hasattr(self, "object_edit_box"):
|
||||
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:
|
||||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||||
return f"零件 {self.selected_part_id}"
|
||||
if self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||||
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:
|
||||
feature_label = self._selected_feature_label()
|
||||
if feature_label:
|
||||
@@ -1147,10 +1380,10 @@ class WindowStateMixin:
|
||||
self.property_expand_button.setVisible(has_hidden_rows)
|
||||
if expanded:
|
||||
self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项")
|
||||
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。")
|
||||
self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几项。")
|
||||
else:
|
||||
self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)")
|
||||
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。")
|
||||
self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
|
||||
self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
|
||||
|
||||
def toggle_property_table_expanded(self) -> None:
|
||||
if not hasattr(self, "property_table"):
|
||||
@@ -1172,6 +1405,8 @@ class WindowStateMixin:
|
||||
action_info: dict[str, object],
|
||||
) -> list[dict[str, object]]:
|
||||
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)
|
||||
for key, value in self._ordered_property_info_items(info):
|
||||
if key in used_keys:
|
||||
@@ -1180,7 +1415,7 @@ class WindowStateMixin:
|
||||
{
|
||||
"key": key,
|
||||
"label": INFO_LABELS.get(key, key),
|
||||
"current_text": _format_value(value),
|
||||
"current_text": _format_info_value(key, value),
|
||||
"current_raw": value,
|
||||
"target_text": "",
|
||||
"editable": False,
|
||||
@@ -1204,6 +1439,86 @@ class WindowStateMixin:
|
||||
)
|
||||
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]]:
|
||||
items = self._ordered_info_items(info)
|
||||
if not self._is_feature_like_info(info):
|
||||
@@ -1456,6 +1771,75 @@ class WindowStateMixin:
|
||||
return base
|
||||
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(
|
||||
*,
|
||||
key: str,
|
||||
@@ -1641,6 +2025,26 @@ class WindowStateMixin:
|
||||
),
|
||||
)
|
||||
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_tip = (
|
||||
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
|
||||
@@ -2896,33 +3300,49 @@ class WindowStateMixin:
|
||||
current_reference_diameter = (
|
||||
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(
|
||||
key="cone_reference_radius",
|
||||
label="参考半径(整体)",
|
||||
label="参考半径",
|
||||
current_raw=current_reference_radius if current_reference_radius is not None else "",
|
||||
target_text=numeric_text(current_reference_radius),
|
||||
action="resize_cone_reference_radius",
|
||||
target_attr="cone_reference_radius_input",
|
||||
enabled=current_reference_radius is not None,
|
||||
enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴径向缩放所属对象。",
|
||||
disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。",
|
||||
enabled=reference_radius_enabled,
|
||||
enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
|
||||
disabled_tip=(
|
||||
reference_radius_disabled_reason
|
||||
or "当前圆锥面缺少稳定参考半径,不能直接修改。"
|
||||
),
|
||||
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",),
|
||||
**positive_minimum(),
|
||||
)
|
||||
add_spec(
|
||||
key="cone_reference_diameter",
|
||||
label="参考直径(整体)",
|
||||
label="参考直径",
|
||||
current_raw=current_reference_diameter if current_reference_diameter is not None else "",
|
||||
target_text=numeric_text(current_reference_diameter),
|
||||
action="resize_cone_reference_radius",
|
||||
target_attr="cone_reference_radius_input",
|
||||
enabled=current_reference_diameter is not None,
|
||||
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放所属对象。",
|
||||
disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。",
|
||||
enabled=current_reference_diameter is not None and reference_radius_supported,
|
||||
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。",
|
||||
disabled_tip=(
|
||||
reference_radius_disabled_reason
|
||||
or "当前圆锥面缺少稳定参考直径,不能直接修改。"
|
||||
),
|
||||
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",
|
||||
used=("feature_reference_diameter",),
|
||||
**positive_minimum(),
|
||||
@@ -2931,25 +3351,32 @@ class WindowStateMixin:
|
||||
current_semi_angle_degrees = (
|
||||
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(
|
||||
key="cone_semi_angle_degrees",
|
||||
label="半角(整体)",
|
||||
label="圆锥半角",
|
||||
current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "",
|
||||
target_text=numeric_text(current_semi_angle_degrees),
|
||||
action="resize_cone_reference_radius",
|
||||
action="resize_cone_semi_angle",
|
||||
target_attr="cone_reference_radius_input",
|
||||
enabled=current_reference_radius is not None and current_semi_angle is not None,
|
||||
enabled_tip="输入圆锥面的目标半角,单位是度;程序会换算为目标参考半径后围绕圆锥轴径向缩放所属对象。",
|
||||
disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。",
|
||||
enabled=semi_angle_enabled,
|
||||
enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
|
||||
disabled_tip=(
|
||||
semi_angle_disabled_reason
|
||||
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
|
||||
),
|
||||
value_type="positive",
|
||||
range_hint="这是整体径向缩放所属对象,不是只替换单个圆锥面的历史半角参数;建议先小幅修改,当前版本要求半角大于 0 且小于 89 度。",
|
||||
range_hint=(
|
||||
"简单圆锥会解析重建;嵌入式锥孔会优先保持小端半径和深度,只改变锥孔开口。"
|
||||
"复杂圆锥/拔模面暂不使用整体缩放兜底。当前版本要求半角大于 0 且小于 89 度。"
|
||||
),
|
||||
max_value=89.0,
|
||||
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",),
|
||||
**positive_minimum(),
|
||||
)
|
||||
@@ -3971,6 +4398,31 @@ class WindowStateMixin:
|
||||
row, _spec, _text = changed[0]
|
||||
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:
|
||||
specs = getattr(self, "property_editor_specs", [])
|
||||
if row < 0 or row >= len(specs):
|
||||
@@ -3990,6 +4442,7 @@ class WindowStateMixin:
|
||||
QMessageBox.information(self, "目标值无效", validation_error)
|
||||
return
|
||||
try:
|
||||
self._activate_property_source_feature(spec)
|
||||
if not is_command:
|
||||
self._sync_property_edit_target(spec, text)
|
||||
self._sync_property_preselects(spec)
|
||||
|
||||
Reference in New Issue
Block a user