From 5799d5d813b235136d2d49c617645df965ee6408 Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Tue, 4 Aug 2026 09:35:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Face=20=E4=B8=80?= =?UTF-8?q?=E7=BA=A7=E5=85=B3=E7=B3=BB=E7=BC=96=E8=BE=91=E5=92=8C=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 52 +- assets/ico/logo_new.ico | Bin 0 -> 370070 bytes scripts/GeometryParametric.spec | 3 + scripts/verify_associated_features.py | 97 ++ scripts/verify_cone_semi_angle_isolation.py | 758 +++++++++ ...verify_cylindrical_first_level_topology.py | 234 +++ scripts/verify_edge_coordinate_edit.py | 196 +++ scripts/verify_edge_edit_suite.py | 4 + scripts/verify_face_edit_integrity_guards.py | 328 ++++ scripts/verify_face_edit_suite.py | 12 + scripts/verify_face_first_level_topology.py | 161 ++ scripts/verify_face_mixed_surface_guard.py | 11 + scripts/verify_face_resize_semantics.py | 2 + scripts/verify_feature_parameter_policy.py | 117 ++ scripts/verify_isolated_face_edit.py | 158 +- scripts/verify_prismatic_feature.py | 130 ++ scripts/verify_property_editor_specs.py | 81 +- step_editor/app.py | 159 +- step_editor/features.py | 252 +++ step_editor/info_panel.py | 24 +- step_editor/isolated_edit_worker.py | 23 +- step_editor/model.py | 721 ++++++++- step_editor/operations.py | 1404 ++++++++++++++++- step_editor/polydata.py | 21 +- step_editor/transforms.py | 4 + step_editor/ui_helpers.py | 67 +- step_editor/window_actions.py | 768 ++++++++- step_editor/window_core.py | 188 ++- step_editor/window_state.py | 509 +++++- 29 files changed, 6295 insertions(+), 189 deletions(-) create mode 100644 assets/ico/logo_new.ico create mode 100644 scripts/verify_associated_features.py create mode 100644 scripts/verify_cone_semi_angle_isolation.py create mode 100644 scripts/verify_cylindrical_first_level_topology.py create mode 100644 scripts/verify_edge_coordinate_edit.py create mode 100644 scripts/verify_face_edit_integrity_guards.py create mode 100644 scripts/verify_face_first_level_topology.py create mode 100644 scripts/verify_feature_parameter_policy.py create mode 100644 scripts/verify_prismatic_feature.py diff --git a/README.md b/README.md index 297bdf2..308f31f 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/assets/ico/logo_new.ico b/assets/ico/logo_new.ico new file mode 100644 index 0000000000000000000000000000000000000000..88521cc08d225440c8e2efaca08e49575ff29b8b GIT binary patch literal 370070 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 literal 0 HcmV?d00001 diff --git a/scripts/GeometryParametric.spec b/scripts/GeometryParametric.spec index b8e04ca..bde7603 100644 --- a/scripts/GeometryParametric.spec +++ b/scripts/GeometryParametric.spec @@ -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, diff --git a/scripts/verify_associated_features.py b/scripts/verify_associated_features.py new file mode 100644 index 0000000..0b1d2a4 --- /dev/null +++ b/scripts/verify_associated_features.py @@ -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()) diff --git a/scripts/verify_cone_semi_angle_isolation.py b/scripts/verify_cone_semi_angle_isolation.py new file mode 100644 index 0000000..422819e --- /dev/null +++ b/scripts/verify_cone_semi_angle_isolation.py @@ -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()) diff --git a/scripts/verify_cylindrical_first_level_topology.py b/scripts/verify_cylindrical_first_level_topology.py new file mode 100644 index 0000000..8654c16 --- /dev/null +++ b/scripts/verify_cylindrical_first_level_topology.py @@ -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()) diff --git a/scripts/verify_edge_coordinate_edit.py b/scripts/verify_edge_coordinate_edit.py new file mode 100644 index 0000000..9d73732 --- /dev/null +++ b/scripts/verify_edge_coordinate_edit.py @@ -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()) diff --git a/scripts/verify_edge_edit_suite.py b/scripts/verify_edge_edit_suite.py index 5f1ea88..0eb88e3 100644 --- a/scripts/verify_edge_edit_suite.py +++ b/scripts/verify_edge_edit_suite.py @@ -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",), diff --git a/scripts/verify_face_edit_integrity_guards.py b/scripts/verify_face_edit_integrity_guards.py new file mode 100644 index 0000000..03749bb --- /dev/null +++ b/scripts/verify_face_edit_integrity_guards.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()) diff --git a/scripts/verify_face_edit_suite.py b/scripts/verify_face_edit_suite.py index 9a3c339..b537705 100644 --- a/scripts/verify_face_edit_suite.py +++ b/scripts/verify_face_edit_suite.py @@ -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",), + ), ) diff --git a/scripts/verify_face_first_level_topology.py b/scripts/verify_face_first_level_topology.py new file mode 100644 index 0000000..990c47f --- /dev/null +++ b/scripts/verify_face_first_level_topology.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()) diff --git a/scripts/verify_face_mixed_surface_guard.py b/scripts/verify_face_mixed_surface_guard.py index ce328b4..da718c9 100644 --- a/scripts/verify_face_mixed_surface_guard.py +++ b/scripts/verify_face_mixed_surface_guard.py @@ -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": diff --git a/scripts/verify_face_resize_semantics.py b/scripts/verify_face_resize_semantics.py index f2f3759..4dc316d 100644 --- a/scripts/verify_face_resize_semantics.py +++ b/scripts/verify_face_resize_semantics.py @@ -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 ''}") + 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: diff --git a/scripts/verify_feature_parameter_policy.py b/scripts/verify_feature_parameter_policy.py new file mode 100644 index 0000000..5a41fed --- /dev/null +++ b/scripts/verify_feature_parameter_policy.py @@ -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()) diff --git a/scripts/verify_isolated_face_edit.py b/scripts/verify_isolated_face_edit.py index 8e0fc01..e65beee 100644 --- a/scripts/verify_isolated_face_edit.py +++ b/scripts/verify_isolated_face_edit.py @@ -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 diff --git a/scripts/verify_prismatic_feature.py b/scripts/verify_prismatic_feature.py new file mode 100644 index 0000000..9007fa3 --- /dev/null +++ b/scripts/verify_prismatic_feature.py @@ -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()) diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index abbcbd5..789c0c8 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -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() diff --git a/step_editor/app.py b/step_editor/app.py index 812db8f..c8ea75b 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -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() diff --git a/step_editor/features.py b/step_editor/features.py index c51112a..41e98f6 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -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": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。", } diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py index ccb0466..244ae68 100644 --- a/step_editor/info_panel.py +++ b/step_editor/info_panel.py @@ -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)) diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index 570c5c3..3201c10 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -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()) diff --git a/step_editor/model.py b/step_editor/model.py index a42cfd5..c3428b3 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -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)) diff --git a/step_editor/operations.py b/step_editor/operations.py index 3c3c8d6..ed42540 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -24,7 +24,7 @@ from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCone, BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism from OCC.Core.Bnd import Bnd_Box from OCC.Core.GeomAbs import ( GeomAbs_BSplineCurve, @@ -77,7 +77,91 @@ from .geometry_utils import * # noqa: F403 from .step_io import _prepare_shape_for_step_export +def _int_or_none(value: object) -> int | None: + if value in {"", None}: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _angle_degrees_or_none(value: object) -> float | None: + radians = _float_or_none(value) + if radians is None: + return None + return abs(math.degrees(radians)) + + +def _result_value_error(actual: object, target: object) -> float: + if isinstance(actual, tuple) and isinstance(target, tuple): + if len(actual) != len(target): + return math.inf + return max(abs(float(actual[index]) - float(target[index])) for index in range(len(actual))) + try: + return abs(float(actual) - float(target)) + except (TypeError, ValueError): + return math.inf + + +def _format_result_number(value: object) -> str: + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + if not math.isfinite(number): + return str(number) + return f"{number:g}" + + class OperationMixin: + def _face_first_level_plan_fields(self, face_id: int) -> dict[str, object]: + try: + topology = self.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), + "first_level_adjacent_face_ids": (), + "first_level_adjacent_face_count": 0, + "first_level_boundary_edge_ids": (), + "first_level_boundary_edge_count": 0, + "first_level_boundary_vertex_count": 0, + "same_domain_face_ids": (face_id,), + "same_domain_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", ""), + "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"), + "selected_boundary_edge_ids": topology.get("selected_boundary_edge_ids", ()), + "selected_boundary_edge_count": topology.get("selected_boundary_edge_count", 0), + "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_adjacent_surface_types": topology.get("first_level_adjacent_surface_types", ()), + "first_level_shared_edges_by_face": topology.get("first_level_shared_edges_by_face", ()), + "first_level_face_ids": topology.get("first_level_face_ids", ()), + "first_level_face_count": topology.get("first_level_face_count", 0), + "first_level_topology_note": topology.get("first_level_topology_note", ""), + } + fields["first_level_edit_semantics"] = ( + "当前 Face 阶段只使用一级共享边拓扑:当前同域 Face 区域会作为编辑对象," + "直接相邻 Face 会跟随重建或作为推拉侧壁参与结果校验;二级/三级关系暂不递归传播。" + ) + return fields + def repair_model(self) -> str: before_stats = self.stats() repaired_parts = 0 @@ -2321,6 +2405,45 @@ class OperationMixin: risk = _max_risk(risk, "medium") warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。") + cap_extension_info: dict[str, object] | None = None + if distance > 0: + outward_for_cap = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) + if outward_for_cap is not None: + try: + cap_extension_info = self._cylindrical_cap_extension_plan(face_id, distance, outward_for_cap) + except Exception: + cap_extension_info = None + if cap_extension_info is not None: + old_height = _float_or_none(cap_extension_info.get("old_height")) + new_height = _float_or_none(cap_extension_info.get("new_height")) + radius = _float_or_none(cap_extension_info.get("radius")) + growth_ratio = ( + new_height / old_height + if old_height is not None and old_height > 1e-9 and new_height is not None + else None + ) + distance_to_height_ratio = ( + distance_abs / old_height if old_height is not None and old_height > 1e-9 else None + ) + if growth_ratio is not None and growth_ratio > 3.0: + status = "blocked" + risk = "blocked" + blockers.append( + "当前操作会把圆柱高度一次性放大到原来的 " + f"{_format_result_number(growth_ratio)} 倍;当前版本会先阻止这种大跨度圆柱端面推拉," + "避免 OCCT 布尔长时间计算、卡死或返回无效 B-Rep。请先分多次小幅修改,或改用整体高度/所属对象缩放语义。" + ) + elif distance_to_height_ratio is not None and distance_to_height_ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append( + "当前圆柱端面推拉距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。" + ) + elif radius is not None and radius > 0 and distance_abs > radius * 4.0: + risk = _max_risk(risk, "high") + warnings.append( + "当前圆柱端面推拉距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" + ) + if risk in {"medium", "high"} and status != "blocked": status = "caution" if blockers: @@ -2330,6 +2453,14 @@ class OperationMixin: else: message = "可以尝试推拉该平面。" + plane_origin = _tuple_or_none(info.get("plane_origin")) + outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) + current_plane_position = None + target_plane_position = None + if plane_origin is not None and outward_direction is not None: + current_plane_position = _tuple_dot(plane_origin, outward_direction) + target_plane_position = current_plane_position + float(distance) + return { "status": status, "risk": risk, @@ -2339,13 +2470,27 @@ class OperationMixin: "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], + **self._face_first_level_plan_fields(face_id), "distance": distance, "surface": info.get("surface"), "area": info.get("area"), "bbox_diagonal": info.get("bbox_diagonal"), + "plane_origin": plane_origin, + "plane_direction": outward_direction, + "current_plane_position": current_plane_position, + "target_plane_position": target_plane_position, "push_pull_inward_material_depth": inward_material_depth, "push_pull_inward_cut_ratio": inward_cut_ratio, - "outward_direction": info.get("push_pull_outward_direction"), + "cylindrical_cap_extension_old_height": ( + cap_extension_info.get("old_height") if cap_extension_info is not None else None + ), + "cylindrical_cap_extension_new_height": ( + cap_extension_info.get("new_height") if cap_extension_info is not None else None + ), + "cylindrical_cap_extension_radius": ( + cap_extension_info.get("radius") if cap_extension_info is not None else None + ), + "outward_direction": outward_direction, "direction_confidence": direction_confidence, "direction_note": info.get("push_pull_note"), "push_pull_scope_face_ids": tuple(scope_face_ids), @@ -2359,7 +2504,7 @@ class OperationMixin: ) -> tuple[tuple[float, float, float], tuple[float, float, float], float] | None: if face_id < 0 or face_id >= len(self.faces): return None - info = self.face_info(face_id) + info = self.quick_face_info(face_id) if str(info.get("surface", "")) != "plane": return None origin = _tuple_or_none(info.get("plane_origin")) @@ -2641,11 +2786,13 @@ class OperationMixin: plan = self.face_center_owning_translation_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) vector = tuple(plan["translation_vector"]) if plan.get("target_kind") == "solid": result = self.translate_solid(int(plan["solid_id"]), vector) else: result = self.translate_part(int(plan["part_id"]), vector) + result_check = self._face_edit_result_summary(plan) return ( "Face center move completed by owning-shape translation: " f"face {face_id}, " @@ -2653,14 +2800,16 @@ class OperationMixin: f"target_center={plan.get('target_face_center')}, " f"move={plan.get('face_center_move_vector')}, " f"target={plan.get('target_kind')}, " - f"risk={plan['risk']}. {result}" + f"risk={plan['risk']}. {result_check} {result}" ) def move_face_plane_offset_local(self, face_id: int, distance: float) -> str: plan = self.face_plane_offset_local_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_local_face_deform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face plane offset completed by local face-only deformation: " f"face {face_id}, " @@ -2670,17 +2819,19 @@ class OperationMixin: f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def translate_face_plane_offset_owning(self, face_id: int, distance: float) -> str: plan = self.face_plane_offset_owning_translation_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) if plan.get("target_kind") == "solid": result = self.translate_solid(int(plan["solid_id"]), tuple(plan["translation_vector"])) else: result = self.translate_part(int(plan["part_id"]), tuple(plan["translation_vector"])) + result_check = self._face_edit_result_summary(plan) return ( "Face plane offset completed by owning-shape translation: " f"face {face_id}, " @@ -2689,6 +2840,7 @@ class OperationMixin: f"distance={float(plan['plane_offset_distance']):g}, " f"target={plan.get('target_kind')}, " f"risk={plan['risk']}. " + f"{result_check} " f"{result}" ) @@ -2997,6 +3149,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "current_face_center": current_center, "target_face_center": target, @@ -3141,6 +3294,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "current_area": current_area, "target_area": target_area, @@ -3296,6 +3450,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "face_size_axis": axis_key, "face_size_label": axis_label, @@ -3744,7 +3899,9 @@ class OperationMixin: plan = self.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"])) + result_check = self._face_edit_result_summary(plan) return ( "Shell thickness resize completed by planar push/pull: " f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, " @@ -3752,7 +3909,7 @@ class OperationMixin: f"delta={float(plan['shell_delta_thickness']):g}, " f"opposite_face={plan.get('shell_opposite_face_id')}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " - f"risk={plan['risk']}. {push_result}" + f"risk={plan['risk']}. {result_check} {push_result}" ) def shell_thickness_owning_scale_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: @@ -3942,10 +4099,12 @@ class OperationMixin: plan = self.shell_thickness_owning_scale_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) if plan.get("owning_shell_thickness_rebuild_mode") == "planar-rebuild": self._apply_local_face_deform(plan) else: self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Shell thickness resize completed by axis owning-shape scaling: " f"face {face_id}, " @@ -3953,7 +4112,7 @@ class OperationMixin: f"scale={float(plan['affine_scale']):g}, " f"rebuild_mode={plan.get('owning_shell_thickness_rebuild_mode')}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def resize_cylindrical_height(self, face_id: int, target_height: float) -> str: @@ -4497,7 +4656,7 @@ class OperationMixin: status = "blocked" if blockers else "caution" if blockers: risk = "blocked" - return { + plan = { "status": status, "risk": risk, "message": " ".join(blockers + warnings), @@ -4532,11 +4691,16 @@ class OperationMixin: "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } + self._annotate_simple_conical_rebuild_plan(plan, "reference-radius") + self._block_obvious_complex_conical_fallback_plan(plan, "reference-radius") + self._annotate_embedded_conical_recut_plan(plan, "reference-radius") + self._block_unstable_conical_reference_radius_plan(plan) + return plan def conical_semi_angle_plan(self, face_id: int, target_angle_degrees: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") - info = self.face_info(face_id) + info = self.quick_face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "圆锥半角修改会换算为围绕圆锥轴的径向缩放;高度不变,两个端面半径会按同一比例变化。" @@ -4571,17 +4735,23 @@ class OperationMixin: else 0.0 ) base_plan = self.conical_reference_radius_plan(face_id, target_radius) + base_strategy = str(base_plan.get("resize_strategy") or "") + ignore_reference_fallback_blocker = base_strategy.startswith("blocked-cone-reference-radius") or base_strategy.startswith( + "blocked-complex-cone-reference-radius" + ) base_blockers = str(base_plan.get("blockers") or "") blocker_parts = [part for part in blockers if part] - if base_blockers: + if base_blockers and not ignore_reference_fallback_blocker: blocker_parts.extend(part for part in base_blockers.split(";") if part and part not in blocker_parts) base_warnings = str(base_plan.get("warnings") or "") warning_parts = [part for part in warnings if part] if base_warnings: warning_parts.extend(part for part in base_warnings.split(";") if part and part not in warning_parts) - status = "blocked" if blocker_parts else str(base_plan.get("status") or "caution") - risk = "blocked" if blocker_parts else str(base_plan.get("risk") or "medium") + base_status = "caution" if ignore_reference_fallback_blocker else str(base_plan.get("status") or "caution") + base_risk = "medium" if ignore_reference_fallback_blocker else str(base_plan.get("risk") or "medium") + status = "blocked" if blocker_parts else base_status + risk = "blocked" if blocker_parts else base_risk base_plan.update( { "status": status, @@ -4598,27 +4768,877 @@ class OperationMixin: "edit_semantics": "按目标半角换算径向缩放比例;圆锥高度不变,端面半径和相邻径向尺寸会跟随变化。", } ) + self._annotate_simple_conical_rebuild_plan(base_plan, "semi-angle") + self._block_obvious_complex_conical_fallback_plan(base_plan, "semi-angle") + self._annotate_embedded_conical_recut_plan(base_plan, "semi-angle") + self._block_unstable_conical_semi_angle_plan(base_plan) return base_plan + def _annotate_simple_conical_rebuild_plan(self, plan: dict[str, object], mode: str) -> None: + if str(plan.get("status")) == "blocked": + return + try: + spec = self._simple_conical_rebuild_spec(plan) + except Exception: + spec = None + if spec is None: + plan["analytic_rebuild_available"] = False + return + if mode == "reference-radius" and not bool(spec.get("reference_radius_matches_current", False)): + plan["analytic_rebuild_available"] = False + plan["analytic_rebuild_skip_reason"] = "cone-reference-radius-does-not-match-a-cap" + return + plan["analytic_rebuild_available"] = True + plan["analytic_cone_rebuild_height"] = spec.get("height") + plan["analytic_cone_rebuild_reference_radius"] = spec.get("reference_radius") + plan["analytic_cone_rebuild_other_radius"] = spec.get("other_radius") + plan["analytic_cone_reference_radius_matches_current"] = spec.get("reference_radius_matches_current") + plan["fallback_resize_strategy"] = plan.get("resize_strategy") + if mode == "semi-angle": + plan["resize_strategy"] = "analytic-cone-rebuild-semi-angle" + else: + plan["resize_strategy"] = "analytic-cone-rebuild-reference-radius" + + def _block_obvious_complex_conical_fallback_plan(self, plan: dict[str, object], mode: str) -> None: + if str(plan.get("status")) == "blocked": + return + if bool(plan.get("analytic_rebuild_available", False)): + return + if bool(plan.get("embedded_cone_recut_available", False)): + return + expected_strategy = { + "reference-radius": "radial-affine-scale-cone-reference-radius", + "semi-angle": "radial-affine-scale-cone-semi-angle", + }.get(mode) + if expected_strategy is None or str(plan.get("resize_strategy") or "") != expected_strategy: + return + + current_angle = _float_or_none(plan.get("semi_angle_degrees")) + current_radius = _float_or_none(plan.get("current_reference_radius")) + face_id = int(plan.get("face_id", -1)) + if current_angle is None or current_angle > 3.0 or current_radius is None: + return + + face_diagonal = 0.0 + if 0 <= face_id < len(self.faces): + try: + face_diagonal = _shape_diagonal(self.faces[face_id]) + except Exception: + face_diagonal = 0.0 + owning_diagonal = 0.0 + part_id = int(plan.get("part_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is not None: + try: + owning_diagonal = _shape_diagonal(part.shape) + except Exception: + owning_diagonal = 0.0 + if current_radius <= max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0): + return + + current_angle_text = _format_result_number(current_angle) + current_radius_text = _format_result_number(current_radius) + if mode == "reference-radius": + target_radius = _float_or_none(plan.get("target_reference_radius")) + target_radius_text = _format_result_number(target_radius) + reason = ( + "当前对象仍然是 Face,但底层是复杂浅锥/拔模面;" + f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。" + f"把参考半径改到 {target_radius_text} 这类操作不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。" + "当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改;" + "复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。" + ) + blocked_strategy = "blocked-complex-cone-reference-radius-shallow-far-axis" + label = "暂不开放复杂浅锥参考半径修改" + else: + target_angle = _float_or_none(plan.get("target_semi_angle_degrees")) + target_radius = _float_or_none(plan.get("target_reference_radius")) + target_angle_text = _format_result_number(target_angle) + target_radius_text = _format_result_number(target_radius) + reason = ( + "当前对象仍然是 Face,但底层是复杂浅锥/拔模面;" + f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。" + f"把半角改到 {target_angle_text}° 会换算出约 {target_radius_text} 的参考半径," + "不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。" + "当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放半角修改;" + "复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。" + ) + blocked_strategy = "blocked-complex-cone-semi-angle-shallow-far-axis" + label = "暂不开放复杂浅锥半角修改" + existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] + if reason not in existing_blockers: + existing_blockers.append(reason) + plan.update( + { + "status": "blocked", + "risk": "blocked", + "blockers": ";".join(existing_blockers), + "message": reason, + "resize_strategy": blocked_strategy, + "edit_strategy_label": label, + "edit_semantics": reason, + "cone_fast_block_face_diagonal": face_diagonal, + "cone_fast_block_owning_diagonal": owning_diagonal, + } + ) + + def _block_unstable_conical_reference_radius_plan(self, plan: dict[str, object]) -> None: + if str(plan.get("status")) == "blocked": + return + if bool(plan.get("analytic_rebuild_available", False)): + return + if bool(plan.get("embedded_cone_recut_available", False)): + return + if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-reference-radius": + return + + if str(plan.get("analytic_rebuild_skip_reason") or "") == "cone-reference-radius-does-not-match-a-cap": + reason = ( + "当前圆锥面的参考半径不在可识别的圆形端面上;当前版本不能稳定保留这个参考位置来直接修改参考半径," + "否则容易出现目标值无法回读、圆锥面退化或编辑结果被回滚。" + "当前只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改。" + ) + strategy = "blocked-cone-reference-radius-non-cap" + label = "暂不开放非端面参考半径" + else: + reason = ( + "当前对象仍然是 Face,但它的底层曲面类型是复杂圆锥面/拔模面;" + "当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔," + "因此不再使用整体径向缩放兜底修改参考半径/直径。整体缩放会影响所属特征的其它尺寸," + "并且在复杂 STEP 上容易生成无效 B-Rep。" + ) + strategy = "blocked-complex-cone-reference-radius-unsupported-fallback" + label = "暂不开放复杂圆锥参考半径兜底修改" + existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] + if reason not in existing_blockers: + existing_blockers.append(reason) + warnings = [part for part in str(plan.get("warnings") or "").split(";") if part] + plan.update( + { + "status": "blocked", + "risk": "blocked", + "blockers": ";".join(existing_blockers), + "message": reason, + "resize_strategy": strategy, + "edit_strategy_label": label, + "edit_semantics": reason, + } + ) + + def _block_unstable_conical_semi_angle_plan(self, plan: dict[str, object]) -> None: + if str(plan.get("status")) == "blocked": + return + if bool(plan.get("analytic_rebuild_available", False)): + return + if bool(plan.get("embedded_cone_recut_available", False)): + return + if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-semi-angle": + return + + current_angle = _float_or_none(plan.get("semi_angle_degrees")) + target_angle = _float_or_none(plan.get("target_semi_angle_degrees")) + current_radius = _float_or_none(plan.get("current_reference_radius")) + target_radius = _float_or_none(plan.get("target_reference_radius")) + scale = _float_or_none(plan.get("affine_scale")) + face_id = int(plan.get("face_id", -1)) + + face_diagonal = 0.0 + if 0 <= face_id < len(self.faces): + face_diagonal = _shape_diagonal(self.faces[face_id]) + owning_diagonal = 0.0 + try: + _target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) + owning_diagonal = _shape_diagonal(source_shape) + except Exception: + try: + owning_diagonal = _shape_diagonal(self.shape) + except Exception: + owning_diagonal = 0.0 + + model_size = max(face_diagonal, owning_diagonal, 1.0) + angle_delta = ( + abs(float(target_angle) - float(current_angle)) + if current_angle is not None and target_angle is not None + else 0.0 + ) + shallow_far_axis = ( + current_angle is not None + and current_angle <= 3.0 + and current_radius is not None + and current_radius > max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0) + ) + extreme_scale = scale is None or scale <= 0.0 or scale > 3.0 or scale < (1.0 / 3.0) + extreme_reference = target_radius is not None and target_radius > model_size * 8.0 + large_angle_jump = angle_delta >= 3.0 + + current_angle_text = _format_result_number(current_angle) + target_angle_text = _format_result_number(target_angle) + current_radius_text = _format_result_number(current_radius) + target_radius_text = _format_result_number(target_radius) + scale_text = _format_result_number(scale) + is_extreme_shallow = shallow_far_axis and (extreme_scale or extreme_reference or large_angle_jump) + if is_extreme_shallow: + reason = ( + "当前对象仍然是 Face,但它的底层曲面类型是复杂浅锥/拔模面;" + "1° 左右的浅锥面视觉上很像平面,不适合用整体径向缩放直接修改半角。" + f"半角从 {current_angle_text}° 改到 {target_angle_text}° 会把参考半径从 " + f"{current_radius_text} 放大到 {target_radius_text},缩放比例约 {scale_text};" + "这类结果在复杂 STEP 上容易生成无效 B-Rep。当前版本只对简单圆锥解析重建," + "或可识别的锥孔/沉孔局部重切开放大幅半角修改。" + ) + blocked_strategy = "blocked-complex-cone-semi-angle-extreme-scale" + label = "暂不开放复杂浅锥半角大幅修改" + else: + reason = ( + "当前对象仍然是 Face,但它的底层曲面类型是圆锥面/拔模面;" + "当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔," + "因此不再使用整体径向缩放兜底修改半角。整体缩放会影响所属特征的其它尺寸," + "并且在复杂 STEP 上容易生成无效 B-Rep。" + ) + blocked_strategy = "blocked-complex-cone-semi-angle-unsupported-fallback" + label = "暂不开放复杂圆锥半角兜底修改" + existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] + if reason not in existing_blockers: + existing_blockers.append(reason) + warnings = [part for part in str(plan.get("warnings") or "").split(";") if part] + plan.update( + { + "status": "blocked", + "risk": "blocked", + "blockers": ";".join(existing_blockers), + "message": reason, + "resize_strategy": blocked_strategy, + "edit_strategy_label": label, + "edit_semantics": reason, + "cone_semi_angle_block_face_diagonal": face_diagonal, + "cone_semi_angle_block_owning_diagonal": owning_diagonal, + } + ) + + def _annotate_embedded_conical_recut_plan(self, plan: dict[str, object], mode: str) -> None: + if str(plan.get("status")) == "blocked": + return + if bool(plan.get("analytic_rebuild_available", False)): + return + try: + spec = self._embedded_conical_recut_spec(plan, mode) + except Exception: + spec = None + if spec is None: + plan["embedded_cone_recut_available"] = False + return + + recut_mode = str(spec.get("embedded_cone_recut_mode") or "enlarge") + if mode == "reference-radius": + recut_action = "先补料封回旧锥孔,再按目标参考半径重切" if recut_mode == "shrink" else "按目标参考半径局部扩大重切" + strategy = "bounded-cone-recut-preserve-angle-reference-radius" + semantics = ( + "保持锥孔当前半角和轴向深度不变,按目标参考半径局部重切锥孔;" + "不会整体缩放所属对象,也不会自动联动相连的圆柱孔直径。" + ) + else: + recut_action = "先补料封回旧锥孔,再按目标半角重切" if recut_mode == "shrink" else "按目标半角局部扩大重切" + strategy = "bounded-cone-recut-fixed-small-radius-semi-angle" + semantics = ( + "保持锥孔较小端半径和轴向深度不变;放大半角时直接用目标圆锥 cutter 扩大开口," + "缩小半角时先补料封回旧锥孔再按目标半角重切。" + ) + warning = ( + "检测到嵌入式锥孔/沉孔类圆锥 Face;本次会优先局部重切圆锥开口," + f"不再围绕圆锥轴缩放整个所属对象。执行方式:{recut_action}。" + ) + warnings = [ + part + for part in str(plan.get("warnings") or "").split(";") + if part + and "径向缩放" not in part + and "缩放所属" not in part + and "同一零件上的其它尺寸" not in part + and "同一 Solid 上的其它尺寸" not in part + ] + if warning not in warnings: + warnings.append(warning) + plan.update( + { + **spec, + "embedded_cone_recut_available": True, + "fallback_resize_strategy": plan.get("resize_strategy"), + "resize_strategy": strategy, + "edit_strategy_label": "锥孔局部重切", + "edit_semantics": semantics, + "risk": _max_risk(str(plan.get("risk") or "medium"), "high"), + "status": "caution", + "warnings": ";".join(warnings), + "message": " ".join([part for part in str(plan.get("blockers") or "").split(";") if part] + warnings), + } + ) + + def _embedded_conical_recut_spec(self, plan: dict[str, object], mode: str) -> dict[str, object] | None: + if mode not in {"semi-angle", "reference-radius"}: + return None + face_id = int(plan.get("face_id", -1)) + if face_id < 0 or face_id >= len(self.faces): + return None + + axis_point = _tuple_or_none(plan.get("axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis"))) + if axis_point is None or axis_direction is None: + return None + circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction) + if len(circles) != 2: + return None + + circles = sorted(circles, key=lambda item: float(item["radius"])) + small = circles[0] + large = circles[1] + small_radius = float(small["radius"]) + large_radius = float(large["radius"]) + small_center = tuple(small["center"]) + large_center = tuple(large["center"]) + height = _vector_length(_tuple_sub(large_center, small_center)) + if small_radius <= 1e-9 or large_radius <= small_radius or height <= 1e-9: + return None + + current_tangent = (large_radius - small_radius) / height + if current_tangent <= 1e-9: + return None + tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, large_radius * 1e-5, 1e-5) + + if mode == "semi-angle": + target_angle_degrees = _float_or_none(plan.get("target_semi_angle_degrees")) + if target_angle_degrees is None or target_angle_degrees <= 0.0 or target_angle_degrees >= 89.0: + return None + target_tangent = math.tan(math.radians(target_angle_degrees)) + if target_tangent <= 1e-9: + return None + target_small_radius = small_radius + target_large_radius = small_radius + height * target_tangent + if abs(target_large_radius - large_radius) <= tolerance: + return None + edit_mode = "enlarge" if target_large_radius > large_radius else "shrink" + else: + target_reference_radius = _float_or_none(plan.get("target_reference_radius")) + current_reference_radius = _float_or_none(plan.get("current_reference_radius")) + if ( + target_reference_radius is None + or current_reference_radius is None + or target_reference_radius <= 1e-9 + or current_reference_radius <= 1e-9 + ): + return None + reference_parameter = (current_reference_radius - small_radius) / current_tangent + reference_matches_small = abs(reference_parameter) <= max(tolerance / current_tangent, tolerance) + reference_matches_large = abs(reference_parameter - height) <= max(tolerance / current_tangent, tolerance) + target_small_radius = target_reference_radius - reference_parameter * current_tangent + target_large_radius = target_small_radius + height * current_tangent + if target_small_radius <= tolerance or target_large_radius <= target_small_radius + tolerance: + return None + radial_delta = target_reference_radius - current_reference_radius + if abs(radial_delta) <= tolerance: + return None + target_tangent = current_tangent + target_angle_degrees = math.degrees(math.atan(current_tangent)) + edit_mode = "enlarge" if radial_delta > 0.0 else "shrink" + + target_kind, source_shape, _part, source_solid = self._edge_length_affine_target(plan) + classifier_solid = source_solid or source_shape + if not self._embedded_conical_face_is_hole_like(classifier_solid, small, large, tolerance): + return None + + tool_direction = _tuple_normalized(_tuple_sub(large_center, small_center)) + if tool_direction is None: + return None + if target_large_radius <= target_small_radius + tolerance: + return None + end_margin = ( + min(max(height * 0.01, target_large_radius * 0.005, 0.02), max(height * 0.05, 0.05)) + if edit_mode == "enlarge" + else 0.0 + ) + tool_height = height + end_margin + tool_end_radius = target_large_radius + target_tangent * end_margin + diagonal = max(_shape_diagonal(source_shape), target_large_radius, 1.0) + if tool_end_radius > max(diagonal * 1.2, large_radius * 6.0): + return None + + return { + "embedded_cone_recut_mode": edit_mode, + "embedded_cone_fixed_small_radius": small_radius if mode == "semi-angle" else None, + "embedded_cone_current_small_radius": small_radius, + "embedded_cone_target_small_radius": target_small_radius, + "embedded_cone_current_large_radius": large_radius, + "embedded_cone_target_large_radius": target_large_radius, + "embedded_cone_current_angle_degrees": math.degrees(math.atan(current_tangent)), + "embedded_cone_target_angle_degrees": target_angle_degrees, + "embedded_cone_reference_parameter": reference_parameter if mode == "reference-radius" else None, + "embedded_cone_reference_matches_small": reference_matches_small if mode == "reference-radius" else None, + "embedded_cone_reference_matches_large": reference_matches_large if mode == "reference-radius" else None, + "embedded_cone_height": height, + "embedded_cone_tool_start_point": small_center, + "embedded_cone_tool_direction": tool_direction, + "embedded_cone_tool_start_radius": target_small_radius, + "embedded_cone_tool_end_radius": tool_end_radius, + "embedded_cone_tool_height": tool_height, + "embedded_cone_tool_end_margin": end_margin, + "embedded_cone_fill_start_radius": small_radius, + "embedded_cone_fill_end_radius": large_radius, + "embedded_cone_fill_height": height, + "embedded_cone_target_kind": target_kind, + } + + def _conical_face_circle_boundaries( + self, + face_id: int, + axis_point: tuple[float, float, float], + axis_direction: tuple[float, float, float], + ) -> list[dict[str, object]]: + face = self.faces[face_id] + axis_origin = gp_Pnt(*axis_point) + axis_dir = gp_Dir(*axis_direction) + tolerance = max(_shape_diagonal(face) * 1e-6, 1e-6) + circles: list[dict[str, object]] = [] + for edge in _explore(face, TopAbs_EDGE): + try: + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Circle: + continue + circle = curve.Circle() + center = circle.Location() + radius = float(circle.Radius()) + if radius <= 1e-9: + continue + if abs(_direction_dot(circle.Axis().Direction(), axis_dir)) < 0.95: + continue + if _point_axis_distance(axis_origin, axis_dir, center) > max(radius * 1e-5, tolerance): + continue + parameter = _axis_parameter(axis_origin, axis_dir, center) + sample = curve.Value(curve.FirstParameter()) + item = { + "radius": radius, + "center": _point_tuple(center), + "axis_parameter": parameter, + "sample_point": _point_tuple(sample), + } + duplicate = False + for existing in circles: + if ( + abs(float(existing["radius"]) - radius) <= tolerance + and _vector_length(_tuple_sub(tuple(existing["center"]), item["center"])) <= tolerance + ): + duplicate = True + break + if not duplicate: + circles.append(item) + except Exception: + continue + return circles + + def _embedded_conical_face_is_hole_like( + self, + solid: TopoDS_Shape, + small: dict[str, object], + large: dict[str, object], + tolerance: float, + ) -> bool: + small_center = tuple(small["center"]) + large_center = tuple(large["center"]) + large_sample = tuple(large["sample_point"]) + radial = _tuple_normalized(_tuple_sub(large_sample, large_center)) + if radial is None: + return False + small_radius = float(small["radius"]) + large_radius = float(large["radius"]) + mid_center = ( + (small_center[0] + large_center[0]) * 0.5, + (small_center[1] + large_center[1]) * 0.5, + (small_center[2] + large_center[2]) * 0.5, + ) + mid_radius = (small_radius + large_radius) * 0.5 + sample_point = _tuple_add(mid_center, _tuple_scale(radial, mid_radius)) + offset = max((large_radius - small_radius) * 0.08, large_radius * 0.02, tolerance * 10.0, 0.02) + toward_axis = gp_Pnt(*_tuple_sub(sample_point, _tuple_scale(radial, offset))) + away_axis = gp_Pnt(*_tuple_add(sample_point, _tuple_scale(radial, offset))) + return _solid_state(solid, toward_axis) == "outside" and _solid_state(solid, away_axis) == "inside" + + def _apply_conical_analytic_rebuild_if_simple(self, plan: dict[str, object]) -> bool: + if not bool(plan.get("analytic_rebuild_available", False)): + return False + spec = self._simple_conical_rebuild_spec(plan) + if spec is None: + return False + + target_kind, _source_shape, part, source_solid = self._edge_length_affine_target(plan) + maker = BRepPrimAPI_MakeCone( + float(spec["reference_radius"]), + float(spec["other_radius"]), + float(spec["height"]), + ) + rebuilt = maker.Shape() + if rebuilt.IsNull(): + raise RuntimeError("Analytic cone rebuild produced an empty shape.") + + transform = self._axis_placement_transform( + spec["reference_center"], + spec["reference_to_other_direction"], + ) + builder = BRepBuilderAPI_Transform(rebuilt, transform, True) + builder.Build() + if not builder.IsDone(): + raise RuntimeError("Analytic cone placement transform failed.") + transformed = builder.Shape() + if transformed.IsNull(): + raise RuntimeError("Analytic cone placement transform produced an empty shape.") + transformed = _ensure_valid_or_repaired_shape(transformed, "analytic cone rebuild") + + if target_kind == "part": + part.shape = transformed + else: + part_solids = _explore(part.shape, TopAbs_SOLID) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and source_solid is not None and _same_shape(item, source_solid): + shapes.append(transformed) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + + self.refresh_topology() + return True + + def _apply_embedded_conical_recut_if_available(self, plan: dict[str, object]) -> bool: + if not bool(plan.get("embedded_cone_recut_available", False)): + return False + target_kind, source_shape, part, source_solid = self._edge_length_affine_target(plan) + start = _tuple_or_none(plan.get("embedded_cone_tool_start_point")) + direction = _tuple_normalized(_tuple_or_none(plan.get("embedded_cone_tool_direction"))) + start_radius = _float_or_none(plan.get("embedded_cone_tool_start_radius")) + end_radius = _float_or_none(plan.get("embedded_cone_tool_end_radius")) + height = _float_or_none(plan.get("embedded_cone_tool_height")) + recut_mode = str(plan.get("embedded_cone_recut_mode") or "enlarge") + if ( + start is None + or direction is None + or start_radius is None + or end_radius is None + or height is None + or start_radius <= 1e-9 + or end_radius <= start_radius + or height <= 1e-9 + ): + raise RuntimeError("Embedded conical recut plan is missing a valid cutter.") + + source_for_cut = source_shape + if recut_mode == "shrink": + fill_start_radius = _float_or_none(plan.get("embedded_cone_fill_start_radius")) + fill_end_radius = _float_or_none(plan.get("embedded_cone_fill_end_radius")) + fill_height = _float_or_none(plan.get("embedded_cone_fill_height")) + if ( + fill_start_radius is None + or fill_end_radius is None + or fill_height is None + or fill_start_radius <= 1e-9 + or fill_end_radius <= fill_start_radius + or fill_height <= 1e-9 + ): + raise RuntimeError("Embedded conical recut shrink plan is missing a valid filler.") + filler = BRepPrimAPI_MakeCone( + gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)), + float(fill_start_radius), + float(fill_end_radius), + float(fill_height), + ).Shape() + if filler.IsNull(): + raise RuntimeError("Embedded conical recut produced an empty filler.") + fuse = BRepAlgoAPI_Fuse(source_shape, filler) + source_for_cut = _finalize_boolean_result(fuse, "embedded conical recut fill old cone", use_glue=False) + + cutter = BRepPrimAPI_MakeCone( + gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)), + float(start_radius), + float(end_radius), + float(height), + ).Shape() + if cutter.IsNull(): + raise RuntimeError("Embedded conical recut produced an empty cutter.") + op = BRepAlgoAPI_Cut(source_for_cut, cutter) + result = _finalize_boolean_result(op, "embedded conical recut") + + if target_kind == "part": + part.shape = result + else: + part_solids = _explore(part.shape, TopAbs_SOLID) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and source_solid is not None and _same_shape(item, source_solid): + shapes.append(result) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + + self.refresh_topology() + return True + + def _simple_conical_rebuild_spec(self, plan: dict[str, object]) -> dict[str, object] | None: + target_kind, source_shape, _part, _source_solid = self._edge_length_affine_target(plan) + if len(_explore(source_shape, TopAbs_SOLID)) != 1: + return None + + current_reference_radius = _float_or_none(plan.get("current_reference_radius")) + target_reference_radius = _float_or_none(plan.get("target_reference_radius")) + scale = _float_or_none(plan.get("affine_scale")) + if ( + current_reference_radius is None + or target_reference_radius is None + or scale is None + or current_reference_radius <= 1e-9 + or target_reference_radius <= 1e-9 + or scale <= 1e-9 + ): + return None + + faces = _explore(source_shape, TopAbs_FACE) + cone_faces: list[TopoDS_Shape] = [] + cap_specs: list[dict[str, object]] = [] + for face in faces: + try: + surf = BRepAdaptor_Surface(face) + surface_type = surf.GetType() + except Exception: + return None + if surface_type == GeomAbs_Cone: + cone_faces.append(face) + continue + if surface_type != GeomAbs_Plane: + return None + + edge_count = len(_explore(face, TopAbs_EDGE)) + if edge_count != 1: + return None + props = GProp_GProps() + try: + brepgprop.SurfaceProperties(face, props) + except Exception: + return None + area = float(props.Mass()) + if area <= 1e-9: + return None + radius = math.sqrt(area / math.pi) + center = _point_tuple(props.CentreOfMass()) + cap_specs.append({"radius": radius, "center": center}) + + if target_kind not in {"part", "solid"} or len(cone_faces) != 1 or len(cap_specs) not in {1, 2}: + return None + + length_reference = max( + _shape_diagonal(source_shape), + current_reference_radius, + target_reference_radius, + 1.0, + ) + radius_tolerance = max(length_reference * 1e-5, 1e-5) + if len(cap_specs) == 1: + reference_cap = cap_specs[0] + reference_center = tuple(reference_cap["center"]) + apex = self._simple_conical_apex_point(source_shape, reference_center, radius_tolerance, plan) + if apex is None: + return None + direction = _tuple_normalized(_tuple_sub(apex, reference_center)) + height = _vector_length(_tuple_sub(apex, reference_center)) + if direction is None or height <= radius_tolerance: + return None + reference_radius = float(reference_cap["radius"]) * scale + if reference_radius <= 1e-9: + return None + return { + "reference_center": reference_center, + "reference_to_other_direction": direction, + "reference_radius": reference_radius, + "other_radius": 0.0, + "height": height, + "reference_radius_matches_current": abs(float(reference_cap["radius"]) - current_reference_radius) + <= radius_tolerance, + } + + matching_caps = [ + cap for cap in cap_specs if abs(float(cap["radius"]) - current_reference_radius) <= radius_tolerance + ] + reference_radius_matches_current = len(matching_caps) == 1 + if reference_radius_matches_current: + reference_cap = matching_caps[0] + other_cap = cap_specs[0] if reference_cap is cap_specs[1] else cap_specs[1] + else: + reference_cap = cap_specs[0] + other_cap = cap_specs[1] + + reference_center = tuple(reference_cap["center"]) + other_center = tuple(other_cap["center"]) + direction = _tuple_normalized(_tuple_sub(other_center, reference_center)) + height = _vector_length(_tuple_sub(other_center, reference_center)) + if direction is None or height <= radius_tolerance: + return None + + reference_radius = float(reference_cap["radius"]) * scale + other_radius = float(other_cap["radius"]) * scale + if reference_radius <= 1e-9 or other_radius <= 1e-9: + return None + + return { + "reference_center": reference_center, + "reference_to_other_direction": direction, + "reference_radius": reference_radius, + "other_radius": other_radius, + "height": height, + "reference_radius_matches_current": reference_radius_matches_current, + } + + def _simple_conical_apex_point( + self, + shape: TopoDS_Shape, + cap_center: tuple[float, float, float], + tolerance: float, + plan: dict[str, object], + ) -> tuple[float, float, float] | None: + axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis"))) + unique_points: list[tuple[float, float, float]] = [] + for vertex in _explore(shape, TopAbs_VERTEX): + try: + point = _point_tuple(BRep_Tool.Pnt(topods.Vertex(vertex))) + except Exception: + continue + if any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in unique_points): + continue + unique_points.append(point) + if not unique_points: + return None + + best_point: tuple[float, float, float] | None = None + best_score = -math.inf + for point in unique_points: + vector = _tuple_sub(point, cap_center) + if axis_direction is not None: + score = abs(_tuple_dot(vector, axis_direction)) + else: + score = _vector_length(vector) + if score > best_score: + best_score = score + best_point = point + if best_point is None or best_score <= tolerance: + return None + return best_point + + def _axis_placement_transform( + self, + origin: tuple[float, float, float], + z_direction: tuple[float, float, float], + ) -> gp_Trsf: + w = _tuple_normalized(z_direction) + if w is None: + raise ValueError("Missing analytic cone placement direction.") + helper = (1.0, 0.0, 0.0) if abs(w[0]) < 0.85 else (0.0, 1.0, 0.0) + u = _tuple_normalized(_tuple_cross(helper, w)) + if u is None: + helper = (0.0, 0.0, 1.0) + u = _tuple_normalized(_tuple_cross(helper, w)) + if u is None: + raise ValueError("Could not build analytic cone placement basis.") + v = _tuple_cross(w, u) + transform = gp_Trsf() + transform.SetValues( + u[0], + v[0], + w[0], + origin[0], + u[1], + v[1], + w[1], + origin[1], + u[2], + v[2], + w[2], + origin[2], + ) + return transform + def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str: plan = self.conical_reference_radius_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) + if self._apply_conical_analytic_rebuild_if_simple(plan): + result_check = self._face_edit_result_summary(plan) + return ( + "Conical face reference radius resize completed by analytic cone rebuild: " + f"face {face_id}, " + f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) + if self._apply_embedded_conical_recut_if_available(plan): + result_check = self._face_edit_result_summary(plan) + return ( + "Conical face reference radius resize completed by embedded local cone recut: " + f"face {face_id}, " + f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " + f"mode={plan.get('embedded_cone_recut_mode')}, " + f"small_radius={float(plan['embedded_cone_current_small_radius']):g}->{float(plan['embedded_cone_target_small_radius']):g}, " + f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, " + f"height={float(plan['embedded_cone_height']):g}, " + f"target={plan.get('embedded_cone_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Conical face reference radius resize completed by radial affine scaling: " f"face {face_id}, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def resize_conical_semi_angle(self, face_id: int, target_angle_degrees: float) -> str: plan = self.conical_semi_angle_plan(face_id, target_angle_degrees) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) + if self._apply_conical_analytic_rebuild_if_simple(plan): + result_check = self._face_edit_result_summary(plan) + return ( + "Conical face semi-angle resize completed by analytic cone rebuild: " + f"face {face_id}, " + f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, " + f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) + if self._apply_embedded_conical_recut_if_available(plan): + result_check = self._face_edit_result_summary(plan) + return ( + "Conical face semi-angle resize completed by embedded local cone recut: " + f"face {face_id}, " + f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, " + f"mode={plan.get('embedded_cone_recut_mode')}, " + f"fixed_small_radius={float(plan['embedded_cone_fixed_small_radius']):g}, " + f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, " + f"height={float(plan['embedded_cone_height']):g}, " + f"target={plan.get('embedded_cone_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Conical face semi-angle resize completed by radial affine scaling: " f"face {face_id}, " @@ -4626,7 +5646,7 @@ class OperationMixin: f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def spherical_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]: @@ -4717,14 +5737,16 @@ class OperationMixin: plan = self.spherical_radius_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Spherical face radius resize completed by uniform scaling: " f"face {face_id}, " f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]: @@ -4824,7 +5846,9 @@ class OperationMixin: plan = self.toroidal_radius_plan(face_id, target_radius, mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor" current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius") target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius") @@ -4833,7 +5857,7 @@ class OperationMixin: f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def face_area_scale_plan(self, face_id: int, target_area: float) -> dict[str, object]: @@ -4930,14 +5954,16 @@ class OperationMixin: plan = self.face_area_scale_plan(face_id, target_area) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face area resize completed by uniform scaling fallback: " f"face {face_id}, " f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def cylindrical_resize_preview_polydata( @@ -5335,7 +6361,9 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_local_face_deform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face center move completed by local face-only deformation: " f"face {face_id}, " @@ -5345,7 +6373,7 @@ class OperationMixin: f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def face_area_local_resize_preview_polydata( @@ -5366,7 +6394,9 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_local_face_deform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face area resize completed by local face-only deformation: " f"face {face_id}, " @@ -5377,7 +6407,7 @@ class OperationMixin: f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def face_size_local_resize_preview_polydata( @@ -5399,7 +6429,9 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) self._apply_local_face_deform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face local size resize completed by local face-only deformation: " f"face {face_id}, " @@ -5411,7 +6443,7 @@ class OperationMixin: f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def face_size_owning_scale_preview_polydata( @@ -5436,10 +6468,12 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + self._remember_face_target_logical_id(plan, face_id) if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild": self._apply_local_face_deform(plan) else: self._apply_edge_length_affine_transform(plan) + result_check = self._face_edit_result_summary(plan) return ( "Face owning size resize completed by axis-affine scaling: " f"face {face_id}, " @@ -5450,9 +6484,263 @@ class OperationMixin: f"scale={float(plan['face_size_scale']):g}, " f"rebuild_mode={plan.get('owning_face_size_rebuild_mode')}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) + def _remember_face_target_logical_id(self, plan: dict[str, object], face_id: int) -> None: + try: + if 0 <= int(face_id) < len(self.faces): + plan["target_logical_id"] = self.face_region_logical_id(int(face_id)) + except Exception: + pass + + def _face_edit_result_summary(self, plan: dict[str, object]) -> str: + check = self._face_edit_result_check(plan) + if check is None: + return "Face result check unavailable." + if int(check.get("face_id", -1)) < 0: + return f"Face result check: no matching Face found, metric={check.get('metric')}, scope={check.get('scope')}." + target = check.get("target") + actual = check.get("actual") + target_text = _format_tuple(target) if isinstance(target, tuple) else _format_result_number(target) + actual_text = _format_tuple(actual) if isinstance(actual, tuple) else _format_result_number(actual) + return ( + "Face result check: " + f"nearest_face={check['face_id']}, " + f"metric={check['metric']}, " + f"actual={actual_text}, " + f"target={target_text}, " + f"error={float(check['error']):g}, " + f"tolerance={float(check['tolerance']):g}, " + f"scope={check['scope']}." + ) + + def _face_edit_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: + face_ids, scope = self._face_edit_result_candidate_ids(plan) + if not face_ids: + return None + + metric = "" + target: float | tuple[float, ...] | None = None + tolerance = 1e-4 + getter: Callable[[int], float | tuple[float, ...] | None] | None = None + + target_area = _float_or_none(plan.get("target_area")) + target_size = _float_or_none(plan.get("target_face_size")) + target_center = _tuple_or_none(plan.get("target_face_center")) + target_position = _float_or_none(plan.get("target_plane_position")) + target_thickness = _float_or_none(plan.get("shell_target_thickness")) + embedded_small_radius = _float_or_none(plan.get("embedded_cone_target_small_radius")) + embedded_large_radius = _float_or_none(plan.get("embedded_cone_target_large_radius")) + + if target_area is not None and target_area > 0: + metric = "area" + target = target_area + tolerance = max(abs(target_area) * 0.02, 1e-4) + getter = lambda face_id: _float_or_none(self.face_info(face_id).get("area")) + elif target_size is not None and target_size > 0: + axis_key = "height" if str(plan.get("face_size_axis") or "").lower() == "height" else "width" + info_key = "local_face_height" if axis_key == "height" else "local_face_width" + metric = info_key + target = target_size + tolerance = max(abs(target_size) * 0.02, 1e-4) + getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key)) + elif target_center is not None: + metric = "center" + target = target_center + reference = max(max(abs(item) for item in target_center), 1.0) + tolerance = max(reference * 1e-5, 1e-4) + + def center_getter(face_id: int) -> tuple[float, float, float] | None: + info = self.face_info(face_id) + return _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + + getter = center_getter + elif target_position is not None: + direction = ( + _tuple_normalized(_tuple_or_none(plan.get("plane_direction"))) + or _tuple_normalized(_tuple_or_none(plan.get("outward_direction"))) + ) + if direction is None: + return None + metric = "plane_position" + target = target_position + bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or _shape_diagonal(self.shape) + tolerance = max(bbox_diagonal * 1e-4, abs(target_position) * 1e-5, 1e-4) + + def plane_position_getter(face_id: int) -> float | None: + origin = _tuple_or_none(self.face_info(face_id).get("plane_origin")) + if origin is None: + return None + return _tuple_dot(origin, direction) + + getter = plane_position_getter + elif target_thickness is not None and target_thickness > 0: + metric = "shell_thickness" + target = target_thickness + tolerance = max(abs(target_thickness) * 0.03, 1e-4) + getter = lambda face_id: _float_or_none(self.feature_info(face_id).get("shell_thickness_estimate")) + elif ( + embedded_small_radius is not None + and embedded_small_radius > 0 + and embedded_large_radius is not None + and embedded_large_radius > embedded_small_radius + ): + metric = "cone_boundary_radii" + target = (embedded_small_radius, embedded_large_radius) + tolerance = max(abs(embedded_large_radius) * 0.01, abs(embedded_small_radius) * 0.01, 1e-4) + + def cone_boundary_getter(face_id: int) -> tuple[float, float] | None: + info = self.face_info(face_id) + axis_point = _tuple_or_none(info.get("axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + if axis_point is None or axis_direction is None: + return None + circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction) + radii = sorted(float(circle["radius"]) for circle in circles) + if len(radii) != 2: + return None + return (radii[0], radii[1]) + + getter = cone_boundary_getter + else: + resize_strategy = str(plan.get("resize_strategy") or "") + surface = str(plan.get("surface") or "") + if "semi-angle" in resize_strategy: + numeric_specs = ( + ("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05), + ("reference_radius", "target_reference_radius", 0.01, 1e-4), + ("radius", "target_radius", 0.01, 1e-4), + ) + elif surface == "torus": + if str(plan.get("torus_radius_mode") or "") == "major": + numeric_specs = ( + ("major_radius", "target_major_radius", 0.01, 1e-4), + ("minor_radius", "target_minor_radius", 0.01, 1e-4), + ) + else: + numeric_specs = ( + ("minor_radius", "target_minor_radius", 0.01, 1e-4), + ("major_radius", "target_major_radius", 0.01, 1e-4), + ) + else: + numeric_specs = ( + ("reference_radius", "target_reference_radius", 0.01, 1e-4), + ("major_radius", "target_major_radius", 0.01, 1e-4), + ("minor_radius", "target_minor_radius", 0.01, 1e-4), + ("radius", "target_radius", 0.01, 1e-4), + ("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05), + ) + for info_key, target_key, ratio, floor in numeric_specs: + value = _float_or_none(plan.get(target_key)) + if value is None or value <= 0: + continue + metric = info_key + target = value + tolerance = max(abs(value) * ratio, floor) + if info_key == "semi_angle_degrees": + getter = lambda face_id: _angle_degrees_or_none(self.face_info(face_id).get("semi_angle")) + else: + getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key)) + break + + if getter is None or target is None: + return None + + best: dict[str, object] | None = None + for face_id in face_ids: + try: + actual = getter(face_id) + except Exception: + actual = None + if actual is None: + continue + error = _result_value_error(actual, target) + if best is None or error < float(best["error"]): + best = { + "face_id": face_id, + "metric": metric, + "actual": actual, + "target": target, + "error": error, + "tolerance": tolerance, + "scope": scope, + } + if best is not None: + return best + return { + "face_id": -1, + "metric": metric, + "actual": "", + "target": target, + "error": math.inf, + "tolerance": tolerance, + "scope": scope, + } + + def _face_edit_result_candidate_ids(self, plan: dict[str, object]) -> tuple[list[int], str]: + part_id = _int_or_none(plan.get("part_id")) + solid_id = _int_or_none(plan.get("solid_id")) + surface = str(plan.get("surface") or "") + target_kind = str( + plan.get("local_face_deform_target_kind") + or plan.get("affine_target_kind") + or plan.get("target_kind") + or ("solid" if solid_id is not None and solid_id >= 0 else "part") + ) + scope = f"part {part_id}" if part_id is not None else "model" + if target_kind == "solid" and solid_id is not None and solid_id >= 0: + scope = f"solid {solid_id}" + + primary: list[int] = [] + logical_id = _int_or_none(plan.get("target_logical_id")) + if logical_id is not None: + try: + primary.extend(self.face_ids_for_logical_id(logical_id)) + except Exception: + pass + plan_face_id = _int_or_none(plan.get("face_id")) + if plan_face_id is not None: + primary.append(plan_face_id) + + def filtered(source_ids: Iterable[int], *, require_solid: bool = True) -> list[int]: + result: list[int] = [] + for candidate_id in source_ids: + try: + face_id = int(candidate_id) + except (TypeError, ValueError): + continue + if not (0 <= face_id < len(self.faces)): + continue + if part_id is not None and int(self.face_part_ids[face_id]) != part_id: + continue + if ( + require_solid + and solid_id is not None + and solid_id >= 0 + and int(self.face_solid_ids[face_id]) != solid_id + ): + continue + if surface in {"plane", "cylinder", "cone", "sphere", "torus"}: + try: + if self.face_surface_kind(face_id) != surface: + continue + except Exception: + continue + if face_id not in result: + result.append(face_id) + return result + + primary_ids = filtered(primary) + all_ids = filtered(range(len(self.faces))) + if not all_ids and solid_id is not None and solid_id >= 0: + all_ids = filtered(range(len(self.faces)), require_solid=False) + combined: list[int] = [] + for face_id in [*primary_ids, *all_ids]: + if face_id not in combined: + combined.append(face_id) + return combined, scope + def _edge_length_result_summary(self, plan: dict[str, object]) -> str: check = self._edge_length_result_check(plan) if check is None: @@ -6184,6 +7472,7 @@ class OperationMixin: def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None: target_kind, source_shape, part, solid = self._edge_length_affine_target(plan) + previous_logical_ids = tuple(getattr(self, "face_logical_ids", ())) transformed = self._affine_scaled_shape_along_edge(source_shape, plan) _ensure_valid_shape(transformed) if target_kind == "part": @@ -6203,6 +7492,7 @@ class OperationMixin: part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() + self._restore_face_logical_ids_if_count_matches(previous_logical_ids) def _edge_length_affine_target( self, @@ -6299,6 +7589,7 @@ class OperationMixin: target_region_mapping_specs: list[dict[str, object]] = [] try: target_logical_id = self.face_region_logical_id(face_id) + plan["target_logical_id"] = target_logical_id target_region_mapping_specs = self._translated_face_region_mapping_specs( self._face_region_mapping_specs([face_id]), _tuple_scale(outward, float(distance)), @@ -6317,6 +7608,7 @@ class OperationMixin: self.refresh_topology() self._apply_face_region_mapping_specs(side_region_mapping_specs) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) + result_check = self._face_edit_result_summary(plan) return ( "Planar face push/pull completed: cylindrical cap extension, " f"semantic_distance={distance:g}, " @@ -6326,7 +7618,7 @@ class OperationMixin: f"side_faces={cap_extension['side_face_ids']}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) overlap = _boolean_overlap_distance(part.shape, distance) @@ -6347,6 +7639,7 @@ class OperationMixin: self._apply_face_region_mapping_specs(side_region_mapping_specs) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) action = "fused outward prism" if distance >= 0 else "cut inward prism" + result_check = self._face_edit_result_summary(plan) return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " @@ -6354,7 +7647,7 @@ class OperationMixin: f"scope_faces={len(scope_face_ids)}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) def _cylindrical_cap_extension_plan( @@ -6937,7 +8230,7 @@ class OperationMixin: "span_tolerance": span_tolerance, } if candidate["matched"]: - return candidate + return self._attach_cylindrical_first_level_result_check(candidate) if score < best_score: best_score = score best = candidate @@ -7016,7 +8309,7 @@ class OperationMixin: "center_distance_tolerance": distance_tolerance, } if candidate["matched"]: - return candidate + return self._attach_cylindrical_first_level_result_check(candidate) if score < best_score: best_score = score best = candidate @@ -7119,7 +8412,7 @@ class OperationMixin: "center_tolerance": center_tolerance, } if candidate["matched"]: - return candidate + return self._attach_cylindrical_first_level_result_check(candidate) if score < best_score: best_score = score best = candidate @@ -7623,6 +8916,63 @@ class OperationMixin: f"verified_face={verification.get('face_id', '')}." ) + def _attach_cylindrical_first_level_result_check(self, candidate: dict[str, object]) -> dict[str, object]: + if not candidate.get("matched"): + return candidate + face_ids: list[int] = [] + for key in ("face_id", "paired_face_id"): + face_id = _int_or_none(candidate.get(key)) + if face_id is not None and face_id not in face_ids: + face_ids.append(face_id) + if not face_ids: + return { + **candidate, + "matched": False, + "first_level_topology_matched": False, + "detail": " Result verification matched geometry but did not identify a Face ID for first-level topology.", + } + + summaries: list[dict[str, object]] = [] + for face_id in face_ids: + try: + topology = self.cylindrical_feature_first_level_topology(face_id) + side_face_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0) + boundary_edge_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) + adjacent_face_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) + except Exception as exc: + return { + **candidate, + "matched": False, + "first_level_topology_matched": False, + "detail": f" Result Face {face_id} matched target geometry but first-level topology failed: {exc}", + } + summaries.append( + { + "face_id": face_id, + "side_face_count": side_face_count, + "boundary_edge_count": boundary_edge_count, + "adjacent_face_count": adjacent_face_count, + } + ) + if side_face_count <= 0 or boundary_edge_count <= 0 or adjacent_face_count <= 0: + return { + **candidate, + "matched": False, + "first_level_topology_matched": False, + "first_level_topology_summaries": tuple(summaries), + "detail": ( + f" Result Face {face_id} matched target geometry but lost first-level topology " + f"(side={side_face_count}, boundary_edges={boundary_edge_count}, " + f"adjacent_faces={adjacent_face_count})." + ), + } + + return { + **candidate, + "first_level_topology_matched": True, + "first_level_topology_summaries": tuple(summaries), + } + def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_diameter = float(plan.get("target_diameter", 0.0)) target_radius = target_diameter / 2.0 @@ -7669,7 +9019,7 @@ class OperationMixin: "axis_tolerance": axis_tolerance, } if candidate["matched"]: - return candidate + return self._attach_cylindrical_first_level_result_check(candidate) if score < best_score: best_score = score best = candidate diff --git a/step_editor/polydata.py b/step_editor/polydata.py index 15f4688..567b31a 100644 --- a/step_editor/polydata.py +++ b/step_editor/polydata.py @@ -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): diff --git a/step_editor/transforms.py b/step_editor/transforms.py index 448af7c..b15d8c9 100644 --- a/step_editor/transforms.py +++ b/step_editor/transforms.py @@ -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']}." diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 283d0ba..e1c2db6 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -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 [] diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 8aca0f9..42880b2 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -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() diff --git a/step_editor/window_core.py b/step_editor/window_core.py index b68b852..5a42606 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -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() diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 476640d..c10144b 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -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)