From a76282d7ddee1c06297144ee46551e47b546592a Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Tue, 4 Aug 2026 18:15:29 +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=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +- scripts/verify_cone_semi_angle_isolation.py | 24 +- ...verify_cylindrical_first_level_topology.py | 53 + scripts/verify_cylindrical_height_resize.py | 295 ++ scripts/verify_face_complex_boundary_guard.py | 30 + scripts/verify_face_coplanar_push_pull.py | 3 + scripts/verify_face_edit_integrity_guards.py | 207 ++ scripts/verify_face_edit_suite.py | 20 + scripts/verify_face_first_level_topology.py | 66 +- scripts/verify_face_mixed_surface_guard.py | 386 ++- scripts/verify_face_push_pull_limits.py | 8 + scripts/verify_face_resize_semantics.py | 2 + scripts/verify_face_ui_isolation_contract.py | 488 +++ scripts/verify_feature_recognition_summary.py | 189 ++ scripts/verify_first_level_fact_graph.py | 218 ++ scripts/verify_hole_resize.py | 2 + scripts/verify_isolated_face_edit.py | 150 +- scripts/verify_large_stepped_cap_push_pull.py | 370 +++ scripts/verify_property_editor_specs.py | 130 +- step_editor/app.py | 3 + step_editor/features.py | 137 + step_editor/isolated_edit_worker.py | 6 + step_editor/model.py | 473 ++- step_editor/operations.py | 2829 ++++++++++++++++- step_editor/ui_helpers.py | 74 +- step_editor/window_actions.py | 654 +++- step_editor/window_core.py | 3 + step_editor/window_state.py | 269 +- 28 files changed, 6872 insertions(+), 270 deletions(-) create mode 100644 scripts/verify_cylindrical_height_resize.py create mode 100644 scripts/verify_face_ui_isolation_contract.py create mode 100644 scripts/verify_feature_recognition_summary.py create mode 100644 scripts/verify_first_level_fact_graph.py create mode 100644 scripts/verify_large_stepped_cap_push_pull.py diff --git a/README.md b/README.md index 308f31f..f6f50c5 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ git diff --check - 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 +- 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以推拉,但“只改当前面局部尺寸/中心/偏移”会被标成受限修改。 - 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 @@ -137,7 +138,25 @@ git diff --check 所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 -当前 Face 参数化修改已经接入隔离子进程执行,包括 `推拉当前面`、`面偏移(当前面)`、`面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)`、`薄壁厚度(当前面)`、`面积(整体)`、`面宽/面高(整体)`、`薄壁厚度(整体)`,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。 +当前 Face 参数化修改已经接入隔离子进程执行,包括 `推拉当前面`、`面偏移(当前面)`、`面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)`、`薄壁厚度(当前面)`、`面积(整体)`、`面宽/面高(整体)`、`薄壁厚度(整体)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。 + +Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示底层 OCCT 异常。当前会尽量附带诊断信息:操作对象、Face 类型、目标值/目标位置、一级边界 Edge/Vertex、共享边相邻 Face、内孔/内边界数量、已识别的端盖专用路径,以及是否可能因为孔壁、槽底、台阶终点等二级/三级关系暂未传播而失败。 + +复杂大 STEP 里的多边界平面端盖不再在 UI 线程同步生成完整 `push_pull_plan`。界面只做轻量预检查并标记高风险,然后把完整一级关系识别、解析重建或快速阻止放到后台隔离进程里执行,避免用户点击 `参数化建模` 后先卡住几秒到几十秒。 + +如果轻量预检查已经能判断出风险不可接受,比如复杂大模型里的多内孔端盖做向内推拉,界面会直接阻止并说明需要二级关系判断,不再提交隔离子进程等待失败返回。 + +复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)` 或 `面偏移(当前面)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`薄壁厚度(当前面)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和推拉距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 + +普通平面 Face 的核心修改路径现在还会在模型层做结果守门:执行后必须通过 B-Rep 校验、能重新找到目标 Face 的目标值,并且能重新识别该 Face 的一级共享边邻域。矩形、多边形 Face 会检查边界 Edge 和 Vertex;圆形端盖这类解析边界会允许 1 条闭合圆 Edge。圆锥参考半径/半角、球面半径、环面半径这类解析 Face 也会强制校验目标值和 B-Rep。若目标值没达到、一级邻域丢失或 B-Rep 无效,模型层会恢复操作前 shape,不把坏结果交给界面或导出流程。 + +`推拉当前面` 对一次性较大的向外推拉不会按绝对距离一刀切阻止;如果距离相对当前 Face 或所属实体跨度较大,会标为高风险并走隔离子进程执行。这样可以允许用户做类似 CAD 里的大距离拉伸,同时如果 OCCT 布尔失败、超时或返回无效 B-Rep,主程序和原模型仍保持不变。 + +实心圆柱或同轴空心筒体的端盖推拉会优先走解析/局部重建:选中圆柱上/下底面或环形端面时,程序会识别相邻外圆柱面、内孔圆柱面和端盖一级关系。纯简单圆柱/筒体会直接重建新的完整高度;如果同一 Solid 上还有不碰端盖边界的侧向缺口、台阶等额外特征,向外推拉会只生成旧端盖到新端盖之间的局部延长段去补料,向内推拉会只切除旧端盖到新端盖之间的局部段,避免拿整根新圆柱/新筒体或通用平面 prism 和原模型做大范围布尔。同轴阶梯/盲孔式环形端盖允许内外圆柱侧面的旧深度不同;向外推拉时会保留旧侧面的复杂底部裁剪,只删除旧端盖,并补上旧端盖到新端盖之间的侧面段和新端盖,结果校验会优先检查新增端盖而不是全模型扫描。如果端盖边界本身连接了槽、缺口或台阶等额外一级相邻 Face,向外推拉会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长;向内推拉如果没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短;如果越过槽底/台阶内侧终点,会快速阻止并说明原因。无法确认同轴圆柱/筒体一级关系时,才退回隔离子进程里的通用推拉和回滚保护。 + +带多个内孔/内边界的大平面端盖向外推拉时,如果不能归类成同轴圆柱/筒体端盖,会改走 `boundary-shell-rebuild`:移动当前端面,并把该端面的一级边界 Edge 沿推拉方向生成延长侧壁后局部缝合,避免复杂 STEP 退回 180s 通用布尔慢路径;向内推拉如果没有识别到安全的端盖缩短路径,或需要判断槽底、台阶终点、孔壁背后的二级/三级关系,会快速阻止并把原因写清楚。 + +选中普通完整圆柱侧面修改 `高度` 时,局部语义会换算成端盖推拉:执行后必须重新检测到同轴、同半径、目标高度的圆柱侧面,并且圆柱一级边界/相邻 Face 关系没有丢失,否则自动回滚。`调整整个特征` 语义会沿圆柱轴向缩放所属特征或 Solid,执行后按轴向跨度校验目标高度;这条语义可能影响同一对象上的孔距、台阶位置和其它轴向尺寸。 产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 @@ -174,7 +193,7 @@ git diff --check | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | --- | --- | --- | --- | | Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | -| Face / 面 | 面积、面宽/面高、中心、面偏移、薄壁厚度。 | 推拉当前平面;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域。 | +| Face / 面 | 面积、面宽/面高、中心、面偏移、薄壁厚度;圆柱端盖或完整圆柱侧面的高度。 | 推拉当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | | 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 | | 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或推拉端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | @@ -187,14 +206,14 @@ git diff --check | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | --- | --- | --- | -| Face | 平面 Face 可以改 `面积`、`面宽`、`面高`、`中心` 和 `面偏移`;这些行会在 `影响范围` 里选择 `只改当前面`、`推拉当前面`、`移动整个特征` 或 `调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`。 | `面宽/面高` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`面偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid;`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、圆柱端盖这类所属 Solid 含曲面的平面 Face、复杂壳体局部区域和带内孔/复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用 `只改当前面` 并在提示里说明具体原因,建议改用孔/槽专门入口、`推拉当前面` 或整体调整。 | +| Face | 平面 Face 可以改 `面积`、`面宽`、`面高`、`中心` 和 `面偏移`;这些行会在 `影响范围` 里选择 `只改当前面`、`推拉当前面`、`移动整个特征` 或 `调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `推拉当前面` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖推拉,或者选择沿轴向 `调整整个特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面宽/面高` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`面偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid;`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内推拉越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`推拉当前面` 的简单端盖路径或整体调整。 | | 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `影响范围` 选择 `只改孔/槽壁` 或 `调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | | 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度` 或 `调整整个特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | | 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁` 或 `调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`推拉端盖`、`移动凸台`、`调整整个特征` 或 `移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | | 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | -| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | +| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | @@ -233,6 +252,8 @@ git diff --check - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面Face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。 +- 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 +- 模型层提供统一的一级拓扑事实包 `face_first_level_facts()`:它把当前主体 Face 区域、边界 Edge、边界 Vertex、共享边直接相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Face、孔、槽等编辑计划也会携带这份事实包;`只改当前 Face` 这类局部重建计划会在执行前先检查一级事实图是否 ready,若无法确认当前 Face、边界 Edge/Vertex 和共享边相邻 Face,会直接 blocked 并说明原因。Face 编辑成功后还会重建目标 Face 的一级事实图做结果校验,若目标值虽然达到但一级事实图不再成立,会自动回滚。后续孔、槽、凸台、圆角等候选识别应优先使用这份事实包,再叠加各自的语义判断和置信度。 - 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式: - 零件名称 - Solid 数量 @@ -526,8 +547,8 @@ 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、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 +- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 +- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。特征参数表也会显示圆柱特征的 `一级关系` 摘要,让用户知道当前孔/槽修改会围绕哪些直接邻域执行;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 - 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 - Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象。 @@ -942,12 +963,16 @@ pythonocc-step-editor/ verify_face_edit_suite.py # 一键运行 Face 专项修改验证 verify_face_extreme_target_guards.py # 验证 Face 面积、面宽/面高、面偏移和薄壁厚度不能输入过大或过小的极端目标值 verify_face_invalid_target_guards.py # 验证 Face 面积、面宽/面高 和薄壁厚度不能输入非数字、0 或负数 + verify_large_stepped_cap_push_pull.py # 验证大 STEP 里的同轴阶梯环形端盖推拉走局部 shell 重建而不是 180s 布尔慢路径 verify_face_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面 verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留推拉 verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、面宽/面高、面偏移和推拉 verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止 + verify_first_level_fact_graph.py # 验证平面、孔、槽会输出统一一级拓扑事实包,且编辑计划会携带这份事实 + verify_feature_recognition_summary.py # 验证平面、孔、槽候选会输出统一识别摘要、置信度、风险和证据 verify_face_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何 verify_face_push_pull_limits.py # 验证 Face 向内推拉不会允许切穿实体厚度 + verify_face_ui_isolation_contract.py # 验证 Face UI 入口和 isolated worker 的隔离操作列表一致 verify_face_rectangular_axes.py # 验证矩形 Face 的面宽 和面高 方向不会混淆 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 @@ -1056,8 +1081,9 @@ git diff --check - `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 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 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_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、大 STEP 同轴阶梯环形端盖局部 shell 推拉、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 +- `scripts/verify_face_edit_integrity_guards.py`:验证 Face 编辑结果不能丢 Solid、不能新增无效 B-Rep、不能只达到目标数值却破坏一级拓扑;若目标检查、一级拓扑计数或一级事实图校验失败,模型层会恢复编辑前快照。 +- `scripts/verify_face_first_level_topology.py`:加载 `cube_10mm.step`,验证正方体上表面 Face 的一级关系只包含当前 Face、4 条边界 Edge、4 个边界 Vertex 和 4 个共享边相邻侧面;局部移动上表面后,目标 Face 的边界 Vertex 与共享边 Edge 会到达新高度,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 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。 @@ -1067,11 +1093,16 @@ git diff --check - `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `只改当前面`、`调整整个特征`、`移动整个特征`、`推拉当前面` 语义,在中心、面积、面宽/面高 和面偏移场景下都能保持单 Solid。 - `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面宽/面高、面积、中心坐标和面偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `推拉当前面` 之间不会互相混淆。 - `scripts/verify_face_noop_guards.py`:验证 Face 的推拉、面偏移、中心、面积、面宽/面高 和薄壁厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 +- `scripts/verify_first_level_fact_graph.py`:验证正方体平面 Face、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 推拉/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。 +- `scripts/verify_feature_recognition_summary.py`:验证平面 Face、带内孔平面 Face、通孔圆柱面和槽/半孔候选都会输出统一的 `识别摘要`、候选类型、置信度、风险、证据项、`当前可改` 和 `当前受限修改`;这些摘要只使用已有识别结果生成,不额外触发深层几何扫描。 - `scripts/verify_face_property_readback.py`:验证 Face 的面宽、面积、中心和面偏移编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `面偏移` 的 `只改当前面`、`移动整个特征` 和 `推拉当前面` 三种语义。 -- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内推拉时会估算当前面背后的材料厚度;浅切削允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked。 +- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内推拉时会估算当前面背后的材料厚度;浅切削允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked;向外一次性推拉较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。 +- `scripts/verify_face_ui_isolation_contract.py`:验证 Face 阶段属性表入口使用的隔离操作集合、`WindowActionMixin._isolation_for_plan()` 的 UI 隔离列表,以及 `isolated_edit_worker` 真正支持的 worker 操作完全一致;脚本还会反向追踪属性表暴露的 Face 修改动作是否最终进入隔离 worker,防止后续新增 Face 参数行时忘记接入后台隔离执行,导致 UI 卡死或主进程被危险 OCCT 计算拖崩。 - `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `面宽` 和 `面高` 两个方向上可以分别做 `只改当前面` 与 `调整整个特征`,另一个方向不会被误改,属性表也会读回新值。 -- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路。 -- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的面偏移、面积、面宽/面高、中心和薄壁厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;这用于证明大参数 Face 编辑不会再直接冒险拖垮主界面进程。 +- `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖推拉并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`调整整个特征` 的轴向缩放会按轴向跨度校验结果。 +- `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面向内推拉会快速阻止且说明二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 +- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 +- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的面偏移、面积、面宽/面高、中心和薄壁厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 - `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、面宽/面高 或面偏移编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `只改当前面` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 - `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 diff --git a/scripts/verify_cone_semi_angle_isolation.py b/scripts/verify_cone_semi_angle_isolation.py index 422819e..3830e88 100644 --- a/scripts/verify_cone_semi_angle_isolation.py +++ b/scripts/verify_cone_semi_angle_isolation.py @@ -572,10 +572,15 @@ def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None: ) 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( + if probe.model is not model: + raise SystemExit("isolated embedded reference-radius edit should not replace the window model off the UI thread") + edited_model = result.get("after_model") + if not isinstance(edited_model, StepModel): + raise SystemExit(f"isolated embedded reference-radius edit should return after_model: {result}") + if result.get("model_polydata") is None or result.get("edge_polydata") is None: + raise SystemExit("isolated embedded reference-radius edit should return prebuilt display polydata") + edited_face_id, edited_info, _score = _best_cone_face_by_angle(edited_model, 30.963756532) + circles = edited_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"])), @@ -738,10 +743,13 @@ def main() -> int: ) 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 + if probe.model is not model: + raise SystemExit("isolated cone semi-angle edit should not replace the window model off the UI thread") + edited_model = result.get("after_model") + if not isinstance(edited_model, StepModel): + raise SystemExit(f"isolated cone semi-angle edit should return after_model: {result}") + if result.get("model_polydata") is None or result.get("edge_polydata") is None: + raise SystemExit("isolated cone semi-angle edit should return prebuilt display polydata") 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") diff --git a/scripts/verify_cylindrical_first_level_topology.py b/scripts/verify_cylindrical_first_level_topology.py index 8654c16..6270300 100644 --- a/scripts/verify_cylindrical_first_level_topology.py +++ b/scripts/verify_cylindrical_first_level_topology.py @@ -220,12 +220,65 @@ def _verify_missing_first_level_neighbor_blocks_plan() -> None: ) +def _verify_result_check_rejects_degraded_first_level_topology() -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_result_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) + plan = model.cylindrical_resize_plan(face_id, 8.0) + + healthy = model._attach_cylindrical_first_level_result_check( # noqa: SLF001 + {"matched": True, "face_id": face_id}, + plan, + ) + _assert( + healthy.get("first_level_topology_matched") is True, + f"healthy result topology should pass: {healthy}", + ) + minimums = dict(healthy.get("first_level_topology_minimums") or {}) + _assert( + int(minimums.get("adjacent_face_count", 0) or 0) >= 2, + f"through-hole result guard should derive a direct-neighbor minimum: {healthy}", + ) + + original_topology = model.cylindrical_feature_first_level_topology + + def degraded_topology(target_face_id: int) -> dict[str, object]: + topology = dict(original_topology(target_face_id)) + topology["cylindrical_feature_adjacent_face_ids"] = tuple( + tuple(topology.get("cylindrical_feature_adjacent_face_ids", ()) or ())[:1] + ) + topology["cylindrical_feature_adjacent_face_count"] = 1 + return topology + + model.cylindrical_feature_first_level_topology = degraded_topology # type: ignore[method-assign] + degraded = model._attach_cylindrical_first_level_result_check( # noqa: SLF001 + {"matched": True, "face_id": face_id}, + plan, + ) + _assert( + degraded.get("matched") is False, + f"result topology with too few direct adjacent Faces should fail: {degraded}", + ) + _assert( + degraded.get("first_level_topology_matched") is False, + f"degraded result should mark first-level topology mismatch: {degraded}", + ) + detail = str(degraded.get("detail") or "") + _assert( + "first-level topology" in detail and "adjacent_face_count" in detail, + f"degraded result should explain the weaker first-level topology: {degraded}", + ) + + 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() + _verify_result_check_rejects_degraded_first_level_topology() print("cylindrical first-level topology verification passed") return 0 diff --git a/scripts/verify_cylindrical_height_resize.py b/scripts/verify_cylindrical_height_resize.py new file mode 100644 index 0000000..60dcb58 --- /dev/null +++ b/scripts/verify_cylindrical_height_resize.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + +from step_editor.geometry_utils import _finalize_boolean_result +from step_editor.model import StepModel +from step_editor.step_io import _write_step + + +def _write_solid_cylinder(path: Path) -> None: + _write_step(BRepPrimAPI_MakeCylinder(3.0, 11.0).Shape(), path) + + +def _write_hollow_cylinder(path: Path) -> None: + outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + inner = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 3.0, + 80.0, + ).Shape() + cut = BRepAlgoAPI_Cut(outer, inner) + _write_step(_finalize_boolean_result(cut, "verify hollow cylinder height", use_glue=False), path) + + +def _write_multi_hole_cylinder(path: Path) -> None: + shape = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + for x_value in (-3.0, 3.0): + cutter = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(x_value, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 1.2, + 80.0, + ).Shape() + shape = _finalize_boolean_result( + BRepAlgoAPI_Cut(shape, cutter), + "verify multi-hole cylinder cut", + use_glue=False, + ) + _write_step(shape, path) + + +def _write_top_notched_cylinder(path: Path) -> None: + cylinder = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + notch = BRepPrimAPI_MakeBox(gp_Pnt(2.0, -1.0, 60.0), gp_Pnt(4.5, 1.0, 90.0)).Shape() + cut = BRepAlgoAPI_Cut(cylinder, notch) + _write_step(_finalize_boolean_result(cut, "verify top-notched cylinder height", use_glue=False), path) + + +def _first_cylinder_face(model: StepModel) -> int: + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") == "cylinder": + return face_id + raise SystemExit("no cylindrical Face was found") + + +def _top_planar_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 = info.get("area_center") or info.get("bbox_center") + if not isinstance(center, tuple) or len(center) != 3: + continue + z_value = float(center[2]) + if best is None or z_value > best[0]: + best = (z_value, face_id) + if best is None: + raise SystemExit("no planar cap Face was found") + return best[1] + + +def _bbox_height(model: StepModel) -> float: + bbox_size = model.geometry_stats().get("bbox_size") + if not isinstance(bbox_size, tuple) or len(bbox_size) != 3: + raise SystemExit(f"bbox_size is missing: {model.geometry_stats()}") + return float(bbox_size[2]) + + +def _assert_height(model: StepModel, target_height: float, label: str) -> None: + height = _bbox_height(model) + if abs(height - target_height) > 1e-4: + raise SystemExit(f"{label}: height should be {target_height:g}, got {height:g}") + + +def _assert_radii(model: StepModel, expected: tuple[float, ...], label: str) -> None: + radii: list[float] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + radius = float(info.get("radius") or 0.0) + if radius <= 0.0: + continue + if not any(abs(radius - item) <= max(radius, item, 1.0) * 1e-5 for item in radii): + radii.append(radius) + radii.sort() + expected_values = list(expected) + if len(radii) != len(expected_values) or any(abs(a - b) > 1e-4 for a, b in zip(radii, expected_values)): + raise SystemExit(f"{label}: radii should be {expected_values}, got {radii}") + + +def _run_isolated_worker( + input_path: Path, + operation: str, + args: list[object], + label: str, + *, + timeout_seconds: float = 90.0, +) -> tuple[StepModel, str, float]: + with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_isolated_") as temp_dir: + temp_root = Path(temp_dir) + output_path = temp_root / "output.step" + request_path = temp_root / "request.json" + request_path.write_text( + json.dumps( + { + "input_path": str(input_path), + "output_path": str(output_path), + "operation": operation, + "args": args, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + started = time.perf_counter() + completed = subprocess.run( + [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout_seconds, + check=False, + ) + elapsed = time.perf_counter() - started + response_path = request_path.with_suffix(".response.json") + response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {} + if completed.returncode != 0 or not response.get("ok"): + raise SystemExit( + f"{label}: isolated worker failed: returncode={completed.returncode}, " + f"stdout={completed.stdout!r}, stderr={completed.stderr!r}, response={response}" + ) + if elapsed > 45.0: + raise SystemExit(f"{label}: isolated worker took too long: {elapsed:.3f}s") + if not output_path.exists(): + raise SystemExit(f"{label}: isolated worker did not export output STEP") + return StepModel.load(output_path), str(response.get("message") or ""), elapsed + + +def _run_height_case( + path: Path, + target_height: float, + label: str, + expected_method: str | None = None, +) -> StepModel: + model = StepModel.load(path) + face_id = _first_cylinder_face(model) + plan = model.cylindrical_height_plan(face_id, target_height) + if plan.get("status") == "blocked": + raise SystemExit(f"{label}: height plan should not be blocked: {plan}") + if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) <= 0: + raise SystemExit(f"{label}: height plan should carry cylindrical first-level topology: {plan}") + result = model.resize_cylindrical_height(face_id, target_height) + _assert_height(model, target_height, label) + if "verified_face=" not in result: + raise SystemExit(f"{label}: result should include cylindrical height verification: {result}") + if expected_method is not None and expected_method not in result: + raise SystemExit(f"{label}: result should use {expected_method}: {result}") + isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker( + path, + "resize_cylindrical_height", + [face_id, target_height], + f"{label} isolated", + ) + _assert_height(isolated_model, target_height, f"{label} isolated") + if "verified_face=" not in isolated_message: + raise SystemExit(f"{label}: isolated result should include cylindrical height verification: {isolated_message}") + if expected_method is not None and expected_method not in isolated_message: + raise SystemExit(f"{label}: isolated result should use {expected_method}: {isolated_message}") + print(f"{label} ok: {result}") + print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}") + return model + + +def _run_owning_scale_case(path: Path, target_height: float, label: str) -> None: + model = StepModel.load(path) + face_id = _first_cylinder_face(model) + plan = model.cylindrical_height_owning_scale_plan(face_id, target_height) + if plan.get("status") == "blocked": + raise SystemExit(f"{label}: owning-scale plan should not be blocked: {plan}") + result = model.resize_cylindrical_height_owning_scale(face_id, target_height) + _assert_height(model, target_height, label) + if "verified_axis_span=" not in result: + raise SystemExit(f"{label}: result should include axis-span verification: {result}") + print(f"{label} ok: {result}") + + +def _run_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None: + model = StepModel.load(path) + face_id = _top_planar_face(model) + plan = model.push_pull_plan(face_id, distance) + if plan.get("status") == "blocked": + raise SystemExit(f"{label}: cap push/pull plan should not be blocked: {plan}") + if plan.get("cylindrical_cap_extension_kind") != "coaxial-tube": + raise SystemExit(f"{label}: expected coaxial-tube cap recognition, got: {plan}") + result = model.push_pull_face(face_id, distance) + if "cylindrical cap analytic rebuild" not in result: + raise SystemExit(f"{label}: cap push/pull should use analytic rebuild: {result}") + _assert_height(model, target_height, label) + _assert_radii(model, (3.0, 8.0), label) + isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker( + path, + "push_pull_face", + [face_id, distance], + f"{label} isolated", + ) + if "cylindrical cap analytic rebuild" not in isolated_message: + raise SystemExit(f"{label}: isolated cap push/pull should use analytic rebuild: {isolated_message}") + _assert_height(isolated_model, target_height, f"{label} isolated") + _assert_radii(isolated_model, (3.0, 8.0), f"{label} isolated") + print(f"{label} ok: {result}") + print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}") + + +def _run_prismatic_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None: + model = StepModel.load(path) + face_id = _top_planar_face(model) + result = model.push_pull_face(face_id, distance) + if "prismatic cap analytic rebuild" not in result: + raise SystemExit(f"{label}: multi-hole cap push/pull should use prismatic rebuild: {result}") + _assert_height(model, target_height, label) + _assert_radii(model, (1.2, 8.0), label) + isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker( + path, + "push_pull_face", + [face_id, distance], + f"{label} isolated", + ) + if "prismatic cap analytic rebuild" not in isolated_message: + raise SystemExit(f"{label}: isolated multi-hole cap push/pull should use prismatic rebuild: {isolated_message}") + _assert_height(isolated_model, target_height, f"{label} isolated") + _assert_radii(isolated_model, (1.2, 8.0), f"{label} isolated") + print(f"{label} ok: {result}") + print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_") as temp_dir: + root = Path(temp_dir) + solid_path = root / "solid_cylinder.step" + hollow_path = root / "hollow_cylinder.step" + multi_hole_path = root / "multi_hole_cylinder.step" + notch_path = root / "top_notched_cylinder.step" + _write_solid_cylinder(solid_path) + _write_hollow_cylinder(hollow_path) + _write_multi_hole_cylinder(multi_hole_path) + _write_top_notched_cylinder(notch_path) + + _run_height_case(solid_path, 50.0, "solid cylinder side Face height", "analytic rebuild") + _run_owning_scale_case(solid_path, 50.0, "solid cylinder owning-axis height") + hollow_model = _run_height_case(hollow_path, 167.0, "hollow cylinder side Face height", "analytic rebuild") + _assert_radii(hollow_model, (3.0, 8.0), "hollow cylinder side Face height") + _run_cap_push_pull_case(hollow_path, 89.0, 167.0, "hollow cylinder cap Face large push/pull") + _run_prismatic_cap_push_pull_case( + multi_hole_path, + 89.0, + 167.0, + "multi-hole cylinder cap Face large push/pull", + ) + _run_height_case(notch_path, 98.0, "top-notched cylinder side Face height", "profile-prism") + + print("cylindrical Face height resize ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_face_complex_boundary_guard.py b/scripts/verify_face_complex_boundary_guard.py index b0691ad..f7058b6 100644 --- a/scripts/verify_face_complex_boundary_guard.py +++ b/scripts/verify_face_complex_boundary_guard.py @@ -51,6 +51,36 @@ def _assert_push_pull_keeps_single_solid(path: Path, distance: float) -> str: stats = model.stats() if stats.solids != 1: raise SystemExit(f"holed planar Face push/pull should keep one solid: {stats}") + if "inner_wires=" not in result: + raise SystemExit(f"holed planar Face push/pull should report inner-wire verification: {result}") + target_position = plan.get("target_plane_position") + direction = plan.get("plane_direction") or plan.get("outward_direction") + if not isinstance(target_position, (int, float)) or not isinstance(direction, tuple) or len(direction) != 3: + raise SystemExit(f"holed planar Face push/pull plan should expose a target plane position: {plan}") + best: tuple[float, int, dict[str, object]] | None = None + for candidate_id in range(len(model.faces)): + info = model.face_info(candidate_id) + if info.get("surface") != "plane": + continue + origin = info.get("plane_origin") + if not isinstance(origin, tuple) or len(origin) != 3: + continue + actual_position = ( + float(origin[0]) * float(direction[0]) + + float(origin[1]) * float(direction[1]) + + float(origin[2]) * float(direction[2]) + ) + error = abs(actual_position - float(target_position)) + if best is None or error < best[0]: + best = (error, candidate_id, info) + if best is None: + raise SystemExit("holed planar Face push/pull should leave a measurable target plane") + _error, matched_face_id, matched_info = best + if int(matched_info.get("inner_boundary_wires") or 0) < 1: + raise SystemExit( + "holed planar Face push/pull should preserve the target Face inner boundary: " + f"matched_face={matched_face_id}, info={matched_info}, result={result}" + ) return result diff --git a/scripts/verify_face_coplanar_push_pull.py b/scripts/verify_face_coplanar_push_pull.py index 02ed90b..f69643b 100644 --- a/scripts/verify_face_coplanar_push_pull.py +++ b/scripts/verify_face_coplanar_push_pull.py @@ -94,6 +94,9 @@ def main() -> int: scope_ids = tuple(plan.get("push_pull_scope_face_ids", ())) if set(scope_ids) != set(top_faces): raise SystemExit(f"expected push/pull scope {top_faces}, got {scope_ids}") + scope_area = float(plan.get("push_pull_scope_area") or 0.0) + if abs(scope_area - 100.0) > args.tolerance: + raise SystemExit(f"expected push/pull scope area 100, got {scope_area:g}") if plan["status"] == "blocked": raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}") diff --git a/scripts/verify_face_edit_integrity_guards.py b/scripts/verify_face_edit_integrity_guards.py index 03749bb..a753e71 100644 --- a/scripts/verify_face_edit_integrity_guards.py +++ b/scripts/verify_face_edit_integrity_guards.py @@ -17,6 +17,7 @@ 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 +from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402 class _Probe(WindowActionMixin): @@ -194,6 +195,183 @@ def main() -> int: after_model=edited_cube, ), ) + rollback_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step") + rollback_before_stats = rollback_cube.stats() + rollback_plan = rollback_cube.face_area_local_resize_plan(0, 144.0) + rollback_plan["target_area"] = 999.0 + _expect_blocked( + "Face model-layer rollback on failed target check", + lambda: rollback_cube._run_checked_face_edit( + rollback_plan, + lambda: rollback_cube._apply_local_face_deform(rollback_plan), + ), + ) + if rollback_cube.stats() != rollback_before_stats: + raise SystemExit( + "Face model-layer rollback should restore topology stats: " + f"before={rollback_before_stats}, after={rollback_cube.stats()}" + ) + if abs(float(rollback_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5: + raise SystemExit("Face model-layer rollback should restore the original Face area") + topology_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step") + topology_before_stats = topology_guard_cube.stats() + topology_plan = topology_guard_cube.face_area_local_resize_plan(0, 144.0) + original_topology_method = topology_guard_cube.face_first_level_topology + + def apply_face_then_weaken_first_level_topology(): + topology_guard_cube._apply_local_face_deform(topology_plan) + + def weak_topology(face_id: int) -> dict[str, object]: + topology = dict(original_topology_method(face_id)) + topology["first_level_boundary_edge_count"] = 3 + topology["first_level_boundary_vertex_count"] = 3 + topology["first_level_adjacent_face_count"] = 1 + return topology + + topology_guard_cube.face_first_level_topology = weak_topology + + try: + _expect_blocked( + "Face model-layer rollback on weakened first-level topology", + lambda: topology_guard_cube._run_checked_face_edit( + topology_plan, + apply_face_then_weaken_first_level_topology, + ), + ) + finally: + topology_guard_cube.face_first_level_topology = original_topology_method + if topology_guard_cube.stats() != topology_before_stats: + raise SystemExit( + "Face model-layer topology rollback should restore topology stats: " + f"before={topology_before_stats}, after={topology_guard_cube.stats()}" + ) + if abs(float(topology_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5: + raise SystemExit("Face model-layer topology rollback should restore the original Face area") + + fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step") + fact_before_stats = fact_guard_cube.stats() + fact_plan = fact_guard_cube.face_area_local_resize_plan(0, 144.0) + original_fact_method = fact_guard_cube.face_first_level_facts + + def apply_face_then_weaken_first_level_facts(): + fact_guard_cube._apply_local_face_deform(fact_plan) + + def weak_facts(face_id: int, scope: str = "auto") -> dict[str, object]: + facts = dict(original_fact_method(face_id, scope=scope)) + facts["first_level_fact_boundary_edge_count"] = 1 + facts["first_level_fact_boundary_vertex_count"] = 1 + facts["first_level_fact_adjacent_face_count"] = 0 + facts["first_level_fact_ignored_relation_depths"] = ("second-level",) + return facts + + fact_guard_cube.face_first_level_facts = weak_facts + + try: + _expect_blocked( + "Face model-layer rollback on weakened first-level fact graph", + lambda: fact_guard_cube._run_checked_face_edit( + fact_plan, + apply_face_then_weaken_first_level_facts, + ), + ) + finally: + fact_guard_cube.face_first_level_facts = original_fact_method + if fact_guard_cube.stats() != fact_before_stats: + raise SystemExit( + "Face model-layer fact-graph rollback should restore topology stats: " + f"before={fact_before_stats}, after={fact_guard_cube.stats()}" + ) + if abs(float(fact_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5: + raise SystemExit("Face model-layer fact-graph rollback should restore the original Face area") + + with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_coplanar_area_") as temp_dir: + split_path = Path(temp_dir) / "split_top_box.step" + _write_split_top_box(split_path) + split_model = StepModel.load(split_path) + top_faces = _top_faces(split_model, 10.0, 2e-4) + if len(top_faces) != 2: + raise SystemExit(f"coplanar area guard expected two top Faces, got {top_faces}") + split_plan = split_model.push_pull_plan(top_faces[0], 1.0) + if split_plan["status"] == "blocked": + raise SystemExit(f"coplanar area guard plan was blocked: {split_plan['message']}") + original_scope_area = float(split_plan.get("push_pull_scope_area") or 0.0) + if abs(original_scope_area - 100.0) > 2e-4: + raise SystemExit(f"coplanar area guard expected source area 100, got {original_scope_area:g}") + split_model.push_pull_face(top_faces[0], 1.0) + split_plan["push_pull_scope_area"] = original_scope_area * 2.0 + _expect_blocked( + "Face push/pull scope area mismatch", + lambda: split_model._checked_face_edit_result_summary(split_plan), + ) + + plan_fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step") + plan_info = plan_fact_guard_cube.face_info(0) + plan_center = plan_info.get("area_center") or plan_info.get("bbox_center") + if not isinstance(plan_center, tuple) or len(plan_center) != 3: + raise SystemExit(f"Face plan guard probe needs a stable center: {plan_info}") + plan_width = float(plan_info.get("local_face_width") or 0.0) + plan_height = float(plan_info.get("local_face_height") or 0.0) + if plan_width <= 0 or plan_height <= 0: + raise SystemExit(f"Face plan guard probe needs local width/height: {plan_info}") + original_fact_plan_method = plan_fact_guard_cube._first_level_fact_plan_fields + + def unavailable_fact_plan(face_id: int, scope: str) -> dict[str, object]: + return { + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_source_model": scope, + "first_level_fact_status": "unavailable", + "first_level_fact_relation_depth": 1, + "first_level_fact_relation_boundary": "shared-edge", + "first_level_fact_scope": scope, + "first_level_fact_subject_role": "selected Face", + "first_level_fact_subject_face_ids": (face_id,), + "first_level_fact_subject_face_count": 1, + "first_level_fact_boundary_edge_ids": (), + "first_level_fact_boundary_edge_count": 0, + "first_level_fact_boundary_vertex_points": (), + "first_level_fact_boundary_vertex_count": 0, + "first_level_fact_adjacent_face_ids": (), + "first_level_fact_adjacent_face_count": 0, + "first_level_fact_included_face_ids": (face_id,), + "first_level_fact_included_face_count": 1, + "first_level_fact_role_groups": (), + "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "first_level_fact_summary": "test-only unavailable first-level fact graph", + } + + plan_fact_guard_cube._first_level_fact_plan_fields = unavailable_fact_plan + try: + local_plans = ( + ( + "Face local center plan", + plan_fact_guard_cube.face_center_local_move_plan( + 0, + (float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])), + ), + ), + ("Face local area plan", plan_fact_guard_cube.face_area_local_resize_plan(0, 144.0)), + ("Face local width plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_width * 1.2, "width")), + ("Face local height plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_height * 1.2, "height")), + ("Face local plane offset plan", plan_fact_guard_cube.face_plane_offset_local_plan(0, 1.0)), + ) + for label, plan in local_plans: + if plan.get("status") != "blocked": + raise SystemExit(f"{label} should be blocked when first-level facts are unavailable: {plan}") + blocker_text = str(plan.get("message") or plan.get("blockers") or "") + if "一级事实图" not in blocker_text: + raise SystemExit(f"{label} should explain the first-level fact blocker: {plan}") + + owning_plan = plan_fact_guard_cube.face_center_owning_translation_plan( + 0, + (float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])), + ) + if owning_plan.get("status") == "blocked": + raise SystemExit( + "Owning-shape Face translation should not be blocked by local first-level fact guard: " + f"{owning_plan}" + ) + finally: + plan_fact_guard_cube._first_level_fact_plan_fields = original_fact_plan_method 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) @@ -299,6 +477,35 @@ def main() -> int: after_model=edited_cone, ), ) + with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_cone_rollback_") as temp_dir: + cone_path = Path(temp_dir) / "simple_cone.step" + _write_step(BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape(), cone_path) + rollback_cone = StepModel.load(cone_path) + cone_face_id = _first_face_by_surface(rollback_cone, "cone") + before_stats = rollback_cone.stats() + before_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0) + rollback_plan = rollback_cone.conical_reference_radius_plan(cone_face_id, 5.0) + + def apply_cone_then_fail_target_check(): + applied = rollback_cone._apply_conical_analytic_rebuild_if_simple(rollback_plan) + rollback_plan["target_reference_radius"] = 999.0 + return applied + + _expect_blocked( + "Conical Face model-layer rollback on failed target check", + lambda: rollback_cone._run_checked_face_edit(rollback_plan, apply_cone_then_fail_target_check), + ) + if rollback_cone.stats() != before_stats: + raise SystemExit( + "Conical Face model-layer rollback should restore topology stats: " + f"before={before_stats}, after={rollback_cone.stats()}" + ) + restored_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0) + if abs(restored_radius - before_radius) > 1e-5: + raise SystemExit( + "Conical Face model-layer rollback should restore the original reference radius: " + f"before={before_radius:g}, after={restored_radius:g}" + ) _expect_allowed( "Non-Face edit solid-count change only warns", lambda: probe._verified_edit_quality_warnings( diff --git a/scripts/verify_face_edit_suite.py b/scripts/verify_face_edit_suite.py index b537705..349a0fc 100644 --- a/scripts/verify_face_edit_suite.py +++ b/scripts/verify_face_edit_suite.py @@ -86,6 +86,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "planar Face on curved Solid disables local-only deformation", ("verify_face_mixed_surface_guard.py",), ), + ( + "cylindrical side Face height edits are checked", + ("verify_cylindrical_height_resize.py",), + ), + ( + "large stepped cylindrical cap push pull stays local", + ("verify_large_stepped_cap_push_pull.py",), + ), ( "non-rectangular planar Face local edits", ("verify_face_nonrectangular_local_edit.py",), @@ -102,6 +110,18 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "property editor keeps generic Face edits out of cylindrical features", ("verify_property_editor_specs.py",), ), + ( + "Face UI isolation contract", + ("verify_face_ui_isolation_contract.py",), + ), + ( + "first-level fact graph is exposed", + ("verify_first_level_fact_graph.py",), + ), + ( + "feature recognition summary is exposed", + ("verify_feature_recognition_summary.py",), + ), ( "Face edited values read back into property rows", ("verify_face_property_readback.py",), diff --git a/scripts/verify_face_first_level_topology.py b/scripts/verify_face_first_level_topology.py index 990c47f..7497833 100644 --- a/scripts/verify_face_first_level_topology.py +++ b/scripts/verify_face_first_level_topology.py @@ -3,6 +3,11 @@ from __future__ import annotations from pathlib import Path import sys +from OCC.Core.BRep import BRep_Tool +from OCC.Core.TopAbs import TopAbs_VERTEX +from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopoDS import topods + PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: @@ -24,13 +29,21 @@ def _center(info: dict[str, object]) -> tuple[float, float, float]: def _top_plane_face(model: StepModel) -> int: + return _extreme_plane_face(model, highest=True) + + +def _bottom_plane_face(model: StepModel) -> int: + return _extreme_plane_face(model, highest=False) + + +def _extreme_plane_face(model: StepModel, *, highest: bool) -> 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]: + if best is None or (center[2] > best[0] if highest else center[2] < best[0]): best = (center[2], face_id) if best is None: raise SystemExit("No planar Face found.") @@ -52,6 +65,33 @@ def _assert_close(actual: float, expected: float, label: str) -> None: raise SystemExit(f"{label}: expected {expected:g}, got {actual:g}") +def _edge_vertex_points(model: StepModel, edge_id: int) -> list[tuple[float, float, float]]: + points: list[tuple[float, float, float]] = [] + explorer = TopExp_Explorer(model.edges[edge_id], TopAbs_VERTEX) + while explorer.More(): + vertex = topods.Vertex(explorer.Current()) + point = BRep_Tool.Pnt(vertex) + item = (float(point.X()), float(point.Y()), float(point.Z())) + if not any( + abs(item[0] - existing[0]) <= TOLERANCE + and abs(item[1] - existing[1]) <= TOLERANCE + and abs(item[2] - existing[2]) <= TOLERANCE + for existing in points + ): + points.append(item) + explorer.Next() + return points + + +def _assert_points_z(points: object, expected: float, label: str) -> None: + if not isinstance(points, (tuple, list)) or len(points) == 0: + raise SystemExit(f"{label}: expected non-empty 3D points, got {points!r}") + for index, point in enumerate(points): + if not isinstance(point, (tuple, list)) or len(point) != 3: + raise SystemExit(f"{label}: point {index} is not a 3D coordinate: {point!r}") + _assert_close(float(point[2]), expected, f"{label} point {index} Z") + + 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: @@ -149,10 +189,34 @@ def main() -> int: _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") + moved_face_id = _top_plane_face(model) + moved_topology = _assert_first_level_topology(model, moved_face_id) + _assert_points_z( + moved_topology.get("first_level_boundary_vertex_points"), + 12.0, + "Moved Face first-level boundary Vertex", + ) + for edge_id in moved_topology.get("first_level_boundary_edge_ids", ()): + _assert_points_z( + _edge_vertex_points(model, int(edge_id)), + 12.0, + f"Moved Face first-level boundary Edge {edge_id}", + ) + + bottom_face_id = _bottom_plane_face(model) + bottom_topology = model.face_first_level_topology(bottom_face_id) + _assert_points_z( + bottom_topology.get("first_level_boundary_vertex_points"), + 0.0, + "Second-level bottom Face boundary Vertex", + ) + 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(f"moved_face_id={moved_face_id}") + print(f"moved_boundary_edges={moved_topology.get('first_level_boundary_edge_ids')}") print(result.encode("ascii", "backslashreplace").decode("ascii")) return 0 diff --git a/scripts/verify_face_mixed_surface_guard.py b/scripts/verify_face_mixed_surface_guard.py index da718c9..aabceb8 100644 --- a/scripts/verify_face_mixed_surface_guard.py +++ b/scripts/verify_face_mixed_surface_guard.py @@ -4,12 +4,15 @@ import sys import tempfile from pathlib import Path -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) +from step_editor.geometry_utils import _finalize_boolean_result from step_editor.model import StepModel from step_editor.step_io import _write_step from step_editor.window_state import WindowStateMixin @@ -35,6 +38,54 @@ def _write_cylinder(path: Path) -> None: _write_step(cylinder, path) +def _write_hollow_cylinder(path: Path) -> None: + outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + inner = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 3.0, + 80.0, + ).Shape() + cut = BRepAlgoAPI_Cut(outer, inner) + _write_step(_finalize_boolean_result(cut, "verify hollow cylinder cut", use_glue=False), path) + + +def _write_hollow_cylinder_with_side_feature(path: Path) -> None: + outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + inner = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 3.0, + 80.0, + ).Shape() + hollow = _finalize_boolean_result(BRepAlgoAPI_Cut(outer, inner), "verify complex hollow cylinder cut", use_glue=False) + notch = BRepPrimAPI_MakeBox(gp_Pnt(5.0, -3.0, 20.0), gp_Pnt(12.0, 3.0, 35.0)).Shape() + cut = BRepAlgoAPI_Cut(hollow, notch) + _write_step(_finalize_boolean_result(cut, "verify complex hollow cylinder side notch", use_glue=False), path) + + +def _write_hollow_cylinder_with_top_boundary_feature(path: Path) -> None: + outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + inner = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 3.0, + 80.0, + ).Shape() + hollow = _finalize_boolean_result( + BRepAlgoAPI_Cut(outer, inner), + "verify hollow cylinder top-boundary base cut", + use_glue=False, + ) + notch = BRepPrimAPI_MakeBox(gp_Pnt(4.0, -1.0, 60.0), gp_Pnt(5.5, 1.0, 90.0)).Shape() + cut = BRepAlgoAPI_Cut(hollow, notch) + _write_step(_finalize_boolean_result(cut, "verify hollow cylinder top-boundary notch", use_glue=False), path) + + +def _write_solid_cylinder_with_top_boundary_feature(path: Path) -> None: + cylinder = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + notch = BRepPrimAPI_MakeBox(gp_Pnt(2.0, -1.0, 60.0), gp_Pnt(4.5, 1.0, 90.0)).Shape() + cut = BRepAlgoAPI_Cut(cylinder, notch) + _write_step(_finalize_boolean_result(cut, "verify solid cylinder top-boundary notch", use_glue=False), path) + + def _first_planar_cap_face(model: StepModel) -> int: for face_id in range(len(model.faces)): info = model.face_info(face_id) @@ -43,6 +94,37 @@ def _first_planar_cap_face(model: StepModel) -> int: raise SystemExit("no planar cylinder cap Face was found") +def _top_planar_cap_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 = info.get("area_center") or info.get("bbox_center") + if not isinstance(center, tuple) or len(center) != 3: + continue + z_value = float(center[2]) + if best is None or z_value > best[0]: + best = (z_value, face_id) + if best is None: + raise SystemExit("no top planar cylinder cap Face was found") + return best[1] + + +def _cylinder_radii(model: StepModel) -> list[float]: + radii: list[float] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + radius = float(info.get("radius") or 0.0) + if radius <= 0.0: + continue + if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii): + radii.append(radius) + return sorted(radii) + + def _assert_blocked(plan: dict[str, object], label: str) -> None: if plan.get("status") != "blocked": raise SystemExit(f"{label} should be blocked for a planar Face on a curved Solid: {plan}") @@ -131,15 +213,38 @@ def main() -> int: 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}") + if huge_push_plan.get("status") == "blocked": + raise SystemExit(f"huge simple cylinder cap push/pull should use analytic rebuild: {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}") + raise SystemExit(f"huge cap push/pull should expose the large height growth: {huge_push_plan}") + if huge_push_plan.get("cylindrical_cap_extension_kind") != "solid-cylinder": + raise SystemExit(f"huge simple cylinder cap push/pull should expose solid-cylinder kind: {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}") + if "解析" not in huge_message and "analytic" not in huge_message.lower(): + raise SystemExit(f"huge cap push/pull should explain analytic rebuild: {huge_push_plan}") + + large_model = StepModel.load(path) + large_face_id = _first_planar_cap_face(large_model) + large_result = large_model.push_pull_face(large_face_id, 50.0) + if "analytic rebuild" not in large_result: + raise SystemExit(f"large simple cylinder cap push/pull should use analytic rebuild: {large_result}") + if abs(_bbox_height(large_model) - 58.0) > 1e-4: + raise SystemExit(f"large simple cylinder cap push/pull should rebuild height to 58: {_bbox_height(large_model)}") + + shrink_model = StepModel.load(path) + shrink_face_id = _first_planar_cap_face(shrink_model) + shrink_plan = shrink_model.push_pull_plan(shrink_face_id, -3.0) + if shrink_plan.get("status") == "blocked": + raise SystemExit(f"simple cylinder cap inward push/pull should use analytic shrink: {shrink_plan}") + if shrink_plan.get("cylindrical_cap_operation") != "retract": + raise SystemExit(f"simple cylinder cap inward push/pull should expose retract operation: {shrink_plan}") + shrink_result = shrink_model.push_pull_face(shrink_face_id, -3.0) + if "analytic rebuild" not in shrink_result or "cap_operation=retract" not in shrink_result: + raise SystemExit(f"simple cylinder cap inward push/pull should use analytic retract: {shrink_result}") + if abs(_bbox_height(shrink_model) - 5.0) > 1e-4: + raise SystemExit(f"simple cylinder cap inward push/pull should shrink height to 5: {_bbox_height(shrink_model)}") before_height = _bbox_height(model) push_plan = model.push_pull_plan(face_id, 1.0) @@ -158,6 +263,275 @@ def main() -> int: f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, " f"push_pull={push_result}" ) + + hollow_path = Path(temp_dir) / "hollow_cylinder.step" + _write_hollow_cylinder(hollow_path) + hollow = StepModel.load(hollow_path) + hollow_face_id = _top_planar_cap_face(hollow) + hollow_plan = hollow.push_pull_plan(hollow_face_id, 89.0) + if hollow_plan.get("status") == "blocked": + raise SystemExit(f"hollow cylinder cap push/pull should not be blocked: {hollow_plan}") + if hollow_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube": + raise SystemExit(f"hollow cylinder cap should be recognized as coaxial-tube: {hollow_plan}") + if abs(float(hollow_plan.get("cylindrical_cap_extension_inner_radius") or 0.0) - 3.0) > 1e-4: + raise SystemExit(f"hollow cylinder cap should preserve inner radius in plan: {hollow_plan}") + hollow_result = hollow.push_pull_face(hollow_face_id, 89.0) + if "analytic rebuild" not in hollow_result or "coaxial-tube" not in hollow_result: + raise SystemExit(f"hollow cylinder cap push/pull should use tube analytic rebuild: {hollow_result}") + if abs(_bbox_height(hollow) - 167.0) > 1e-4: + raise SystemExit(f"hollow cylinder cap push/pull should rebuild height to 167: {_bbox_height(hollow)}") + radii = _cylinder_radii(hollow) + if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-4 or abs(radii[1] - 8.0) > 1e-4: + raise SystemExit(f"hollow cylinder cap push/pull should preserve inner/outer cylindrical walls: {radii}") + print(f"hollow cylinder cap analytic push/pull ok: radii={radii}, result={hollow_result}") + + hollow_shrink = StepModel.load(hollow_path) + hollow_shrink_face_id = _top_planar_cap_face(hollow_shrink) + hollow_shrink_plan = hollow_shrink.push_pull_plan(hollow_shrink_face_id, -39.0) + if hollow_shrink_plan.get("status") == "blocked": + raise SystemExit(f"hollow cylinder cap inward push/pull should not be blocked: {hollow_shrink_plan}") + if hollow_shrink_plan.get("cylindrical_cap_operation") != "retract": + raise SystemExit(f"hollow cylinder cap inward push/pull should expose retract operation: {hollow_shrink_plan}") + hollow_shrink_result = hollow_shrink.push_pull_face(hollow_shrink_face_id, -39.0) + if "analytic rebuild" not in hollow_shrink_result or "coaxial-tube" not in hollow_shrink_result: + raise SystemExit(f"hollow cylinder cap inward push/pull should use tube analytic shrink: {hollow_shrink_result}") + if abs(_bbox_height(hollow_shrink) - 39.0) > 1e-4: + raise SystemExit(f"hollow cylinder cap inward push/pull should shrink height to 39: {_bbox_height(hollow_shrink)}") + shrink_radii = _cylinder_radii(hollow_shrink) + if len(shrink_radii) != 2 or abs(shrink_radii[0] - 3.0) > 1e-4 or abs(shrink_radii[1] - 8.0) > 1e-4: + raise SystemExit(f"hollow cylinder cap inward push/pull should preserve radii: {shrink_radii}") + print(f"hollow cylinder cap analytic retraction ok: radii={shrink_radii}, result={hollow_shrink_result}") + + complex_hollow_path = Path(temp_dir) / "hollow_cylinder_with_side_feature.step" + _write_hollow_cylinder_with_side_feature(complex_hollow_path) + complex_hollow = StepModel.load(complex_hollow_path) + complex_hollow_face_id = _top_planar_cap_face(complex_hollow) + complex_plan = complex_hollow.push_pull_plan(complex_hollow_face_id, 89.0) + if complex_plan.get("status") == "blocked": + raise SystemExit(f"complex hollow cylinder cap push/pull should not be blocked: {complex_plan}") + if complex_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube": + raise SystemExit(f"complex hollow cylinder cap should be recognized as coaxial-tube: {complex_plan}") + if not complex_plan.get("cylindrical_cap_extension_uses_local_segment"): + raise SystemExit(f"complex hollow cylinder cap should expose local extension segment: {complex_plan}") + complex_result = complex_hollow.push_pull_face(complex_hollow_face_id, 89.0) + if "local extension" not in complex_result or "coaxial-tube" not in complex_result: + raise SystemExit(f"complex hollow cylinder cap should use local extension segment: {complex_result}") + if abs(_bbox_height(complex_hollow) - 167.0) > 1e-4: + raise SystemExit( + f"complex hollow cylinder cap push/pull should extend height to 167: {_bbox_height(complex_hollow)}" + ) + complex_radii = _cylinder_radii(complex_hollow) + if len(complex_radii) < 2 or abs(complex_radii[0] - 3.0) > 1e-4 or abs(complex_radii[-1] - 8.0) > 1e-4: + raise SystemExit( + f"complex hollow cylinder cap should preserve inner/outer cylindrical walls: {complex_radii}" + ) + print( + "complex hollow cylinder cap local extension ok: " + f"radii={complex_radii}, result={complex_result}" + ) + + complex_shrink = StepModel.load(complex_hollow_path) + complex_shrink_face_id = _top_planar_cap_face(complex_shrink) + complex_shrink_plan = complex_shrink.push_pull_plan(complex_shrink_face_id, -20.0) + if complex_shrink_plan.get("status") == "blocked": + raise SystemExit(f"complex hollow cylinder cap inward push/pull should not be blocked: {complex_shrink_plan}") + if complex_shrink_plan.get("cylindrical_cap_operation") != "retract": + raise SystemExit(f"complex hollow cylinder cap inward push/pull should expose retract: {complex_shrink_plan}") + complex_shrink_result = complex_shrink.push_pull_face(complex_shrink_face_id, -20.0) + if "local retraction" not in complex_shrink_result or "coaxial-tube" not in complex_shrink_result: + raise SystemExit(f"complex hollow cylinder cap inward push/pull should use local retraction: {complex_shrink_result}") + if abs(_bbox_height(complex_shrink) - 58.0) > 1e-4: + raise SystemExit( + f"complex hollow cylinder cap inward push/pull should shrink height to 58: {_bbox_height(complex_shrink)}" + ) + complex_shrink_radii = _cylinder_radii(complex_shrink) + if ( + len(complex_shrink_radii) < 2 + or abs(complex_shrink_radii[0] - 3.0) > 1e-4 + or abs(complex_shrink_radii[-1] - 8.0) > 1e-4 + ): + raise SystemExit( + f"complex hollow cylinder cap inward push/pull should preserve radii: {complex_shrink_radii}" + ) + print( + "complex hollow cylinder cap local retraction ok: " + f"radii={complex_shrink_radii}, result={complex_shrink_result}" + ) + + top_boundary_path = Path(temp_dir) / "hollow_cylinder_with_top_boundary_feature.step" + _write_hollow_cylinder_with_top_boundary_feature(top_boundary_path) + top_boundary = StepModel.load(top_boundary_path) + top_boundary_face_id = _top_planar_cap_face(top_boundary) + top_boundary_plan = top_boundary.push_pull_plan(top_boundary_face_id, 89.0) + if top_boundary_plan.get("status") == "blocked": + raise SystemExit( + f"top-boundary hollow cylinder cap push/pull should preserve extra first-level openings: {top_boundary_plan}" + ) + if top_boundary_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube": + raise SystemExit( + f"top-boundary hollow cylinder cap should be recognized as coaxial-tube: {top_boundary_plan}" + ) + if top_boundary_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism": + raise SystemExit( + f"top-boundary hollow cylinder cap should use cap profile prism method: {top_boundary_plan}" + ) + if int(top_boundary_plan.get("cap_extra_adjacent_face_count") or 0) <= 0: + raise SystemExit( + f"top-boundary hollow cylinder cap should expose extra adjacent faces: {top_boundary_plan}" + ) + top_boundary_message = str(top_boundary_plan.get("message") or "") + if "真实轮廓拉伸" not in top_boundary_message: + raise SystemExit( + f"top-boundary hollow cylinder cap should explain profile-prism extension: {top_boundary_plan}" + ) + try: + top_boundary.push_pull_face(top_boundary_face_id, 89.0) + except ValueError as exc: + blocker_text = str(exc) + if "解析重建" not in blocker_text and "OCCT" not in blocker_text: + raise SystemExit( + "top-boundary hollow cylinder large cap push/pull should explain the fast blocker: " + f"{blocker_text}" + ) + else: + raise SystemExit( + "top-boundary hollow cylinder large cap push/pull should be blocked instead of entering slow Boolean." + ) + print( + "top-boundary hollow cylinder cap large profile-prism blocker ok: " + f"extra_adjacent={top_boundary_plan.get('cap_extra_adjacent_face_count')}, " + f"distance=89" + ) + + top_boundary_shallow_cut = StepModel.load(top_boundary_path) + shallow_face_id = _top_planar_cap_face(top_boundary_shallow_cut) + shallow_plan = top_boundary_shallow_cut.push_pull_plan(shallow_face_id, -10.0) + if shallow_plan.get("status") == "blocked": + raise SystemExit( + f"top-boundary hollow cylinder shallow inward push/pull should stay available: {shallow_plan}" + ) + if shallow_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism": + raise SystemExit( + f"top-boundary hollow cylinder shallow inward push/pull should use cap profile prism: {shallow_plan}" + ) + shallow_result = top_boundary_shallow_cut.push_pull_face(shallow_face_id, -10.0) + if "profile-prism retraction" not in shallow_result or "coaxial-tube" not in shallow_result: + raise SystemExit( + f"top-boundary hollow cylinder shallow inward push/pull should use profile-prism retraction: {shallow_result}" + ) + if abs(_bbox_height(top_boundary_shallow_cut) - 68.0) > 1e-4: + raise SystemExit( + "top-boundary hollow cylinder shallow inward push/pull should lower height to 68: " + f"{_bbox_height(top_boundary_shallow_cut)}" + ) + shallow_top_face = _top_planar_cap_face(top_boundary_shallow_cut) + shallow_info = top_boundary_shallow_cut.face_info(shallow_top_face) + if int(shallow_info.get("boundary_wires") or 0) < 3: + raise SystemExit( + "top-boundary hollow cylinder shallow inward push/pull should preserve openings: " + f"face_id={shallow_top_face}, info={shallow_info}" + ) + print( + "top-boundary hollow cylinder cap profile-prism shallow retraction ok: " + f"result={shallow_result}" + ) + + top_boundary_deep_cut = StepModel.load(top_boundary_path) + deep_face_id = _top_planar_cap_face(top_boundary_deep_cut) + deep_plan = top_boundary_deep_cut.push_pull_plan(deep_face_id, -20.0) + if deep_plan.get("status") != "blocked": + raise SystemExit( + f"top-boundary hollow cylinder deep inward push/pull should be blocked: {deep_plan}" + ) + deep_message = str(deep_plan.get("message") or "") + if "越过" not in deep_message or "内侧终点" not in deep_message: + raise SystemExit( + f"top-boundary hollow cylinder deep inward blocker should explain the retract limit: {deep_plan}" + ) + print( + "top-boundary hollow cylinder cap deep retraction blocker ok: " + f"message={deep_message}" + ) + + solid_top_boundary_path = Path(temp_dir) / "solid_cylinder_with_top_boundary_feature.step" + _write_solid_cylinder_with_top_boundary_feature(solid_top_boundary_path) + solid_top_boundary = StepModel.load(solid_top_boundary_path) + solid_top_face_id = _top_planar_cap_face(solid_top_boundary) + solid_extend_plan = solid_top_boundary.push_pull_plan(solid_top_face_id, 20.0) + if solid_extend_plan.get("status") == "blocked": + raise SystemExit( + f"solid top-boundary cylinder cap outward push/pull should stay available: {solid_extend_plan}" + ) + if solid_extend_plan.get("cylindrical_cap_extension_kind") != "solid-cylinder": + raise SystemExit( + f"solid top-boundary cylinder cap should be recognized as solid-cylinder: {solid_extend_plan}" + ) + if solid_extend_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism": + raise SystemExit( + f"solid top-boundary cylinder cap should use profile prism extension: {solid_extend_plan}" + ) + solid_extend_result = solid_top_boundary.push_pull_face(solid_top_face_id, 20.0) + if "profile-prism extension" not in solid_extend_result or "solid-cylinder" not in solid_extend_result: + raise SystemExit( + f"solid top-boundary cylinder cap should use profile-prism extension: {solid_extend_result}" + ) + if abs(_bbox_height(solid_top_boundary) - 98.0) > 1e-4: + raise SystemExit( + f"solid top-boundary cylinder cap outward push/pull should extend height to 98: {_bbox_height(solid_top_boundary)}" + ) + solid_extended_top = _top_planar_cap_face(solid_top_boundary) + solid_extended_info = solid_top_boundary.face_info(solid_extended_top) + if int(solid_extended_info.get("boundary_wires") or 0) < 2: + raise SystemExit( + "solid top-boundary cylinder cap outward push/pull should preserve the notch opening: " + f"face_id={solid_extended_top}, info={solid_extended_info}" + ) + print( + "solid top-boundary cylinder cap profile-prism extension ok: " + f"result={solid_extend_result}" + ) + + solid_shallow_cut = StepModel.load(solid_top_boundary_path) + solid_shallow_face_id = _top_planar_cap_face(solid_shallow_cut) + solid_shallow_plan = solid_shallow_cut.push_pull_plan(solid_shallow_face_id, -10.0) + if solid_shallow_plan.get("status") == "blocked": + raise SystemExit( + f"solid top-boundary cylinder shallow inward push/pull should stay available: {solid_shallow_plan}" + ) + if solid_shallow_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism": + raise SystemExit( + f"solid top-boundary cylinder shallow inward push/pull should use profile prism: {solid_shallow_plan}" + ) + solid_shallow_result = solid_shallow_cut.push_pull_face(solid_shallow_face_id, -10.0) + if "profile-prism retraction" not in solid_shallow_result or "solid-cylinder" not in solid_shallow_result: + raise SystemExit( + f"solid top-boundary cylinder shallow inward push/pull should use profile-prism retraction: {solid_shallow_result}" + ) + if abs(_bbox_height(solid_shallow_cut) - 68.0) > 1e-4: + raise SystemExit( + f"solid top-boundary cylinder shallow inward push/pull should lower height to 68: {_bbox_height(solid_shallow_cut)}" + ) + print( + "solid top-boundary cylinder cap profile-prism shallow retraction ok: " + f"result={solid_shallow_result}" + ) + + solid_deep_cut = StepModel.load(solid_top_boundary_path) + solid_deep_face_id = _top_planar_cap_face(solid_deep_cut) + solid_deep_plan = solid_deep_cut.push_pull_plan(solid_deep_face_id, -20.0) + if solid_deep_plan.get("status") != "blocked": + raise SystemExit( + f"solid top-boundary cylinder deep inward push/pull should be blocked: {solid_deep_plan}" + ) + solid_deep_message = str(solid_deep_plan.get("message") or "") + if "越过" not in solid_deep_message or "内侧终点" not in solid_deep_message: + raise SystemExit( + f"solid top-boundary cylinder deep inward blocker should explain the retract limit: {solid_deep_plan}" + ) + print( + "solid top-boundary cylinder cap deep retraction blocker ok: " + f"message={solid_deep_message}" + ) return 0 diff --git a/scripts/verify_face_push_pull_limits.py b/scripts/verify_face_push_pull_limits.py index 920543f..a0dfba8 100644 --- a/scripts/verify_face_push_pull_limits.py +++ b/scripts/verify_face_push_pull_limits.py @@ -46,6 +46,14 @@ def main() -> int: if float(near_through.get("push_pull_inward_cut_ratio") or 0.0) < 0.85: raise SystemExit(f"near-through cut should report a high inward cut ratio: {near_through}") + large_outward = model.push_pull_plan(face_id, 11.0) + _assert_plan("large outward push/pull", large_outward, "caution", "high") + large_message = str(large_outward.get("message") or large_outward.get("warnings") or "") + if "隔离子进程" not in large_message and "OCCT" not in large_message: + raise SystemExit(f"large outward push/pull warning should explain isolated high-risk execution: {large_outward}") + if float(large_outward.get("push_pull_distance_to_owning_axis_span_ratio") or 0.0) <= 1.0: + raise SystemExit(f"large outward push/pull should report the owning-axis span ratio: {large_outward}") + through = model.push_pull_plan(face_id, -10.0) _assert_plan("through inward cut", through, "blocked", "blocked") message = str(through.get("message") or through.get("blockers") or "") diff --git a/scripts/verify_face_resize_semantics.py b/scripts/verify_face_resize_semantics.py index 4dc316d..a50dab7 100644 --- a/scripts/verify_face_resize_semantics.py +++ b/scripts/verify_face_resize_semantics.py @@ -283,6 +283,8 @@ def main() -> int: 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}") + if "First-level check:" not in result: + raise SystemExit(f"Planar Face edit result should include a first-level topology check, got: {result}") after = model.stats() if after.solids != before.solids: diff --git a/scripts/verify_face_ui_isolation_contract.py b/scripts/verify_face_ui_isolation_contract.py new file mode 100644 index 0000000..2306ba0 --- /dev/null +++ b/scripts/verify_face_ui_isolation_contract.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import ast +import sys +import time +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +EXPECTED_FACE_ISOLATED_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_cylindrical_height", + "resize_cylindrical_boss_height", + "resize_cylindrical_height_owning_scale", + "resize_cone_reference_radius", + "resize_cone_semi_angle", + "resize_sphere_radius", + "resize_torus_radius", +} + + +def _source_tree(relative_path: str) -> ast.Module: + return ast.parse((PROJECT_ROOT / relative_path).read_text(encoding="utf-8")) + + +def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + raise SystemExit(f"function {name!r} was not found") + + +def _constant_string_set(node: ast.AST, variable_name: str) -> set[str]: + for child in ast.walk(node): + if not isinstance(child, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == variable_name for target in child.targets): + continue + if not isinstance(child.value, (ast.Set, ast.Tuple, ast.List)): + raise SystemExit(f"{variable_name} should be a literal collection") + values: set[str] = set() + for item in child.value.elts: + if not isinstance(item, ast.Constant) or not isinstance(item.value, str): + raise SystemExit(f"{variable_name} contains a non-string item: {ast.dump(item)}") + values.add(item.value) + return values + raise SystemExit(f"{variable_name} assignment was not found") + + +def _worker_operation_set(node: ast.AST) -> set[str]: + operations: set[str] = set() + for child in ast.walk(node): + if not isinstance(child, ast.Compare): + continue + if not isinstance(child.left, ast.Name) or child.left.id != "operation": + continue + if len(child.ops) != 1 or not isinstance(child.ops[0], ast.Eq): + continue + if len(child.comparators) != 1: + continue + comparator = child.comparators[0] + if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str): + operations.add(comparator.value) + return operations + + +def _assert_same(label: str, actual: set[str], expected: set[str]) -> None: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing or extra: + raise SystemExit(f"{label} mismatch: missing={missing}, extra={extra}") + + +PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = { + "push_pull_face": "push_pull_face", + "move_selected_face_plane_position_local": "move_face_plane_offset_local", + "resize_face_area_local": "resize_face_area_local", + "resize_face_area": "resize_face_area", + "resize_face_width_local": "resize_face_size_local", + "resize_face_height_local": "resize_face_size_local", + "resize_face_width_owning_scale": "resize_face_size_owning_scale", + "resize_face_height_owning_scale": "resize_face_size_owning_scale", + "move_selected_face_center_local": "move_face_center_local", + "resize_shell_thickness": "resize_shell_thickness", + "resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale", + "resize_cylinder_height": "resize_cylindrical_height", + "resize_boss_height": "resize_cylindrical_boss_height", + "resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale", + "resize_cone_reference_radius": "resize_cone_reference_radius", + "resize_cone_semi_angle": "resize_cone_semi_angle", + "resize_sphere_radius": "resize_sphere_radius", + "resize_torus_major_radius": "resize_torus_radius", + "resize_torus_minor_radius": "resize_torus_radius", +} + + +PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD = { + # Pure owning-shape affine translations are intentionally lightweight and do + # not enter the OCC rebuild/Boolean isolation worker. + "move_selected_face_center", + "move_selected_face_plane_position_by_translation", +} + + +ACTION_IMPLEMENTATION_METHOD = { + "resize_face_width_local": "_resize_face_size_local", + "resize_face_height_local": "_resize_face_size_local", + "resize_face_width_owning_scale": "_resize_face_size_owning_scale", + "resize_face_height_owning_scale": "_resize_face_size_owning_scale", + "resize_torus_major_radius": "_resize_torus_radius", + "resize_torus_minor_radius": "_resize_torus_radius", +} + + +def _property_spec_actions_for_keys(specs: list[dict[str, object]], keys: set[str]) -> set[str]: + actions: set[str] = set() + for spec in specs: + key = str(spec.get("key", "")) + if key not in keys: + continue + action = str(spec.get("action") or "") + if action: + actions.add(action) + scope_modes = spec.get("scope_modes") + if isinstance(scope_modes, dict): + for mode in scope_modes.values(): + if not isinstance(mode, dict): + continue + action = str(mode.get("action") or "") + if action: + actions.add(action) + return actions + + +def _face_property_actions_from_specs() -> set[str]: + from verify_property_editor_specs import _specs + + plane_info = { + "surface": "plane", + "area": 100.0, + "area_center": (5.0, 5.0, 0.0), + "bbox_center": (5.0, 5.0, 0.0), + "local_face_width": 10.0, + "local_face_height": 10.0, + "local_face_size_center": (5.0, 5.0, 0.0), + "plane_origin": (0.0, 0.0, 0.0), + "push_pull_outward_direction": (0.0, 0.0, 1.0), + "normal": (0.0, 0.0, 1.0), + "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, + } + shell_info = { + **plane_info, + "shell_region_status": "candidate", + "shell_thickness_estimate": 2.0, + "shell_current_thickness": 2.0, + "shell_signed_thickness": 2.0, + "shell_opposite_face_id": 2, + "shell_overlap_ratio_estimate": 1.0, + } + generic_cylinder_info = { + "surface": "cylinder", + "feature_guess": "", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 6.283185307179586, + "height_estimate": 11.0, + "same_domain_height_estimate": 11.0, + "area": 207.0, + "area_center": (0.0, 0.0, 5.5), + "bbox_center": (0.0, 0.0, 5.5), + "axis": (0.0, 0.0, 1.0), + "axis_point": (0.0, 0.0, 0.0), + "axis_center": (0.0, 0.0, 5.5), + } + boss_info = { + **generic_cylinder_info, + "feature_guess": "boss/outer-round candidate", + "height_estimate": 5.0, + "same_domain_height_estimate": 5.0, + "feature_start_end_face_ids": (1,), + } + cases = ( + ( + plane_info, + { + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + }, + ), + (shell_info, {"shell_thickness_estimate"}), + (generic_cylinder_info, {"cylinder_height"}), + (boss_info, {"boss_height"}), + ( + { + "surface": "cone", + "reference_radius": 5.0, + "diameter": 10.0, + "radius": 5.0, + "semi_angle": 0.5, + "area": 100.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "axis": (0.0, 0.0, 1.0), + "axis_point": (0.0, 0.0, 0.0), + }, + {"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"}, + ), + ( + { + "surface": "sphere", + "radius": 5.0, + "diameter": 10.0, + "area": 100.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "center": (0.0, 0.0, 0.0), + }, + {"sphere_radius", "sphere_diameter"}, + ), + ( + { + "surface": "torus", + "major_radius": 8.0, + "minor_radius": 2.0, + "major_diameter": 16.0, + "minor_diameter": 4.0, + "area": 100.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "center": (0.0, 0.0, 0.0), + }, + {"torus_major_radius", "torus_major_diameter", "torus_minor_radius", "torus_minor_diameter"}, + ), + ) + actions: set[str] = set() + for info, keys in cases: + actions.update(_property_spec_actions_for_keys(_specs(info), keys)) + return actions + + +def _method_isolation_operations(tree: ast.Module, method_name: str) -> set[str]: + method = _function_node(tree, method_name) + operations: set[str] = set() + for child in ast.walk(method): + if not isinstance(child, ast.Call): + continue + callee = child.func + if not isinstance(callee, ast.Attribute) or callee.attr != "_isolation_for_plan": + continue + if len(child.args) < 2: + raise SystemExit(f"{method_name} calls _isolation_for_plan without an operation argument") + operation_arg = child.args[1] + if not isinstance(operation_arg, ast.Constant) or not isinstance(operation_arg.value, str): + raise SystemExit(f"{method_name} should pass a literal isolated operation name") + operations.add(operation_arg.value) + return operations + + +def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None: + property_actions = _face_property_actions_from_specs() + covered = set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) | PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD + missing = sorted(property_actions - covered) + stale = sorted(set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) - property_actions) + if missing or stale: + raise SystemExit(f"Face property action contract mismatch: missing={missing}, stale={stale}") + + for action, isolated_operation in sorted(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION.items()): + if isolated_operation not in EXPECTED_FACE_ISOLATED_OPERATIONS: + raise SystemExit(f"{action} maps to non-worker isolated operation {isolated_operation!r}") + method_name = ACTION_IMPLEMENTATION_METHOD.get(action, action) + actual_operations = _method_isolation_operations(actions_tree, method_name) + if isolated_operation not in actual_operations: + raise SystemExit( + f"{action} should enter isolated operation {isolated_operation!r} through {method_name}; " + f"actual={sorted(actual_operations)}" + ) + + +def main() -> int: + worker_execute = _function_node(_source_tree("step_editor/isolated_edit_worker.py"), "_execute") + worker_ops = _worker_operation_set(worker_execute) + _assert_same("isolated worker operations", worker_ops, EXPECTED_FACE_ISOLATED_OPERATIONS) + + actions_tree = _source_tree("step_editor/window_actions.py") + isolation_func = _function_node(actions_tree, "_isolation_for_plan") + ui_ops = _constant_string_set(isolation_func, "isolated_face_operations") + _assert_same("UI isolation operations", ui_ops, EXPECTED_FACE_ISOLATED_OPERATIONS) + _assert_same("UI/worker isolation operation contract", ui_ops, worker_ops) + _assert_property_face_actions_are_isolated(actions_tree) + + from step_editor.window_actions import WindowActionMixin + + class _Probe(WindowActionMixin): + pass + + probe = _Probe() + for risk in ("low", "medium", "high"): + for operation in sorted(EXPECTED_FACE_ISOLATED_OPERATIONS): + isolation = probe._isolation_for_plan( + {"risk": risk}, + operation, + [1, 2, 3], + timeout_seconds=7.5, + ) + if isolation is None: + raise SystemExit(f"{operation}/{risk} should request isolated execution") + if isolation.get("operation") != operation: + raise SystemExit(f"{operation}/{risk} returned wrong operation: {isolation}") + if isolation.get("args") != [1, 2, 3]: + raise SystemExit(f"{operation}/{risk} returned wrong args: {isolation}") + if float(isolation.get("timeout_seconds") or 0.0) != 7.5: + raise SystemExit(f"{operation}/{risk} returned wrong timeout: {isolation}") + if str(isolation.get("reason") or "") != f"{risk}-risk-face-occ-edit": + raise SystemExit(f"{operation}/{risk} returned wrong reason: {isolation}") + + if probe._isolation_for_plan({"risk": "blocked"}, "push_pull_face", [0, 1]) is not None: + raise SystemExit("blocked plans should not enter isolated execution") + if probe._isolation_for_plan({"risk": "high"}, "resize_cylindrical_hole", [0, 20]) is not None: + raise SystemExit("non-Face feature operations should not be covered by the Face isolation contract") + if probe._isolation_for_plan({"risk": "high"}, "unknown_operation", []) is not None: + raise SystemExit("unknown operations should not enter isolated execution") + + from step_editor.model import StepModel + + complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" + if complex_path.exists(): + model = StepModel.load(complex_path) + complex_face_id = 1 + complex_probe = _Probe() + complex_probe.model = model + complex_probe.step_path = complex_path + complex_probe.selected_face_id = complex_face_id + complex_probe.current_info_values = model.quick_face_info(complex_face_id) + checks = ( + ("area local", lambda: complex_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0), "blocked"), + ( + "width local", + lambda: complex_probe._quick_blocked_face_size_local_plan(complex_face_id, 50.0, "width"), + "blocked", + ), + ( + "offset local", + lambda: complex_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0), + "blocked", + ), + ( + "center local", + lambda: complex_probe._quick_blocked_face_center_local_plan(complex_face_id, (0.0, 50.0, 0.0)), + "blocked", + ), + ( + "shell thickness", + lambda: complex_probe._deferred_shell_thickness_plan_if_needed(complex_face_id, 10.0), + "caution", + ), + ) + for label, callback, expected_status in checks: + started = time.perf_counter() + plan = callback() + elapsed = time.perf_counter() - started + if plan is None: + raise SystemExit(f"{label} complex Face UI preflight should return a quick plan") + if elapsed > 0.05: + raise SystemExit(f"{label} complex Face UI preflight should be quick, elapsed={elapsed:.3f}s") + if str(plan.get("status")) != expected_status: + raise SystemExit(f"{label} complex Face UI preflight returned wrong status: {plan}") + if expected_status == "blocked" and not plan.get("ui_quick_blocked_local_face_plan"): + raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}") + if label == "shell thickness" and not plan.get("ui_deferred_shell_thickness_plan"): + raise SystemExit(f"{label} should defer full shell-thickness planning: {plan}") + + uncached_probe = _Probe() + uncached_probe.model = model + uncached_probe.step_path = complex_path + uncached_probe.selected_face_id = complex_face_id + uncached_probe.current_info_values = {} + uncached_checks = ( + ("area local uncached", lambda: uncached_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0)), + ( + "offset local uncached", + lambda: uncached_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0), + ), + ) + for label, callback in uncached_checks: + started = time.perf_counter() + plan = callback() + elapsed = time.perf_counter() - started + if elapsed > 0.05: + raise SystemExit(f"{label} should use quick_face_info fallback, elapsed={elapsed:.3f}s") + if plan is None or plan.get("status") != "blocked": + raise SystemExit(f"{label} should still return a quick blocked plan: {plan}") + if not plan.get("ui_quick_blocked_local_face_plan"): + raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}") + + multi_boundary_face_id = 594 + multi_boundary_probe = _Probe() + multi_boundary_probe.model = model + multi_boundary_probe.step_path = complex_path + multi_boundary_probe.selected_face_id = multi_boundary_face_id + multi_boundary_probe.current_info_values = model.quick_face_info(multi_boundary_face_id) + started = time.perf_counter() + inward_plan = multi_boundary_probe._push_pull_plan_for_action(multi_boundary_face_id, -1.0) + elapsed = time.perf_counter() - started + if elapsed > 0.05: + raise SystemExit(f"multi-boundary inward push/pull UI preflight should be quick, elapsed={elapsed:.3f}s") + if inward_plan.get("status") != "blocked": + raise SystemExit(f"multi-boundary inward push/pull UI preflight should be blocked: {inward_plan}") + if not inward_plan.get("ui_quick_blocked_push_pull_plan"): + raise SystemExit(f"multi-boundary inward push/pull should be marked as a quick blocker: {inward_plan}") + message = str(inward_plan.get("message") or "") + if "二级关系" not in message or "通用 OCCT 布尔" not in message: + raise SystemExit( + f"multi-boundary inward push/pull blocker should explain topology depth and Boolean risk: " + f"{inward_plan}" + ) + for required in ("Face 594", "内边界", "一级边界 Edge", "一级相邻 Face"): + if required not in message: + raise SystemExit( + f"multi-boundary inward push/pull blocker should include readable topology evidence " + f"{required!r}: {inward_plan}" + ) + diagnostics = multi_boundary_probe._edit_failure_diagnostics( + { + "operation_name": "推拉平面", + "target": f"Face {multi_boundary_face_id}", + "parameters": { + "surface": inward_plan.get("surface"), + "semantic_distance": -1.0, + "current_plane_position": inward_plan.get("current_plane_position"), + "target_plane_position": inward_plan.get("target_plane_position"), + "edit_strategy_label": inward_plan.get("edit_strategy_label"), + "push_pull_message": inward_plan.get("message"), + "push_pull_risk": inward_plan.get("risk"), + "selected_boundary_wires": inward_plan.get("selected_boundary_wires"), + "selected_inner_boundary_wires": inward_plan.get("selected_inner_boundary_wires"), + "selected_has_inner_boundaries": inward_plan.get("selected_has_inner_boundaries"), + "same_domain_face_count": inward_plan.get("same_domain_face_count"), + "first_level_boundary_edge_count": inward_plan.get("first_level_boundary_edge_count"), + "first_level_boundary_vertex_count": inward_plan.get("first_level_boundary_vertex_count"), + "first_level_adjacent_face_count": inward_plan.get("first_level_adjacent_face_count"), + "first_level_topology_note": inward_plan.get("first_level_topology_note"), + "topology_ignored_relation_note": inward_plan.get("topology_ignored_relation_note"), + "planar_cap_extension_kind": inward_plan.get("planar_cap_extension_kind"), + "planar_cap_extension_method": inward_plan.get("planar_cap_extension_method"), + }, + "isolation": { + "operation": "push_pull_face", + "args": [multi_boundary_face_id, -1.0], + "timeout_seconds": 180.0, + "reason": "high-risk-face-occ-edit", + }, + } + ) + for required in ("诊断信息", "操作: 推拉平面 / Face 594", "一级关系证据", "可能原因", "二级", "隔离保护"): + if required not in diagnostics: + raise SystemExit(f"edit failure diagnostics should include {required!r}: {diagnostics}") + if multi_boundary_probe._isolation_for_plan(inward_plan, "push_pull_face", [multi_boundary_face_id, -1.0]) is not None: + raise SystemExit(f"blocked multi-boundary inward push/pull should not enter isolated execution: {inward_plan}") + + print( + "Face UI isolation contract ok: " + f"{len(EXPECTED_FACE_ISOLATED_OPERATIONS)} operations are shared by UI and worker." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py new file mode 100644 index 0000000..e817b01 --- /dev/null +++ b/scripts/verify_feature_recognition_summary.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeTorus + +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 step_editor.step_io import _write_step +from step_editor.ui_helpers import INFO_LABELS + +from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402 +from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402 + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _top_planar_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 = info.get("area_center") + if not isinstance(center, tuple) or len(center) != 3: + continue + z_value = float(center[2]) + if best is None or z_value > best[0]: + best = (z_value, face_id) + if best is None: + raise AssertionError("no planar Face was found") + return best[1] + + +def _first_planar_face_with_inner_boundary(model: StepModel) -> int: + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") == "plane" and int(info.get("inner_boundary_wires", 0) or 0) > 0: + return face_id + raise AssertionError("no planar Face with an inner boundary was found") + + +def _first_surface_face(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 AssertionError(f"no {surface} Face was found") + + +def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]) -> None: + summary = str(info.get("recognition_summary") or "") + candidate = str(info.get("recognition_candidate") or "") + confidence = str(info.get("recognition_confidence") or "") + risk = str(info.get("recognition_risk") or "") + decision = str(info.get("recognition_decision") or "") + evidence_keys = set(str(item) for item in tuple(info.get("recognition_evidence_keys") or ())) + evidence = str(info.get("recognition_evidence") or "") + score = int(info.get("recognition_score", -1)) + + _assert(summary, f"{label}: recognition_summary is missing") + _assert(candidate, f"{label}: recognition_candidate is missing") + _assert(confidence in {"unchecked", "none", "low", "medium", "high"}, f"{label}: bad confidence {confidence!r}") + _assert(risk in {"low", "medium", "high", "blocked"}, f"{label}: bad risk {risk!r}") + _assert(0 <= score <= 100, f"{label}: bad recognition_score {score!r}") + _assert( + decision in {"高可信候选", "可尝试候选", "需人工确认", "不建议自动修改", "已阻止"}, + f"{label}: bad recognition_decision {decision!r}", + ) + _assert(required_keys <= evidence_keys, f"{label}: evidence keys missing {required_keys - evidence_keys}") + _assert(evidence, f"{label}: recognition_evidence is missing") + _assert(candidate in summary, f"{label}: summary should mention candidate {candidate!r}: {summary!r}") + _assert(f"评分={score}" in summary, f"{label}: summary should mention score {score}: {summary!r}") + _assert(decision in summary, f"{label}: summary should mention decision {decision!r}: {summary!r}") + + +def _verify_planar_summary(root: Path) -> None: + path = root / "box.step" + _write_step(BRepPrimAPI_MakeBox(10.0, 8.0, 6.0).Shape(), path) + model = StepModel.load(path) + face_id = _top_planar_face(model) + quick_info = model.quick_face_info(face_id) + full_info = model.feature_info(face_id) + _assert_summary(quick_info, "quick planar Face", {"surface", "boundary_edges"}) + _assert_summary(full_info, "feature planar Face", {"surface", "boundary_edges", "first_level_topology"}) + + +def _verify_hole_summary(root: Path) -> None: + path = root / "through_hole.step" + _write_through_hole_model(path) + model = StepModel.load(path) + face_id = _first_hole_face(model, blind=False) + info = model.feature_info(face_id) + _assert(info.get("feature_guess") == "hole/groove candidate", f"hole was not recognized: {info}") + _assert_summary(info, "through-hole feature", {"surface", "material_votes", "first_level_topology"}) + _assert(info.get("resize_status") == "ready", f"hole diameter resize should be ready: {info}") + _assert(info.get("recognition_risk") != "blocked", f"ready hole candidate should not be globally blocked: {info}") + _assert(info.get("recognition_decision") != "已阻止", f"ready hole candidate should not be marked blocked: {info}") + _assert(not str(info.get("recognition_blockers") or ""), f"ready hole blockers should stay empty: {info}") + ready_actions = str(info.get("recognition_ready_actions") or "") + limited_actions = str(info.get("recognition_limited_actions") or "") + _assert("孔/槽/圆柱直径" in ready_actions, f"hole ready actions should include diameter resize: {info}") + _assert("盲孔/盲槽深度" in limited_actions, f"through-hole limited actions should include blind depth: {info}") + limitations = str(info.get("recognition_limitations") or "") + _assert(limitations, f"ready hole should still explain unsupported sub-capabilities: {info}") + _assert("受限能力" in str(info.get("recognition_summary") or ""), f"hole summary should show limitations: {info}") + _assert("可改:" in str(info.get("recognition_summary") or ""), f"hole summary should show ready actions: {info}") + + +def _verify_holed_planar_summary(root: Path) -> None: + path = root / "holed_plate.step" + _write_through_hole_model(path) + model = StepModel.load(path) + face_id = _first_planar_face_with_inner_boundary(model) + info = model.feature_info(face_id) + _assert_summary(info, "holed planar Face", {"surface", "boundary_edges", "first_level_topology"}) + _assert(info.get("local_face_deform_ready") is False, f"holed Face should block local deformation: {info}") + _assert(info.get("recognition_risk") != "blocked", f"holed Face should keep push/pull available: {info}") + _assert(info.get("recognition_decision") != "已阻止", f"holed Face should not be globally blocked: {info}") + _assert(not str(info.get("recognition_blockers") or ""), f"holed Face blockers should stay empty: {info}") + ready_actions = str(info.get("recognition_ready_actions") or "") + limited_actions = str(info.get("recognition_limited_actions") or "") + _assert("平面推拉" in ready_actions, f"holed Face should expose push/pull as ready: {info}") + _assert( + "当前面局部尺寸/中心/偏移" in limited_actions, + f"holed Face should expose local deformation as limited: {info}", + ) + + +def _verify_slot_summary(root: Path) -> None: + path = root / "half_round_slot.step" + _write_half_round_slot_model(path) + model = StepModel.load(path) + face_id = _first_slot_face(model) + info = model.feature_info(face_id) + _assert(info.get("slot_status") == "candidate", f"slot was not recognized: {info}") + _assert_summary(info, "half-round slot feature", {"surface", "material_votes", "slot_geometry"}) + + +def _verify_torus_summary(root: Path) -> None: + path = root / "torus.step" + _write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path) + model = StepModel.load(path) + face_id = _first_surface_face(model, "torus") + info = model.feature_info(face_id) + _assert(info.get("feature_type") == "环面候选", f"torus was not recognized safely: {info}") + _assert(info.get("feature_highlight_face_ids") == (face_id,), f"torus highlight should stay on source Face: {info}") + _assert_summary(info, "torus feature", {"surface", "boundary_edges"}) + + +def main() -> int: + for key in ( + "recognition_summary", + "recognition_candidate", + "recognition_confidence", + "recognition_risk", + "recognition_score", + "recognition_decision", + "recognition_evidence", + "recognition_ready_actions", + "recognition_limited_actions", + "recognition_blockers", + "recognition_limitations", + ): + _assert(key in INFO_LABELS, f"{key} should have a user-facing label") + + with tempfile.TemporaryDirectory(prefix="geom_param_recognition_summary_") as temp_dir: + root = Path(temp_dir) + _verify_planar_summary(root) + _verify_holed_planar_summary(root) + _verify_hole_summary(root) + _verify_slot_summary(root) + _verify_torus_summary(root) + + print("feature recognition summary ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_first_level_fact_graph.py b/scripts/verify_first_level_fact_graph.py new file mode 100644 index 0000000..912f382 --- /dev/null +++ b/scripts/verify_first_level_fact_graph.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +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 step_editor.ui_helpers import INFO_LABELS +from step_editor.window_state import WindowStateMixin + +from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402 +from verify_shell_thickness_resize import _first_shell_face, _write_plate_model # noqa: E402 +from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402 + + +DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" + + +class _FactProbe(WindowStateMixin): + def __init__(self, model: StepModel) -> None: + self.model = model + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +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 AssertionError(f"Face has no stable center: {info}") + return float(value[0]), float(value[1]), float(value[2]) + + +def _number(value: object, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +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 AssertionError("No planar Face found.") + return best[1] + + +def _assert_common_facts(facts: dict[str, object], label: str) -> None: + _assert(facts.get("first_level_fact_model") == "STEP/B-Rep first-level fact graph", f"{label}: bad model") + _assert(facts.get("first_level_fact_status") == "ready", f"{label}: bad status {facts}") + _assert(facts.get("first_level_fact_relation_depth") == 1, f"{label}: bad relation depth") + _assert(facts.get("first_level_fact_relation_boundary") == "shared-edge", f"{label}: bad boundary") + _assert( + int(facts.get("first_level_fact_subject_face_count", 0) or 0) >= 1, + f"{label}: missing subject Faces", + ) + _assert( + int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) >= 1, + f"{label}: missing boundary Edges", + ) + _assert( + int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) >= 1, + f"{label}: missing first-level adjacent Faces", + ) + ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ()) + _assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing") + _assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing") + + +def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None: + _assert_common_facts(plan, label) + _assert(plan.get("first_level_fact_scope") == scope, f"{label}: wrong fact scope {plan}") + _assert( + int(plan.get("first_level_fact_included_face_count", 0) or 0) >= + int(plan.get("first_level_fact_subject_face_count", 0) or 0), + f"{label}: included facts should cover subject Faces", + ) + + +def _verify_planar_face_facts() -> None: + model = StepModel.load(DEFAULT_MODEL) + face_id = _top_plane_face(model) + info = model.face_info(face_id) + facts = model.face_first_level_facts(face_id, scope="face") + _assert_common_facts(facts, "cube planar Face") + _assert(facts.get("first_level_fact_scope") == "face", f"cube planar Face: wrong scope {facts}") + _assert(int(facts.get("first_level_fact_subject_face_count", 0) or 0) == 1, f"cube subject count: {facts}") + _assert(int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) == 4, f"cube boundary count: {facts}") + _assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}") + _assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}") + _assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}") + + probe = _FactProbe(model) + fields = probe._face_first_level_selection_fields(face_id) + _assert(fields.get("first_level_fact_summary"), f"selection fields should expose fact summary: {fields}") + enriched = model.quick_face_info(face_id) + enriched.update(fields) + enriched.update(model._recognition_summary_fields(enriched)) # noqa: SLF001 + _assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"summary missed facts: {enriched}") + + center = _center(info) + area = _number(info.get("area"), 100.0) + width = _number(info.get("local_face_width"), 10.0) + plans = ( + ( + model.face_center_local_move_plan(face_id, (center[0], center[1], center[2] + 2.0)), + "cube Face center local move plan", + ), + ( + model.face_center_owning_translation_plan(face_id, (center[0], center[1], center[2] + 2.0)), + "cube Face center owning translation plan", + ), + (model.face_area_local_resize_plan(face_id, area * 1.2), "cube Face area local resize plan"), + (model.face_area_scale_plan(face_id, area * 1.2), "cube Face area owning scale plan"), + (model.face_size_local_resize_plan(face_id, width * 1.2, "width"), "cube Face width local resize plan"), + (model.face_size_owning_scale_plan(face_id, width * 1.2, "width"), "cube Face width owning scale plan"), + (model.face_plane_offset_local_plan(face_id, 2.0), "cube Face plane offset local plan"), + (model.face_plane_offset_owning_translation_plan(face_id, 2.0), "cube Face plane offset owning plan"), + (model.push_pull_plan(face_id, 2.0), "cube Face push-pull plan"), + ) + for plan, label in plans: + _assert_plan_facts(plan, label, "face") + + +def _verify_shell_plan_facts(root: Path) -> None: + shell_path = root / "thin_plate.step" + _write_plate_model(shell_path) + model = StepModel.load(shell_path) + face_id = _first_shell_face(model, 2.0, 2e-4) + plans = ( + (model.shell_thickness_plan(face_id, 3.0), "thin wall thickness local plan"), + (model.shell_thickness_owning_scale_plan(face_id, 3.0), "thin wall thickness owning plan"), + ) + for plan, label in plans: + _assert_plan_facts(plan, label, "face") + + +def _verify_cylindrical_facts(root: Path) -> None: + hole_path = root / "through_hole.step" + _write_through_hole_model(hole_path) + hole_model = StepModel.load(hole_path) + hole_face_id = _first_hole_face(hole_model, blind=False) + hole_facts = hole_model.face_first_level_facts(hole_face_id, scope="cylindrical-feature") + _assert_common_facts(hole_facts, "through-hole cylinder") + _assert( + hole_facts.get("first_level_fact_scope") == "cylindrical-feature", + f"through-hole cylinder: wrong scope {hole_facts}", + ) + role_groups = tuple(hole_facts.get("first_level_fact_role_groups") or ()) + _assert(any(isinstance(item, dict) and item.get("role") == "cylindrical-side" for item in role_groups), role_groups) + _assert(any(isinstance(item, dict) and item.get("role") == "end/opening" for item in role_groups), role_groups) + + probe = _FactProbe(hole_model) + fields = probe._cylindrical_first_level_selection_fields(hole_face_id) + enriched = hole_model.feature_info(hole_face_id) + enriched.update(fields) + enriched.update(hole_model._recognition_summary_fields(enriched)) # noqa: SLF001 + _assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"hole summary missed facts: {enriched}") + + diameter = _number(hole_model.face_info(hole_face_id).get("diameter"), 6.0) + plan = hole_model.cylindrical_resize_plan(hole_face_id, diameter * 1.2) + _assert_plan_facts(plan, "through-hole diameter resize plan", "cylindrical-feature") + + +def _verify_slot_facts(root: Path) -> None: + slot_path = root / "half_round_slot.step" + _write_half_round_slot_model(slot_path) + model = StepModel.load(slot_path) + face_id = _first_slot_face(model) + facts = model.face_first_level_facts(face_id, scope="cylindrical-feature") + _assert_common_facts(facts, "half-round slot") + role_groups = tuple(facts.get("first_level_fact_role_groups") or ()) + _assert(any(isinstance(item, dict) and item.get("role") == "slot-boundary" for item in role_groups), role_groups) + + feature = model.feature_info(face_id) + width = _number(feature.get("slot_width"), _number(model.face_info(face_id).get("diameter"), 6.0)) + plan = model.cylindrical_slot_resize_plan(face_id, width * 1.1, "width") + _assert_plan_facts(plan, "half-round slot width resize plan", "cylindrical-feature") + + +def main() -> int: + for key in ( + "first_level_fact_model", + "first_level_fact_status", + "first_level_fact_summary", + "first_level_fact_subject_face_count", + "first_level_fact_adjacent_face_count", + ): + _assert(key in INFO_LABELS, f"{key} should have a user-facing label") + + _verify_planar_face_facts() + with tempfile.TemporaryDirectory(prefix="geom_param_first_level_facts_") as temp_dir: + root = Path(temp_dir) + _verify_shell_plan_facts(root) + _verify_cylindrical_facts(root) + _verify_slot_facts(root) + + print("first-level fact graph ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_hole_resize.py b/scripts/verify_hole_resize.py index f53b3c4..9f46142 100644 --- a/scripts/verify_hole_resize.py +++ b/scripts/verify_hole_resize.py @@ -330,6 +330,8 @@ def _run_blind_depth_case(target: float, tolerance: float) -> None: verified_face, depth, error = _nearest_blind_depth(model, target) if error > tolerance: raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}") + if "verified_face=" not in result: + raise SystemExit(f"blind depth operation should report internal result verification: {result}") print("mode=blind_depth") print(f"source_face={face_id}") print(f"bottom_face={bottom_face_id}") diff --git a/scripts/verify_isolated_face_edit.py b/scripts/verify_isolated_face_edit.py index e65beee..4c7fa52 100644 --- a/scripts/verify_isolated_face_edit.py +++ b/scripts/verify_isolated_face_edit.py @@ -6,7 +6,9 @@ import sys import tempfile from pathlib import Path -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -15,6 +17,7 @@ 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 @@ -207,6 +210,17 @@ def _write_torus_model(path: Path) -> None: _write_step(BRepPrimAPI_MakeTorus(8.0, 2.0).Shape(), path) +def _write_hollow_cylinder_model(path: Path) -> None: + outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape() + inner = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)), + 3.0, + 80.0, + ).Shape() + cut = BRepAlgoAPI_Cut(outer, inner) + _write_step(_finalize_boolean_result(cut, "verify isolated hollow cylinder cut", use_glue=False), 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: @@ -214,6 +228,23 @@ def _first_face_by_surface(model: StepModel, surface: str) -> int: raise SystemExit(f"no {surface} Face was recognized") +def _top_planar_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 = info.get("area_center") or info.get("bbox_center") + if not isinstance(center, tuple) or len(center) != 3: + continue + z_value = float(center[2]) + if best is None or z_value > best[0]: + best = (z_value, face_id) + if best is None: + raise SystemExit("no top planar Face was recognized") + return best[1] + + 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)): @@ -270,7 +301,78 @@ def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float print(f"matched_torus_radii={matches}") +def _assert_hollow_cylinder_height(label: str, model: StepModel, target_height: float) -> None: + bbox_size = model.geometry_stats().get("bbox_size") + if not isinstance(bbox_size, tuple) or len(bbox_size) != 3: + raise SystemExit(f"{label}: output bbox_size is missing") + height = float(bbox_size[2]) + if abs(height - target_height) > 1e-4: + raise SystemExit(f"{label}: hollow cylinder height should be {target_height:g}, got {height:g}") + radii: list[float] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + radius = float(info.get("radius") or 0.0) + if radius <= 0.0: + continue + if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii): + radii.append(radius) + radii.sort() + if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-4 or abs(radii[1] - 8.0) > 1e-4: + raise SystemExit(f"{label}: hollow cylinder should preserve inner/outer radii, got {radii}") + print(f"matched_hollow_cylinder_height={height:g}, radii={radii}") + + +def _assert_hollow_cylinder_extension(label: str, model: StepModel) -> None: + _assert_hollow_cylinder_height(label, model, 167.0) + + def main() -> int: + push_pull_probe = StepModel.load(DEFAULT_MODEL) + large_push_plan = push_pull_probe.push_pull_plan(0, 11.0) + if large_push_plan.get("status") == "blocked" or str(large_push_plan.get("risk")) != "high": + raise SystemExit(f"large push/pull should be allowed as an isolated high-risk edit: {large_push_plan}") + _run_worker_case( + label="large push/pull current Face", + operation="push_pull_face", + args=[0, 11.0], + validator=lambda label, model: _assert_plane_position(label, model, 11.0), + ) + with tempfile.TemporaryDirectory(prefix="geom_param_isolated_hollow_cylinder_verify_") as temp_dir: + hollow_path = Path(temp_dir) / "hollow_cylinder.step" + _write_hollow_cylinder_model(hollow_path) + hollow_probe = StepModel.load(hollow_path) + hollow_face_id = _top_planar_face(hollow_probe) + hollow_plan = hollow_probe.push_pull_plan(hollow_face_id, 89.0) + if hollow_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube": + raise SystemExit(f"hollow cylinder cap should use coaxial-tube plan in worker test: {hollow_plan}") + _run_worker_case( + label="large hollow cylinder cap push/pull", + operation="push_pull_face", + args=[hollow_face_id, 89.0], + validator=_assert_hollow_cylinder_extension, + input_path=hollow_path, + ) + _run_worker_case( + label="hollow cylinder cap inward push/pull", + operation="push_pull_face", + args=[hollow_face_id, -39.0], + validator=lambda label, model: _assert_hollow_cylinder_height(label, model, 39.0), + input_path=hollow_path, + ) + hollow_height_probe = StepModel.load(hollow_path) + hollow_cylinder_face_id = _first_face_by_surface(hollow_height_probe, "cylinder") + height_plan = hollow_height_probe.cylindrical_height_plan(hollow_cylinder_face_id, 167.0) + if height_plan.get("status") == "blocked": + raise SystemExit(f"hollow cylinder side Face height edit should be isolated, not blocked: {height_plan}") + _run_worker_case( + label="large hollow cylinder side Face height", + operation="resize_cylindrical_height", + args=[hollow_cylinder_face_id, 167.0], + validator=_assert_hollow_cylinder_extension, + input_path=hollow_path, + ) _run_worker_case( label="面偏移(当前面)", operation="move_face_plane_offset_local", @@ -384,8 +486,30 @@ def main() -> int: def __init__(self) -> None: self.model = StepModel.load(DEFAULT_MODEL) self.step_path = DEFAULT_MODEL + self.current_info_values = self.model.face_info(0) probe = _IsolatedJobProbe() + action_plan = probe._push_pull_plan_for_action(0, 11.0) + if action_plan.get("status") == "blocked" or str(action_plan.get("risk")) != "high": + raise SystemExit(f"UI push/pull action plan should use the model-layer high-risk plan: {action_plan}") + if float(action_plan.get("push_pull_distance_to_owning_axis_span_ratio") or 0.0) <= 1.0: + raise SystemExit(f"UI push/pull action plan should expose model-layer span ratio: {action_plan}") + + complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" + if complex_path.exists(): + complex_probe = _IsolatedJobProbe() + complex_probe.model = StepModel.load(complex_path) + complex_probe.step_path = complex_path + complex_probe.selected_face_id = 1 + complex_probe.current_info_values = complex_probe.model.quick_face_info(1) + complex_plan = complex_probe._push_pull_plan_for_action(1, 89.0) + if not complex_plan.get("ui_deferred_model_plan"): + raise SystemExit(f"complex holed cap UI plan should defer full model plan: {complex_plan}") + if str(complex_plan.get("risk")) != "high": + raise SystemExit(f"deferred complex holed cap UI plan should be high risk: {complex_plan}") + if complex_probe._isolation_for_plan(complex_plan, "push_pull_face", [1, 89.0]) is None: + raise SystemExit(f"deferred complex holed cap UI plan should request isolated execution: {complex_plan}") + if probe._isolated_edit_command(Path("request.json")) != [ sys.executable, "-m", @@ -445,9 +569,18 @@ def main() -> int: ) if "隔离子进程" not in str(result.get("message", "")): raise SystemExit(f"isolated window job did not report isolated execution: {result}") + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"isolated window job should return after_model for UI-thread installation: {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): + if _logical_region_has_width(probe.model, 0, 25.0): + raise SystemExit("isolated window job replaced the window model before returning to the UI thread") + if after_model.stats().solids != 1: + raise SystemExit(f"isolated window after_model changed solid count unexpectedly: {after_model.stats()}") + if result.get("model_polydata") is None or result.get("edge_polydata") is None: + raise SystemExit("isolated window job should return prebuilt display polydata") + if not _logical_region_has_width(after_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") @@ -474,8 +607,17 @@ def main() -> int: ) 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): + owning_after_model = owning_result.get("after_model") + if not isinstance(owning_after_model, StepModel): + raise SystemExit( + f"isolated owning window job should return after_model for UI-thread installation: {owning_result}" + ) + if _logical_region_has_width(owning_probe.model, 0, 25.0): + raise SystemExit("isolated owning window job replaced the window model before returning to the UI thread") + if owning_result.get("model_polydata") is None or owning_result.get("edge_polydata") is None: + raise SystemExit("isolated owning window job should return prebuilt display polydata") + _assert_face_width("面宽(整体窗口任务)", owning_after_model, 25.0) + if not _logical_region_has_width(owning_after_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() diff --git a/scripts/verify_large_stepped_cap_push_pull.py b/scripts/verify_large_stepped_cap_push_pull.py new file mode 100644 index 0000000..ec59d16 --- /dev/null +++ b/scripts/verify_large_stepped_cap_push_pull.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import sys +import time +import json +import subprocess +import tempfile +from pathlib import Path + +from OCC.Core.BRepAdaptor import BRepAdaptor_Surface +from OCC.Core.BRepGProp import brepgprop +from OCC.Core.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane +from OCC.Core.GProp import GProp_GProps +from OCC.Core.TopAbs import TopAbs_WIRE +from OCC.Core.TopExp import TopExp_Explorer + +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 + + +def _wire_count(face) -> int: + count = 0 + explorer = TopExp_Explorer(face, TopAbs_WIRE) + while explorer.More(): + count += 1 + explorer.Next() + return count + + +def _adjacent_cylinder_radii(model: StepModel, face_id: int) -> list[float]: + adjacent_ids: list[int] = [] + for edge_id in model._face_boundary_edge_ids(face_id): + for adjacent_id in model._edge_adjacent_face_ids(edge_id): + if adjacent_id == face_id or adjacent_id in adjacent_ids: + continue + adjacent_ids.append(adjacent_id) + + radii: list[float] = [] + for adjacent_id in adjacent_ids: + surf = BRepAdaptor_Surface(model.faces[adjacent_id]) + if surf.GetType() != GeomAbs_Cylinder: + continue + radius = float(surf.Cylinder().Radius()) + if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii): + radii.append(radius) + return sorted(radii) + + +def _large_stepped_cap_face(model: StepModel) -> int: + best: tuple[float, int] | None = None + for face_id, face in enumerate(model.faces): + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + continue + if _wire_count(face) != 2: + continue + radii = _adjacent_cylinder_radii(model, face_id) + if len(radii) != 2: + continue + if abs(radii[0] - 4.1) > 1e-4 or abs(radii[1] - 15.0) > 1e-4: + continue + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + center = props.CentreOfMass() + score = abs(center.X() - 120.0) + abs(center.Y() + 39.0) + abs(center.Z() + 131.0) + if best is None or score < best[0]: + best = (score, face_id) + if best is None: + raise SystemExit("large stepped cap Face was not found in geom_extract.step") + return best[1] + + +def _large_multi_boundary_cap_face(model: StepModel) -> int: + best: tuple[float, int] | None = None + for face_id, face in enumerate(model.faces): + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + continue + if _wire_count(face) != 6: + continue + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + area = float(props.Mass()) + if area < 20000.0: + continue + center = props.CentreOfMass() + score = abs(center.Y() + 57.5) + abs(center.Z() + 249.7) * 0.02 + if best is None or score < best[0]: + best = (score, face_id) + if best is None: + raise SystemExit("large multi-boundary cap Face was not found in geom_extract.step") + return best[1] + + +def _face_topology_counts(model: StepModel, face_id: int) -> dict[str, int]: + topology = model.face_first_level_topology(face_id) + info = model.quick_face_info(face_id) + return { + "boundary_edges": int(topology.get("first_level_boundary_edge_count", 0) or 0), + "boundary_vertices": int(topology.get("first_level_boundary_vertex_count", 0) or 0), + "adjacent_faces": int(topology.get("first_level_adjacent_face_count", 0) or 0), + "inner_wires": int(info.get("inner_boundary_wires", 0) or 0), + } + + +def _assert_topology_preserved( + label: str, + before: dict[str, int], + after: dict[str, int], + *, + min_inner_wires: int | None = None, +) -> None: + for key in ("boundary_edges", "boundary_vertices", "adjacent_faces"): + if after[key] < before[key]: + raise SystemExit(f"{label} lost first-level {key}: before={before}, after={after}") + expected_inner_wires = before["inner_wires"] if min_inner_wires is None else min_inner_wires + if after["inner_wires"] < expected_inner_wires: + raise SystemExit(f"{label} lost inner boundary wires: before={before}, after={after}") + + +def main() -> int: + path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" + if not path.exists(): + print("large stepped cap fixture is absent; skipping") + return 0 + + model = StepModel.load(path) + face_id = _large_stepped_cap_face(model) + logical_id = model.face_region_logical_id(face_id) + before_topology = _face_topology_counts(model, face_id) + plan = model.push_pull_plan(face_id, 89.0) + if plan.get("cylindrical_cap_extension_kind") != "coaxial-stepped-cap": + raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}") + if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild": + raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}") + + started = time.perf_counter() + result = model.push_pull_face(face_id, 89.0) + elapsed = time.perf_counter() - started + if elapsed > 30.0: + raise SystemExit(f"large stepped cap push/pull took too long: {elapsed:.3f}s; result={result}") + if "local shell rebuild" not in result or "coaxial-stepped-cap" not in result: + raise SystemExit(f"large stepped cap push/pull should report local shell rebuild: {result}") + stats = model.stats() + if stats.solids != 1: + raise SystemExit(f"large stepped cap push/pull should keep one Solid: {stats}") + if "actual=50" not in result or "inner_wires=1" not in result: + raise SystemExit(f"large stepped cap push/pull should verify target cap and inner wire: {result}") + retained_ids = model.face_ids_for_logical_id(logical_id) + if len(retained_ids) != 1: + raise SystemExit(f"large stepped cap logical Face should map to one new cap: {retained_ids}") + retained_info = model.quick_face_info(retained_ids[0]) + retained_origin = retained_info.get("plane_origin") + retained_outward = retained_info.get("push_pull_outward_direction") or retained_info.get("normal") + retained_position = ( + sum(float(retained_origin[index]) * float(retained_outward[index]) for index in range(3)) + if isinstance(retained_origin, (tuple, list)) + and isinstance(retained_outward, (tuple, list)) + and len(retained_origin) == 3 + and len(retained_outward) == 3 + else None + ) + if retained_position is None or abs(retained_position - 50.0) > 1e-4: + raise SystemExit( + f"large stepped cap logical Face should follow the moved cap at 50: " + f"ids={retained_ids}, position={retained_position}" + ) + after_topology = _face_topology_counts(model, retained_ids[0]) + _assert_topology_preserved("large stepped cap", before_topology, after_topology) + + with tempfile.TemporaryDirectory(prefix="verify_large_stepped_cap_isolated_") 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(path), + "output_path": str(output_path), + "operation": "push_pull_face", + "args": [face_id, 89.0], + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + started = time.perf_counter() + completed = subprocess.run( + [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=60.0, + check=False, + ) + isolated_elapsed = time.perf_counter() - started + response_path = request_path.with_suffix(".response.json") + response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {} + if completed.returncode != 0 or not response.get("ok"): + raise SystemExit( + "large stepped cap isolated worker failed: " + f"returncode={completed.returncode}, stdout={completed.stdout!r}, " + f"stderr={completed.stderr!r}, response={response}" + ) + isolated_message = str(response.get("message") or "") + if isolated_elapsed > 30.0: + raise SystemExit( + f"large stepped cap isolated worker took too long: {isolated_elapsed:.3f}s; " + f"message={isolated_message}" + ) + if not output_path.exists(): + raise SystemExit("large stepped cap isolated worker did not export output STEP") + if "local shell rebuild" not in isolated_message or "actual=50" not in isolated_message: + raise SystemExit(f"large stepped cap isolated worker used the wrong path: {isolated_message}") + + multi_model = StepModel.load(path) + multi_face_id = _large_multi_boundary_cap_face(multi_model) + multi_logical_id = multi_model.face_region_logical_id(multi_face_id) + multi_before_topology = _face_topology_counts(multi_model, multi_face_id) + multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5) + if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9: + raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}") + if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9: + raise SystemExit(f"multi-boundary cap should target 92: {multi_plan}") + if multi_plan.get("planar_cap_extension_kind") != "multi-boundary-planar-cap": + raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}") + if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild": + raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}") + started = time.perf_counter() + multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0) + multi_inward_elapsed = time.perf_counter() - started + if multi_inward_elapsed > 5.0: + raise SystemExit( + f"multi-boundary cap inward push/pull plan should be quick: {multi_inward_elapsed:.3f}s" + ) + if multi_inward_plan.get("status") != "blocked": + raise SystemExit( + f"multi-boundary cap inward push/pull should be blocked until second-level propagation exists: " + f"{multi_inward_plan}" + ) + multi_inward_message = str(multi_inward_plan.get("message") or "") + if "二级关系" not in multi_inward_message or "通用 OCCT 布尔" not in multi_inward_message: + raise SystemExit( + f"multi-boundary cap inward blocker should explain topology depth and slow Boolean risk: " + f"{multi_inward_plan}" + ) + + started = time.perf_counter() + multi_result = multi_model.push_pull_face(multi_face_id, 34.5) + multi_elapsed = time.perf_counter() - started + if multi_elapsed > 15.0: + raise SystemExit( + f"multi-boundary cap push/pull took too long: {multi_elapsed:.3f}s; result={multi_result}" + ) + if "boundary-shell rebuild" not in multi_result: + raise SystemExit(f"multi-boundary cap push/pull should report boundary-shell rebuild: {multi_result}") + multi_stats = multi_model.stats() + if multi_stats.solids != 1: + raise SystemExit(f"multi-boundary cap push/pull should keep one Solid: {multi_stats}") + if "actual=92" not in multi_result or "inner_wires=5" not in multi_result: + raise SystemExit( + f"multi-boundary cap push/pull should verify target cap and inner wires: {multi_result}" + ) + multi_retained_ids = multi_model.face_ids_for_logical_id(multi_logical_id) + if len(multi_retained_ids) != 1: + raise SystemExit( + f"multi-boundary cap logical Face should map to one moved cap: {multi_retained_ids}" + ) + multi_retained_info = multi_model.quick_face_info(multi_retained_ids[0]) + multi_origin = multi_retained_info.get("plane_origin") + multi_outward = multi_retained_info.get("push_pull_outward_direction") or multi_retained_info.get("normal") + multi_position = ( + sum(float(multi_origin[index]) * float(multi_outward[index]) for index in range(3)) + if isinstance(multi_origin, (tuple, list)) + and isinstance(multi_outward, (tuple, list)) + and len(multi_origin) == 3 + and len(multi_outward) == 3 + else None + ) + if multi_position is None or abs(multi_position - 92.0) > 1e-4: + raise SystemExit( + f"multi-boundary cap logical Face should follow the moved cap at 92: " + f"ids={multi_retained_ids}, position={multi_position}" + ) + if int(multi_retained_info.get("inner_boundary_wires", 0) or 0) < 5: + raise SystemExit( + f"multi-boundary cap logical Face should keep inner boundary wires: {multi_retained_info}" + ) + multi_after_topology = _face_topology_counts(multi_model, multi_retained_ids[0]) + _assert_topology_preserved( + "multi-boundary cap", + multi_before_topology, + multi_after_topology, + min_inner_wires=5, + ) + + with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") 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(path), + "output_path": str(output_path), + "operation": "push_pull_face", + "args": [multi_face_id, 34.5], + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + started = time.perf_counter() + completed = subprocess.run( + [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=60.0, + check=False, + ) + multi_isolated_elapsed = time.perf_counter() - started + response_path = request_path.with_suffix(".response.json") + response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {} + if completed.returncode != 0 or not response.get("ok"): + raise SystemExit( + "large multi-boundary cap isolated worker failed: " + f"returncode={completed.returncode}, stdout={completed.stdout!r}, " + f"stderr={completed.stderr!r}, response={response}" + ) + multi_isolated_message = str(response.get("message") or "") + if multi_isolated_elapsed > 30.0: + raise SystemExit( + f"large multi-boundary cap isolated worker took too long: {multi_isolated_elapsed:.3f}s; " + f"message={multi_isolated_message}" + ) + if not output_path.exists(): + raise SystemExit("large multi-boundary cap isolated worker did not export output STEP") + if "boundary-shell rebuild" not in multi_isolated_message or "actual=92" not in multi_isolated_message: + raise SystemExit( + f"large multi-boundary cap isolated worker used the wrong path: {multi_isolated_message}" + ) + + print( + "large stepped cap push/pull ok: " + f"face_id={face_id}, elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, " + f"topology_before={before_topology}, topology_after={after_topology}, result={result}" + ) + print( + "large multi-boundary cap push/pull ok: " + f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, " + f"isolated_elapsed={multi_isolated_elapsed:.3f}s, " + f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}" + ) + 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 789c0c8..3d3a5da 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -9,6 +9,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.window_state import WindowStateMixin +from step_editor.ui_helpers import _format_value class _PropertySpecProbe(WindowStateMixin): @@ -315,6 +316,10 @@ def _assert_holed_plane_local_scopes_disabled() -> None: def main() -> int: + relation_depths_text = _format_value(("second-level", "third-level", "deeper")) + if "second-level" not in relation_depths_text or "deeper" not in relation_depths_text: + raise SystemExit(f"relation-depth tuple should render as text: {relation_depths_text!r}") + plane_info = { "surface": "plane", "area": 100.0, @@ -485,6 +490,68 @@ def main() -> int: _assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature") _assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature") + cylinder_topology_info = { + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 6.283185307179586, + "area": 188.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "axis": (0.0, 0.0, 1.0), + "axis_point": (0.0, 0.0, 0.0), + "axis_center": (0.0, 0.0, 5.0), + "topology_relation_depth": 1, + "topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level", + "topology_relation_status": "ready", + "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", + "cylindrical_feature_side_face_count": 1, + "cylindrical_feature_boundary_edge_count": 2, + "cylindrical_feature_boundary_vertex_count": 4, + "cylindrical_feature_adjacent_face_count": 2, + "cylindrical_feature_end_face_count": 2, + "cylindrical_feature_opening_face_count": 2, + "first_level_topology_note": "Cylindrical side Faces=1, boundary Edges=2, boundary Vertices=4, direct adjacent Faces=2.", + } + cylinder_topology_specs = _specs(cylinder_topology_info) + cylinder_topology_spec = _spec(cylinder_topology_specs, "cylindrical_feature_first_level_topology") + cylinder_topology_text = str(cylinder_topology_spec.get("current_text") or "") + for fragment in ("侧壁 Face 1 个", "边界 Edge 2 条", "共享边相邻 Face 2 个"): + if fragment not in cylinder_topology_text: + raise SystemExit(f"cylindrical feature topology row should explain first-level counts: {cylinder_topology_spec}") + if "二级、三级" not in str(cylinder_topology_spec.get("disabled_tip") or ""): + raise SystemExit(f"cylindrical feature topology tip should document ignored deeper topology: {cylinder_topology_spec}") + cylinder_feature_probe = _PropertySpecProbe() + cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info) + cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info) + _spec(cylinder_feature_rows, "cylindrical_feature_first_level_topology") + + generic_cylinder_specs = _specs( + { + "surface": "cylinder", + "feature_guess": "", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 6.283185307179586, + "height_estimate": 11.0, + "same_domain_height_estimate": 11.0, + "area": 207.0, + "area_center": (0.0, 0.0, 5.5), + "bbox_center": (0.0, 0.0, 5.5), + "axis": (0.0, 0.0, 1.0), + "axis_point": (0.0, 0.0, 0.0), + "axis_center": (0.0, 0.0, 5.5), + } + ) + generic_height = _spec(generic_cylinder_specs, "cylinder_height") + if generic_height.get("scope_default") != "owning": + raise SystemExit(f"generic cylinder height should default to owning-axis resize: {generic_height}") + if generic_height.get("action") != "resize_cylindrical_height_owning_scale": + raise SystemExit(f"generic cylinder height should use owning-axis action by default: {generic_height}") + if generic_height.get("scope_text") != "调整整个特征": + raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}") + slot_keys = _spec_keys( { "surface": "cylinder", @@ -510,26 +577,31 @@ def main() -> int: "slot/half-hole feature", ) - boss_keys = _spec_keys( - { - "surface": "cylinder", - "feature_guess": "boss/outer-round candidate", - "diameter": 6.0, - "radius": 3.0, - "angular_span": 6.283185307179586, - "height_estimate": 5.0, - "same_domain_height_estimate": 5.0, - "area": 188.0, - "area_center": (0.0, 0.0, 0.0), - "bbox_center": (0.0, 0.0, 0.0), - "axis": (0.0, 0.0, 1.0), - "axis_point": (0.0, 0.0, 0.0), - "axis_center": (0.0, 0.0, 5.0), - "feature_start_end_face_ids": (1,), - } - ) + boss_info = { + "surface": "cylinder", + "feature_guess": "boss/outer-round candidate", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 6.283185307179586, + "height_estimate": 5.0, + "same_domain_height_estimate": 5.0, + "area": 188.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "axis": (0.0, 0.0, 1.0), + "axis_point": (0.0, 0.0, 0.0), + "axis_center": (0.0, 0.0, 5.0), + "feature_start_end_face_ids": (1,), + } + boss_specs = _specs(boss_info) + boss_keys = {str(spec.get("key", "")) for spec in boss_specs} _assert_no_generic_face_leak(boss_keys, "boss feature") _assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature") + boss_height = _spec(boss_specs, "boss_height") + if boss_height.get("scope_default") != "owning": + raise SystemExit(f"boss height should default to owning-axis resize: {boss_height}") + if boss_height.get("action") != "resize_cylindrical_height_owning_scale": + raise SystemExit(f"boss height should use owning-axis action by default: {boss_height}") fillet_keys = _spec_keys( { @@ -609,6 +681,28 @@ def main() -> int: if surface_spec.get("current_text") != "圆锥面 / 拔模面": raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}") + low_recognition_specs = _specs( + { + "surface": "sphere", + "radius": 5.0, + "diameter": 10.0, + "area": 100.0, + "area_center": (0.0, 0.0, 0.0), + "bbox_center": (0.0, 0.0, 0.0), + "center": (0.0, 0.0, 0.0), + "recognition_score": 24, + "recognition_confidence": "low", + "recognition_risk": "medium", + "recognition_blockers": "ambiguous analytic surface", + } + ) + for key in ("sphere_radius", "sphere_diameter"): + spec = _spec(low_recognition_specs, key) + if spec.get("enabled"): + raise SystemExit(f"{key} should be read-only when analytic surface recognition is low: {spec}") + if "ambiguous analytic surface" not in str(spec.get("disabled_tip") or ""): + raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}") + _assert_target_change_detection() _assert_holed_plane_local_scopes_disabled() _assert_no_legacy_face_source_terms() diff --git a/step_editor/app.py b/step_editor/app.py index c8ea75b..a43af79 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -214,6 +214,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edit_thread: QThread | None = None self.edit_worker: EditWorker | None = None self.pending_edit_context: dict[str, object] | None = None + self.active_isolated_edit_process = None + self.isolated_edit_cancel_requested = False + self.close_after_edit_cancel = False self.scan_in_progress = False self.scan_thread: QThread | None = None self.scan_worker: ScanWorker | None = None diff --git a/step_editor/features.py b/step_editor/features.py index 41e98f6..9fd8d95 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -64,7 +64,41 @@ from .geometry_utils import * # noqa: F403 class FeatureMixin: + def _first_level_fact_plan_fields(self, face_id: int, scope: str) -> dict[str, object]: + try: + return self.face_first_level_facts(face_id, scope=scope) + except Exception as exc: + resolved_scope = str(scope or "face") + return { + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_source_model": resolved_scope, + "first_level_fact_status": "unavailable", + "first_level_fact_relation_depth": 1, + "first_level_fact_relation_boundary": "shared-edge", + "first_level_fact_scope": resolved_scope, + "first_level_fact_subject_role": "selected Face", + "first_level_fact_subject_face_ids": (face_id,), + "first_level_fact_subject_face_count": 1, + "first_level_fact_boundary_edge_ids": (), + "first_level_fact_boundary_edge_count": 0, + "first_level_fact_boundary_vertex_points": (), + "first_level_fact_boundary_vertex_count": 0, + "first_level_fact_adjacent_face_ids": (), + "first_level_fact_adjacent_face_count": 0, + "first_level_fact_adjacent_surface_types": (), + "first_level_fact_shared_edges_by_face": (), + "first_level_fact_included_face_ids": (face_id,), + "first_level_fact_included_face_count": 1, + "first_level_fact_role_groups": (), + "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "first_level_fact_ignored_relation_note": ( + "Only first-level shared-edge relations are considered in this stage." + ), + "first_level_fact_summary": f"当前 Face 的一级事实图暂时无法生成:{exc}", + } + def _cylindrical_feature_first_level_plan_fields(self, face_id: int) -> dict[str, object]: + fact_fields = self._first_level_fact_plan_fields(face_id, "cylindrical-feature") try: topology = self.cylindrical_feature_first_level_topology(face_id) except Exception as exc: @@ -80,6 +114,7 @@ class FeatureMixin: "first_level_adjacent_face_count": 0, "cylindrical_feature_side_face_ids": (face_id,), "cylindrical_feature_side_face_count": 1, + **fact_fields, } fields = { @@ -121,6 +156,7 @@ class FeatureMixin: 0, ), } + fields.update(fact_fields) 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; " @@ -2394,6 +2430,8 @@ class FeatureMixin: "solid_id": info["solid_id"], "diameter": info.get("diameter"), "radius": info.get("radius"), + "axis": info.get("axis"), + "axis_point": info.get("axis_point"), "angular_span": info.get("angular_span"), "height_estimate": scoped_info.get("height_estimate"), "feature_type": feature.get("feature_type"), @@ -3067,11 +3105,110 @@ class FeatureMixin: ) return samples + def _cylindrical_cap_push_pull_direction( + self, + face_id: int, + surf: BRepAdaptor_Surface, + ) -> dict[str, object] | None: + if face_id < 0 or face_id >= len(self.faces): + return None + if surf.GetType() != GeomAbs_Plane: + return None + + plane_point = surf.Plane().Location() + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) + if not adjacent_face_ids: + return None + + candidates: list[tuple[float, tuple[float, float, float], dict[str, object]]] = [] + diagonal = _shape_diagonal(self.shape) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + for adjacent_id in adjacent_face_ids: + if adjacent_id < 0 or adjacent_id >= len(self.faces): + continue + try: + side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) + except Exception: + continue + if side_surf.GetType() != GeomAbs_Cylinder: + continue + + cylinder = side_surf.Cylinder() + radius = max(float(cylinder.Radius()), 0.0) + axis = cylinder.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + try: + axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) + except Exception: + axis_range = { + "v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())), + "v_max": max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())), + } + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + height = max(v_max - v_min, 1e-9) + cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point) + start_distance = abs(cap_parameter - v_min) + end_distance = abs(cap_parameter - v_max) + end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + if start_distance <= end_distance and start_distance <= end_tolerance: + outward = _neg_tuple(_dir_tuple(axis_dir)) + end_label = "start" + score = start_distance + elif end_distance <= end_tolerance: + outward = _dir_tuple(axis_dir) + end_label = "end" + score = end_distance + else: + continue + candidates.append( + ( + score, + outward, + { + "cap_axis_face_id": adjacent_id, + "cap_axis_end": end_label, + "cap_axis_parameter": cap_parameter, + "cap_axis_start_parameter": v_min, + "cap_axis_end_parameter": v_max, + }, + ) + ) + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + _score, outward, details = candidates[0] + for _other_score, other_outward, _other_details in candidates[1:]: + if _tuple_dot(outward, other_outward) < 0.92: + return None + return { + "outward_direction": outward, + "inward_direction": _neg_tuple(outward), + "plus_side_state": "cylindrical-cap-axis", + "minus_side_state": "cylindrical-cap-axis", + "confidence": "high", + "note": "cylindrical cap direction inferred from adjacent cylinder axis", + "push_pull_outward_direction": outward, + "push_pull_inward_direction": _neg_tuple(outward), + "push_pull_plus_side": "cylindrical-cap-axis", + "push_pull_minus_side": "cylindrical-cap-axis", + "push_pull_confidence": "high", + "push_pull_note": "cylindrical cap direction inferred from adjacent cylinder axis", + **details, + } + def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: face = self.faces[face_id] direction = surf.Plane().Axis().Direction() axis_tuple = _dir_tuple(direction) oriented_tuple = _oriented_dir_tuple(direction, face) + cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf) + if cap_direction is not None: + return cap_direction fallback = { "outward_direction": oriented_tuple, "inward_direction": _neg_tuple(oriented_tuple), diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index 3201c10..60e67a0 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -33,6 +33,12 @@ 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_cylindrical_height": + return model.resize_cylindrical_height(int(args[0]), float(args[1])) + if operation == "resize_cylindrical_boss_height": + return model.resize_cylindrical_boss_height(int(args[0]), float(args[1])) + if operation == "resize_cylindrical_height_owning_scale": + return model.resize_cylindrical_height_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": diff --git a/step_editor/model.py b/step_editor/model.py index c3428b3..9f94881 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -113,6 +113,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd 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._face_first_level_fact_cache: dict[tuple[int, str], 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 @@ -196,6 +197,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._same_domain_face_ids_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() + self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() self._edge_duplicate_key_ids_cache = None self._same_domain_internal_edge_ids_cache = None @@ -290,6 +292,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._same_domain_face_ids_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() + self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool: @@ -303,6 +306,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._same_domain_face_ids_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() + self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() return True @@ -349,12 +353,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["push_pull_confidence"] = "unchecked" info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。" + cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf) + if cap_direction is not None: + info.update(cap_direction) info["push_pull_status"] = "candidate" info["feature_type"] = "可推拉平面候选" info["feature_source_face_id"] = face_id info["feature_highlight_face_ids"] = (face_id,) info["feature_edit_actions"] = "推拉平面" - info.update(self._local_face_deform_readiness(face_id)) + if bool(info.get("has_inner_boundaries")): + info["local_face_deform_ready"] = False + info["local_face_deform_face_count"] = 1 + info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用推拉当前面、孔或槽的专门修改入口。" + else: + info["local_face_deform_ready"] = True + info["local_face_deform_face_count"] = 1 + info["local_face_deform_blocker"] = "" + info["local_face_deform_status"] = "deferred" info.update( self._local_face_plane_size_info( face_id, @@ -416,6 +431,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["feature_torus_major_radius"] = torus.MajorRadius() info["feature_torus_minor_radius"] = torus.MinorRadius() + info.update(self._recognition_summary_fields(info)) self._quick_face_info_cache[face_id] = dict(info) return dict(info) @@ -512,6 +528,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["axis"] = _dir_tuple(torus.Axis().Direction()) info["major_radius"] = torus.MajorRadius() info["minor_radius"] = torus.MinorRadius() + info.update(self._recognition_summary_fields(info)) self._face_info_cache[face_id] = dict(info) return dict(info) @@ -545,6 +562,267 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd except Exception: return None + def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]: + surface = str(info.get("surface") or "") + candidate = ( + str(info.get("feature_type") or "").strip() + or str(info.get("feature_guess") or "").strip() + or (f"{surface} Face" if surface else "Face") + ) + confidence = ( + str(info.get("confidence") or "").strip() + or str(info.get("push_pull_confidence") or "").strip() + or str(info.get("shell_confidence") or "").strip() + or "unchecked" + ) + if confidence == "unchecked": + feature_guess = str(info.get("feature_guess") or "") + has_axis = info.get("axis") not in {None, ""} or info.get("axis_point") not in {None, ""} + if surface == "plane" and info.get("boundary_edges") not in {None, ""}: + confidence = "high" + elif surface == "cylinder" and _float_or_none(info.get("radius")) is not None and has_axis: + confidence = "high" if feature_guess else "medium" + elif surface == "cone" and _float_or_none(info.get("reference_radius")) is not None and _float_or_none(info.get("semi_angle")) is not None: + confidence = "medium" + elif surface == "sphere" and _float_or_none(info.get("radius")) is not None: + confidence = "high" + elif surface == "torus" and _float_or_none(info.get("major_radius")) is not None and _float_or_none(info.get("minor_radius")) is not None: + confidence = "high" + + risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} + capability_specs = ( + ("resize_status", "resize_risk", "resize_blockers", "孔/槽/圆柱直径"), + ("push_pull_status", "push_pull_risk", "push_pull_blockers", "平面推拉"), + ("shell_status", "shell_risk", "shell_blockers", "薄壁厚度"), + ("cylinder_resize_status", "cylinder_resize_risk", "cylinder_resize_blockers", "圆柱直径/半径"), + ("boss_resize_status", "boss_resize_risk", "boss_resize_blockers", "圆柱凸台直径/高度/轴心"), + ("depth_status", "depth_risk", "depth_blockers", "盲孔/盲槽深度"), + ("suppress_status", "suppress_risk", "suppress_blockers", "封堵孔/槽"), + ("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"), + ("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"), + ) + available_capabilities = { + status_key + for status_key, _risk_key, _blocker_key, _label in capability_specs + if str(info.get(status_key) or "").strip() in {"ready", "caution", "candidate"} + } + feature_type_text = str(info.get("feature_type") or "") + feature_actions_text = str(info.get("feature_edit_actions") or "") + has_planar_push_pull_candidate = ( + surface == "plane" + and ( + "推拉" in feature_type_text + or "推拉" in feature_actions_text + or str(info.get("push_pull_status") or "").strip() == "candidate" + ) + ) + has_local_face_deform = bool(info.get("local_face_deform_ready")) + has_slot_candidate = str(info.get("slot_status") or "").strip() == "candidate" + has_shell_candidate = str(info.get("shell_region_status") or "").strip() == "candidate" + has_analytic_surface_candidate = surface in {"cone", "sphere", "torus"} and bool(feature_type_text) + has_available_capability = bool( + available_capabilities + or has_planar_push_pull_candidate + or has_local_face_deform + or has_slot_candidate + or has_shell_candidate + or has_analytic_surface_candidate + ) + risk = "low" + for key in ( + "risk", + "first_level_topology_risk", + ): + value = str(info.get(key) or "").strip() + if risk_rank.get(value, -1) > risk_rank.get(risk, -1): + risk = value + for status_key, risk_key, _blocker_key, _label in capability_specs: + value = str(info.get(risk_key) or "").strip() + if value == "blocked" and has_available_capability: + continue + if risk_rank.get(value, -1) > risk_rank.get(risk, -1): + risk = value + if confidence in {"low", "unchecked", "none"} and risk == "low": + risk = "medium" + + evidence: list[str] = [] + evidence_keys: list[str] = [] + + def add(key: str, text: str) -> None: + if text and key not in evidence_keys: + evidence_keys.append(key) + evidence.append(text) + + if surface: + add("surface", f"曲面={surface}") + if surface == "cylinder" and info.get("radius") not in {None, ""}: + add("cylinder_geometry", f"圆柱半径={info.get('radius')}") + if surface == "cone": + if info.get("reference_radius") not in {None, ""}: + add("cone_geometry", f"圆锥参考半径={info.get('reference_radius')}") + if info.get("semi_angle") not in {None, ""}: + add("cone_angle", f"圆锥半角={info.get('semi_angle')}") + if surface == "sphere" and info.get("radius") not in {None, ""}: + add("sphere_geometry", f"球半径={info.get('radius')}") + if surface == "torus": + if info.get("major_radius") not in {None, ""}: + add("torus_major_radius", f"环面主半径={info.get('major_radius')}") + if info.get("minor_radius") not in {None, ""}: + add("torus_minor_radius", f"环面小半径={info.get('minor_radius')}") + if info.get("boundary_edges") not in {None, ""}: + add("boundary_edges", f"边界Edge={info.get('boundary_edges')}") + if info.get("same_domain_face_count") not in {None, ""}: + add("same_domain", f"同域Face={info.get('same_domain_face_count')}") + if info.get("first_level_adjacent_face_count") not in {None, ""}: + add("first_level_topology", f"一级相邻Face={info.get('first_level_adjacent_face_count')}") + elif info.get("feature_adjacent_face_ids") not in {None, ""}: + try: + adjacent_count = len(tuple(info.get("feature_adjacent_face_ids") or ())) + except TypeError: + adjacent_count = 0 + add("first_level_topology", f"一级相邻Face={adjacent_count}") + if info.get("first_level_fact_summary") not in {None, ""}: + add("first_level_fact_graph", f"一级事实={info.get('first_level_fact_summary')}") + if info.get("material_vote_summary") not in {None, ""}: + add("material_votes", f"材料采样={info.get('material_vote_summary')}") + if info.get("feature_end_face_ids") not in {None, ""}: + try: + end_count = len(tuple(info.get("feature_end_face_ids") or ())) + except TypeError: + end_count = 0 + add("end_faces", f"端面Face={end_count}") + if info.get("feature_bottom_face_ids") not in {None, ""}: + try: + bottom_count = len(tuple(info.get("feature_bottom_face_ids") or ())) + except TypeError: + bottom_count = 0 + add("bottom_faces", f"疑似底面Face={bottom_count}") + if info.get("slot_status") == "candidate": + add("slot_geometry", "部分圆柱槽/半孔几何") + if info.get("shell_region_status") == "candidate": + add("shell_opposite_face", "找到相对平面/薄壁候选") + + ready_actions: list[str] = [] + limited_actions: list[str] = [] + blockers: list[str] = [] + limitations: list[str] = [] + + def add_unique(items: list[str], text: str) -> None: + if text and text not in items: + items.append(text) + + def add_action(items: list[str], text: str) -> None: + if text and text not in items: + items.append(text) + + for status_key, _risk_key, blocker_key, label in capability_specs: + status = str(info.get(status_key) or "").strip() + blocker_text = str(info.get(blocker_key) or "").strip() + if status in {"ready", "caution", "candidate"}: + add_action(ready_actions, label) + elif status == "blocked" and blocker_text: + add_action(limited_actions, label) + + if has_planar_push_pull_candidate: + add_action(ready_actions, "平面推拉") + if has_local_face_deform: + add_action(ready_actions, "当前面面积/面宽/面高/中心/偏移") + elif str(info.get("local_face_deform_blocker") or "").strip(): + add_action(limited_actions, "当前面局部尺寸/中心/偏移") + if has_shell_candidate: + add_action(ready_actions, "薄壁厚度") + if has_slot_candidate: + add_action(ready_actions, "槽/半孔宽度/深度/弧长") + if surface == "cone" and feature_type_text: + add_action(ready_actions, "圆锥参考半径/直径/半角") + elif surface == "sphere" and feature_type_text: + add_action(ready_actions, "球面半径/直径") + elif surface == "torus" and feature_type_text: + add_action(ready_actions, "环面主/小半径或直径") + + for key in ("local_face_deform_blocker", "first_level_topology_blockers"): + text = str(info.get(key) or "").strip() + if not text: + continue + if has_available_capability: + add_unique(limitations, text) + else: + add_unique(blockers, text) + for status_key, _risk_key, blocker_key, _label in capability_specs: + text = str(info.get(blocker_key) or "").strip() + if not text: + continue + status = str(info.get(status_key) or "").strip() + if status == "blocked" and has_available_capability: + add_unique(limitations, text) + else: + add_unique(blockers, text) + + note = ( + str(info.get("feature_mode") or "").strip() + or str(info.get("note") or "").strip() + or str(info.get("push_pull_note") or "").strip() + ) + if note: + add("note", note) + + confidence_points = {"high": 72, "medium": 56, "low": 34, "unchecked": 22, "none": 0} + risk_penalty = {"low": 0, "medium": 14, "high": 30, "blocked": 72} + score = confidence_points.get(confidence, 22) + score += min(len(evidence_keys) * 5, 24) + score -= risk_penalty.get(risk, 14) + if blockers: + score -= 35 + elif limitations: + score -= min(len(limitations) * 4, 12) + score = max(0, min(100, int(round(score)))) + + if risk == "blocked" or blockers: + decision = "已阻止" + elif score >= 76 and risk == "low": + decision = "高可信候选" + elif score >= 56: + decision = "可尝试候选" + elif score >= 36: + decision = "需人工确认" + else: + decision = "不建议自动修改" + + summary_parts = [candidate, f"置信度={confidence}", f"风险={risk}", f"评分={score}", f"结论={decision}"] + if evidence: + summary_evidence = list(evidence[:5]) + if "first_level_fact_graph" in evidence_keys: + fact_text = evidence[evidence_keys.index("first_level_fact_graph")] + if fact_text not in summary_evidence: + if len(summary_evidence) >= 5: + summary_evidence[-1] = fact_text + else: + summary_evidence.append(fact_text) + summary_parts.append("证据:" + ";".join(summary_evidence)) + if ready_actions: + summary_parts.append("可改:" + ";".join(ready_actions[:4])) + if blockers: + summary_parts.append("限制:" + ";".join(blockers[:2])) + elif limitations: + summary_parts.append("受限能力:" + ";".join(limitations[:2])) + if limited_actions: + summary_parts.append("受限修改:" + ";".join(limited_actions[:4])) + + return { + "recognition_candidate": candidate, + "recognition_confidence": confidence, + "recognition_risk": risk, + "recognition_score": score, + "recognition_decision": decision, + "recognition_evidence": ";".join(evidence), + "recognition_evidence_keys": tuple(evidence_keys), + "recognition_ready_actions": ";".join(ready_actions), + "recognition_limited_actions": ";".join(limited_actions), + "recognition_blockers": ";".join(blockers), + "recognition_limitations": ";".join(limitations), + "recognition_summary": ";".join(summary_parts), + } + def cached_feature_info(self, face_id: int) -> dict[str, object] | None: cached = self._feature_info_cache.get(face_id) return dict(cached) if cached is not None else None @@ -584,6 +862,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ), } ) + result.update(self._recognition_summary_fields(result)) self._feature_info_cache[face_id] = dict(result) return dict(result) @@ -702,11 +981,9 @@ 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": ( @@ -1824,6 +2101,133 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._cylindrical_first_level_topology_cache[item] = dict(topology) return dict(topology) + def face_first_level_facts(self, face_id: int, scope: str = "auto") -> dict[str, object]: + """Return a unified first-level fact graph for recognition and UI. + + This is intentionally a fact layer, not a feature-history guess. It + normalizes the selected region, boundary Edges/Vertices, direct + shared-edge neighbors, and ignored deeper relation depths so Face, + cylinder, hole, slot, and later feature recognizers can share the same + evidence contract. + """ + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + requested_scope = str(scope or "auto") + surface = self.face_surface_kind(face_id) + resolved_scope = requested_scope + if requested_scope == "auto": + resolved_scope = "cylindrical-feature" if surface == "cylinder" else "face" + if resolved_scope not in {"face", "cylindrical-feature"}: + raise ValueError(f"Unknown first-level fact scope: {scope}") + cache_key = (int(face_id), resolved_scope) + cached = self._face_first_level_fact_cache.get(cache_key) + if cached is not None: + return dict(cached) + + if resolved_scope == "cylindrical-feature": + topology = self.cylindrical_feature_first_level_topology(face_id) + subject_face_ids = tuple(_int_values(topology.get("cylindrical_feature_side_face_ids"))) or (face_id,) + boundary_edge_ids = tuple(_int_values(topology.get("cylindrical_feature_boundary_edge_ids"))) + boundary_vertex_points = tuple(topology.get("cylindrical_feature_boundary_vertex_points") or ()) + adjacent_face_ids = tuple(_int_values(topology.get("cylindrical_feature_adjacent_face_ids"))) + included_face_ids = tuple(_int_values(topology.get("cylindrical_feature_first_level_face_ids"))) or tuple( + sorted({*subject_face_ids, *adjacent_face_ids}) + ) + role_groups = ( + { + "role": "cylindrical-side", + "face_ids": subject_face_ids, + "count": len(subject_face_ids), + }, + { + "role": "direct-adjacent", + "face_ids": adjacent_face_ids, + "count": len(adjacent_face_ids), + }, + { + "role": "end/opening", + "face_ids": tuple(_int_values(topology.get("cylindrical_feature_end_face_ids"))), + "count": int(topology.get("cylindrical_feature_end_face_count", 0) or 0), + }, + { + "role": "blind-bottom", + "face_ids": tuple(_int_values(topology.get("cylindrical_feature_bottom_face_ids"))), + "count": int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0), + }, + { + "role": "slot-boundary", + "face_ids": tuple(_int_values(topology.get("cylindrical_feature_slot_boundary_face_ids"))), + "count": int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0), + }, + ) + subject_role = "cylindrical side region" + source_model = "cylindrical-feature" + else: + topology = self.face_first_level_topology(face_id) + subject_face_ids = tuple(_int_values(topology.get("same_domain_face_ids"))) or (face_id,) + boundary_edge_ids = tuple(_int_values(topology.get("first_level_boundary_edge_ids"))) + boundary_vertex_points = tuple(topology.get("first_level_boundary_vertex_points") or ()) + adjacent_face_ids = tuple(_int_values(topology.get("first_level_adjacent_face_ids"))) + included_face_ids = tuple(_int_values(topology.get("first_level_face_ids"))) or tuple( + sorted({*subject_face_ids, *adjacent_face_ids}) + ) + role_groups = ( + { + "role": "selected-same-domain-region", + "face_ids": subject_face_ids, + "count": len(subject_face_ids), + }, + { + "role": "direct-adjacent", + "face_ids": adjacent_face_ids, + "count": len(adjacent_face_ids), + }, + ) + subject_role = "selected same-domain Face region" + source_model = "face" + + adjacent_surface_types = tuple(topology.get("first_level_adjacent_surface_types") or ()) or tuple( + topology.get("cylindrical_feature_adjacent_surface_types") or () + ) + shared_edges = tuple(topology.get("first_level_shared_edges_by_face") or ()) or tuple( + topology.get("cylindrical_feature_shared_edges_by_face") or () + ) + ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level")) + summary = ( + f"{subject_role}: Face {len(subject_face_ids)}, boundary Edge {len(boundary_edge_ids)}, " + f"boundary Vertex {len(boundary_vertex_points)}, direct adjacent Face {len(adjacent_face_ids)}; " + "deeper relations are recorded as future propagation targets, not edited automatically." + ) + facts = { + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_source_model": source_model, + "first_level_fact_status": "ready", + "first_level_fact_relation_depth": 1, + "first_level_fact_relation_boundary": "shared-edge", + "first_level_fact_scope": resolved_scope, + "first_level_fact_subject_role": subject_role, + "first_level_fact_subject_face_ids": tuple(sorted(set(subject_face_ids))), + "first_level_fact_subject_face_count": len(set(subject_face_ids)), + "first_level_fact_boundary_edge_ids": tuple(sorted(set(boundary_edge_ids))), + "first_level_fact_boundary_edge_count": len(set(boundary_edge_ids)), + "first_level_fact_boundary_vertex_points": boundary_vertex_points, + "first_level_fact_boundary_vertex_count": len(boundary_vertex_points), + "first_level_fact_adjacent_face_ids": tuple(sorted(set(adjacent_face_ids))), + "first_level_fact_adjacent_face_count": len(set(adjacent_face_ids)), + "first_level_fact_adjacent_surface_types": adjacent_surface_types, + "first_level_fact_shared_edges_by_face": shared_edges, + "first_level_fact_included_face_ids": tuple(sorted(set(included_face_ids))), + "first_level_fact_included_face_count": len(set(included_face_ids)), + "first_level_fact_role_groups": role_groups, + "first_level_fact_ignored_relation_depths": ignored_depths, + "first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""), + "first_level_fact_summary": summary, + } + for item in facts["first_level_fact_subject_face_ids"]: + self._face_first_level_fact_cache[(int(item), resolved_scope)] = dict(facts) + self._face_first_level_fact_cache[cache_key] = dict(facts) + return dict(facts) + def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]: try: boundary_wires = len(_explore(face, TopAbs_WIRE)) @@ -2109,6 +2513,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd source_solid_id = self.face_solid_ids[face_id] tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + visited = {face_id} + queue = [face_id] + while queue: + current_id = queue.pop(0) + for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): + if adjacent_id in visited: + continue + if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: + continue + candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) + if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): + visited.add(adjacent_id) + queue.append(adjacent_id) + shared_edge_result = sorted(visited) + if len(self.faces) > 600 or len(shared_edge_result) > 1: + return shared_edge_result + interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4) plane = source_surf.Plane() axis_point = plane.Location() @@ -2141,20 +2562,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd visited.add(candidate_id) queue.append(candidate_id) return sorted(visited) - - visited = {face_id} - queue = [face_id] - while queue: - current_id = queue.pop(0) - for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): - if adjacent_id in visited: - continue - if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: - continue - candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) - if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): - visited.add(adjacent_id) - queue.append(adjacent_id) return sorted(visited) def connected_same_domain_face_ids(self, face_id: int) -> list[int]: @@ -2189,9 +2596,25 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd radius = max(float(cylinder.Radius()), 0.0) diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + visited = {face_id} + queue = [face_id] + while queue: + current_id = queue.pop(0) + for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): + if adjacent_id in visited: + continue + if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: + continue + candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) + if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance): + visited.add(adjacent_id) + queue.append(adjacent_id) + shared_edge_result = sorted(visited) + if len(self.faces) > 600 or len(shared_edge_result) > 1: + return shared_edge_result + interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3) source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir) - candidates: dict[int, tuple[float, float]] = {} for candidate_id, face in enumerate(self.faces): if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: @@ -2220,20 +2643,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd visited.add(candidate_id) queue.append(candidate_id) return sorted(visited) - - visited = {face_id} - queue = [face_id] - while queue: - current_id = queue.pop(0) - for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): - if adjacent_id in visited: - continue - if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: - continue - candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) - if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance): - visited.add(adjacent_id) - queue.append(adjacent_id) return sorted(visited) def _cylindrical_axis_range( diff --git a/step_editor/operations.py b/step_editor/operations.py index ed42540..b32d8b1 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -116,6 +116,21 @@ def _format_result_number(value: object) -> str: class OperationMixin: def _face_first_level_plan_fields(self, face_id: int) -> dict[str, object]: + boundary_info: dict[str, object] = {} + try: + source_info = self.quick_face_info(face_id) + boundary_info = { + "selected_boundary_wires": source_info.get("boundary_wires", 0), + "selected_inner_boundary_wires": source_info.get("inner_boundary_wires", 0), + "selected_has_inner_boundaries": bool(source_info.get("has_inner_boundaries")), + } + except Exception: + boundary_info = { + "selected_boundary_wires": 0, + "selected_inner_boundary_wires": 0, + "selected_has_inner_boundaries": False, + } + fact_fields = self._first_level_fact_plan_fields(face_id, "face") try: topology = self.face_first_level_topology(face_id) except Exception as exc: @@ -131,6 +146,8 @@ class OperationMixin: "first_level_boundary_vertex_count": 0, "same_domain_face_ids": (face_id,), "same_domain_face_count": 1, + **boundary_info, + **fact_fields, } fields = { "topology_relation_depth": topology.get("topology_relation_depth", 1), @@ -155,13 +172,73 @@ class OperationMixin: "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", ""), + **boundary_info, } + fields.update(fact_fields) fields["first_level_edit_semantics"] = ( "当前 Face 阶段只使用一级共享边拓扑:当前同域 Face 区域会作为编辑对象," "直接相邻 Face 会跟随重建或作为推拉侧壁参与结果校验;二级/三级关系暂不递归传播。" ) return fields + def _face_first_level_plan_blockers( + self, + fields: dict[str, object], + *, + operation_label: str, + require_adjacent: bool = True, + ) -> list[str]: + blockers: list[str] = [] + topology_status = str(fields.get("topology_relation_status") or "") + fact_status = str(fields.get("first_level_fact_status") or "") + fact_scope = str(fields.get("first_level_fact_scope") or "") + fact_boundary = str(fields.get("first_level_fact_relation_boundary") or "") + try: + fact_depth = int(fields.get("first_level_fact_relation_depth", 0) or 0) + except (TypeError, ValueError): + fact_depth = 0 + + if topology_status != "ready": + message = str(fields.get("topology_relation_message") or "").strip() + blockers.append( + f"{operation_label}需要先确认当前 Face 的一级共享边拓扑;当前拓扑关系不可用。" + + (f" 原因:{message}" if message else "") + ) + if fact_status != "ready" or fact_scope != "face" or fact_depth != 1 or fact_boundary != "shared-edge": + blockers.append( + f"{operation_label}需要当前 Face 的一级事实图处于 ready 状态;" + f"当前 status={fact_status or 'unknown'}, scope={fact_scope or 'unknown'}, " + f"depth={fact_depth}, boundary={fact_boundary or 'unknown'}。" + ) + + boundary_edges = int(fields.get("first_level_fact_boundary_edge_count", 0) or 0) + boundary_vertices = int(fields.get("first_level_fact_boundary_vertex_count", 0) or 0) + adjacent_faces = int(fields.get("first_level_fact_adjacent_face_count", 0) or 0) + subject_faces = int(fields.get("first_level_fact_subject_face_count", 0) or 0) + included_faces = int(fields.get("first_level_fact_included_face_count", 0) or 0) + if subject_faces <= 0 or included_faces < subject_faces: + blockers.append( + f"{operation_label}没有拿到稳定的当前 Face 主体区域,不能判断哪些面要作为同一局部面处理。" + ) + if boundary_edges < 3 or boundary_vertices < 3: + blockers.append( + f"{operation_label}需要至少 3 条边界 Edge 和 3 个边界 Vertex;" + f"当前 Edge={boundary_edges}, Vertex={boundary_vertices}。" + ) + if require_adjacent and adjacent_faces <= 0: + blockers.append(f"{operation_label}没有识别到共享边的一级相邻 Face,不能稳定重建周边面。") + + ignored_depths = tuple(str(item) for item in fields.get("first_level_fact_ignored_relation_depths", ()) or ()) + if "second-level" not in ignored_depths or "third-level" not in ignored_depths: + blockers.append(f"{operation_label}没有明确记录二级/三级关系暂不传播,当前计划不够明确。") + + role_groups = tuple(fields.get("first_level_fact_role_groups") or ()) + if not any(isinstance(item, dict) and item.get("role") == "selected-same-domain-region" for item in role_groups): + blockers.append(f"{operation_label}缺少当前同域 Face 区域角色,不能确定局部编辑主体。") + if require_adjacent and not any(isinstance(item, dict) and item.get("role") == "direct-adjacent" for item in role_groups): + blockers.append(f"{operation_label}缺少直接相邻 Face 角色,不能确定一级联动范围。") + return blockers + def repair_model(self) -> str: before_stats = self.stats() repaired_parts = 0 @@ -2327,6 +2404,19 @@ class OperationMixin: inward_depth = max(-float(interval[0]), 0.0) return inward_depth if inward_depth > 1e-9 else None + def _push_pull_plan_face_info( + self, + face_id: int, + surf: BRepAdaptor_Surface, + ) -> dict[str, object]: + quick_info = self.quick_face_info(face_id) + cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf) + if cap_direction is not None: + info = dict(quick_info) + info.update(cap_direction) + return info + return self.face_info(face_id) + def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -2345,8 +2435,18 @@ class OperationMixin: "distance": distance, } - info = self.face_info(face_id) + info = self._push_pull_plan_face_info(face_id, surf) + first_level_fields = self._face_first_level_plan_fields(face_id) scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) + scope_area = 0.0 + for scope_face_id in scope_face_ids: + try: + scope_area += float(self.quick_face_info(scope_face_id).get("area") or 0.0) + except Exception: + try: + scope_area += float(self.face_info(scope_face_id).get("area") or 0.0) + except Exception: + pass if len(scope_face_ids) > 1: scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" else: @@ -2354,13 +2454,44 @@ class OperationMixin: direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) + face_distance_ratio = distance_abs / bbox_diagonal if bbox_diagonal > 1e-9 else None + plane_origin = _tuple_or_none(info.get("plane_origin")) + outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) + owning_axis_span: float | None = None + distance_to_owning_axis_span_ratio: float | None = None + if distance > 0 and plane_origin is not None and outward_direction is not None: + source_shape = None + solid_id_for_extent = _int_or_none(info.get("solid_id")) + if solid_id_for_extent is not None and 0 <= solid_id_for_extent < len(self.solids): + source_shape = self.solids[solid_id_for_extent][1] + else: + part_id_for_extent = _int_or_none(info.get("part_id")) + part_for_extent = self.part_by_id(part_id_for_extent) if part_id_for_extent is not None else None + source_shape = part_for_extent.shape if part_for_extent is not None else None + if source_shape is not None: + try: + axis_interval = _shape_axis_interval( + source_shape, + gp_Pnt(*plane_origin), + gp_Dir(*outward_direction), + ) + except Exception: + axis_interval = None + if axis_interval is not None: + owning_axis_span = max(float(axis_interval[1]) - float(axis_interval[0]), 0.0) + if owning_axis_span > 1e-9: + distance_to_owning_axis_span_ratio = distance_abs / owning_axis_span warnings: list[str] = [] blockers: list[str] = [] risk = "low" status = "ready" - inward_material_depth = self._push_pull_inward_material_depth( - face_id, - _tuple_or_none(info.get("push_pull_outward_direction")), + inward_material_depth = ( + self._push_pull_inward_material_depth( + face_id, + outward_direction, + ) + if distance < 0 + else None ) inward_cut_ratio = ( distance_abs / inward_material_depth @@ -2406,8 +2537,8 @@ class OperationMixin: 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 abs(distance) > 1e-9: + outward_for_cap = outward_direction if outward_for_cap is not None: try: cap_extension_info = self._cylindrical_cap_extension_plan(face_id, distance, outward_for_cap) @@ -2417,6 +2548,42 @@ class OperationMixin: 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")) + extra_adjacent_count = int(cap_extension_info.get("cap_extra_adjacent_face_count") or 0) + if extra_adjacent_count > 0: + cap_method = str(cap_extension_info.get("cap_extension_method") or "") + if cap_method == "cap-profile-prism" and distance > 0: + risk = _max_risk(risk, "high") + warnings.append( + "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" + "本次向外推拉会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长,避免通用大布尔长时间计算。" + ) + elif cap_method == "cap-profile-prism": + risk = _max_risk(risk, "high") + warnings.append( + "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" + "本次向内推拉没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短。" + ) + else: + status = "blocked" + risk = "blocked" + target_parameter = _float_or_none(cap_extension_info.get("cap_profile_prism_target_parameter")) + limit_parameter = _float_or_none(cap_extension_info.get("cap_profile_prism_retract_limit_parameter")) + limit_text = "" + if target_parameter is not None and limit_parameter is not None: + limit_text = ( + f"目标端面轴向位置约 {_format_result_number(target_parameter)}," + f"额外开口内侧终点约 {_format_result_number(limit_parameter)};" + ) + blockers.append( + "已识别为圆柱/筒体端盖,但端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" + f"{limit_text}" + "本次向内推拉会越过这些开口/台阶的内侧终点,当前版本还不能判断槽底或台阶是否应一起移动。" + "已快速阻止,避免留下残余台阶、长时间布尔或丢失这些特征。" + ) + simple_cap_rebuild = self._simple_cylindrical_cap_extension_rebuild_available( + face_id, + cap_extension_info, + ) growth_ratio = ( new_height / old_height if old_height is not None and old_height > 1e-9 and new_height is not None @@ -2425,23 +2592,108 @@ class OperationMixin: 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。请先分多次小幅修改,或改用整体高度/所属对象缩放语义。" + if status != "blocked": + if distance < 0 and growth_ratio is not None and growth_ratio < 0.2: + risk = _max_risk(risk, "high") + warnings.append( + "当前圆柱/筒体端面向内推拉后剩余高度低于原高度的 20%;" + "已识别端盖一级关系,执行时会优先切除旧端盖到新端盖之间的局部段。" + ) + elif distance < 0: + if str(cap_extension_info.get("cap_extension_method") or "") == "cap-profile-prism": + warnings.append( + "已识别圆柱/筒体端盖一级关系,向内推拉会优先走端盖真实轮廓切削," + "避免通用大范围 prism 切削过慢。" + ) + else: + warnings.append( + "已识别圆柱/筒体端盖一级关系,向内推拉会优先切除旧端盖到新端盖之间的局部段," + "避免通用平面 prism 切削过慢。" + ) + elif growth_ratio is not None and growth_ratio > 3.0 and simple_cap_rebuild: + risk = _max_risk(risk, "high") + warnings.append( + "当前圆柱端面会一次性加长很多;已识别为简单圆柱/筒体端盖,执行时会优先走解析重建," + "避免通用布尔长时间计算。" + ) + elif growth_ratio is not None and growth_ratio > 3.0: + risk = _max_risk(risk, "high") + warnings.append( + "当前操作会把圆柱高度一次性放大到原来的 " + f"{_format_result_number(growth_ratio)} 倍;已识别为圆柱/筒体端盖一级关系,执行时会只生成旧端盖到新端盖之间的局部延长段," + "避免拿整根新圆柱做布尔。复杂 STEP 上仍建议检查结果。" + ) + 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( + "当前圆柱端面推拉距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" + ) + + boundary_shell_extension_info: dict[str, object] | None = None + if status != "blocked" and cap_extension_info is None and outward_direction is not None: + boundary_shell_extension_info = self._planar_cap_boundary_shell_extension_plan( + face_id, + float(distance), + outward_direction, + scope_face_ids, + ) + if boundary_shell_extension_info is not None: + risk = _max_risk(risk, "medium") + warnings.append( + "已识别为带内孔/多边界的平面端盖;本次向外推拉会移动当前端面," + "并沿所有一级边界 Edge 生成延长侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" ) - elif distance_to_height_ratio is not None and distance_to_height_ratio > 1.0: + + if ( + status != "blocked" + and cap_extension_info is None + and boundary_shell_extension_info is None + and len(self.faces) > 600 + and int(info.get("inner_boundary_wires", 0) or 0) > 0 + and distance < 0.0 + ): + status = "blocked" + risk = "blocked" + first_level_adjacent = int(first_level_fields.get("first_level_adjacent_face_count", 0) or 0) + first_level_edges = int(first_level_fields.get("first_level_boundary_edge_count", 0) or 0) + inner_wires = int(info.get("inner_boundary_wires", 0) or 0) + blockers.append( + f"当前 Face {face_id} 是复杂大 STEP 中带内孔/多边界的平面端盖:" + f"内边界 {inner_wires} 个,一级边界 Edge {first_level_edges} 条," + f"共享边一级相邻 Face {first_level_adjacent} 个。" + "但没有识别到可安全向内切削的端盖一级关系。" + "向内推拉这类面通常需要判断孔壁、槽底、台阶内侧终点或其它相邻特征是否一起移动," + "这已经涉及一级相邻面背后的二级关系;当前阶段只自动处理一级关系。" + "已快速阻止,避免进入通用 OCCT 布尔后长时间卡住、超时或生成无效 B-Rep。" + ) + + if status != "blocked" and cap_extension_info is None: + if distance > 0 and ( + ( + distance_to_owning_axis_span_ratio is not None + and distance_to_owning_axis_span_ratio > 1.0 + ) + or (face_distance_ratio is not None and face_distance_ratio > 0.6) + ): risk = _max_risk(risk, "high") warnings.append( - "当前圆柱端面推拉距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。" + "本次向外推拉距离相对当前 Face 或所属实体跨度较大,可能需要较长 OCCT 布尔计算;" + "程序会在隔离子进程中执行,并在失败时保持原模型不变。" ) - elif radius is not None and radius > 0 and distance_abs > radius * 4.0: + elif ( + distance > 0 + and distance_to_owning_axis_span_ratio is not None + and distance_to_owning_axis_span_ratio > 0.6 + ): risk = _max_risk(risk, "high") warnings.append( - "当前圆柱端面推拉距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" + "本次向外推拉距离已经超过所属实体当前法向跨度的 60%,布尔延长可能较慢;" + "建议优先小幅修改。" ) if risk in {"medium", "high"} and status != "blocked": @@ -2470,11 +2722,14 @@ class OperationMixin: "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], - **self._face_first_level_plan_fields(face_id), + **first_level_fields, "distance": distance, "surface": info.get("surface"), "area": info.get("area"), "bbox_diagonal": info.get("bbox_diagonal"), + "push_pull_distance_to_face_diagonal_ratio": face_distance_ratio, + "push_pull_owning_axis_span": owning_axis_span, + "push_pull_distance_to_owning_axis_span_ratio": distance_to_owning_axis_span_ratio, "plane_origin": plane_origin, "plane_direction": outward_direction, "current_plane_position": current_plane_position, @@ -2490,11 +2745,75 @@ class OperationMixin: "cylindrical_cap_extension_radius": ( cap_extension_info.get("radius") if cap_extension_info is not None else None ), + "cylindrical_cap_extension_inner_radius": ( + cap_extension_info.get("inner_radius") if cap_extension_info is not None else None + ), + "cylindrical_cap_extension_kind": ( + cap_extension_info.get("cap_extension_kind") if cap_extension_info is not None else None + ), + "cylindrical_cap_operation": ( + cap_extension_info.get("cap_operation") if cap_extension_info is not None else None + ), + "cylindrical_cap_extension_height": ( + cap_extension_info.get("extension_height") if cap_extension_info is not None else None + ), + "cylindrical_cap_extension_uses_local_segment": bool( + cap_extension_info is not None and cap_extension_info.get("extension_shape") is not None + ), + "cylindrical_cap_extension_method": ( + cap_extension_info.get("cap_extension_method") if cap_extension_info is not None else None + ), + "cap_extra_adjacent_face_ids": ( + cap_extension_info.get("cap_extra_adjacent_face_ids") if cap_extension_info is not None else () + ), + "cap_extra_adjacent_face_count": ( + cap_extension_info.get("cap_extra_adjacent_face_count") if cap_extension_info is not None else 0 + ), + "cap_scope_face_ids": ( + cap_extension_info.get("cap_scope_face_ids") if cap_extension_info is not None else () + ), + "cap_profile_prism_target_parameter": ( + cap_extension_info.get("cap_profile_prism_target_parameter") if cap_extension_info is not None else None + ), + "cap_profile_prism_retract_limit_parameter": ( + cap_extension_info.get("cap_profile_prism_retract_limit_parameter") if cap_extension_info is not None else None + ), + "planar_cap_extension_kind": ( + boundary_shell_extension_info.get("cap_extension_kind") + if boundary_shell_extension_info is not None + else None + ), + "planar_cap_extension_method": ( + boundary_shell_extension_info.get("cap_extension_method") + if boundary_shell_extension_info is not None + else None + ), + "planar_cap_boundary_edge_count": ( + boundary_shell_extension_info.get("cap_boundary_edge_count") + if boundary_shell_extension_info is not None + else 0 + ), + "planar_cap_bridge_face_count": ( + boundary_shell_extension_info.get("bridge_face_count") + if boundary_shell_extension_info is not None + else 0 + ), + "planar_cap_inner_boundary_wires": ( + boundary_shell_extension_info.get("inner_boundary_wires") + if boundary_shell_extension_info is not None + else 0 + ), + "planar_cap_adjacent_face_count": ( + boundary_shell_extension_info.get("adjacent_face_count") + if boundary_shell_extension_info is not None + else 0 + ), "outward_direction": outward_direction, "direction_confidence": direction_confidence, "direction_note": info.get("push_pull_note"), "push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids), + "push_pull_scope_area": scope_area, "push_pull_scope_note": scope_note, } @@ -2643,6 +2962,8 @@ 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"), "target_kind": target_kind, "current_plane_position": current_position, "target_plane_position": target_position, @@ -2672,6 +2993,8 @@ class OperationMixin: plan.update( { "face_id": face_id, + **self._face_first_level_plan_fields(face_id), + "surface": info.get("surface"), "current_plane_position": current_position, "target_plane_position": target_position, "plane_origin": plane_origin, @@ -2694,7 +3017,7 @@ class OperationMixin: 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) target = _tuple_or_none(target_center) current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) @@ -2743,6 +3066,8 @@ 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"), "target_kind": target_kind, "current_face_center": current_center, "target_face_center": target, @@ -2765,6 +3090,8 @@ class OperationMixin: plan.update( { "face_id": face_id, + **self._face_first_level_plan_fields(face_id), + "surface": info.get("surface"), "current_face_center": current_center, "target_face_center": target, "face_center_move_vector": vector, @@ -2788,11 +3115,14 @@ class OperationMixin: 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) + result, result_check = self._run_checked_face_edit( + plan, + lambda: ( + self.translate_solid(int(plan["solid_id"]), vector) + if plan.get("target_kind") == "solid" + else self.translate_part(int(plan["part_id"]), vector) + ), + ) return ( "Face center move completed by owning-shape translation: " f"face {face_id}, " @@ -2808,8 +3138,10 @@ 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_local_face_deform(plan), + ) return ( "Face plane offset completed by local face-only deformation: " f"face {face_id}, " @@ -2827,11 +3159,14 @@ class OperationMixin: 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) + result, result_check = self._run_checked_face_edit( + plan, + lambda: ( + self.translate_solid(int(plan["solid_id"]), tuple(plan["translation_vector"])) + if plan.get("target_kind") == "solid" + else self.translate_part(int(plan["part_id"]), tuple(plan["translation_vector"])) + ), + ) return ( "Face plane offset completed by owning-shape translation: " f"face {face_id}, " @@ -2854,6 +3189,7 @@ class OperationMixin: ] risk = "low" status = "ready" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_thickness = float(target_thickness) @@ -2957,6 +3293,7 @@ class OperationMixin: "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), + **first_level_fields, "surface": info.get("surface"), "shell_region_kind": info.get("shell_region_kind"), "shell_confidence": confidence, @@ -3068,6 +3405,7 @@ class OperationMixin: ] risk = "low" status = "ready" + first_level_fields = self._face_first_level_plan_fields(face_id) if info.get("surface") != "plane": blockers.append("当前 Face 不是平面,不能执行“只移动当前Face”。") @@ -3123,6 +3461,13 @@ class OperationMixin: blockers.append("局部 Face 移动不可用:部分 Face 顶点环无法稳定读取。") break + if not blockers: + blockers.extend( + self._face_first_level_plan_blockers( + first_level_fields, + operation_label="只移动当前 Face", + ) + ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: @@ -3149,7 +3494,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, - **self._face_first_level_plan_fields(face_id), + **first_level_fields, "surface": info.get("surface"), "current_face_center": current_center, "target_face_center": target, @@ -3189,6 +3534,7 @@ class OperationMixin: ] risk = "low" status = "ready" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_area = float(target_area) @@ -3259,6 +3605,13 @@ class OperationMixin: blockers.append("局部 Face 面积缩放不可用:部分 Face 顶点环无法稳定读取。") break + if not blockers: + blockers.extend( + self._face_first_level_plan_blockers( + first_level_fields, + operation_label="只缩放当前 Face 面积", + ) + ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: @@ -3294,7 +3647,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, - **self._face_first_level_plan_fields(face_id), + **first_level_fields, "surface": info.get("surface"), "current_area": current_area, "target_area": target_area, @@ -3337,6 +3690,7 @@ class OperationMixin: ] risk = "low" status = "ready" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_size = float(target_size) @@ -3415,6 +3769,13 @@ class OperationMixin: blockers.append(f"局部 Face {axis_label}修改不可用:部分 Face 顶点环无法稳定读取。") break + if not blockers: + blockers.extend( + self._face_first_level_plan_blockers( + first_level_fields, + operation_label=f"{axis_label}(当前面)", + ) + ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: @@ -3450,7 +3811,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, - **self._face_first_level_plan_fields(face_id), + **first_level_fields, "surface": info.get("surface"), "face_size_axis": axis_key, "face_size_label": axis_label, @@ -3502,6 +3863,7 @@ class OperationMixin: f"{axis_label}(整体)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。" ] risk = "medium" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_size = float(target_size) @@ -3626,6 +3988,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **first_level_fields, "surface": info.get("surface"), "face_size_axis": axis_key, "face_size_label": axis_label, @@ -3678,6 +4041,11 @@ class OperationMixin: 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) + if info.get("surface") == "cylinder" + else {} + ) blockers: list[str] = [] edit_label = "凸台高度" if require_boss else "圆柱高度" warnings: list[str] = [ @@ -3832,10 +4200,12 @@ class OperationMixin: "diameter": info.get("diameter"), "radius": info.get("radius"), "axis": info.get("axis"), + "axis_point": info.get("axis_point"), "same_domain_face_ids": axis_range.get("same_domain_face_ids"), "same_domain_face_count": axis_range.get("same_domain_face_count"), "feature_start_end_face_ids": feature.get("feature_start_end_face_ids"), "feature_end_end_face_ids": feature.get("feature_end_end_face_ids"), + **topology_fields, "resize_strategy": ( "push-pull-cylindrical-boss-end-cap-to-target-height" if require_boss @@ -3870,10 +4240,30 @@ class OperationMixin: plan = self.cylindrical_boss_height_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - push_result = self.push_pull_face( - int(plan["boss_height_cap_face_id"]), - float(plan["push_pull_distance"]), - ) + self._remember_face_target_logical_id(plan, face_id) + part_id = int(plan.get("part_id", self.face_part_ids[face_id])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + push_result = self.push_pull_face( + int(plan["boss_height_cap_face_id"]), + float(plan["push_pull_distance"]), + ) + verification = self._verify_cylindrical_height_result(plan, part_id) + if not verification.get("matched"): + detail = str(verification.get("detail", "cylindrical height result verification failed")) + raise RuntimeError( + "圆柱凸台高度修改返回了结果,但没有检测到达到目标高度且保留一级关系的圆柱面," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( "Cylindrical boss height resize completed by end-cap push/pull: " f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " @@ -3881,7 +4271,7 @@ class OperationMixin: f"target_height={float(plan['target_height']):g}, " f"delta={float(plan['delta_height']):g}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " - f"risk={plan['risk']}. {push_result}" + f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}" ) def shell_thickness_preview_polydata( @@ -3900,8 +4290,10 @@ class OperationMixin: 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) + push_result, result_check = self._run_checked_face_edit( + plan, + lambda: self.push_pull_face(face_id, float(plan["push_pull_distance"])), + ) return ( "Shell thickness resize completed by planar push/pull: " f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, " @@ -3921,6 +4313,7 @@ class OperationMixin: "该方式会沿薄壁/壳体厚度方向缩放所属特征或 Solid;它不是推拉当前平面,也不是局部壳命令参数。" ] risk = "medium" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_thickness = float(target_thickness) @@ -4059,6 +4452,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **first_level_fields, "surface": info.get("surface"), "shell_region_kind": info.get("shell_region_kind"), "shell_confidence": confidence, @@ -4100,11 +4494,14 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: ( + self._apply_local_face_deform(plan) + if plan.get("owning_shell_thickness_rebuild_mode") == "planar-rebuild" + else self._apply_edge_length_affine_transform(plan) + ), + ) return ( "Shell thickness resize completed by axis owning-shape scaling: " f"face {face_id}, " @@ -4119,10 +4516,30 @@ class OperationMixin: plan = self.cylindrical_height_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - push_result = self.push_pull_face( - int(plan["boss_height_cap_face_id"]), - float(plan["push_pull_distance"]), - ) + self._remember_face_target_logical_id(plan, face_id) + part_id = int(plan.get("part_id", self.face_part_ids[face_id])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + push_result = self.push_pull_face( + int(plan["boss_height_cap_face_id"]), + float(plan["push_pull_distance"]), + ) + verification = self._verify_cylindrical_height_result(plan, part_id) + if not verification.get("matched"): + detail = str(verification.get("detail", "cylindrical height result verification failed")) + raise RuntimeError( + "圆柱高度修改返回了结果,但没有检测到达到目标高度且保留一级关系的圆柱面," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( "Cylindrical height resize completed by end-cap push/pull: " f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " @@ -4130,7 +4547,7 @@ class OperationMixin: f"target_height={float(plan['target_height']):g}, " f"delta={float(plan['delta_height']):g}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " - f"risk={plan['risk']}. {push_result}" + f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}" ) def cylindrical_height_owning_scale_plan(self, face_id: int, target_height: float) -> dict[str, object]: @@ -4141,6 +4558,11 @@ class OperationMixin: feature = self.feature_info(face_id) except Exception: feature = {} + topology_fields = ( + self._cylindrical_feature_first_level_plan_fields(face_id) + if info.get("surface") == "cylinder" + else {} + ) blockers: list[str] = [] warnings: list[str] = [ "该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是推拉某个端盖,也不是只修改单个圆柱面。" @@ -4255,6 +4677,7 @@ class OperationMixin: "diameter": info.get("diameter"), "radius": info.get("radius"), "axis": axis_direction, + "axis_point": axis_point, "scale_center": scale_center, "resize_strategy": "axis-scale-owning-shape-from-cylinder-height", "edit_strategy_label": "高度(整体)", @@ -4272,20 +4695,41 @@ class OperationMixin: "affine_anchor_source": "cylinder axis center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, + **topology_fields, } def resize_cylindrical_height_owning_scale(self, face_id: int, target_height: float) -> str: plan = self.cylindrical_height_owning_scale_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - self._apply_edge_length_affine_transform(plan) + self._remember_face_target_logical_id(plan, face_id) + part_id = int(plan.get("part_id", self.face_part_ids[face_id])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + self._apply_edge_length_affine_transform(plan) + verification = self._verify_axis_height_span_result(plan, part_id) + if not verification.get("matched"): + detail = str(verification.get("detail", "axis height span verification failed")) + raise RuntimeError( + "圆柱高度整体缩放返回了结果,但所属对象的轴向跨度没有达到目标高度," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( "Cylindrical height resize completed by axis owning-shape scaling: " f"face {face_id}, " f"height={float(plan['current_height']):g}->{float(plan['target_height']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}, verified_axis_span={_format_result_number(verification.get('axis_span'))}." ) def cylindrical_depth_owning_scale_plan( @@ -5574,8 +6018,13 @@ class OperationMixin: 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) + if bool(plan.get("analytic_rebuild_available", False)): + applied, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_conical_analytic_rebuild_if_simple(plan), + ) + if not applied: + raise RuntimeError("Analytic cone rebuild was planned but not applied.") return ( "Conical face reference radius resize completed by analytic cone rebuild: " f"face {face_id}, " @@ -5584,8 +6033,13 @@ class OperationMixin: 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) + if bool(plan.get("embedded_cone_recut_available", False)): + applied, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_embedded_conical_recut_if_available(plan), + ) + if not applied: + raise RuntimeError("Embedded conical recut was planned but not applied.") return ( "Conical face reference radius resize completed by embedded local cone recut: " f"face {face_id}, " @@ -5597,8 +6051,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_edge_length_affine_transform(plan), + ) return ( "Conical face reference radius resize completed by radial affine scaling: " f"face {face_id}, " @@ -5613,8 +6069,13 @@ class OperationMixin: 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) + if bool(plan.get("analytic_rebuild_available", False)): + applied, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_conical_analytic_rebuild_if_simple(plan), + ) + if not applied: + raise RuntimeError("Analytic cone rebuild was planned but not applied.") return ( "Conical face semi-angle resize completed by analytic cone rebuild: " f"face {face_id}, " @@ -5624,8 +6085,13 @@ class OperationMixin: 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) + if bool(plan.get("embedded_cone_recut_available", False)): + applied, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_embedded_conical_recut_if_available(plan), + ) + if not applied: + raise RuntimeError("Embedded conical recut was planned but not applied.") return ( "Conical face semi-angle resize completed by embedded local cone recut: " f"face {face_id}, " @@ -5637,8 +6103,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_edge_length_affine_transform(plan), + ) return ( "Conical face semi-angle resize completed by radial affine scaling: " f"face {face_id}, " @@ -5738,8 +6206,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_edge_length_affine_transform(plan), + ) return ( "Spherical face radius resize completed by uniform scaling: " f"face {face_id}, " @@ -5847,8 +6317,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_edge_length_affine_transform(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") @@ -5869,6 +6341,7 @@ class OperationMixin: "目标面面积不是 STEP 原始 CAD 历史参数;当前采用围绕当前面的面积中心均匀缩放所属特征或 Solid 的语义。" ] risk = "high" + first_level_fields = self._face_first_level_plan_fields(face_id) try: target_area = float(target_area) @@ -5929,6 +6402,7 @@ class OperationMixin: "face_id": face_id, "part_id": part_id, "solid_id": solid_id, + **first_level_fields, "surface": info.get("surface"), "current_area": current_area, "target_area": target_area, @@ -5955,8 +6429,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_edge_length_affine_transform(plan), + ) return ( "Face area resize completed by uniform scaling fallback: " f"face {face_id}, " @@ -6362,8 +6838,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_local_face_deform(plan), + ) return ( "Face center move completed by local face-only deformation: " f"face {face_id}, " @@ -6395,8 +6873,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_local_face_deform(plan), + ) return ( "Face area resize completed by local face-only deformation: " f"face {face_id}, " @@ -6430,8 +6910,10 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_local_face_deform(plan), + ) return ( "Face local size resize completed by local face-only deformation: " f"face {face_id}, " @@ -6469,11 +6951,14 @@ class OperationMixin: 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) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: ( + self._apply_local_face_deform(plan) + if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild" + else self._apply_edge_length_affine_transform(plan) + ), + ) return ( "Face owning size resize completed by axis-affine scaling: " f"face {face_id}, " @@ -6494,6 +6979,227 @@ class OperationMixin: except Exception: pass + def _face_edit_snapshot(self, plan: dict[str, object]) -> list[tuple[object, TopoDS_Shape]]: + part_id = _int_or_none(plan.get("part_id")) + snapshot: list[tuple[object, TopoDS_Shape]] = [] + if part_id is not None: + part = self.part_by_id(part_id) + if part is not None: + snapshot.append((part, part.shape)) + if snapshot: + return snapshot + for part in self.display_parts(): + snapshot.append((part, part.shape)) + return snapshot + + def _restore_face_edit_snapshot(self, snapshot: list[tuple[object, TopoDS_Shape]]) -> None: + for part, shape in snapshot: + part.shape = shape + self.refresh_topology() + + def _checked_face_edit_result_summary(self, plan: dict[str, object]) -> str: + part_id = _int_or_none(plan.get("part_id")) + part = self.part_by_id(part_id) if part_id is not None else None + if part is not None: + _ensure_valid_shape(part.shape) + else: + _ensure_valid_shape(self.shape) + + check = self._face_edit_result_check(plan) + if check is None: + return "Face result check unavailable." + metric = check.get("metric") + scope = check.get("scope") + if int(check.get("face_id", -1)) < 0: + raise RuntimeError(f"Face edit result has no matching target Face, metric={metric}, scope={scope}.") + error = float(check.get("error", math.inf)) + tolerance = float(check.get("tolerance", 0.0)) + if not math.isfinite(error) or error > tolerance: + 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) + raise RuntimeError( + "Face edit result target check failed: " + f"metric={metric}, actual={actual_text}, target={target_text}, " + f"error={error:g}, tolerance={tolerance:g}, scope={scope}." + ) + + first_level_summary = "" + if str(plan.get("surface") or "") == "plane": + matched_face_id = int(check["face_id"]) + topology = self.face_first_level_topology(matched_face_id) + boundary_edges = int(topology.get("first_level_boundary_edge_count", 0) or 0) + boundary_vertices = int(topology.get("first_level_boundary_vertex_count", 0) or 0) + adjacent_faces = int(topology.get("first_level_adjacent_face_count", 0) or 0) + expected_edges = int(plan.get("first_level_boundary_edge_count", 0) or 0) + expected_vertices = int(plan.get("first_level_boundary_vertex_count", 0) or 0) + expected_adjacent = int(plan.get("first_level_adjacent_face_count", 0) or 0) + expected_inner_wires = int(plan.get("selected_inner_boundary_wires", 0) or 0) + matched_info = self.face_info(matched_face_id) + boundary_wires = int(matched_info.get("boundary_wires", 0) or 0) + inner_boundary_wires = int(matched_info.get("inner_boundary_wires", 0) or 0) + expected_scope_area = _float_or_none(plan.get("push_pull_scope_area")) + matched_area = _float_or_none(matched_info.get("area")) + same_domain_count = int(plan.get("same_domain_face_count", 1) or 1) + polygonal_plan = expected_edges >= 3 and expected_vertices >= 3 + area_summary = "" + if ( + expected_scope_area is not None + and expected_scope_area > 1e-9 + and matched_area is not None + and str(check.get("metric") or "") == "plane_position" + ): + area_tolerance = max(expected_scope_area * 0.02, 1e-4) + if abs(matched_area - expected_scope_area) > area_tolerance: + raise RuntimeError( + "Face edit result lost part of the pushed/pulled Face region area; " + f"expected_area={expected_scope_area:g}, actual_area={matched_area:g}, " + f"tolerance={area_tolerance:g}." + ) + area_summary = f"area={matched_area:g}, expected_area={expected_scope_area:g}; " + if polygonal_plan: + if same_domain_count > 1: + minimum_edges = 3 + minimum_vertices = 3 + minimum_adjacent = min(max(expected_adjacent, 1), 3) if expected_adjacent > 0 else 0 + else: + minimum_edges = expected_edges + minimum_vertices = expected_vertices + minimum_adjacent = expected_adjacent + else: + minimum_edges = 1 if expected_edges > 0 else 0 + minimum_vertices = 0 + minimum_adjacent = 1 if expected_adjacent > 0 else 0 + if boundary_edges <= 0: + raise RuntimeError( + "Face edit result first-level topology check failed: " + f"boundary_edges={boundary_edges}, boundary_vertices={boundary_vertices}." + ) + if minimum_vertices > 0 and boundary_vertices < minimum_vertices: + raise RuntimeError( + "Face edit result lost required first-level boundary vertices; " + f"required_after>={minimum_vertices}, expected_before={expected_vertices}, " + f"actual_after={boundary_vertices}." + ) + if minimum_edges > 0 and boundary_edges < minimum_edges: + raise RuntimeError( + "Face edit result lost required first-level boundary edges; " + f"required_after>={minimum_edges}, expected_before={expected_edges}, " + f"actual_after={boundary_edges}." + ) + if minimum_adjacent > 0 and adjacent_faces < minimum_adjacent: + raise RuntimeError( + "Face edit result lost required direct shared-edge adjacent Faces; " + f"required_after>={minimum_adjacent}, expected_before={expected_adjacent}, " + f"actual_after={adjacent_faces}." + ) + if expected_inner_wires > 0 and inner_boundary_wires < expected_inner_wires: + raise RuntimeError( + "Face edit result lost required inner boundary wire(s); " + f"required_after>={expected_inner_wires}, actual_after={inner_boundary_wires}, " + f"boundary_wires={boundary_wires}." + ) + + try: + facts = self.face_first_level_facts(matched_face_id, scope="face") + except Exception as exc: + raise RuntimeError( + "Face edit result first-level fact graph check failed: " + f"could not rebuild facts for Face {matched_face_id}: {exc}" + ) from exc + + fact_status = str(facts.get("first_level_fact_status") or "") + fact_scope = str(facts.get("first_level_fact_scope") or "") + fact_boundary = str(facts.get("first_level_fact_relation_boundary") or "") + fact_depth = int(facts.get("first_level_fact_relation_depth", 0) or 0) + fact_subject = int(facts.get("first_level_fact_subject_face_count", 0) or 0) + fact_boundary_edges = int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) + fact_boundary_vertices = int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) + fact_adjacent = int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) + fact_included = int(facts.get("first_level_fact_included_face_count", 0) or 0) + expected_fact_edges = int(plan.get("first_level_fact_boundary_edge_count", expected_edges) or 0) + expected_fact_vertices = int(plan.get("first_level_fact_boundary_vertex_count", expected_vertices) or 0) + expected_fact_adjacent = int(plan.get("first_level_fact_adjacent_face_count", expected_adjacent) or 0) + fact_ignored_depths = tuple(str(item) for item in facts.get("first_level_fact_ignored_relation_depths", ()) or ()) + fact_role_groups = tuple(facts.get("first_level_fact_role_groups") or ()) + + if fact_status != "ready" or fact_scope != "face" or fact_depth != 1 or fact_boundary != "shared-edge": + raise RuntimeError( + "Face edit result first-level fact graph is not ready: " + f"status={fact_status}, scope={fact_scope}, depth={fact_depth}, boundary={fact_boundary}." + ) + if fact_subject <= 0 or fact_included < fact_subject: + raise RuntimeError( + "Face edit result first-level fact graph lost the edited subject region: " + f"subject_faces={fact_subject}, included_faces={fact_included}." + ) + if "second-level" not in fact_ignored_depths or "third-level" not in fact_ignored_depths: + raise RuntimeError( + "Face edit result first-level fact graph no longer records deferred deeper relations: " + f"ignored_depths={fact_ignored_depths}." + ) + if not any(isinstance(item, dict) and item.get("role") == "selected-same-domain-region" for item in fact_role_groups): + raise RuntimeError("Face edit result first-level fact graph lost the selected-region role group.") + if expected_fact_adjacent > 0 and not any( + isinstance(item, dict) and item.get("role") == "direct-adjacent" for item in fact_role_groups + ): + raise RuntimeError("Face edit result first-level fact graph lost the direct-adjacent role group.") + + if polygonal_plan: + if same_domain_count > 1: + minimum_fact_edges = 3 + minimum_fact_vertices = 3 + minimum_fact_adjacent = min(max(expected_fact_adjacent, 1), 3) if expected_fact_adjacent > 0 else 0 + else: + minimum_fact_edges = expected_fact_edges + minimum_fact_vertices = expected_fact_vertices + minimum_fact_adjacent = expected_fact_adjacent + else: + minimum_fact_edges = 1 if expected_fact_edges > 0 else 0 + minimum_fact_vertices = 0 + minimum_fact_adjacent = 1 if expected_fact_adjacent > 0 else 0 + if minimum_fact_edges > 0 and fact_boundary_edges < minimum_fact_edges: + raise RuntimeError( + "Face edit result first-level fact graph lost boundary Edges; " + f"required_after>={minimum_fact_edges}, expected_before={expected_fact_edges}, " + f"actual_after={fact_boundary_edges}." + ) + if minimum_fact_vertices > 0 and fact_boundary_vertices < minimum_fact_vertices: + raise RuntimeError( + "Face edit result first-level fact graph lost boundary Vertices; " + f"required_after>={minimum_fact_vertices}, expected_before={expected_fact_vertices}, " + f"actual_after={fact_boundary_vertices}." + ) + if minimum_fact_adjacent > 0 and fact_adjacent < minimum_fact_adjacent: + raise RuntimeError( + "Face edit result first-level fact graph lost direct adjacent Faces; " + f"required_after>={minimum_fact_adjacent}, expected_before={expected_fact_adjacent}, " + f"actual_after={fact_adjacent}." + ) + first_level_summary = ( + " First-level check: " + f"boundary_edges={boundary_edges}, boundary_vertices={boundary_vertices}, " + f"adjacent_faces={adjacent_faces}, inner_wires={inner_boundary_wires}; " + f"{area_summary}" + f"fact_subject_faces={fact_subject}, fact_included_faces={fact_included}." + ) + return self._face_edit_result_summary(plan) + first_level_summary + + def _run_checked_face_edit( + self, + plan: dict[str, object], + action: Callable[[], object], + ) -> tuple[object, str]: + snapshot = self._face_edit_snapshot(plan) + try: + action_result = action() + result_check = self._checked_face_edit_result_summary(plan) + return action_result, result_check + except Exception as exc: + self._restore_face_edit_snapshot(snapshot) + raise RuntimeError(f"Face edit failed and the model was restored: {exc}") from exc + def _face_edit_result_summary(self, plan: dict[str, object]) -> str: check = self._face_edit_result_check(plan) if check is None: @@ -6569,9 +7275,15 @@ class OperationMixin: 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: + if face_id < 0 or face_id >= len(self.faces): return None + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + except Exception: + return None + if surf.GetType() != GeomAbs_Plane: + return None + origin = _point_tuple(surf.Plane().Location()) return _tuple_dot(origin, direction) getter = plane_position_getter @@ -6647,6 +7359,38 @@ class OperationMixin: if getter is None or target is None: return None + preferred_ids = _int_values(plan.get("result_candidate_face_ids")) + logical_id = _int_or_none(plan.get("target_logical_id")) + if logical_id is not None: + try: + preferred_ids.extend(self.face_ids_for_logical_id(logical_id)) + except Exception: + pass + if preferred_ids: + preferred_best: dict[str, object] | None = None + for face_id in preferred_ids: + if face_id not in face_ids: + continue + try: + actual = getter(face_id) + except Exception: + actual = None + if actual is None: + continue + error = _result_value_error(actual, target) + if preferred_best is None or error < float(preferred_best["error"]): + preferred_best = { + "face_id": face_id, + "metric": metric, + "actual": actual, + "target": target, + "error": error, + "tolerance": tolerance, + "scope": scope, + } + if preferred_best is not None and float(preferred_best["error"]) <= tolerance: + return preferred_best + best: dict[str, object] | None = None for face_id in face_ids: try: @@ -6702,6 +7446,7 @@ class OperationMixin: plan_face_id = _int_or_none(plan.get("face_id")) if plan_face_id is not None: primary.append(plan_face_id) + primary.extend(_int_values(plan.get("result_candidate_face_ids"))) def filtered(source_ids: Iterable[int], *, require_solid: bool = True) -> list[int]: result: list[int] = [] @@ -6732,6 +7477,13 @@ class OperationMixin: return result primary_ids = filtered(primary) + result_candidate_ids = filtered(_int_values(plan.get("result_candidate_face_ids"))) + if result_candidate_ids: + combined: list[int] = [] + for face_id in [*result_candidate_ids, *primary_ids]: + if face_id not in combined: + combined.append(face_id) + return combined, scope 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) @@ -7579,6 +8331,53 @@ class OperationMixin: outward = plan["outward_direction"] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] + boundary_shell_extension = None + if plan.get("cylindrical_cap_extension_kind") is None: + boundary_shell_extension = self._planar_cap_boundary_shell_extension_plan( + face_id, + float(distance), + outward, + scope_face_ids, + ) + if boundary_shell_extension is not None: + target_logical_id: int | None = None + 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)), + logical_id=target_logical_id, + ) + except Exception: + target_logical_id = None + target_region_mapping_specs = [] + + def apply_boundary_shell_extension() -> None: + previous_face_count = len(self.faces) + part.shape = self._planar_cap_boundary_shell_extension_shape( + face_id, + boundary_shell_extension, + float(distance), + outward, + ) + self.refresh_topology() + if len(self.faces) > previous_face_count: + plan["result_candidate_face_ids"] = tuple(range(previous_face_count, len(self.faces))) + self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) + + _action_result, result_check = self._run_checked_face_edit(plan, apply_boundary_shell_extension) + return ( + "Planar face push/pull completed: planar cap boundary-shell rebuild, " + f"semantic_distance={distance:g}, " + f"boundary_edges={int(boundary_shell_extension['cap_boundary_edge_count'])}, " + f"inner_wires={int(boundary_shell_extension['inner_boundary_wires'])}, " + f"adjacent_faces={int(boundary_shell_extension['adjacent_face_count'])}, " + f"outward_direction={_format_tuple(outward)}, " + f"direction_confidence={plan['direction_confidence']}, " + f"risk={plan['risk']}. {result_check}" + ) profile_shape = self._push_pull_profile_shape(scope_face_ids) boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) side_face_ids = sorted( @@ -7600,21 +8399,136 @@ class OperationMixin: target_region_mapping_specs = [] cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward) - if cap_extension is not None: - op = BRepAlgoAPI_Fuse(part.shape, cap_extension["tool_shape"]) - result = _finalize_boolean_result(op, "cylindrical cap push/pull") - result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) - part.shape = result - 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) + prismatic_cap_rebuild: dict[str, object] | None = None + if cap_extension is None or str(cap_extension.get("cap_extension_method") or "") == "cap-profile-prism": + try: + prismatic_cap_rebuild = self._prismatic_cap_rebuild_candidate( + face_id, + float(distance), + outward, + profile_shape, + ) + except Exception: + prismatic_cap_rebuild = None + if prismatic_cap_rebuild is not None: + def apply_prismatic_cap_rebuild() -> None: + part.shape = prismatic_cap_rebuild["replacement_shape"] + 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) + + _action_result, result_check = self._run_checked_face_edit(plan, apply_prismatic_cap_rebuild) return ( - "Planar face push/pull completed: cylindrical cap extension, " + "Planar face push/pull completed: prismatic cap analytic rebuild, " + f"semantic_distance={distance:g}, " + f"old_height={float(prismatic_cap_rebuild['old_height']):g}, " + f"new_height={float(prismatic_cap_rebuild['new_height']):g}, " + f"side_faces={int(prismatic_cap_rebuild['side_face_count'])}, " + f"cap_planes={int(prismatic_cap_rebuild['cap_plane_count'])}, " + f"outward_direction={_format_tuple(outward)}, " + f"direction_confidence={plan['direction_confidence']}, " + f"risk={plan['risk']}. {result_check}" + ) + slow_boolean_blocker = self._slow_push_pull_boolean_blocker(plan, cap_extension) + if slow_boolean_blocker: + raise ValueError(slow_boolean_blocker) + if cap_extension is not None: + use_cap_profile_prism = ( + int(cap_extension.get("cap_extra_adjacent_face_count") or 0) > 0 + and str(cap_extension.get("cap_extension_method") or "") == "cap-profile-prism" + ) + use_cap_local_shell_rebuild = str(cap_extension.get("cap_extension_method") or "") == "local-shell-rebuild" + simple_replacement = ( + cap_extension["tool_shape"] + if ( + not use_cap_profile_prism + and not use_cap_local_shell_rebuild + and self._simple_cylindrical_cap_extension_rebuild_available(face_id, cap_extension) + ) + else None + ) + if use_cap_profile_prism: + cap_operation = "profile-extend" if float(distance) > 0.0 else "profile-retract" + else: + cap_operation = str(cap_extension.get("cap_operation") or "extend") + if use_cap_profile_prism: + local_delta_shape = self._cap_profile_prism_delta_shape( + profile_shape, + part.shape, + outward, + float(distance), + ) + cap_extension["local_delta_shape"] = local_delta_shape + cap_extension["extension_shape"] = local_delta_shape + cap_extension["cap_extension_method"] = "cap-profile-prism" + elif use_cap_local_shell_rebuild: + local_delta_shape = None + else: + local_delta_shape = ( + cap_extension.get("local_delta_shape") + or cap_extension.get("extension_shape") + or cap_extension.get("removal_shape") + ) + if simple_replacement is None and local_delta_shape is None and not use_cap_local_shell_rebuild: + raise RuntimeError("Cylindrical cap push/pull plan did not produce a local delta shape.") + + def apply_cap_extension() -> None: + if simple_replacement is not None: + result = simple_replacement + elif use_cap_local_shell_rebuild: + result = self._cylindrical_cap_local_shell_rebuild_shape( + face_id, + cap_extension, + float(distance), + outward, + ) + elif cap_operation in {"retract", "profile-retract"}: + op = BRepAlgoAPI_Cut(part.shape, local_delta_shape) + result = _finalize_boolean_result(op, "cylindrical cap push/pull local retraction", use_glue=False) + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) + else: + op = BRepAlgoAPI_Fuse(part.shape, local_delta_shape) + result = _finalize_boolean_result(op, "cylindrical cap push/pull local extension", use_glue=False) + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) + previous_face_count = len(self.faces) + part.shape = result + self.refresh_topology() + if use_cap_local_shell_rebuild and len(self.faces) > previous_face_count: + plan["result_candidate_face_ids"] = tuple(range(previous_face_count, len(self.faces))) + 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_result, result_check = self._run_checked_face_edit(plan, apply_cap_extension) + action = ( + "cylindrical cap analytic rebuild" + if simple_replacement is not None + else ( + "cylindrical cap local shell rebuild" + if use_cap_local_shell_rebuild + else ( + "cylindrical cap profile-prism extension" + if cap_operation == "profile-extend" + else "cylindrical cap profile-prism retraction" + if cap_operation == "profile-retract" + else ( + "cylindrical cap local retraction" + if cap_operation == "retract" + else "cylindrical cap local extension" + ) + ) + ) + ) + return ( + f"Planar face push/pull completed: {action}, " f"semantic_distance={distance:g}, " f"radius={float(cap_extension['radius']):g}, " + f"inner_radius={_format_result_number(cap_extension.get('inner_radius'))}, " f"old_height={float(cap_extension['old_height']):g}, " f"new_height={float(cap_extension['new_height']):g}, " + f"extension_height={_format_result_number(cap_extension.get('extension_height'))}, " + f"cap_operation={cap_operation}, " + f"cap_extension_kind={cap_extension.get('cap_extension_kind')}, " + f"cap_extension_method={cap_extension.get('cap_extension_method')}, " f"side_faces={cap_extension['side_face_ids']}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " @@ -7631,15 +8545,17 @@ class OperationMixin: float(outward[2]) * tool_distance, ) tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() - op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) - result = _finalize_boolean_result(op, "push/pull") - result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) - part.shape = result - 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) + def apply_push_pull() -> None: + op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) + result = _finalize_boolean_result(op, "push/pull") + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) + part.shape = result + 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) + + _action_result, result_check = self._run_checked_face_edit(plan, apply_push_pull) 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}, " @@ -7650,21 +8566,533 @@ class OperationMixin: f"risk={plan['risk']}. {result_check}" ) + def _cylindrical_cap_local_shell_rebuild_shape( + self, + face_id: int, + cap_extension: dict[str, object], + distance: float, + outward: tuple[float, float, float], + ) -> TopoDS_Shape: + part_id = self.face_part_ids[face_id] + solid_id = self.face_solid_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Face {face_id} is not attached to a rebuildable Solid.") + source_solid = self.solids[solid_id][1] + cap_scope_face_ids = _int_values(cap_extension.get("cap_scope_face_ids")) or [face_id] + side_specs = [item for item in cap_extension.get("side_rebuild_specs", ()) if isinstance(item, dict)] + if not side_specs: + raise RuntimeError("Cylindrical cap local shell rebuild has no side Face rebuild specs.") + axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) + if axis_alignment is None: + raise RuntimeError("Cylindrical cap local shell rebuild has no axis direction.") + if float(distance) <= 0.0 or str(cap_extension.get("cap_operation") or "") != "extend": + raise RuntimeError( + "Cylindrical cap local shell rebuild currently supports outward cap extension only." + ) + remove_face_ids = sorted(set(cap_scope_face_ids)) + remove_faces = [self.faces[item] for item in remove_face_ids if 0 <= item < len(self.faces)] + if not remove_faces: + raise RuntimeError("Cylindrical cap local shell rebuild has no removable source Faces.") + + tolerance = max( + _shape_diagonal(source_solid) * 1e-7, + abs(float(distance)) * 1e-7, + 1e-6, + ) + rebuilt_faces: list[TopoDS_Shape] = [] + removed_count = 0 + for source_face in _explore(source_solid, TopAbs_FACE): + if any(_same_shape(source_face, removable) for removable in remove_faces): + removed_count += 1 + continue + rebuilt_faces.append(source_face) + if removed_count < len(remove_faces): + raise RuntimeError( + "Cylindrical cap local shell rebuild could not find every source Face in the Solid." + ) + + for cap_face_id in cap_scope_face_ids: + if cap_face_id < 0 or cap_face_id >= len(self.faces): + continue + moved_cap = _translated_shape( + self.faces[cap_face_id], + (float(outward[0]), float(outward[1]), float(outward[2])), + float(distance), + ) + _ensure_valid_shape(moved_cap) + rebuilt_faces.append(moved_cap) + + for spec in side_specs: + side_face_id = int(spec.get("face_id", -1)) + if side_face_id < 0 or side_face_id >= len(self.faces): + raise RuntimeError("Cylindrical cap local shell rebuild references an invalid side Face.") + surf = BRepAdaptor_Surface(self.faces[side_face_id]) + if surf.GetType() != GeomAbs_Cylinder: + raise RuntimeError("Cylindrical cap local shell rebuild side Face is not cylindrical.") + u_min = float(spec["u_min"]) + u_max = float(spec["u_max"]) + if axis_alignment > 0.0: + v_min = float(spec["old_v_max"]) + v_max = float(spec["new_v_max"]) + else: + v_min = float(spec["new_v_min"]) + v_max = float(spec["old_v_min"]) + if v_max <= v_min + tolerance: + raise RuntimeError("Cylindrical cap local shell rebuild collapsed a side Face.") + side_face = BRepBuilderAPI_MakeFace(surf.Cylinder(), u_min, u_max, v_min, v_max).Face() + side_face.Orientation(self.faces[side_face_id].Orientation()) + _ensure_valid_shape(side_face) + rebuilt_faces.append(side_face) + + sewing = BRepBuilderAPI_Sewing(tolerance) + for rebuilt_face in rebuilt_faces: + sewing.Add(rebuilt_face) + sewing.Perform() + sewed = sewing.SewedShape() + if sewed.IsNull(): + raise RuntimeError("Cylindrical cap local shell rebuild sewing produced an empty shape.") + if sewed.ShapeType() == TopAbs_SHELL: + shell = topods.Shell(sewed) + else: + shells = _explore(sewed, TopAbs_SHELL) + if not shells: + raise RuntimeError("Cylindrical cap local shell rebuild did not produce a sewable shell.") + shell = topods.Shell(shells[0]) + solid_builder = BRepBuilderAPI_MakeSolid(shell) + rebuilt_solid = solid_builder.Solid() + if rebuilt_solid.IsNull(): + raise RuntimeError("Cylindrical cap local shell rebuild could not create a Solid.") + rebuilt_solid = _ensure_valid_or_repaired_shape(rebuilt_solid, "cylindrical cap local shell rebuild") + + part_solids = _explore(part.shape, TopAbs_SOLID) + if not part_solids: + return rebuilt_solid + if len(part_solids) == 1 and _same_shape(part_solids[0], source_solid): + return rebuilt_solid + shapes: list[TopoDS_Shape] = [] + replaced = False + for item in part_solids: + if not replaced and _same_shape(item, source_solid): + shapes.append(rebuilt_solid) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + return _ensure_valid_or_repaired_shape( + _compound_from_shapes(shapes), + "cylindrical cap local shell rebuild compound", + ) + + def _cap_profile_prism_delta_shape( + self, + profile_shape: TopoDS_Shape, + source_shape: TopoDS_Shape, + outward: tuple[float, float, float], + distance: float, + ) -> TopoDS_Shape: + if abs(distance) <= 1e-9: + raise ValueError("Cap profile prism push/pull requires a non-zero distance.") + overlap = _boolean_overlap_distance(source_shape, distance) + if distance > 0: + start_offset = -overlap + tool_distance = distance + overlap + else: + start_offset = overlap + tool_distance = distance - overlap + tool_face = _translated_shape(profile_shape, outward, start_offset) + vec = gp_Vec( + float(outward[0]) * tool_distance, + float(outward[1]) * tool_distance, + float(outward[2]) * tool_distance, + ) + tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() + _ensure_valid_shape(tool_shape) + return tool_shape + + def _prismatic_cap_rebuild_candidate( + self, + face_id: int, + distance: float, + outward: tuple[float, float, float], + profile_shape: TopoDS_Shape, + ) -> dict[str, object] | None: + if abs(distance) <= 1e-9: + return None + if face_id < 0 or face_id >= len(self.faces): + return None + part_id = self.face_part_ids[face_id] + solid_id = self.face_solid_ids[face_id] + part = self.part_by_id(part_id) + if part is None or solid_id < 0 or solid_id >= len(self.solids): + return None + if sum(1 for candidate_part_id, _solid in self.solids if candidate_part_id == part_id) != 1: + return None + + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + if source_surf.GetType() != GeomAbs_Plane: + return None + axis_direction_tuple = _tuple_normalized(outward) + if axis_direction_tuple is None: + return None + axis_point = source_surf.Plane().Location() + axis_direction = gp_Dir(*axis_direction_tuple) + solid = self.solids[solid_id][1] + axis_interval = _shape_axis_interval(solid, axis_point, axis_direction) + if axis_interval is None: + return None + interval_min, interval_max = float(axis_interval[0]), float(axis_interval[1]) + old_height = max(interval_max - interval_min, 0.0) + if old_height <= 1e-9: + return None + tolerance = max(_shape_diagonal(solid) * 1e-6, abs(distance) * 1e-6, old_height * 1e-6, 1e-5) + if abs(interval_max) > max(old_height * 0.08, tolerance * 20.0): + return None + new_height = old_height + float(distance) + if new_height <= max(old_height * 1e-5, tolerance): + return None + + cap_parameters: list[float] = [] + side_face_count = 0 + solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] + if len(solid_face_ids) < 3 or len(solid_face_ids) > 128: + return None + for candidate_face_id in solid_face_ids: + try: + surf = BRepAdaptor_Surface(self.faces[candidate_face_id]) + except Exception: + return None + surface_type = surf.GetType() + if surface_type == GeomAbs_Plane: + normal = surf.Plane().Axis().Direction() + normal_dot = abs(_direction_dot(normal, axis_direction)) + if normal_dot >= 0.92: + center = _surface_center(self.faces[candidate_face_id]) + cap_parameters.append(_axis_parameter(axis_point, axis_direction, center)) + elif normal_dot <= 0.12: + side_face_count += 1 + else: + return None + elif surface_type == GeomAbs_Cylinder: + cylinder_axis = surf.Cylinder().Axis().Direction() + if abs(_direction_dot(cylinder_axis, axis_direction)) < 0.92: + return None + side_face_count += 1 + else: + return None + + unique_cap_parameters: list[float] = [] + for parameter in sorted(cap_parameters): + if not any(abs(parameter - existing) <= tolerance * 20.0 for existing in unique_cap_parameters): + unique_cap_parameters.append(parameter) + if len(unique_cap_parameters) != 2 or side_face_count <= 0: + return None + if abs(max(unique_cap_parameters)) > max(old_height * 0.08, tolerance * 20.0): + return None + if abs(min(unique_cap_parameters) - interval_min) > max(old_height * 0.08, tolerance * 20.0): + return None + + profile_start = _translated_shape(profile_shape, axis_direction_tuple, interval_min) + vec = gp_Vec( + float(axis_direction_tuple[0]) * new_height, + float(axis_direction_tuple[1]) * new_height, + float(axis_direction_tuple[2]) * new_height, + ) + replacement = BRepPrimAPI_MakePrism(profile_start, vec).Shape() + replacement = _ensure_valid_or_repaired_shape(replacement, "prismatic cap analytic rebuild") + replacement = _unify_same_domain_shape(replacement) + replacement = _ensure_valid_or_repaired_shape(replacement, "prismatic cap analytic rebuild unify") + return { + "replacement_shape": replacement, + "old_height": old_height, + "new_height": new_height, + "axis_interval": axis_interval, + "side_face_count": side_face_count, + "cap_plane_count": len(cap_parameters), + "cap_position_count": len(unique_cap_parameters), + "axis_direction": axis_direction_tuple, + "start_offset": interval_min, + } + + def _slow_push_pull_boolean_blocker( + self, + plan: dict[str, object], + cap_extension: dict[str, object] | None, + ) -> str: + distance = abs(float(plan.get("distance", 0.0) or 0.0)) + if distance <= 1e-9: + return "" + face_count = len(self.faces) + selected_inner_wires = int(plan.get("selected_inner_boundary_wires", 0) or 0) + bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or 0.0 + face_ratio = distance / bbox_diagonal if bbox_diagonal > 1e-9 else None + cap_method = str(cap_extension.get("cap_extension_method") or "") if cap_extension is not None else "" + old_height = _float_or_none(cap_extension.get("old_height")) if cap_extension is not None else None + height_ratio = distance / old_height if old_height is not None and old_height > 1e-9 else None + + if ( + cap_method == "cap-profile-prism" + and height_ratio is not None + and height_ratio > 0.35 + ): + return ( + "已识别为带槽口/台阶等额外一级相邻面的端盖,但无法把它安全简化成解析重建;" + "继续走通用 OCCT 布尔很容易长时间卡住。已快速阻止本次大距离推拉," + "请先缩小修改距离,或后续用更明确的槽/台阶一级关系编辑入口处理。" + ) + + if ( + cap_extension is None + and face_count > 600 + and selected_inner_wires > 0 + and ( + face_ratio is None + or face_ratio > 0.2 + or distance > 10.0 + ) + ): + return ( + "当前 Face 有多个内孔/内边界,并且所属 STEP 实体很复杂;程序没有识别到可安全解析重建的单一拉伸体。" + "继续使用通用布尔推拉很可能再次超时。已快速阻止本次大距离推拉;" + "简单空心圆柱、多个贯穿孔圆柱和普通板件端盖已经会走快速解析重建," + "这种复杂大实体需要先做更明确的特征隔离/一级关系编辑。" + ) + return "" + + def _planar_cap_boundary_shell_extension_plan( + self, + face_id: int, + distance: float, + outward: tuple[float, float, float], + scope_face_ids: Iterable[int] | None = None, + ) -> dict[str, object] | None: + if float(distance) <= 1e-9: + return None + if face_id < 0 or face_id >= len(self.faces): + return None + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + except Exception: + return None + if surf.GetType() != GeomAbs_Plane: + return None + solid_id = self.face_solid_ids[face_id] + part_id = self.face_part_ids[face_id] + if solid_id < 0 or solid_id >= len(self.solids): + return None + if self.part_by_id(part_id) is None: + return None + + try: + wire_count = len(_explore(self.faces[face_id], TopAbs_WIRE)) + except Exception: + wire_count = 0 + inner_wires = max(wire_count - 1, 0) + if inner_wires <= 0: + return None + + cap_scope_face_ids = sorted( + { + int(item) + for item in (scope_face_ids or (face_id,)) + if 0 <= int(item) < len(self.faces) and int(self.face_solid_ids[int(item)]) == solid_id + } + ) + if not cap_scope_face_ids: + cap_scope_face_ids = [face_id] + boundary_edge_ids = self._region_boundary_edge_ids(cap_scope_face_ids) + if not boundary_edge_ids: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + boundary_edge_ids = sorted({int(item) for item in boundary_edge_ids if 0 <= int(item) < len(self.edges)}) + if len(boundary_edge_ids) < 4: + return None + + adjacent_ids = sorted( + set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(cap_scope_face_ids) + ) + if not adjacent_ids: + return None + + return { + "cap_extension_kind": "multi-boundary-planar-cap", + "cap_extension_method": "boundary-shell-rebuild", + "cap_operation": "extend", + "cap_scope_face_ids": tuple(cap_scope_face_ids), + "cap_boundary_edge_ids": tuple(boundary_edge_ids), + "cap_boundary_edge_count": len(boundary_edge_ids), + "bridge_face_count": len(boundary_edge_ids), + "inner_boundary_wires": inner_wires, + "boundary_wires": wire_count, + "adjacent_face_ids": tuple(adjacent_ids), + "adjacent_face_count": len(adjacent_ids), + "part_id": part_id, + "solid_id": solid_id, + } + + def _planar_cap_boundary_shell_extension_shape( + self, + face_id: int, + extension: dict[str, object], + distance: float, + outward: tuple[float, float, float], + ) -> TopoDS_Shape: + if float(distance) <= 1e-9: + raise RuntimeError("Planar cap boundary-shell rebuild currently supports outward extension only.") + part_id = self.face_part_ids[face_id] + solid_id = self.face_solid_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Face {face_id} is not attached to a rebuildable Solid.") + source_solid = self.solids[solid_id][1] + cap_scope_face_ids = _int_values(extension.get("cap_scope_face_ids")) or [face_id] + cap_scope_face_ids = [ + item + for item in cap_scope_face_ids + if 0 <= item < len(self.faces) and int(self.face_solid_ids[item]) == solid_id + ] + if not cap_scope_face_ids: + raise RuntimeError("Planar cap boundary-shell rebuild has no cap Face to move.") + boundary_edge_ids = _int_values(extension.get("cap_boundary_edge_ids")) + if not boundary_edge_ids: + boundary_edge_ids = self._region_boundary_edge_ids(cap_scope_face_ids) + boundary_edge_ids = sorted({item for item in boundary_edge_ids if 0 <= item < len(self.edges)}) + if len(boundary_edge_ids) < 3: + raise RuntimeError("Planar cap boundary-shell rebuild has too few boundary Edges.") + + tolerance = max( + _shape_diagonal(source_solid) * 1e-7, + abs(float(distance)) * 1e-7, + 1e-6, + ) + remove_faces = [self.faces[item] for item in cap_scope_face_ids] + rebuilt_faces: list[TopoDS_Shape] = [] + removed_count = 0 + for source_face in _explore(source_solid, TopAbs_FACE): + if any(_same_shape(source_face, removable) for removable in remove_faces): + removed_count += 1 + continue + rebuilt_faces.append(source_face) + if removed_count < len(remove_faces): + raise RuntimeError( + "Planar cap boundary-shell rebuild could not find every source cap Face in the Solid." + ) + + for cap_face_id in cap_scope_face_ids: + moved_cap = _translated_shape( + self.faces[cap_face_id], + (float(outward[0]), float(outward[1]), float(outward[2])), + float(distance), + ) + _ensure_valid_shape(moved_cap) + rebuilt_faces.append(moved_cap) + + bridge_vector = gp_Vec( + float(outward[0]) * float(distance), + float(outward[1]) * float(distance), + float(outward[2]) * float(distance), + ) + bridge_face_count = 0 + for edge_id in boundary_edge_ids: + bridge_shape = BRepPrimAPI_MakePrism(self.edges[edge_id], bridge_vector).Shape() + bridge_faces = _explore(bridge_shape, TopAbs_FACE) + if not bridge_faces and bridge_shape.ShapeType() == TopAbs_FACE: + bridge_faces = [bridge_shape] + if not bridge_faces: + raise RuntimeError( + f"Planar cap boundary-shell rebuild could not create a bridge Face for Edge {edge_id}." + ) + for bridge_face in bridge_faces: + if bridge_face.IsNull(): + raise RuntimeError( + f"Planar cap boundary-shell rebuild created an empty bridge Face for Edge {edge_id}." + ) + rebuilt_faces.append(bridge_face) + bridge_face_count += 1 + if bridge_face_count < len(boundary_edge_ids): + raise RuntimeError( + "Planar cap boundary-shell rebuild created fewer bridge Faces than boundary Edges." + ) + + sewing = BRepBuilderAPI_Sewing(tolerance) + for rebuilt_face in rebuilt_faces: + sewing.Add(rebuilt_face) + sewing.Perform() + sewed = sewing.SewedShape() + if sewed.IsNull(): + raise RuntimeError("Planar cap boundary-shell rebuild sewing produced an empty shape.") + if sewed.ShapeType() == TopAbs_SHELL: + shell = topods.Shell(sewed) + else: + shells = _explore(sewed, TopAbs_SHELL) + if not shells: + raise RuntimeError("Planar cap boundary-shell rebuild did not produce a sewable shell.") + shell = topods.Shell(shells[0]) + solid_builder = BRepBuilderAPI_MakeSolid(shell) + rebuilt_solid = solid_builder.Solid() + if rebuilt_solid.IsNull(): + raise RuntimeError("Planar cap boundary-shell rebuild could not create a Solid.") + rebuilt_solid = _ensure_valid_or_repaired_shape( + rebuilt_solid, + "planar cap boundary-shell rebuild", + ) + + part_solids = _explore(part.shape, TopAbs_SOLID) + if not part_solids: + return rebuilt_solid + if len(part_solids) == 1 and _same_shape(part_solids[0], source_solid): + return rebuilt_solid + shapes: list[TopoDS_Shape] = [] + replaced = False + for item in part_solids: + if not replaced and _same_shape(item, source_solid): + shapes.append(rebuilt_solid) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + return _ensure_valid_or_repaired_shape( + _compound_from_shapes(shapes), + "planar cap boundary-shell rebuild compound", + ) + def _cylindrical_cap_extension_plan( self, face_id: int, distance: float, outward: tuple[float, float, float], ) -> dict[str, object] | None: - if distance <= 0: + if abs(distance) <= 1e-9: return None if face_id < 0 or face_id >= len(self.faces): return None - if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1: + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + if source_surf.GetType() != GeomAbs_Plane: return None - - cap_center = _surface_center(self.faces[face_id]) - boundary_edge_ids = self._face_boundary_edge_ids(face_id) + source_plane = source_surf.Plane() + cap_plane_point = source_plane.Location() + try: + scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id] + except Exception: + scope_face_ids = [face_id] + scope_face_ids = sorted({int(item) for item in scope_face_ids if 0 <= int(item) < len(self.faces)}) + if not scope_face_ids: + scope_face_ids = [face_id] + try: + region_shape = _compound_from_shapes([self.faces[item] for item in scope_face_ids]) + region_props = GProp_GProps() + brepgprop.SurfaceProperties(region_shape, region_props) + cap_center = region_props.CentreOfMass() + except Exception: + cap_center = _surface_center(self.faces[face_id]) + boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) + if not boundary_edge_ids: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) if not adjacent_face_ids: return None @@ -7673,14 +9101,14 @@ class OperationMixin: tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) candidates: list[tuple[float, dict[str, object]]] = [] + cylindrical_adjacent_face_ids: set[int] = set() + adjacent_face_id_set = set(int(item) for item in adjacent_face_ids) for adjacent_id in adjacent_face_ids: side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) if side_surf.GetType() != GeomAbs_Cylinder: continue angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter()) - if angular_span < math.tau * 0.92: - continue cylinder = side_surf.Cylinder() radius = float(cylinder.Radius()) @@ -7690,45 +9118,93 @@ class OperationMixin: axis_alignment = _direction_dot(outward_dir, axis_dir) if abs(axis_alignment) < 0.92: continue - if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0): - continue + center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center) axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) old_height = max(v_max - v_min, 1e-9) - cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center) + cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point) start_distance = abs(cap_parameter - v_min) end_distance = abs(cap_parameter - v_max) end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + delta_abs = abs(float(distance)) + operation = "extend" if distance > 0 else "retract" + segment_overlap = ( + min( + max(tolerance * 20.0, radius * 1e-5, old_height * 1e-5, 1e-5), + max(old_height * 0.02, 1e-5), + max(delta_abs * 0.05, 1e-5), + ) + if operation == "extend" + else 0.0 + ) if axis_alignment > 0 and end_distance <= end_tolerance: new_min = v_min - new_max = v_max + distance + new_max = v_max + distance if distance > 0 else v_max - delta_abs + if new_max <= new_min + max(tolerance * 10.0, old_height * 1e-5, 1e-6): + continue + segment_start_parameter = v_max - segment_overlap if distance > 0 else new_max + segment_height = delta_abs + segment_overlap if distance > 0 else delta_abs end_score = end_distance elif axis_alignment < 0 and start_distance <= end_tolerance: - new_min = v_min - distance + new_min = v_min - distance if distance > 0 else v_min + delta_abs new_max = v_max + if new_max <= new_min + max(tolerance * 10.0, old_height * 1e-5, 1e-6): + continue + segment_start_parameter = new_min if distance > 0 else v_min + segment_height = delta_abs + segment_overlap if distance > 0 else delta_abs end_score = start_distance else: continue height = max(new_max - new_min, 1e-6) start = _point_on_axis(axis_point, axis_dir, new_min) + end = _point_on_axis(axis_point, axis_dir, new_max) + old_start = _point_on_axis(axis_point, axis_dir, v_min) + old_end = _point_on_axis(axis_point, axis_dir, v_max) tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape() + segment_start = _point_on_axis(axis_point, axis_dir, segment_start_parameter) + segment_shape = BRepPrimAPI_MakeCylinder( + gp_Ax2(segment_start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), + radius, + max(segment_height, 1e-6), + ).Shape() score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center) candidate = { "tool_shape": tool_shape, + "extension_shape": segment_shape if operation == "extend" else None, + "removal_shape": segment_shape if operation == "retract" else None, + "local_delta_shape": segment_shape, "side_face_ids": axis_range["same_domain_face_ids"], "radius": radius, "old_height": old_height, "new_height": height, + "extension_height": max(segment_height, 1e-6), + "cap_operation": operation, "axis_alignment": axis_alignment, + "angular_span": angular_span, + "cap_center_axis_distance": center_axis_distance, + "axis_point": _point_tuple(axis_point), + "axis_direction": (float(axis_dir.X()), float(axis_dir.Y()), float(axis_dir.Z())), "cap_axis_parameter": cap_parameter, + "old_start_parameter": v_min, + "old_end_parameter": v_max, "start_parameter": new_min, "end_parameter": new_max, + "old_start_point": _point_tuple(old_start), + "old_end_point": _point_tuple(old_end), + "start_point": _point_tuple(start), + "end_point": _point_tuple(end), + "extension_start_point": _point_tuple(segment_start), + "cap_extension_kind": "solid-cylinder", + "inner_radius": None, + "cap_scope_face_ids": tuple(scope_face_ids), + "cap_boundary_edge_ids": tuple(boundary_edge_ids), } candidates.append((score, candidate)) + cylindrical_adjacent_face_ids.add(int(adjacent_id)) if not candidates: return None @@ -7738,9 +9214,650 @@ class OperationMixin: if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii): distinct_radii.append(radius) if len(distinct_radii) > 1: + tube_candidate = self._coaxial_tube_cap_extension_candidate(candidates, tolerance) + if tube_candidate is not None and self._tube_cap_region_matches_radii( + scope_face_ids, + tube_candidate, + tolerance, + ): + extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) + tube_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) + tube_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) + tube_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids + tube_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) + if extra_adjacent_face_ids: + if distance > 0: + tube_candidate["cap_extension_method"] = "cap-profile-prism" + else: + tube_candidate.update( + self._cap_extra_adjacent_retract_limit( + extra_adjacent_face_ids, + tube_candidate, + tolerance, + ) + ) + if bool(tube_candidate.get("cap_profile_prism_retract_safe")): + tube_candidate["cap_extension_method"] = "cap-profile-prism" + return tube_candidate + stepped_candidate = self._coaxial_stepped_cap_extension_candidate(candidates, tolerance) + if stepped_candidate is not None and self._tube_cap_region_matches_radii( + scope_face_ids, + stepped_candidate, + tolerance, + ): + extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) + stepped_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) + stepped_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) + stepped_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids + stepped_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) + if ( + str(stepped_candidate.get("cap_operation") or "") == "extend" + and not extra_adjacent_face_ids + and self._cylindrical_cap_side_faces_are_simple( + stepped_candidate, + scope_face_ids, + tolerance, + ) + ): + stepped_candidate["cap_extension_method"] = "local-shell-rebuild" + return stepped_candidate + return None + extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) + if len(distinct_radii) == 1 and extra_adjacent_face_ids: + solid_candidate = min(candidates, key=lambda item: item[0])[1] + if self._solid_cap_region_matches_radius(scope_face_ids, solid_candidate, tolerance): + solid_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) + solid_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) + solid_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids + solid_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) + if distance > 0: + solid_candidate["cap_extension_method"] = "cap-profile-prism" + else: + solid_candidate.update( + self._cap_extra_adjacent_retract_limit( + extra_adjacent_face_ids, + solid_candidate, + tolerance, + ) + ) + if bool(solid_candidate.get("cap_profile_prism_retract_safe")): + solid_candidate["cap_extension_method"] = "cap-profile-prism" + return solid_candidate + if cylindrical_adjacent_face_ids != adjacent_face_id_set: + return None + if any(float(candidate.get("angular_span") or 0.0) < math.tau * 0.92 for _score, candidate in candidates): + return None + if any( + float(candidate.get("cap_center_axis_distance") or 0.0) + > max(float(candidate["radius"]) * 0.08, tolerance * 10.0) + for _score, candidate in candidates + ): return None return min(candidates, key=lambda item: item[0])[1] + def _cap_extra_adjacent_retract_limit( + self, + extra_face_ids: Iterable[int], + cap_extension: dict[str, object], + tolerance: float, + ) -> dict[str, object]: + axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) + axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) + if axis_point_tuple is None or axis_direction_tuple is None or axis_alignment is None: + return {"cap_profile_prism_retract_safe": False} + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + old_start = _float_or_none(cap_extension.get("old_start_parameter")) + old_end = _float_or_none(cap_extension.get("old_end_parameter")) + new_start = _float_or_none(cap_extension.get("start_parameter")) + new_end = _float_or_none(cap_extension.get("end_parameter")) + if old_start is None or old_end is None or new_start is None or new_end is None: + return {"cap_profile_prism_retract_safe": False} + + if axis_alignment > 0.0: + old_cap_parameter = old_end + target_parameter = new_end + else: + old_cap_parameter = old_start + target_parameter = new_start + + limit_candidates: list[float] = [] + touch_tolerance = max(abs(old_end - old_start) * 1e-4, tolerance * 50.0, 1e-4) + for face_id in extra_face_ids: + if face_id < 0 or face_id >= len(self.faces): + continue + interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_direction) + if interval is None: + continue + interval_min, interval_max = float(interval[0]), float(interval[1]) + if not (interval_min - touch_tolerance <= old_cap_parameter <= interval_max + touch_tolerance): + continue + limit_candidates.append(interval_min if axis_alignment > 0.0 else interval_max) + + if not limit_candidates: + return { + "cap_profile_prism_retract_safe": False, + "cap_profile_prism_target_parameter": target_parameter, + } + + if axis_alignment > 0.0: + limit_parameter = min(limit_candidates) + safe = target_parameter >= limit_parameter - touch_tolerance + excess = max(limit_parameter - target_parameter, 0.0) + else: + limit_parameter = max(limit_candidates) + safe = target_parameter <= limit_parameter + touch_tolerance + excess = max(target_parameter - limit_parameter, 0.0) + return { + "cap_profile_prism_retract_safe": safe, + "cap_profile_prism_target_parameter": target_parameter, + "cap_profile_prism_retract_limit_parameter": limit_parameter, + "cap_profile_prism_retract_excess": excess, + } + + def _solid_cap_region_matches_radius( + self, + face_ids: Iterable[int], + cap_extension: dict[str, object], + tolerance: float, + ) -> bool: + radius = _float_or_none(cap_extension.get("radius")) + axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) + if radius is None or radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None: + return False + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + radial_values: list[float] = [] + for item in face_ids: + if item < 0 or item >= len(self.faces): + continue + explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX) + while explorer.More(): + vertex = topods.Vertex(explorer.Current()) + radial_values.append(_point_axis_distance(axis_point, axis_direction, BRep_Tool.Pnt(vertex))) + explorer.Next() + if not radial_values: + return False + radial_tolerance = max(radius * 0.04, tolerance * 50.0, 1e-4) + radial_max = max(radial_values) + if radial_max > radius + radial_tolerance: + return False + if abs(radial_max - radius) > radial_tolerance: + return False + cap_extension["cap_region_radial_max"] = radial_max + return True + + def _tube_cap_region_matches_radii( + self, + face_ids: Iterable[int], + cap_extension: dict[str, object], + tolerance: float, + ) -> bool: + outer_radius = _float_or_none(cap_extension.get("radius")) + inner_radius = _float_or_none(cap_extension.get("inner_radius")) + axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) + if ( + outer_radius is None + or inner_radius is None + or outer_radius <= inner_radius + or inner_radius <= 1e-9 + or axis_point_tuple is None + or axis_direction_tuple is None + ): + return False + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + radial_values: list[float] = [] + for item in face_ids: + if item < 0 or item >= len(self.faces): + continue + explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX) + while explorer.More(): + vertex = topods.Vertex(explorer.Current()) + radial_values.append(_point_axis_distance(axis_point, axis_direction, BRep_Tool.Pnt(vertex))) + explorer.Next() + if not radial_values: + return False + radial_tolerance = max(outer_radius * 0.04, inner_radius * 0.04, tolerance * 50.0, 1e-4) + radial_min = min(radial_values) + radial_max = max(radial_values) + if radial_max > outer_radius + radial_tolerance: + return False + if radial_min < inner_radius - radial_tolerance: + return False + if abs(radial_max - outer_radius) > radial_tolerance: + return False + if abs(radial_min - inner_radius) > radial_tolerance: + return False + cap_extension["cap_region_radial_min"] = radial_min + cap_extension["cap_region_radial_max"] = radial_max + return True + + def _coaxial_tube_cap_extension_candidate( + self, + candidates: list[tuple[float, dict[str, object]]], + tolerance: float, + ) -> dict[str, object] | None: + if len(candidates) < 2: + return None + radii = sorted({round(float(candidate["radius"]), 9) for _score, candidate in candidates}) + if len(radii) != 2: + return None + inner_radius = float(radii[0]) + outer_radius = float(radii[1]) + if inner_radius <= 1e-9 or outer_radius <= inner_radius: + return None + + reference = candidates[0][1] + axis_point_tuple = _tuple_or_none(reference.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(reference.get("axis_direction"))) + start_point_tuple = _tuple_or_none(reference.get("start_point")) + end_point_tuple = _tuple_or_none(reference.get("end_point")) + old_start_point_tuple = _tuple_or_none(reference.get("old_start_point")) + old_end_point_tuple = _tuple_or_none(reference.get("old_end_point")) + if ( + axis_point_tuple is None + or axis_direction_tuple is None + or start_point_tuple is None + or end_point_tuple is None + or old_start_point_tuple is None + or old_end_point_tuple is None + ): + return None + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + start_point = gp_Pnt(*start_point_tuple) + end_point = gp_Pnt(*end_point_tuple) + old_start_point = gp_Pnt(*old_start_point_tuple) + old_end_point = gp_Pnt(*old_end_point_tuple) + start_parameter = float(reference["start_parameter"]) + end_parameter = float(reference["end_parameter"]) + old_height = float(reference["old_height"]) + new_height = float(reference["new_height"]) + + side_face_ids: set[int] = set() + score = 0.0 + reference_operation = str(reference.get("cap_operation") or "extend") + for item_score, candidate in candidates: + if str(candidate.get("cap_operation") or "extend") != reference_operation: + return None + candidate_axis_point = _tuple_or_none(candidate.get("axis_point")) + candidate_axis_direction = _tuple_normalized(_tuple_or_none(candidate.get("axis_direction"))) + if candidate_axis_point is None or candidate_axis_direction is None: + return None + if abs(abs(_tuple_dot(axis_direction_tuple, candidate_axis_direction)) - 1.0) > 1e-5: + return None + if _point_axis_distance(axis_point, axis_direction, gp_Pnt(*candidate_axis_point)) > max(tolerance * 10.0, outer_radius * 1e-5): + return None + for key, expected in (("old_height", old_height), ("new_height", new_height)): + if abs(float(candidate[key]) - expected) > max(tolerance * 10.0, old_height * 1e-5, 1e-5): + return None + candidate_start = _tuple_or_none(candidate.get("start_point")) + candidate_end = _tuple_or_none(candidate.get("end_point")) + candidate_old_start = _tuple_or_none(candidate.get("old_start_point")) + candidate_old_end = _tuple_or_none(candidate.get("old_end_point")) + if ( + candidate_start is None + or candidate_end is None + or candidate_old_start is None + or candidate_old_end is None + ): + return None + point_tolerance = max(tolerance * 20.0, outer_radius * 1e-5, old_height * 1e-6, 1e-5) + for actual_point, expected_point in ( + (gp_Pnt(*candidate_start), start_point), + (gp_Pnt(*candidate_end), end_point), + (gp_Pnt(*candidate_old_start), old_start_point), + (gp_Pnt(*candidate_old_end), old_end_point), + ): + if actual_point.Distance(expected_point) > point_tolerance: + return None + side_face_ids.update(_int_values(candidate.get("side_face_ids"))) + score += float(item_score) + + extension_start_point_tuple = _tuple_or_none(reference.get("extension_start_point")) + extension_height = _float_or_none(reference.get("extension_height")) + if extension_start_point_tuple is None or extension_height is None or extension_height <= 1e-9: + return None + extension_start_point = gp_Pnt(*extension_start_point_tuple) + operation = str(reference.get("cap_operation") or "extend") + + tool_shape = self._make_coaxial_tube_shape(start_point, axis_direction, outer_radius, inner_radius, new_height) + local_delta_shape = self._make_coaxial_tube_shape( + extension_start_point, + axis_direction, + outer_radius, + inner_radius, + extension_height, + ) + return { + **reference, + "tool_shape": tool_shape, + "extension_shape": local_delta_shape if operation == "extend" else None, + "removal_shape": local_delta_shape if operation == "retract" else None, + "local_delta_shape": local_delta_shape, + "side_face_ids": tuple(sorted(side_face_ids)), + "radius": outer_radius, + "inner_radius": inner_radius, + "old_height": old_height, + "new_height": new_height, + "extension_height": extension_height, + "cap_operation": operation, + "start_parameter": start_parameter, + "end_parameter": end_parameter, + "cap_extension_kind": "coaxial-tube", + "candidate_score": score, + } + + def _coaxial_stepped_cap_extension_candidate( + self, + candidates: list[tuple[float, dict[str, object]]], + tolerance: float, + ) -> dict[str, object] | None: + if len(candidates) < 2: + return None + radii = sorted({round(float(candidate["radius"]), 9) for _score, candidate in candidates}) + if len(radii) != 2: + return None + inner_radius = float(radii[0]) + outer_radius = float(radii[1]) + if inner_radius <= 1e-9 or outer_radius <= inner_radius: + return None + + reference = candidates[0][1] + axis_point_tuple = _tuple_or_none(reference.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(reference.get("axis_direction"))) + if axis_point_tuple is None or axis_direction_tuple is None: + return None + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + operation = str(reference.get("cap_operation") or "extend") + axis_alignment = _float_or_none(reference.get("axis_alignment")) + reference_cap_parameter = _float_or_none(reference.get("cap_axis_parameter")) + if axis_alignment is None or reference_cap_parameter is None: + return None + + side_face_ids: set[int] = set() + radius_ranges: dict[float, dict[str, float]] = {} + score = 0.0 + target_cap_parameter: float | None = None + for item_score, candidate in candidates: + if str(candidate.get("cap_operation") or "extend") != operation: + return None + candidate_axis_point = _tuple_or_none(candidate.get("axis_point")) + candidate_axis_direction = _tuple_normalized(_tuple_or_none(candidate.get("axis_direction"))) + if candidate_axis_point is None or candidate_axis_direction is None: + return None + if abs(abs(_tuple_dot(axis_direction_tuple, candidate_axis_direction)) - 1.0) > 1e-5: + return None + if _point_axis_distance(axis_point, axis_direction, gp_Pnt(*candidate_axis_point)) > max( + tolerance * 10.0, + outer_radius * 1e-5, + ): + return None + candidate_alignment = _float_or_none(candidate.get("axis_alignment")) + candidate_cap_parameter = _float_or_none(candidate.get("cap_axis_parameter")) + start_parameter = _float_or_none(candidate.get("start_parameter")) + end_parameter = _float_or_none(candidate.get("end_parameter")) + old_start_parameter = _float_or_none(candidate.get("old_start_parameter")) + old_end_parameter = _float_or_none(candidate.get("old_end_parameter")) + radius = _float_or_none(candidate.get("radius")) + if ( + candidate_alignment is None + or candidate_cap_parameter is None + or start_parameter is None + or end_parameter is None + or old_start_parameter is None + or old_end_parameter is None + or radius is None + or radius <= 1e-9 + ): + return None + if candidate_alignment * axis_alignment <= 0.0: + return None + if abs(candidate_cap_parameter - reference_cap_parameter) > max(tolerance * 20.0, 1e-5): + return None + candidate_target = end_parameter if axis_alignment > 0.0 else start_parameter + if target_cap_parameter is None: + target_cap_parameter = candidate_target + elif abs(candidate_target - target_cap_parameter) > max(tolerance * 20.0, 1e-5): + return None + + key = min(radii, key=lambda item: abs(item - float(radius))) + existing = radius_ranges.get(key) + data = { + "radius": float(radius), + "old_start_parameter": old_start_parameter, + "old_end_parameter": old_end_parameter, + "start_parameter": start_parameter, + "end_parameter": end_parameter, + "old_height": max(old_end_parameter - old_start_parameter, 0.0), + "new_height": max(end_parameter - start_parameter, 0.0), + } + if existing is not None: + for field in ("old_start_parameter", "old_end_parameter", "start_parameter", "end_parameter"): + if abs(float(existing[field]) - float(data[field])) > max(tolerance * 20.0, 1e-5): + return None + radius_ranges[key] = data + side_face_ids.update(_int_values(candidate.get("side_face_ids"))) + score += float(item_score) + + if set(radius_ranges) != set(radii) or target_cap_parameter is None: + return None + + side_specs: list[dict[str, object]] = [] + for side_face_id in sorted(side_face_ids): + if side_face_id < 0 or side_face_id >= len(self.faces): + return None + try: + surf = BRepAdaptor_Surface(self.faces[side_face_id]) + except Exception: + return None + if surf.GetType() != GeomAbs_Cylinder: + return None + cylinder = surf.Cylinder() + radius = float(cylinder.Radius()) + key = min(radii, key=lambda item: abs(item - radius)) + if abs(radius - key) > max(key * 1e-5, tolerance * 10.0): + return None + side_axis = cylinder.Axis() + if abs(abs(_direction_dot(axis_direction, side_axis.Direction())) - 1.0) > 1e-5: + return None + if _point_axis_distance(axis_point, axis_direction, side_axis.Location()) > max( + tolerance * 10.0, + outer_radius * 1e-5, + ): + return None + range_data = radius_ranges[key] + side_specs.append( + { + "face_id": side_face_id, + "radius": radius, + "u_min": float(surf.FirstUParameter()), + "u_max": float(surf.LastUParameter()), + "old_v_min": float(range_data["old_start_parameter"]), + "old_v_max": float(range_data["old_end_parameter"]), + "new_v_min": float(range_data["start_parameter"]), + "new_v_max": float(range_data["end_parameter"]), + } + ) + + old_height = max(float(item["old_height"]) for item in radius_ranges.values()) + new_height = max(float(item["new_height"]) for item in radius_ranges.values()) + extension_height = abs(target_cap_parameter - reference_cap_parameter) + return { + **reference, + "tool_shape": None, + "extension_shape": None, + "removal_shape": None, + "local_delta_shape": None, + "side_face_ids": tuple(sorted(side_face_ids)), + "side_rebuild_specs": tuple(side_specs), + "radius": outer_radius, + "inner_radius": inner_radius, + "old_height": old_height, + "new_height": new_height, + "extension_height": extension_height, + "cap_operation": operation, + "start_parameter": min(float(item["start_parameter"]) for item in radius_ranges.values()), + "end_parameter": max(float(item["end_parameter"]) for item in radius_ranges.values()), + "cap_extension_kind": "coaxial-stepped-cap", + "candidate_score": score, + } + + def _cylindrical_cap_side_faces_are_simple( + self, + cap_extension: dict[str, object], + scope_face_ids: Iterable[int], + tolerance: float, + ) -> bool: + side_specs = [item for item in cap_extension.get("side_rebuild_specs", ()) if isinstance(item, dict)] + if not side_specs: + return False + axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) + axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) + if axis_point_tuple is None or axis_direction_tuple is None or axis_alignment is None: + return False + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + cap_scope = {int(item) for item in scope_face_ids if 0 <= int(item) < len(self.faces)} + side_face_ids = {int(item.get("face_id", -1)) for item in side_specs} + side_face_ids = {item for item in side_face_ids if 0 <= item < len(self.faces)} + if not side_face_ids: + return False + end_tolerance = max(tolerance * 50.0, 1e-4) + + for spec in side_specs: + side_face_id = int(spec.get("face_id", -1)) + if side_face_id < 0 or side_face_id >= len(self.faces): + return False + if len(_explore(self.faces[side_face_id], TopAbs_WIRE)) != 1: + return False + edge_ids = self._face_boundary_edge_ids(side_face_id) + if not edge_ids or len(edge_ids) > 6: + return False + fixed_parameter = ( + _float_or_none(spec.get("old_v_min")) + if axis_alignment > 0.0 + else _float_or_none(spec.get("old_v_max")) + ) + if fixed_parameter is None: + return False + adjacent_ids: set[int] = set() + for edge_id in edge_ids: + adjacent_ids.update(self._edge_adjacent_face_ids(edge_id)) + extra_ids = sorted(adjacent_ids - {side_face_id} - side_face_ids - cap_scope) + for extra_id in extra_ids: + if extra_id < 0 or extra_id >= len(self.faces): + return False + interval = _shape_axis_interval(self.faces[extra_id], axis_point, axis_direction) + if interval is None: + return False + interval_min, interval_max = float(interval[0]), float(interval[1]) + if not (interval_min - end_tolerance <= fixed_parameter <= interval_max + end_tolerance): + return False + return True + + def _make_coaxial_tube_shape( + self, + start: gp_Pnt, + axis_direction, + outer_radius: float, + inner_radius: float, + height: float, + ) -> TopoDS_Shape: + axis = gp_Ax2(start, gp_Dir(axis_direction.X(), axis_direction.Y(), axis_direction.Z())) + outer = BRepPrimAPI_MakeCylinder(axis, outer_radius, height).Shape() + extra = max(float(height) * 1e-5, float(outer_radius) * 1e-5, 1e-5) + cutter_start = _point_on_axis(start, axis_direction, -extra) + cutter_axis = gp_Ax2(cutter_start, gp_Dir(axis_direction.X(), axis_direction.Y(), axis_direction.Z())) + inner = BRepPrimAPI_MakeCylinder(cutter_axis, inner_radius, height + 2.0 * extra).Shape() + op = BRepAlgoAPI_Cut(outer, inner) + return _finalize_boolean_result(op, "coaxial tube analytic rebuild", use_glue=False) + + def _simple_cylindrical_cap_extension_rebuild_available( + self, + face_id: int, + cap_extension: dict[str, object], + ) -> bool: + if face_id < 0 or face_id >= len(self.faces): + return False + solid_id = self.face_solid_ids[face_id] + part_id = self.face_part_ids[face_id] + if solid_id < 0 or solid_id >= len(self.solids): + return False + if sum(1 for candidate_part_id, _solid in self.solids if candidate_part_id == part_id) != 1: + return False + + outer_radius = _float_or_none(cap_extension.get("radius")) + inner_radius = _float_or_none(cap_extension.get("inner_radius")) + axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) + if outer_radius is None or outer_radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None: + return False + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + tolerance = max(float(cap_extension.get("new_height") or 0.0) * 1e-6, outer_radius * 1e-5, 1e-5) + expected_radii = [outer_radius] + if inner_radius is not None and inner_radius > 1e-9: + expected_radii.append(inner_radius) + + cylinder_matches: list[float] = [] + cap_parameters: list[float] = [] + face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] + if len(face_ids) < 3 or len(face_ids) > 96: + return False + saw_inner_radius = inner_radius is None or inner_radius <= 1e-9 + saw_outer_radius = False + for candidate_face_id in face_ids: + try: + surf = BRepAdaptor_Surface(self.faces[candidate_face_id]) + except Exception: + return False + if surf.GetType() == GeomAbs_Cylinder: + cylinder = surf.Cylinder() + radius = float(cylinder.Radius()) + if not any(abs(radius - expected) <= max(expected * 1e-5, tolerance) for expected in expected_radii): + return False + if abs(abs(_direction_dot(axis_direction, cylinder.Axis().Direction())) - 1.0) > 1e-5: + return False + if _point_axis_distance(axis_point, axis_direction, cylinder.Axis().Location()) > max(tolerance * 10.0, outer_radius * 1e-5): + return False + if abs(radius - outer_radius) <= max(outer_radius * 1e-5, tolerance): + saw_outer_radius = True + if inner_radius is not None and inner_radius > 1e-9: + if abs(radius - inner_radius) <= max(inner_radius * 1e-5, tolerance): + saw_inner_radius = True + cylinder_matches.append(radius) + elif surf.GetType() == GeomAbs_Plane: + normal = surf.Plane().Axis().Direction() + if abs(_direction_dot(normal, axis_direction)) < 0.92: + return False + center = _surface_center(self.faces[candidate_face_id]) + if _point_axis_distance(axis_point, axis_direction, center) > max(outer_radius * 0.05, tolerance * 10.0): + return False + cap_parameters.append(_axis_parameter(axis_point, axis_direction, center)) + else: + return False + + unique_cap_parameters: list[float] = [] + cap_parameter_tolerance = max( + float(cap_extension.get("new_height") or 0.0) * 1e-5, + outer_radius * 1e-5, + tolerance * 10.0, + 1e-5, + ) + for parameter in sorted(cap_parameters): + if not any(abs(parameter - existing) <= cap_parameter_tolerance for existing in unique_cap_parameters): + unique_cap_parameters.append(parameter) + if len(unique_cap_parameters) != 2: + return False + return bool(saw_outer_radius and saw_inner_radius) + def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": @@ -8230,7 +10347,7 @@ class OperationMixin: "span_tolerance": span_tolerance, } if candidate["matched"]: - return self._attach_cylindrical_first_level_result_check(candidate) + return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate @@ -8309,7 +10426,7 @@ class OperationMixin: "center_distance_tolerance": distance_tolerance, } if candidate["matched"]: - return self._attach_cylindrical_first_level_result_check(candidate) + return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate @@ -8412,7 +10529,7 @@ class OperationMixin: "center_tolerance": center_tolerance, } if candidate["matched"]: - return self._attach_cylindrical_first_level_result_check(candidate) + return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate @@ -8916,7 +11033,39 @@ class OperationMixin: f"verified_face={verification.get('face_id', '')}." ) - def _attach_cylindrical_first_level_result_check(self, candidate: dict[str, object]) -> dict[str, object]: + def _cylindrical_result_first_level_minimums(self, plan: dict[str, object] | None) -> dict[str, int]: + if not isinstance(plan, dict): + return {} + + span = ( + _float_or_none(plan.get("slot_angular_span")) + or _float_or_none(plan.get("slot_target_angular_span")) + or _float_or_none(plan.get("angular_span")) + ) + is_partial = bool( + str(plan.get("slot_kind") or "") + or str(plan.get("slot_resize_mode") or "").startswith("slot") + or (span is not None and span < math.tau * 0.92) + ) + stable_floor = 4 if is_partial else 2 + minimums: dict[str, int] = {} + count_keys = { + "cylindrical_feature_boundary_edge_count": "boundary_edge_count", + "cylindrical_feature_boundary_vertex_count": "boundary_vertex_count", + "cylindrical_feature_adjacent_face_count": "adjacent_face_count", + } + for plan_key, summary_key in count_keys.items(): + value = _int_or_none(plan.get(plan_key)) + if value is None or value <= 0: + continue + minimums[summary_key] = max(1, min(int(value), stable_floor)) + return minimums + + def _attach_cylindrical_first_level_result_check( + self, + candidate: dict[str, object], + plan: dict[str, object] | None = None, + ) -> dict[str, object]: if not candidate.get("matched"): return candidate face_ids: list[int] = [] @@ -8938,7 +11087,12 @@ class OperationMixin: 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) + boundary_vertex_count = int(topology.get("cylindrical_feature_boundary_vertex_count", 0) or 0) adjacent_face_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) + end_face_count = int(topology.get("cylindrical_feature_end_face_count", 0) or 0) + bottom_face_count = int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0) + opening_face_count = int(topology.get("cylindrical_feature_opening_face_count", 0) or 0) + slot_boundary_face_count = int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0) except Exception as exc: return { **candidate, @@ -8951,7 +11105,12 @@ class OperationMixin: "face_id": face_id, "side_face_count": side_face_count, "boundary_edge_count": boundary_edge_count, + "boundary_vertex_count": boundary_vertex_count, "adjacent_face_count": adjacent_face_count, + "end_face_count": end_face_count, + "bottom_face_count": bottom_face_count, + "opening_face_count": opening_face_count, + "slot_boundary_face_count": slot_boundary_face_count, } ) if side_face_count <= 0 or boundary_edge_count <= 0 or adjacent_face_count <= 0: @@ -8966,11 +11125,31 @@ class OperationMixin: f"adjacent_faces={adjacent_face_count})." ), } + minimums = self._cylindrical_result_first_level_minimums(plan) + under_minimum: list[str] = [] + summary_values = summaries[-1] + for key, minimum in minimums.items(): + actual = int(summary_values.get(key, 0) or 0) + if actual < minimum: + under_minimum.append(f"{key}={actual} < {minimum}") + if under_minimum: + return { + **candidate, + "matched": False, + "first_level_topology_matched": False, + "first_level_topology_summaries": tuple(summaries), + "first_level_topology_minimums": dict(minimums), + "detail": ( + f" Result Face {face_id} matched target geometry but its first-level topology is weaker " + f"than the edit plan requires ({'; '.join(under_minimum)})." + ), + } return { **candidate, "first_level_topology_matched": True, "first_level_topology_summaries": tuple(summaries), + "first_level_topology_minimums": self._cylindrical_result_first_level_minimums(plan), } def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: @@ -9019,7 +11198,7 @@ class OperationMixin: "axis_tolerance": axis_tolerance, } if candidate["matched"]: - return self._attach_cylindrical_first_level_result_check(candidate) + return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate @@ -9037,6 +11216,364 @@ class OperationMixin: **best, } + def _verify_cylindrical_height_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_height = _float_or_none(plan.get("target_height")) + target_radius = _float_or_none(plan.get("radius")) + diameter = _float_or_none(plan.get("diameter")) + if target_radius is None and diameter is not None: + target_radius = diameter * 0.5 + axis_direction_tuple = _tuple_normalized( + _tuple_or_none(plan.get("axis")) or _tuple_or_none(plan.get("affine_axis_direction")) + ) + axis_point_tuple = ( + _tuple_or_none(plan.get("axis_point")) + or _tuple_or_none(plan.get("affine_axis_point")) + or _tuple_or_none(plan.get("scale_center")) + ) + if ( + target_height is None + or target_height <= 1e-9 + or target_radius is None + or target_radius <= 1e-9 + or axis_direction_tuple is None + or axis_point_tuple is None + ): + return {"matched": False, "detail": " Missing cylindrical height verification data."} + + target_axis_direction = gp_Dir(*axis_direction_tuple) + target_axis_point = gp_Pnt(*axis_point_tuple) + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_height, target_radius, 1.0) + height_tolerance = max(target_height * 0.005, diagonal * 1e-5, 1e-4) + radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5) + axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + best: dict[str, object] | None = None + best_score = math.inf + + for candidate_face_id, face in enumerate(self.faces): + if self.face_part_ids[candidate_face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + candidate_radius = float(cyl.Radius()) + radius_error = abs(candidate_radius - target_radius) + axis_dot = abs(_direction_dot(target_axis_direction, cyl.Axis().Direction())) + if axis_dot < 1.0 - 1e-5: + continue + axis_distance = _point_axis_distance(target_axis_point, target_axis_direction, cyl.Axis().Location()) + if axis_distance > axis_tolerance: + continue + try: + feature = self.feature_info(candidate_face_id) + except Exception: + feature = {} + axis_range = self._cylindrical_axis_range( + candidate_face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + candidate_height = float(axis_range.get("span", 0.0)) + height_error = abs(candidate_height - target_height) + except Exception: + continue + + score = ( + height_error / max(height_tolerance, 1e-9) + + radius_error / max(radius_tolerance, 1e-9) + + axis_distance / max(axis_tolerance, 1e-9) + ) + candidate = { + "matched": ( + height_error <= height_tolerance + and radius_error <= radius_tolerance + and axis_distance <= axis_tolerance + ), + "face_id": candidate_face_id, + "height": candidate_height, + "target_height": target_height, + "height_error": height_error, + "height_tolerance": height_tolerance, + "radius": candidate_radius, + "target_radius": target_radius, + "radius_error": radius_error, + "radius_tolerance": radius_tolerance, + "axis_distance": axis_distance, + "axis_tolerance": axis_tolerance, + } + if candidate["matched"]: + return self._attach_cylindrical_first_level_result_check(candidate, plan) + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " No same-axis cylindrical Face was found after height edit."} + return { + "matched": False, + "detail": ( + f" Closest cylindrical Face {best['face_id']} height {float(best['height']):.6g}, " + f"target height {target_height:.6g}, height error {float(best['height_error']):.6g}, " + f"radius {float(best['radius']):.6g}, target radius {target_radius:.6g}, " + f"axis distance {float(best['axis_distance']):.6g}." + ), + **best, + } + + def _verify_axis_height_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_height = _float_or_none(plan.get("target_height")) + axis_direction_tuple = _tuple_normalized( + _tuple_or_none(plan.get("affine_axis_direction")) or _tuple_or_none(plan.get("axis")) + ) + axis_point_tuple = ( + _tuple_or_none(plan.get("affine_axis_point")) + or _tuple_or_none(plan.get("scale_center")) + or _tuple_or_none(plan.get("axis_point")) + ) + if target_height is None or target_height <= 1e-9 or axis_direction_tuple is None or axis_point_tuple is None: + return {"matched": False, "detail": " Missing axis span verification data."} + + shape: TopoDS_Shape | None = None + target_kind = str(plan.get("affine_target_kind") or plan.get("target_kind") or "part") + solid_id = _int_or_none(plan.get("solid_id")) + if ( + target_kind == "solid" + and solid_id is not None + and 0 <= solid_id < len(self.solids) + and int(self.solids[solid_id][0]) == int(part_id) + ): + shape = self.solids[solid_id][1] + if shape is None: + part = self.part_by_id(part_id) + shape = part.shape if part is not None else None + if shape is None: + return {"matched": False, "detail": " Could not find edited part or solid for axis span verification."} + + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + try: + axis_interval = _shape_axis_interval(shape, axis_point, axis_direction) + except Exception as exc: + return {"matched": False, "detail": f" Could not measure edited axis span: {exc}"} + actual_height = max(float(axis_interval[1]) - float(axis_interval[0]), 0.0) + diagonal = max(_shape_diagonal(shape), target_height, 1.0) + tolerance = max(target_height * 0.005, diagonal * 1e-5, 1e-4) + error = abs(actual_height - target_height) + return { + "matched": error <= tolerance, + "face_id": _int_or_none(plan.get("face_id")), + "axis_span": actual_height, + "target_height": target_height, + "height_error": error, + "height_tolerance": tolerance, + "axis_interval": axis_interval, + "target_kind": target_kind, + "detail": ( + f" Axis span {actual_height:.6g}, target height {target_height:.6g}, " + f"height error {error:.6g}." + ), + } + + def _verify_cylindrical_depth_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_depth = _float_or_none(plan.get("target_depth")) + target_diameter = _float_or_none(plan.get("diameter") or plan.get("target_diameter")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(plan.get("depth_axis_direction"))) + open_point_tuple = _tuple_or_none(plan.get("depth_open_point")) + if ( + target_depth is None + or target_depth <= 1e-9 + or target_diameter is None + or target_diameter <= 1e-9 + or axis_direction_tuple is None + or open_point_tuple is None + ): + return {"matched": False, "detail": " Missing blind-depth verification data."} + + target_radius = target_diameter * 0.5 + target_axis_direction = gp_Dir(*axis_direction_tuple) + target_open_point = gp_Pnt(*open_point_tuple) + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_depth, target_radius, 1.0) + radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) + axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + depth_tolerance = max(target_depth * 0.03, diagonal * 1e-5, 1e-4) + best: dict[str, object] | None = None + best_score = math.inf + + for candidate_face_id, face in enumerate(self.faces): + if self.face_part_ids[candidate_face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + candidate_radius = float(cyl.Radius()) + radius_error = abs(candidate_radius - target_radius) + axis_dot = abs(_direction_dot(target_axis_direction, cyl.Axis().Direction())) + if axis_dot < 1.0 - 1e-5: + continue + axis_distance = _point_axis_distance(cyl.Axis().Location(), cyl.Axis().Direction(), target_open_point) + if axis_distance > axis_tolerance: + continue + feature = self.feature_info(candidate_face_id) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()) or ()) + if not bottom_face_ids: + continue + depth_plan = self.cylindrical_depth_plan( + candidate_face_id, + target_depth, + bottom_face_id=int(bottom_face_ids[0]), + ) + candidate_depth = _float_or_none(depth_plan.get("current_depth")) + if candidate_depth is None or candidate_depth <= 1e-9: + continue + depth_error = abs(candidate_depth - target_depth) + except Exception: + continue + + score = ( + radius_error / max(radius_tolerance, 1e-9) + + axis_distance / max(axis_tolerance, 1e-9) + + depth_error / max(depth_tolerance, 1e-9) + ) + candidate = { + "matched": ( + radius_error <= radius_tolerance + and axis_distance <= axis_tolerance + and depth_error <= depth_tolerance + ), + "face_id": candidate_face_id, + "depth": candidate_depth, + "target_depth": target_depth, + "depth_error": depth_error, + "depth_tolerance": depth_tolerance, + "diameter": candidate_radius * 2.0, + "radius_error": radius_error, + "axis_distance": axis_distance, + "radius_tolerance": radius_tolerance, + "axis_tolerance": axis_tolerance, + } + if candidate["matched"]: + return self._attach_cylindrical_first_level_result_check(candidate, plan) + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " No measurable blind cylindrical feature remained after depth edit."} + return { + "matched": False, + "detail": ( + f" Closest blind cylindrical Face {best['face_id']} depth {float(best['depth']):.6g}, " + f"target {target_depth:.6g}, depth error {float(best['depth_error']):.6g}, " + f"diameter {float(best['diameter']):.6g}." + ), + **best, + } + + def _verify_cylindrical_suppress_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_radius = _float_or_none(plan.get("radius")) + if target_radius is None: + target_diameter = _float_or_none(plan.get("diameter")) + target_radius = target_diameter * 0.5 if target_diameter is not None else None + axis_point_tuple = _tuple_or_none(plan.get("axis_point")) + axis_direction_tuple = _tuple_normalized(_tuple_or_none(plan.get("axis"))) + fill_start_tuple = _tuple_or_none(plan.get("fill_start_point")) + fill_height = _float_or_none(plan.get("fill_height")) + if ( + target_radius is None + or target_radius <= 1e-9 + or axis_point_tuple is None + or axis_direction_tuple is None + or fill_start_tuple is None + or fill_height is None + or fill_height <= 1e-9 + ): + return {"matched": False, "detail": " Missing cylindrical suppress verification data."} + + axis_point = gp_Pnt(*axis_point_tuple) + axis_direction = gp_Dir(*axis_direction_tuple) + fill_start = gp_Pnt(*fill_start_tuple) + fill_start_parameter = _axis_parameter(axis_point, axis_direction, fill_start) + fill_min = min(fill_start_parameter, fill_start_parameter + fill_height) + fill_max = max(fill_start_parameter, fill_start_parameter + fill_height) + + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, fill_height, 1.0) + radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) + axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + overlap_tolerance = max(fill_height * 0.05, diagonal * 1e-5, 1e-4) + remaining: list[dict[str, object]] = [] + + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + candidate_radius = float(cyl.Radius()) + radius_error = abs(candidate_radius - target_radius) + if radius_error > radius_tolerance: + continue + axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) + if axis_dot < 1.0 - 1e-5: + continue + axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) + if axis_distance > axis_tolerance: + continue + axis_range = self._cylindrical_axis_range(face_id, surf) + candidate_start = _point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), float(axis_range["v_min"])) + candidate_end = _point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), float(axis_range["v_max"])) + candidate_min = min( + _axis_parameter(axis_point, axis_direction, candidate_start), + _axis_parameter(axis_point, axis_direction, candidate_end), + ) + candidate_max = max( + _axis_parameter(axis_point, axis_direction, candidate_start), + _axis_parameter(axis_point, axis_direction, candidate_end), + ) + overlap = max(0.0, min(fill_max, candidate_max) - max(fill_min, candidate_min)) + if overlap <= overlap_tolerance: + continue + feature_guess = str(self.face_info(face_id).get("feature_guess") or "") + except Exception: + continue + remaining.append( + { + "face_id": face_id, + "diameter": candidate_radius * 2.0, + "radius_error": radius_error, + "axis_distance": axis_distance, + "axis_overlap": overlap, + "feature_guess": feature_guess, + } + ) + + if remaining: + first = remaining[0] + return { + "matched": False, + "suppressed": False, + "remaining_face_ids": tuple(int(item["face_id"]) for item in remaining), + "remaining_count": len(remaining), + "detail": ( + f" Suppress result still contains {len(remaining)} same-axis cylindrical Face(s) " + f"in the filled range; first Face {first['face_id']} diameter {float(first['diameter']):.6g}, " + f"axis overlap {float(first['axis_overlap']):.6g}." + ), + } + return { + "matched": True, + "suppressed": True, + "remaining_face_ids": (), + "remaining_count": 0, + } + def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": @@ -9222,29 +11759,45 @@ class OperationMixin: if part is None: raise ValueError(f"Unknown part id {part_id}") - start = gp_Pnt(*plan["depth_tool_start_point"]) - direction = gp_Dir(*plan["depth_axis_direction"]) - axis = gp_Ax2(start, direction) - tool = BRepPrimAPI_MakeCylinder( - axis, - float(plan["depth_tool_radius"]), - float(plan["depth_tool_height"]), - ).Shape() - if plan["depth_mode"] == "deepen": - op = BRepAlgoAPI_Cut(part.shape, tool) - result = _finalize_boolean_result(op, "blind depth cut") - action = "deepened by bounded cut" - else: - op = BRepAlgoAPI_Fuse(part.shape, tool) - result = _finalize_boolean_result(op, "blind depth fill/fuse", use_glue=False) - action = "made shallower by bounded fill" + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + start = gp_Pnt(*plan["depth_tool_start_point"]) + direction = gp_Dir(*plan["depth_axis_direction"]) + axis = gp_Ax2(start, direction) + tool = BRepPrimAPI_MakeCylinder( + axis, + float(plan["depth_tool_radius"]), + float(plan["depth_tool_height"]), + ).Shape() + if plan["depth_mode"] == "deepen": + op = BRepAlgoAPI_Cut(part.shape, tool) + result = _finalize_boolean_result(op, "blind depth cut") + action = "deepened by bounded cut" + else: + op = BRepAlgoAPI_Fuse(part.shape, tool) + result = _finalize_boolean_result(op, "blind depth fill/fuse", use_glue=False) + action = "made shallower by bounded fill" - part.shape = result - self.refresh_topology() + part.shape = result + self.refresh_topology() + verification = self._verify_cylindrical_depth_result(plan, part_id) + if not verification["matched"]: + detail = str(verification.get("detail", "blind depth result verification failed")) + raise RuntimeError( + "盲孔/盲槽深度布尔计算返回了结果,但没有检测到达到目标深度且保留一级关系的圆柱特征," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, " f"mode={plan['depth_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " - f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}." + f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}, " + f"verified_face={verification.get('face_id', '')}." ) diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index e1c2db6..17f4c4f 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -49,6 +49,25 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "curve", "adjacent_face_ids", "adjacent_face_count", + "first_level_fact_model", + "first_level_fact_status", + "first_level_fact_relation_depth", + "first_level_fact_relation_boundary", + "first_level_fact_scope", + "first_level_fact_source_model", + "first_level_fact_subject_role", + "first_level_fact_subject_face_ids", + "first_level_fact_subject_face_count", + "first_level_fact_boundary_edge_ids", + "first_level_fact_boundary_edge_count", + "first_level_fact_boundary_vertex_count", + "first_level_fact_adjacent_face_ids", + "first_level_fact_adjacent_face_count", + "first_level_fact_included_face_ids", + "first_level_fact_included_face_count", + "first_level_fact_role_groups", + "first_level_fact_ignored_relation_depths", + "first_level_fact_summary", ], ), ( @@ -160,6 +179,7 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "push_pull_message", "push_pull_scope_face_ids", "push_pull_scope_face_count", + "push_pull_scope_area", "push_pull_scope_note", "shell_region_kind", "shell_region_status", @@ -193,6 +213,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ ( "特征判断", [ + "recognition_summary", + "recognition_candidate", + "recognition_confidence", + "recognition_risk", + "recognition_score", + "recognition_decision", + "recognition_evidence", + "recognition_evidence_keys", + "recognition_ready_actions", + "recognition_limited_actions", + "recognition_blockers", + "recognition_limitations", "feature_guess", "confidence", "material_vote_summary", @@ -372,6 +404,18 @@ INFO_LABELS = { "feature_mode": "特征模式说明", "feature_type": "特征类型", "feature_source_face_id": "特征来源 Face", + "recognition_summary": "识别摘要", + "recognition_candidate": "识别候选", + "recognition_confidence": "识别置信度", + "recognition_risk": "识别风险", + "recognition_score": "识别评分", + "recognition_decision": "识别结论", + "recognition_evidence": "识别依据", + "recognition_evidence_keys": "识别依据项", + "recognition_ready_actions": "当前可改", + "recognition_limited_actions": "当前受限修改", + "recognition_blockers": "识别限制", + "recognition_limitations": "受限能力", "parts": "零件数", "solids": "Solid 数", "faces": "Face 数", @@ -389,6 +433,25 @@ INFO_LABELS = { "curve": "曲线类型", "adjacent_face_ids": "相邻 Face", "adjacent_face_count": "相邻 Face 数", + "first_level_fact_model": "一级事实模型", + "first_level_fact_status": "一级事实状态", + "first_level_fact_relation_depth": "一级事实深度", + "first_level_fact_relation_boundary": "一级事实边界", + "first_level_fact_scope": "一级事实范围", + "first_level_fact_source_model": "一级事实来源", + "first_level_fact_subject_role": "一级事实主体", + "first_level_fact_subject_face_ids": "一级主体 Face", + "first_level_fact_subject_face_count": "一级主体 Face 数", + "first_level_fact_boundary_edge_ids": "一级边界 Edge", + "first_level_fact_boundary_edge_count": "一级边界 Edge 数", + "first_level_fact_boundary_vertex_count": "一级边界 Vertex 数", + "first_level_fact_adjacent_face_ids": "一级相邻 Face", + "first_level_fact_adjacent_face_count": "一级相邻 Face 数", + "first_level_fact_included_face_ids": "一级范围 Face", + "first_level_fact_included_face_count": "一级范围 Face 数", + "first_level_fact_role_groups": "一级角色分组", + "first_level_fact_ignored_relation_depths": "暂不传播层级", + "first_level_fact_summary": "一级事实摘要", "volume": "体积", "surface_area": "表面积", "area": "面积", @@ -533,6 +596,7 @@ INFO_LABELS = { "push_pull_message": "推拉说明", "push_pull_scope_face_ids": "推拉共面区域 Face", "push_pull_scope_face_count": "推拉共面区域 Face 数", + "push_pull_scope_area": "推拉共面区域面积", "push_pull_scope_note": "推拉共面区域说明", "shell_region_kind": "壳体/薄壁候选类型", "shell_region_status": "壳体/薄壁识别状态", @@ -905,8 +969,14 @@ def _export_quality_text(info: dict[str, object]) -> str: def _format_value(value: object) -> str: if isinstance(value, float): return _format_float(value) - if isinstance(value, tuple): - return "(" + ", ".join(_format_float(float(v)) for v in value) + ")" + if isinstance(value, (tuple, list)): + parts: list[str] = [] + for item in value: + try: + parts.append(_format_float(float(item))) + except (TypeError, ValueError): + parts.append(str(item)) + return "(" + ", ".join(parts) + ")" return str(value) diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 42880b2..db26799 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -20,6 +20,15 @@ from PySide6.QtWidgets import ( from .model import StepModel from .records import OperationRecord +from .geometry_utils import ( + _tuple_add, + _tuple_dot, + _tuple_normalized, + _tuple_or_none, + _tuple_scale, + _tuple_sub, + _vector_length, +) from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker @@ -63,6 +72,11 @@ def _unit_triple_or_none(value: object) -> tuple[float, float, float] | None: return (triple[0] / length, triple[1] / length, triple[2] / length) +def _compact_plan_value(value: object) -> str: + text = _format_value(value) + return text if len(text) <= 120 else text[:117] + "..." + + class WindowActionMixin: def export_all(self) -> None: if self.model is None: @@ -339,7 +353,7 @@ class WindowActionMixin: QMessageBox.critical(self, "距离无效", "请输入数字形式的面移动距离。") return face_id = self.selected_face_id - plan = self._quick_push_pull_plan(face_id, distance) + plan = self._push_pull_plan_for_action(face_id, distance) if plan["status"] == "blocked": QMessageBox.information(self, "不能推拉平面", str(plan["message"])) @@ -411,6 +425,24 @@ class WindowActionMixin: "push_pull_inward_material_depth": plan.get("push_pull_inward_material_depth"), "push_pull_inward_cut_ratio": plan.get("push_pull_inward_cut_ratio"), "bbox_diagonal": plan.get("bbox_diagonal"), + "selected_boundary_wires": plan.get("selected_boundary_wires"), + "selected_inner_boundary_wires": plan.get("selected_inner_boundary_wires"), + "selected_has_inner_boundaries": plan.get("selected_has_inner_boundaries"), + "same_domain_face_count": plan.get("same_domain_face_count"), + "first_level_boundary_edge_count": plan.get("first_level_boundary_edge_count"), + "first_level_boundary_vertex_count": plan.get("first_level_boundary_vertex_count"), + "first_level_adjacent_face_count": plan.get("first_level_adjacent_face_count"), + "topology_relation_status": plan.get("topology_relation_status"), + "topology_ignored_relation_note": plan.get("topology_ignored_relation_note"), + "first_level_topology_note": plan.get("first_level_topology_note"), + "planar_cap_extension_kind": plan.get("planar_cap_extension_kind"), + "planar_cap_extension_method": plan.get("planar_cap_extension_method"), + "planar_cap_boundary_edge_count": plan.get("planar_cap_boundary_edge_count"), + "planar_cap_inner_boundary_wires": plan.get("planar_cap_inner_boundary_wires"), + "planar_cap_adjacent_face_count": plan.get("planar_cap_adjacent_face_count"), + "cylindrical_cap_extension_kind": plan.get("cylindrical_cap_extension_kind"), + "cylindrical_cap_extension_method": plan.get("cylindrical_cap_extension_method"), + "cap_extra_adjacent_face_count": plan.get("cap_extra_adjacent_face_count"), }, target_kind="face", target_id=face_id, @@ -495,6 +527,20 @@ class WindowActionMixin: if risk == "low": risk = "medium" warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。") + model_face_count = len(self.model.faces) if self.model is not None else 0 + inner_wires = int(info.get("inner_boundary_wires", 0) or 0) + if status != "blocked" and model_face_count > 600 and inner_wires > 1: + status = "blocked" + risk = "blocked" + boundary_edges = _compact_plan_value(info.get("first_level_boundary_edge_count", "未知")) + adjacent_faces = _compact_plan_value(info.get("first_level_adjacent_face_count", "未知")) + blockers.append( + f"当前 Face {face_id} 是复杂大模型里的多内孔/多边界平面端盖:" + f"内边界 {inner_wires} 个,一级边界 Edge {boundary_edges} 条," + f"共享边一级相邻 Face {adjacent_faces} 个。向内推拉这类面需要判断孔壁、" + "槽底或台阶背后的二级关系是否一起变化;当前阶段只自动处理一级关系。" + "已在界面预检查阶段阻止,避免进入通用 OCCT 布尔后长时间卡住。" + ) if status != "blocked" and risk in {"medium", "high"}: status = "caution" @@ -538,6 +584,10 @@ class WindowActionMixin: "surface": surface, "area": info.get("area"), "bbox_diagonal": bbox_diagonal, + "boundary_wires": info.get("boundary_wires"), + "inner_boundary_wires": info.get("inner_boundary_wires"), + "has_inner_boundaries": bool(info.get("has_inner_boundaries")), + "model_face_count": len(self.model.faces) if self.model is not None else None, "push_pull_inward_material_depth": inward_material_depth, "push_pull_inward_cut_ratio": inward_cut_ratio, "outward_direction": outward_tuple, @@ -554,8 +604,104 @@ class WindowActionMixin: "push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids), "push_pull_scope_note": "使用当前显示/选中缓存生成快速预览;后台会重新计算真实推拉区域。", + "selected_boundary_wires": info.get("boundary_wires"), + "selected_inner_boundary_wires": info.get("inner_boundary_wires"), + "selected_has_inner_boundaries": bool(info.get("has_inner_boundaries")), + "same_domain_face_count": info.get("same_domain_face_count"), + "first_level_boundary_edge_count": info.get("first_level_boundary_edge_count"), + "first_level_boundary_vertex_count": info.get("first_level_boundary_vertex_count"), + "first_level_adjacent_face_count": info.get("first_level_adjacent_face_count"), + "topology_relation_status": info.get("topology_relation_status"), + "topology_ignored_relation_note": info.get("topology_ignored_relation_note"), + "first_level_topology_note": info.get("first_level_topology_note"), + "ui_quick_blocked_push_pull_plan": bool( + status == "blocked" + and distance < 0 + and (len(self.model.faces) if self.model is not None else 0) > 600 + and int(info.get("inner_boundary_wires", 0) or 0) > 1 + ), } + def _should_defer_push_pull_model_plan( + self, + face_id: int, + distance: float, + quick_plan: dict[str, object], + ) -> bool: + if self.model is None or quick_plan.get("status") == "blocked": + return False + if str(quick_plan.get("surface", "")) != "plane": + return False + try: + model_face_count = len(self.model.faces) + except Exception: + model_face_count = 0 + if model_face_count < 600: + return False + + inner_wires = int(quick_plan.get("inner_boundary_wires") or 0) + boundary_wires = int(quick_plan.get("boundary_wires") or 0) + if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")): + return False + + # Large STEP + holed planar caps are exactly where a full plan can spend + # seconds scanning topology before the actual isolated edit even starts. + return abs(float(distance)) > 1e-9 + + def _deferred_push_pull_model_plan( + self, + quick_plan: dict[str, object], + ) -> dict[str, object]: + plan = dict(quick_plan) + warnings = [item for item in str(plan.get("warnings") or "").split(";") if item] + warnings.append( + "当前是复杂大模型里的多边界平面;完整几何计划将放到后台/隔离子进程里计算,避免主界面先卡住。" + ) + plan["warnings"] = ";".join(warnings) + plan["risk"] = self._max_quick_risk(str(plan.get("risk") or "low"), "high") + if plan.get("status") != "blocked": + plan["status"] = "caution" + plan["ui_deferred_model_plan"] = True + plan["quick_plan_status"] = quick_plan.get("status") + plan["quick_plan_risk"] = quick_plan.get("risk") + plan["message"] = ( + "为避免复杂 STEP 在界面线程生成完整推拉计划时卡顿,本次只做快速预检查;" + "真正的一级关系识别、风险判断、解析重建或快速阻止会在后台隔离进程中完成。" + ) + plan.setdefault("edit_strategy_label", "后台计算完整推拉计划") + plan.setdefault( + "edit_semantics", + "界面先提交后台任务;子进程会按当前 Face 的一级拓扑关系决定是解析重建、局部重建还是阻止。", + ) + return plan + + def _push_pull_plan_for_action(self, face_id: int, distance: float) -> dict[str, object]: + quick_plan = self._quick_push_pull_plan(face_id, distance) + if quick_plan.get("status") == "blocked": + return quick_plan + if self.model is None: + return quick_plan + if self._should_defer_push_pull_model_plan(face_id, distance, quick_plan): + return self._deferred_push_pull_model_plan(quick_plan) + try: + model_plan = self.model.push_pull_plan(face_id, distance) + except Exception as exc: + blocked_plan = dict(quick_plan) + blocked_plan.update( + { + "status": "blocked", + "risk": "blocked", + "message": f"无法生成完整推拉计划:{exc}", + "model_plan_error": str(exc), + "quick_plan_status": quick_plan.get("status"), + "quick_plan_risk": quick_plan.get("risk"), + } + ) + return blocked_plan + model_plan.setdefault("quick_plan_status", quick_plan.get("status")) + model_plan.setdefault("quick_plan_risk", quick_plan.get("risk")) + return model_plan + def resize_shell_thickness(self) -> None: if self.model is None: return @@ -573,7 +719,9 @@ class WindowActionMixin: return face_id = self.selected_face_id - plan = self.model.shell_thickness_plan(face_id, target_thickness) + plan = self._deferred_shell_thickness_plan_if_needed(face_id, target_thickness) + if plan is None: + plan = self.model.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": QMessageBox.information(self, "不能调整薄壁厚度", str(plan["message"])) self.statusBar().showMessage("薄壁厚度调整已阻止") @@ -782,6 +930,11 @@ class WindowActionMixin: info = {} if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id: info = {} + if "surface" not in info and "diameter" not in info: + try: + info = dict(self.model.quick_face_info(selected_face_id)) + except Exception: + pass if "surface" not in info and "diameter" not in info: try: info = dict(self.model.face_info(selected_face_id)) @@ -789,6 +942,286 @@ class WindowActionMixin: pass return info + def _large_complex_local_face_blocker(self, face_id: int, info: dict[str, object]) -> str: + if self.model is None: + return "" + try: + model_face_count = len(self.model.faces) + except Exception: + model_face_count = 0 + if model_face_count < 600: + return "" + if str(info.get("surface", "")) != "plane": + return "" + try: + inner_wires = int(info.get("inner_boundary_wires") or 0) + boundary_wires = int(info.get("boundary_wires") or 0) + except (TypeError, ValueError): + inner_wires = 0 + boundary_wires = 0 + local_ready = info.get("local_face_deform_ready") + if inner_wires <= 0 and boundary_wires <= 1 and local_ready is not False: + return "" + base = str(info.get("local_face_deform_blocker") or "").strip() + if not base: + base = "当前 Face 位于复杂大 STEP 中,并且有内孔、内边界或复杂边界;不适合做只改当前 Face 的局部变形。" + return ( + f"{base} 为避免在界面线程生成完整局部重建计划时卡顿,已在轻量预检查阶段阻止;" + "请改用 `推拉当前面`、孔/槽专门入口、`移动整个特征` 或 `调整整个特征`。" + ) + + def _quick_blocked_local_face_plan( + self, + face_id: int, + blocker: str, + *, + resize_strategy: str, + edit_strategy_label: str, + edit_semantics: str, + info: dict[str, object] | None = None, + extra: dict[str, object] | None = None, + ) -> dict[str, object]: + source = dict(info or self._selected_face_info_snapshot()) + plan = { + "status": "blocked", + "risk": "blocked", + "message": blocker, + "warnings": "", + "blockers": blocker, + "face_id": face_id, + "part_id": source.get("part_id"), + "solid_id": source.get("solid_id"), + "surface": source.get("surface"), + "area": source.get("area"), + "area_center": source.get("area_center"), + "bbox_diagonal": source.get("bbox_diagonal"), + "boundary_wires": source.get("boundary_wires"), + "inner_boundary_wires": source.get("inner_boundary_wires"), + "has_inner_boundaries": bool(source.get("has_inner_boundaries")), + "local_face_deform_ready": source.get("local_face_deform_ready"), + "local_face_deform_blocker": source.get("local_face_deform_blocker"), + "local_face_deform_face_count": source.get("local_face_deform_face_count", 1), + "local_face_deform_moved_point_count": 0, + "local_face_deform_target_kind": "blocked-quick-preflight", + "resize_strategy": resize_strategy, + "edit_strategy_label": edit_strategy_label, + "edit_semantics": edit_semantics, + "quick_preflight": True, + "ui_quick_blocked_local_face_plan": True, + } + if extra: + plan.update(extra) + return plan + + def _quick_blocked_face_area_local_plan(self, face_id: int, target_area: float) -> dict[str, object] | None: + info = self._selected_face_info_snapshot() + blocker = self._large_complex_local_face_blocker(face_id, info) + if not blocker: + return None + current_area = _float_or_none(info.get("area")) + area_delta = target_area - current_area if current_area is not None else None + area_delta_ratio = abs(area_delta) / current_area if current_area is not None and current_area > 1e-9 else None + area_scale = math.sqrt(target_area / current_area) if current_area is not None and current_area > 1e-9 and target_area > 0 else None + return self._quick_blocked_local_face_plan( + face_id, + blocker, + resize_strategy="local-face-area-only-deform", + edit_strategy_label="只缩放当前Face面积", + edit_semantics="只移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + info=info, + extra={ + "current_area": current_area, + "target_area": target_area, + "area_delta": area_delta, + "area_delta_ratio": area_delta_ratio, + "local_face_area_scale": area_scale, + }, + ) + + def _quick_blocked_face_size_local_plan( + self, + face_id: int, + target_size: float, + axis_key: str, + ) -> dict[str, object] | None: + info = self._selected_face_info_snapshot() + blocker = self._large_complex_local_face_blocker(face_id, info) + if not blocker: + return None + current_width = _float_or_none(info.get("local_face_width")) + current_height = _float_or_none(info.get("local_face_height")) + current_size = current_height if axis_key == "height" else current_width + delta = target_size - current_size if current_size is not None else None + ratio = abs(delta) / current_size if current_size is not None and current_size > 1e-9 else None + scale = target_size / current_size if current_size is not None and current_size > 1e-9 else None + axis_label = "面高" if axis_key == "height" else "面宽" + return self._quick_blocked_local_face_plan( + face_id, + blocker, + resize_strategy=f"local-face-{axis_key}-only-deform", + edit_strategy_label=f"{axis_label}(当前面)", + edit_semantics="只沿当前 Face 的一个面内方向移动边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + info=info, + extra={ + "face_size_axis": axis_key, + "face_size_label": axis_label, + "current_face_width": current_width, + "target_face_width": target_size if axis_key == "width" else current_width, + "current_face_height": current_height, + "target_face_height": target_size if axis_key == "height" else current_height, + "current_face_size": current_size, + "target_face_size": target_size, + "face_size_delta": delta, + "face_size_delta_ratio": ratio, + "face_size_scale": scale, + "face_size_center": info.get("area_center") or info.get("bbox_center"), + "face_size_axis_direction": info.get("face_height_direction" if axis_key == "height" else "face_width_direction"), + "face_width_direction": info.get("face_width_direction"), + "face_height_direction": info.get("face_height_direction"), + }, + ) + + def _quick_blocked_face_center_local_plan( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> dict[str, object] | None: + info = self._selected_face_info_snapshot() + blocker = self._large_complex_local_face_blocker(face_id, info) + if not blocker: + return None + current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + move_vector = _tuple_sub(target_center, current_center) if current_center is not None else None + move_distance = _vector_length(move_vector) if move_vector is not None else None + bbox_diagonal = _float_or_none(info.get("bbox_diagonal")) + move_ratio = move_distance / bbox_diagonal if move_distance is not None and bbox_diagonal is not None and bbox_diagonal > 1e-9 else None + return self._quick_blocked_local_face_plan( + face_id, + blocker, + resize_strategy="local-face-only-deform", + edit_strategy_label="只移动当前Face", + edit_semantics="只移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + info=info, + extra={ + "current_face_center": current_center, + "target_face_center": target_center, + "face_center_move_vector": move_vector, + "face_center_move_distance": move_distance, + "face_center_move_ratio": move_ratio, + }, + ) + + def _quick_blocked_face_plane_offset_local_plan(self, face_id: int, distance: float) -> dict[str, object] | None: + info = self._selected_face_info_snapshot() + blocker = self._large_complex_local_face_blocker(face_id, info) + if not blocker: + return None + plane_origin = _tuple_or_none(info.get("plane_origin")) + plane_direction = ( + _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) + or _tuple_normalized(_tuple_or_none(info.get("oriented_normal"))) + or _tuple_normalized(_tuple_or_none(info.get("normal"))) + ) + current_position = _tuple_dot(plane_origin, plane_direction) if plane_origin is not None and plane_direction is not None else None + target_position = current_position + distance if current_position is not None else None + current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + move_vector = _tuple_scale(plane_direction, distance) if plane_direction is not None else None + target_center = _tuple_add(current_center, move_vector) if current_center is not None and move_vector is not None else None + move_distance = abs(float(distance)) + bbox_diagonal = _float_or_none(info.get("bbox_diagonal")) + move_ratio = move_distance / bbox_diagonal if bbox_diagonal is not None and bbox_diagonal > 1e-9 else None + return self._quick_blocked_local_face_plan( + face_id, + blocker, + resize_strategy="local-face-plane-offset-deform", + edit_strategy_label="面偏移(当前面)", + edit_semantics="按当前面垂直方向移动 Face 边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + info=info, + extra={ + "current_plane_position": current_position, + "target_plane_position": target_position, + "plane_origin": plane_origin, + "plane_direction": plane_direction, + "plane_offset_distance": distance, + "current_face_center": current_center, + "target_face_center": target_center, + "face_center_move_vector": move_vector, + "face_center_move_distance": move_distance, + "face_center_move_ratio": move_ratio, + }, + ) + + def _deferred_shell_thickness_plan_if_needed( + self, + face_id: int, + target_thickness: float, + ) -> dict[str, object] | None: + info = self._selected_face_info_snapshot() + blocker = self._large_complex_local_face_blocker(face_id, info) + if not blocker: + return None + current = ( + _float_or_none(info.get("shell_thickness_estimate")) + or _float_or_none(info.get("shell_current_thickness")) + or _float_or_none(info.get("shell_signed_thickness")) + ) + if target_thickness <= 0: + return self._quick_blocked_local_face_plan( + face_id, + "目标薄壁厚度必须大于 0。", + resize_strategy="push-pull-shell-source-plane-to-target-thickness", + edit_strategy_label="薄壁厚度(当前面)", + edit_semantics="移动当前平面区域以接近目标薄壁厚度。", + info=info, + extra={ + "shell_current_thickness": current, + "shell_target_thickness": target_thickness, + "shell_delta_thickness": None, + "shell_delta_ratio": None, + }, + ) + delta = target_thickness - current if current is not None else None + ratio = abs(delta) / current if current is not None and current > 1e-9 else None + warnings = [ + "复杂大 STEP 的薄壁相对面识别会放到后台隔离进程里执行,避免主界面先卡住。", + "执行前请确认这是想修改的局部薄壁区域,而不是孔/槽或台阶端面。", + ] + return { + "status": "caution", + "risk": "high", + "message": ( + "为避免在界面线程扫描复杂模型里的相对面,本次只做快速预检查;" + "真正的薄壁相对面识别、推拉距离计算和结果校验会在后台隔离进程中完成。" + ), + "warnings": ";".join(warnings), + "blockers": "", + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "surface": info.get("surface"), + "shell_current_thickness": current, + "shell_target_thickness": target_thickness, + "shell_delta_thickness": delta, + "shell_delta_ratio": ratio, + "shell_signed_thickness": info.get("shell_signed_thickness"), + "shell_opposite_face_id": info.get("shell_opposite_face_id"), + "shell_overlap_ratio_estimate": info.get("shell_overlap_ratio_estimate"), + "shell_confidence": info.get("shell_confidence", "deferred"), + "shell_source_face_ids": info.get("shell_source_face_ids", (face_id,)), + "shell_region_kind": info.get("shell_region_kind", "deferred-complex-face"), + "push_pull_distance": None, + "outward_direction": info.get("push_pull_outward_direction") or info.get("oriented_normal") or info.get("normal"), + "push_pull_scope_face_ids": info.get("push_pull_scope_face_ids") or (face_id,), + "push_pull_scope_face_count": len(_int_values(info.get("push_pull_scope_face_ids")) or [face_id]), + "push_pull_scope_note": "复杂薄壁计划延后到后台隔离进程重新计算。", + "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", + "edit_strategy_label": "薄壁厚度(当前面)", + "edit_semantics": "后台识别相对平面后,把当前平面区域推拉到目标薄壁厚度;失败会保持原模型不变。", + "quick_preflight": True, + "ui_deferred_model_plan": True, + "ui_deferred_shell_thickness_plan": True, + } + def _quick_cylinder_resize_plan( self, face_id: int, @@ -982,6 +1415,8 @@ class WindowActionMixin: "面高(整体)", "面偏移(当前面)", "只移动当前Face", + "圆柱高度调整", + "高度(整体)缩放所属对象", } def _isolation_for_plan( @@ -1003,6 +1438,9 @@ class WindowActionMixin: "move_face_center_local", "resize_shell_thickness", "resize_shell_thickness_owning_scale", + "resize_cylindrical_height", + "resize_cylindrical_boss_height", + "resize_cylindrical_height_owning_scale", "resize_cone_reference_radius", "resize_cone_semi_angle", "resize_sphere_radius", @@ -1019,6 +1457,108 @@ class WindowActionMixin: "reason": f"{risk}-risk-face-occ-edit", } + def _edit_failure_diagnostics(self, context: dict[str, object]) -> str: + parameters = context.get("parameters") + if not isinstance(parameters, dict): + parameters = {} + lines: list[str] = [] + operation_name = str(context.get("operation_name") or "").strip() + target = str(context.get("target") or "").strip() + if operation_name or target: + lines.append(f"操作: {operation_name or '未知'} / {target or '未知对象'}") + + strategy = str(parameters.get("edit_strategy_label") or "").strip() + if strategy: + lines.append(f"编辑策略: {strategy}") + + surface = parameters.get("surface") + distance = parameters.get("semantic_distance") + current_position = parameters.get("current_plane_position") + target_position = parameters.get("target_plane_position") + surface_chunks: list[str] = [] + if surface not in {None, ""}: + surface_chunks.append(f"面类型={_compact_plan_value(surface)}") + if distance not in {None, ""}: + surface_chunks.append(f"修改量={_compact_plan_value(distance)}") + if current_position not in {None, ""}: + surface_chunks.append(f"当前位置={_compact_plan_value(current_position)}") + if target_position not in {None, ""}: + surface_chunks.append(f"目标位置={_compact_plan_value(target_position)}") + if surface_chunks: + lines.append("当前对象: " + ",".join(surface_chunks)) + + boundary_chunks: list[str] = [] + boundary_keys = ( + ("线圈", "selected_boundary_wires"), + ("内孔/内边界", "selected_inner_boundary_wires"), + ("同域Face", "same_domain_face_count"), + ("一级Edge", "first_level_boundary_edge_count"), + ("一级Vertex", "first_level_boundary_vertex_count"), + ("一级相邻Face", "first_level_adjacent_face_count"), + ) + for label, key in boundary_keys: + value = parameters.get(key) + if value not in {None, ""}: + boundary_chunks.append(f"{label}={_compact_plan_value(value)}") + if boundary_chunks: + lines.append("一级关系证据: " + ",".join(boundary_chunks)) + + topology_note = str(parameters.get("first_level_topology_note") or "").strip() + ignored_note = str(parameters.get("topology_ignored_relation_note") or "").strip() + if topology_note: + lines.append(f"一级关系说明: {topology_note}") + if ignored_note: + lines.append(f"暂不处理范围: {ignored_note}") + + planar_method = str(parameters.get("planar_cap_extension_method") or "").strip() + cylindrical_method = str(parameters.get("cylindrical_cap_extension_method") or "").strip() + if planar_method or cylindrical_method: + method_parts: list[str] = [] + if planar_method: + method_parts.append( + f"平面端盖={_compact_plan_value(parameters.get('planar_cap_extension_kind'))}" + f"/{_compact_plan_value(planar_method)}" + ) + if cylindrical_method: + method_parts.append( + f"圆柱端盖={_compact_plan_value(parameters.get('cylindrical_cap_extension_kind'))}" + f"/{_compact_plan_value(cylindrical_method)}" + ) + lines.append("已识别的专用路径: " + ",".join(method_parts)) + + risk_message = str(parameters.get("push_pull_message") or "").strip() + if risk_message: + lines.append(f"计划阶段判断: {risk_message}") + + isolation = context.get("isolation") + if isinstance(isolation, dict) and isolation: + timeout = isolation.get("timeout_seconds") + reason = str(isolation.get("reason") or "").strip() + if timeout not in {None, ""}: + lines.append(f"隔离保护: 子进程超时上限 {_compact_plan_value(timeout)}s") + if reason: + lines.append(f"隔离原因: {reason}") + + has_inner_boundaries = bool(parameters.get("selected_has_inner_boundaries")) + try: + inner_wires = int(parameters.get("selected_inner_boundary_wires", 0) or 0) + except (TypeError, ValueError): + inner_wires = 0 + if has_inner_boundaries or inner_wires > 0: + lines.append( + "可能原因: 当前面带孔/内边界。若修改方向会越过孔壁、槽底、台阶终点," + "就可能涉及一级相邻面背后的二级或更深拓扑关系;当前阶段只自动传播一级关系。" + ) + elif str(parameters.get("push_pull_risk") or "") == "high": + lines.append( + "可能原因: 当前操作风险较高,底层 OCCT 布尔或局部重建可能返回无效 B-Rep;" + "这通常不是界面卡住,而是几何内核没有稳定给出可用结果。" + ) + + if not lines: + return "" + return "\n\n诊断信息:\n" + "\n".join(f"- {line}" for line in lines) + def resize_hole(self) -> None: if self.model is None: return @@ -2413,7 +2953,7 @@ class WindowActionMixin: self.statusBar().showMessage("已取消圆柱凸台高度调整") return - self._show_cylinder_boss_height_preview(face_id, target_height) + self.clear_edit_preview(render=False) def action(): return self.model.resize_cylindrical_boss_height(face_id, target_height) @@ -2451,6 +2991,11 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=self._isolation_for_plan( + plan, + "resize_cylindrical_boss_height", + [face_id, target_height], + ), ) def resize_cylinder_height(self) -> None: @@ -2527,6 +3072,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=self._isolation_for_plan(plan, "resize_cylindrical_height", [face_id, target_height]), ) def resize_cylindrical_height_owning_scale(self) -> None: @@ -2616,6 +3162,11 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=self._isolation_for_plan( + plan, + "resize_cylindrical_height_owning_scale", + [face_id, target_height], + ), ) def suppress_hole(self) -> None: @@ -4659,7 +5210,9 @@ class WindowActionMixin: return face_id = self.selected_face_id - plan = self.model.face_area_local_resize_plan(face_id, target_area) + plan = self._quick_blocked_face_area_local_plan(face_id, target_area) + if plan is None: + plan = self.model.face_area_local_resize_plan(face_id, target_area) lines = [ f"Face: {face_id}", f"当前面积: {_format_value(plan.get('current_area'))}", @@ -4750,7 +5303,9 @@ class WindowActionMixin: return face_id = self.selected_face_id - plan = self.model.face_size_local_resize_plan(face_id, target_size, axis_key) + plan = self._quick_blocked_face_size_local_plan(face_id, target_size, axis_key) + if plan is None: + plan = self.model.face_size_local_resize_plan(face_id, target_size, axis_key) lines = [ f"Face: {face_id}", f"修改对象: {axis_label}(当前面)", @@ -5010,7 +5565,9 @@ class WindowActionMixin: return face_id = self.selected_face_id - plan = self.model.face_plane_offset_local_plan(face_id, distance) + plan = self._quick_blocked_face_plane_offset_local_plan(face_id, distance) + if plan is None: + plan = self.model.face_plane_offset_local_plan(face_id, distance) lines = [ f"Face: {face_id}", f"当前面偏移: {_format_value(plan.get('current_plane_position'))}", @@ -5177,7 +5734,9 @@ class WindowActionMixin: current_center[1] + vector[1], current_center[2] + vector[2], ) - plan = self.model.face_center_local_move_plan(face_id, target_center) + plan = self._quick_blocked_face_center_local_plan(face_id, target_center) + if plan is None: + plan = self.model.face_center_local_move_plan(face_id, target_center) lines = [ f"Face: {face_id}", f"当前Face中心: {_format_value(plan.get('current_face_center'))}", @@ -6450,7 +7009,10 @@ class WindowActionMixin: raise RuntimeError( f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}" ) from exc - raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc + raise RuntimeError( + f"编辑失败,模型已恢复到操作前状态:{exc}" + f"{self._edit_failure_diagnostics(context)}" + ) from exc model_polydata = None edge_polydata = None try: @@ -6522,22 +7084,38 @@ class WindowActionMixin: ) command = self._isolated_edit_command(request_path) + self.isolated_edit_cancel_requested = False + process: subprocess.Popen[str] | None = None try: - completed = subprocess.run( + process = subprocess.Popen( command, cwd=project_root, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", - timeout=timeout_seconds, - check=False, ) + self.active_isolated_edit_process = process + stdout, stderr = process.communicate(timeout=timeout_seconds) + completed = subprocess.CompletedProcess(command, process.returncode, stdout, stderr) except subprocess.TimeoutExpired as exc: + self._terminate_isolated_edit_process(process) + try: + stdout, stderr = process.communicate(timeout=5.0) if process is not None else ("", "") + except Exception: + stdout, stderr = "", "" raise RuntimeError( f"隔离子进程执行超时,已终止危险计算;主程序和原模型保持不变。" f" 超时时间: {timeout_seconds:g}s" + f"{self._edit_failure_diagnostics(context)}" ) from exc + finally: + if getattr(self, "active_isolated_edit_process", None) is process: + self.active_isolated_edit_process = None + + if getattr(self, "isolated_edit_cancel_requested", False): + raise RuntimeError("隔离子进程已取消;主程序和原模型保持不变。") response_path = request_path.with_suffix(".response.json") response: dict[str, object] = {} @@ -6551,6 +7129,7 @@ class WindowActionMixin: raise RuntimeError( "隔离子进程执行失败;主程序没有崩溃,原模型保持不变。" f" 子进程返回码: {completed.returncode}. 错误: {error}" + f"{self._edit_failure_diagnostics(context)}" ) if not output_path.exists(): raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。") @@ -6576,14 +7155,13 @@ class WindowActionMixin: after_quality, after_model=new_model, ) - self.model = new_model after_geometry: dict[str, object] = {} model_polydata = None edge_polydata = None try: deflection = float(context.get("edit_result_deflection", 1.6)) - model_polydata = self.model.build_face_polydata(deflection=deflection) - edge_polydata = self.model.build_edge_polydata( + model_polydata = new_model.build_face_polydata(deflection=deflection) + edge_polydata = new_model.build_edge_polydata( deflection=deflection, show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)), ) @@ -6600,6 +7178,7 @@ class WindowActionMixin: "after_snapshot": after_snapshot, "after_stats": after_stats, "after_part_stats": after_part_stats, + "after_model": new_model, "quality_warnings": quality_warnings, "after_geometry": after_geometry, "model_polydata": model_polydata, @@ -6611,6 +7190,35 @@ class WindowActionMixin: return [sys.executable, "--isolated-edit-worker", str(request_path)] return [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)] + def _terminate_isolated_edit_process(self, process: subprocess.Popen | None = None) -> bool: + target = process or getattr(self, "active_isolated_edit_process", None) + if target is None: + return False + try: + if target.poll() is not None: + return False + except Exception: + return False + self.isolated_edit_cancel_requested = True + try: + target.terminate() + return True + except Exception: + try: + target.kill() + return True + except Exception: + return False + + def _cancel_active_edit_for_close(self) -> bool: + if not (self.operation_in_progress or (self.edit_thread is not None and self.edit_thread.isRunning())): + return False + if not self._terminate_isolated_edit_process(): + return False + self.close_after_edit_cancel = True + self.statusBar().showMessage("正在取消后台几何计算,子进程已请求终止...") + return True + def _preserve_isolated_face_logical_id( self, model: StepModel, @@ -7285,6 +7893,9 @@ class WindowActionMixin: QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。") self.statusBar().showMessage("编辑结果无法识别") return + after_model = result.get("after_model") + if after_model is not None: + self.model = after_model message = str(result["message"]) try: record = self._make_operation_record( @@ -7347,11 +7958,20 @@ class WindowActionMixin: if hasattr(self, "_is_ui_thread") and not self._is_ui_thread(): self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message)) return + close_after_cancel = bool(getattr(self, "close_after_edit_cancel", False)) + if close_after_cancel: + self.close_after_edit_cancel = False self._end_edit_task(clear_preview=True) - QMessageBox.critical(self, "操作失败", message) + if not close_after_cancel: + QMessageBox.critical(self, "操作失败", message) self._clear_editable_candidates() self._clear_cylinder_candidates() - self.statusBar().showMessage("操作失败,模型已保持在编辑前状态") + self.statusBar().showMessage("后台编辑已取消,模型已保持在编辑前状态" if close_after_cancel else "操作失败,模型已保持在编辑前状态") + if close_after_cancel: + if self.edit_thread is not None and self.edit_thread.isRunning(): + self.edit_thread.quit() + self.edit_thread.wait(1500) + self.close() def _restore_failed_edit_snapshot(self, snapshot: object) -> str: if self.model is None or not isinstance(snapshot, dict): diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 5a42606..d9d8894 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -214,6 +214,9 @@ class WindowCoreMixin: return edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning()) if self.operation_in_progress or edit_thread_running: + if hasattr(self, "_cancel_active_edit_for_close") and self._cancel_active_edit_for_close(): + event.ignore() + return self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。") event.ignore() return diff --git a/step_editor/window_state.py b/step_editor/window_state.py index c10144b..d42c189 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -30,6 +30,7 @@ PROPERTY_ACTION_COLUMN = 4 FEATURE_EDIT_SEMANTICS_KEYS = { "face_first_level_topology", + "cylindrical_feature_first_level_topology", "slot_edit_semantics", "hole_edit_semantics", "boss_edit_semantics", @@ -160,6 +161,26 @@ class WindowStateMixin: enriched["pick_position"] = pick_position return enriched + def _first_level_fact_selection_fields(self, face_id: int, scope: str = "auto") -> dict[str, object]: + if self.model is None: + return {} + try: + return self.model.face_first_level_facts(face_id, scope=scope) + except Exception as exc: + return { + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_status": "unavailable", + "first_level_fact_relation_depth": 1, + "first_level_fact_scope": scope, + "first_level_fact_subject_face_ids": (face_id,), + "first_level_fact_subject_face_count": 1, + "first_level_fact_boundary_edge_count": 0, + "first_level_fact_boundary_vertex_count": 0, + "first_level_fact_adjacent_face_count": 0, + "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "first_level_fact_summary": f"当前 Face 的一级事实图暂时无法生成:{exc}", + } + def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]: if self.model is None: return {} @@ -198,6 +219,70 @@ class WindowStateMixin: "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", ""), + **self._first_level_fact_selection_fields(face_id, scope="face"), + } + + def _cylindrical_first_level_selection_fields(self, face_id: int) -> dict[str, object]: + if self.model is None: + return {} + try: + topology = self.model.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), + "topology_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", + "cylindrical_feature_side_face_count": 1, + "cylindrical_feature_boundary_edge_count": 0, + "cylindrical_feature_boundary_vertex_count": 0, + "cylindrical_feature_adjacent_face_count": 0, + "cylindrical_feature_end_face_count": 0, + "cylindrical_feature_bottom_face_count": 0, + "cylindrical_feature_opening_face_count": 0, + "cylindrical_feature_slot_boundary_face_count": 0, + "first_level_topology_note": f"当前圆柱特征的一级关系暂时无法确认:{exc}", + } + 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", ""), + "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_face_ids": topology.get("cylindrical_feature_first_level_face_ids", (face_id,)), + "first_level_face_count": topology.get("cylindrical_feature_first_level_face_count", 1), + "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_boundary_vertex_count": topology.get("cylindrical_feature_boundary_vertex_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, + ), + **self._first_level_fact_selection_fields(face_id, scope="cylindrical-feature"), } def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]: @@ -275,6 +360,8 @@ class WindowStateMixin: 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)) + elif str(root_info.get("surface", "") or "") == "cylinder" and detection_level != "current-only": + root_info.update(self._cylindrical_first_level_selection_fields(face_id)) associated: list[dict[str, object]] = [] if detection_level in {"associated-only", "secondary"}: try: @@ -323,6 +410,10 @@ class WindowStateMixin: ), } ) + try: + info.update(self.model._recognition_summary_fields(info)) + except Exception: + pass return info def _current_feature_detection_level(self) -> str: @@ -1615,6 +1706,38 @@ class WindowStateMixin: is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate" is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate" + recognition_fields_present = any( + key in action_info + for key in ( + "recognition_score", + "recognition_confidence", + "recognition_risk", + "recognition_blockers", + ) + ) + recognition_score = _int_or_none(action_info.get("recognition_score")) + recognition_confidence = str(action_info.get("recognition_confidence") or "").strip() + recognition_risk = str(action_info.get("recognition_risk") or "").strip() + recognition_blockers = str(action_info.get("recognition_blockers") or "").strip() + analytic_surface_recognition_ready = ( + not recognition_fields_present + or ( + recognition_risk != "blocked" + and not recognition_blockers + and recognition_confidence not in {"low", "none"} + and (recognition_score is None or recognition_score >= 56) + ) + ) + analytic_surface_recognition_reason = "" + if not analytic_surface_recognition_ready: + score_text = _format_float(float(recognition_score)) if recognition_score is not None else "未知" + confidence_text = recognition_confidence or "未知" + risk_text = recognition_risk or "未知" + analytic_surface_recognition_reason = ( + f"当前解析曲面识别还不够稳定:评分 {score_text},置信度 {confidence_text},风险 {risk_text}。" + ) + if recognition_blockers: + analytic_surface_recognition_reason = f"{analytic_surface_recognition_reason} 限制:{recognition_blockers}" is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92 is_slot_or_half_hole = ( is_hole_or_groove @@ -1970,6 +2093,62 @@ class WindowStateMixin: ) if has_face: + topology_depth = _int_or_none(action_info.get("topology_relation_depth")) + is_cylindrical_topology = bool( + is_cylinder + and topology_depth == 1 + and ( + "cylindrical_feature_side_face_count" in action_info + or "cylindrical-feature" in str(action_info.get("topology_relation_model") or "") + ) + ) + if is_cylindrical_topology: + side_count = _int_or_none(action_info.get("cylindrical_feature_side_face_count")) or 0 + boundary_edge_count = _int_or_none(action_info.get("cylindrical_feature_boundary_edge_count")) or 0 + boundary_vertex_count = _int_or_none(action_info.get("cylindrical_feature_boundary_vertex_count")) or ( + _int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0 + ) + adjacent_face_count = _int_or_none(action_info.get("cylindrical_feature_adjacent_face_count")) or 0 + end_face_count = _int_or_none(action_info.get("cylindrical_feature_end_face_count")) or 0 + bottom_face_count = _int_or_none(action_info.get("cylindrical_feature_bottom_face_count")) or 0 + opening_face_count = _int_or_none(action_info.get("cylindrical_feature_opening_face_count")) or 0 + slot_boundary_face_count = ( + _int_or_none(action_info.get("cylindrical_feature_slot_boundary_face_count")) or 0 + ) + relation_parts = [ + f"侧壁 Face {side_count} 个", + f"边界 Edge {boundary_edge_count} 条", + f"边界 Vertex {boundary_vertex_count} 个", + f"共享边相邻 Face {adjacent_face_count} 个", + ] + detail_parts = [] + if end_face_count: + detail_parts.append(f"端面/开口 Face {end_face_count} 个") + if bottom_face_count: + detail_parts.append(f"底面 Face {bottom_face_count} 个") + if opening_face_count: + detail_parts.append(f"开口 Face {opening_face_count} 个") + if slot_boundary_face_count: + detail_parts.append(f"槽边界 Face {slot_boundary_face_count} 个") + topology_note = str(action_info.get("first_level_topology_note") or "").strip() + ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip() + status_message = str(action_info.get("topology_relation_message") or "").strip() + topology_tip = "\n".join( + item + for item in ( + topology_note, + ignored_note, + status_message, + "当前阶段只把圆柱侧壁及其共享边直接相邻 Face 作为一级关系,不会自动沿相邻面继续传播到二级、三级关系。", + ) + if item + ) + add_readonly_spec( + key="cylindrical_feature_first_level_topology", + label="一级关系", + text=";".join(relation_parts + detail_parts) + "。", + tip=topology_tip, + ) if is_hole_or_groove: if is_slot_or_half_hole: add_readonly_spec( @@ -2025,7 +2204,6 @@ 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 @@ -3037,18 +3215,32 @@ class WindowStateMixin: current_boss_height = _float_or_none(action_info.get("same_domain_height_estimate")) if current_boss_height is None: current_boss_height = _float_or_none(action_info.get("height_estimate")) + boss_height_can_local = bool( + is_full_cylinder + and current_boss_height is not None + and ( + _int_values(action_info.get("feature_start_end_face_ids")) + or _int_values(action_info.get("feature_end_end_face_ids")) + ) + ) + boss_height_can_scale = bool( + current_boss_height is not None + and current_boss_height > 0 + and _triple_or_none(action_info.get("axis_point")) is not None + and _triple_or_none(action_info.get("axis")) is not None + ) add_scoped_spec( key="boss_height", label="高度", current_raw=current_boss_height if current_boss_height is not None else "", target_text=numeric_text(current_boss_height), - scope_default="local", + scope_default="owning" if boss_height_can_scale else "local", scope_modes={ "local": { "label": "推拉端盖", "action": "resize_boss_height", "target_attr": "boss_height_input", - "enabled": bool(is_full_cylinder and current_boss_height is not None), + "enabled": boss_height_can_local, "enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可推拉的凸台端盖 Face。", "disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", "range_hint": relative_range_hint(current_boss_height, 0.3, 0.8), @@ -3057,12 +3249,7 @@ class WindowStateMixin: "label": "调整整个特征", "action": "resize_cylindrical_height_owning_scale", "target_attr": "boss_height_input", - "enabled": bool( - current_boss_height is not None - and current_boss_height > 0 - and _triple_or_none(action_info.get("axis_point")) is not None - and _triple_or_none(action_info.get("axis")) is not None - ), + "enabled": boss_height_can_scale, "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。", "disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "range_hint": ( @@ -3153,12 +3340,18 @@ class WindowStateMixin: current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate")) if current_cylinder_height is None: current_cylinder_height = _float_or_none(action_info.get("height_estimate")) + cylinder_height_can_scale = bool( + current_cylinder_height is not None + and current_cylinder_height > 0 + and generic_scale_axis_point is not None + and generic_scale_axis_direction is not None + ) add_scoped_spec( key="cylinder_height", label="高度", current_raw=current_cylinder_height if current_cylinder_height is not None else "", target_text=numeric_text(current_cylinder_height), - scope_default="local", + scope_default="owning" if cylinder_height_can_scale else "local", scope_modes={ "local": { "label": "推拉端盖", @@ -3173,12 +3366,7 @@ class WindowStateMixin: "label": "调整整个特征", "action": "resize_cylindrical_height_owning_scale", "target_attr": "boss_height_input", - "enabled": bool( - current_cylinder_height is not None - and current_cylinder_height > 0 - and generic_scale_axis_point is not None - and generic_scale_axis_direction is not None - ), + "enabled": cylinder_height_can_scale, "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。", "disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "range_hint": ( @@ -3295,6 +3483,14 @@ class WindowStateMixin: used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"), **positive_minimum(), ) + def analytic_surface_enabled(value_present: bool, capability_supported: bool = True) -> bool: + return bool(value_present and capability_supported and analytic_surface_recognition_ready) + + def analytic_surface_disabled_tip(base: str) -> str: + if not analytic_surface_recognition_ready and analytic_surface_recognition_reason: + return analytic_surface_recognition_reason + return base + if is_cone: current_reference_radius = _float_or_none(action_info.get("reference_radius")) current_reference_diameter = ( @@ -3303,7 +3499,10 @@ class WindowStateMixin: 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 + reference_radius_enabled = analytic_surface_enabled( + current_reference_radius is not None, + reference_radius_supported, + ) add_spec( key="cone_reference_radius", label="参考半径", @@ -3313,7 +3512,7 @@ class WindowStateMixin: target_attr="cone_reference_radius_input", enabled=reference_radius_enabled, enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。", - disabled_tip=( + disabled_tip=analytic_surface_disabled_tip( reference_radius_disabled_reason or "当前圆锥面缺少稳定参考半径,不能直接修改。" ), @@ -3332,9 +3531,12 @@ class WindowStateMixin: 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 and reference_radius_supported, + enabled=analytic_surface_enabled( + current_reference_diameter is not None, + reference_radius_supported, + ), enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。", - disabled_tip=( + disabled_tip=analytic_surface_disabled_tip( reference_radius_disabled_reason or "当前圆锥面缺少稳定参考直径,不能直接修改。" ), @@ -3356,6 +3558,7 @@ class WindowStateMixin: current_reference_radius is not None and current_semi_angle is not None and semi_angle_supported + and analytic_surface_recognition_ready ) add_spec( key="cone_semi_angle_degrees", @@ -3366,7 +3569,7 @@ class WindowStateMixin: target_attr="cone_reference_radius_input", enabled=semi_angle_enabled, enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。", - disabled_tip=( + disabled_tip=analytic_surface_disabled_tip( semi_angle_disabled_reason or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。" ), @@ -3390,9 +3593,9 @@ class WindowStateMixin: target_text=numeric_text(current_sphere_radius), action="resize_sphere_radius", target_attr="sphere_radius_input", - enabled=current_sphere_radius is not None, + enabled=analytic_surface_enabled(current_sphere_radius is not None), enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。", - disabled_tip="当前球面缺少稳定半径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前球面缺少稳定半径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史半径参数。 {relative_range_hint(current_sphere_radius, 0.25, 0.6)}", used=("radius",), @@ -3405,9 +3608,9 @@ class WindowStateMixin: target_text=numeric_text(current_sphere_diameter), action="resize_sphere_radius", target_attr="sphere_radius_input", - enabled=current_sphere_diameter is not None, + enabled=analytic_surface_enabled(current_sphere_diameter is not None), enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放所属对象。", - disabled_tip="当前球面缺少稳定直径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前球面缺少稳定直径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史直径参数。 {relative_range_hint(current_sphere_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", @@ -3430,9 +3633,9 @@ class WindowStateMixin: target_text=numeric_text(current_major_radius), action="resize_torus_major_radius", target_attr="torus_radius_input", - enabled=current_major_radius is not None, + enabled=analytic_surface_enabled(current_major_radius is not None), enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", - disabled_tip="当前环面缺少稳定主半径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定主半径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只改环面主半径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_radius, 0.25, 0.6)}", used=("major_radius", "feature_torus_major_radius"), @@ -3445,9 +3648,9 @@ class WindowStateMixin: target_text=numeric_text(current_major_diameter), action="resize_torus_major_radius", target_attr="torus_radius_input", - enabled=current_major_diameter is not None, + enabled=analytic_surface_enabled(current_major_diameter is not None), enabled_tip="输入环面的目标主直径;程序会换算为主半径后整体缩放所属对象。", - disabled_tip="当前环面缺少稳定主直径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定主直径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只改环面主直径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", @@ -3461,9 +3664,9 @@ class WindowStateMixin: target_text=numeric_text(current_minor_radius), action="resize_torus_minor_radius", target_attr="torus_radius_input", - enabled=current_minor_radius is not None, + enabled=analytic_surface_enabled(current_minor_radius is not None), enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", - disabled_tip="当前环面缺少稳定小半径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定小半径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只改环面小半径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_radius, 0.25, 0.6)}", used=("minor_radius", "feature_torus_minor_radius"), @@ -3476,9 +3679,9 @@ class WindowStateMixin: target_text=numeric_text(current_minor_diameter), action="resize_torus_minor_radius", target_attr="torus_radius_input", - enabled=current_minor_diameter is not None, + enabled=analytic_surface_enabled(current_minor_diameter is not None), enabled_tip="输入环面的目标小直径;程序会换算为小半径后整体缩放所属对象。", - disabled_tip="当前环面缺少稳定小直径,不能直接修改。", + disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定小直径,不能直接修改。"), value_type="positive", range_hint=f"这是整体缩放所属对象,不是只改环面小直径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius",