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

This commit is contained in:
2026-08-04 18:15:29 +08:00
parent 5799d5d813
commit a76282d7dd
28 changed files with 6872 additions and 270 deletions
+42 -11
View File
@@ -124,6 +124,7 @@ git diff --check
- 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 识别结果会区分 `当前可改``当前受限修改``识别限制``受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以推拉,但“只改当前面局部尺寸/中心/偏移”会被标成受限修改。
- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。 - 用户可以导出修改后的完整模型。
@@ -137,7 +138,25 @@ git diff --check
所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 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 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。
@@ -174,7 +193,7 @@ git diff --check
| 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | | 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 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度``调整整个特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁``调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | | 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁``调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | | 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径``参考直径``半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | | 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径``参考直径``半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
@@ -233,6 +252,8 @@ git diff --check
- 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面Face,并一起高亮相关局部区域。 - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻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 数量 - Solid 数量
@@ -526,8 +547,8 @@ Face 阶段的当前验收口径:
- 选中平面 Face 时,`当前选中对象` 的参数表会显示 `一级关系` 说明行,直接列出当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并提示二级、三级关系暂不自动传播。 - 选中平面 Face 时,`当前选中对象` 的参数表会显示 `一级关系` 说明行,直接列出当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并提示二级、三级关系暂不自动传播。
- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。 - `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积``面宽``面高``中心``面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积``面宽``面高``中心``面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。
- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动只带动一级侧面,二级底面不跟随移动。 - `python scripts\verify_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、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 - `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。特征参数表也会显示圆柱特征的 `一级关系` 摘要,让用户知道当前孔/槽修改会围绕哪些直接邻域执行;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。
- 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 - 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。
- Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象。 - Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象。
@@ -942,12 +963,16 @@ pythonocc-step-editor/
verify_face_edit_suite.py # 一键运行 Face 专项修改验证 verify_face_edit_suite.py # 一键运行 Face 专项修改验证
verify_face_extreme_target_guards.py # 验证 Face 面积、面宽/面高、面偏移和薄壁厚度不能输入过大或过小的极端目标值 verify_face_extreme_target_guards.py # 验证 Face 面积、面宽/面高、面偏移和薄壁厚度不能输入过大或过小的极端目标值
verify_face_invalid_target_guards.py # 验证 Face 面积、面宽/面高 和薄壁厚度不能输入非数字、0 或负数 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_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面
verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留推拉 verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留推拉
verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、面宽/面高、面偏移和推拉 verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、面宽/面高、面偏移和推拉
verify_face_noop_guards.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_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何
verify_face_push_pull_limits.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_rectangular_axes.py # 验证矩形 Face 的面宽 和面高 方向不会混淆
verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义
verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 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_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。
- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。 - `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。
- `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。 - `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。
- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 - `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、大 STEP 同轴阶梯环形端盖局部 shell 推拉、非矩形平面 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_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_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 推拉,并确认推拉后原逻辑 Face ID 会定位到新生成的整片顶面。
- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 - `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。
- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。 - `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。
@@ -1067,11 +1093,16 @@ git diff --check
- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `只改当前面``调整整个特征``移动整个特征``推拉当前面` 语义,在中心、面积、面宽/面高 和面偏移场景下都能保持单 Solid。 - `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `只改当前面``调整整个特征``移动整个特征``推拉当前面` 语义,在中心、面积、面宽/面高 和面偏移场景下都能保持单 Solid。
- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面宽/面高、面积、中心坐标和面偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `推拉当前面` 之间不会互相混淆。 - `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面宽/面高、面积、中心坐标和面偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `推拉当前面` 之间不会互相混淆。
- `scripts/verify_face_noop_guards.py`:验证 Face 的推拉、面偏移、中心、面积、面宽/面高 和薄壁厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 - `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_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_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `面宽``面高` 两个方向上可以分别做 `只改当前面``调整整个特征`,另一个方向不会被误改,属性表也会读回新值。
- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路 - `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖推拉并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`调整整个特征` 的轴向缩放会按轴向跨度校验结果
- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的面偏移、面积、面宽/面高、中心和薄壁厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;这用于证明大参数 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_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、面宽/面高 或面偏移编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `只改当前面` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。
- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 - `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。
- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
+16 -8
View File
@@ -572,10 +572,15 @@ def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None:
) )
if not result.get("message"): if not result.get("message"):
raise SystemExit("isolated embedded reference-radius edit did not return a completion message") raise SystemExit("isolated embedded reference-radius edit did not return a completion message")
if probe.model is model: if probe.model is not model:
raise SystemExit("successful isolated embedded reference-radius edit should load the edited model") raise SystemExit("isolated embedded reference-radius edit should not replace the window model off the UI thread")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(probe.model, 30.963756532) edited_model = result.get("after_model")
circles = probe.model._conical_face_circle_boundaries( 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, edited_face_id,
tuple(float(value) for value in edited_info["axis_point"]), tuple(float(value) for value in edited_info["axis_point"]),
_unit(tuple(float(value) for value in edited_info["axis"])), _unit(tuple(float(value) for value in edited_info["axis"])),
@@ -738,10 +743,13 @@ def main() -> int:
) )
if not result.get("message"): if not result.get("message"):
raise SystemExit("isolated cone semi-angle edit did not return a completion message") raise SystemExit("isolated cone semi-angle edit did not return a completion message")
if probe.model is model: if probe.model is not model:
raise SystemExit("successful isolated cone semi-angle edit should load the edited model") raise SystemExit("isolated cone semi-angle edit should not replace the window model off the UI thread")
edited_model = result.get("after_model")
edited_model = probe.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) best_angle = _best_cone_angle_degrees(edited_model)
if best_angle is None: if best_angle is None:
raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face") raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face")
@@ -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: def main() -> int:
_verify_through_hole() _verify_through_hole()
_verify_blind_hole() _verify_blind_hole()
_verify_half_round_slot() _verify_half_round_slot()
_verify_obround_slot_length_plan() _verify_obround_slot_length_plan()
_verify_missing_first_level_neighbor_blocks_plan() _verify_missing_first_level_neighbor_blocks_plan()
_verify_result_check_rejects_degraded_first_level_topology()
print("cylindrical first-level topology verification passed") print("cylindrical first-level topology verification passed")
return 0 return 0
+295
View File
@@ -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())
@@ -51,6 +51,36 @@ def _assert_push_pull_keeps_single_solid(path: Path, distance: float) -> str:
stats = model.stats() stats = model.stats()
if stats.solids != 1: if stats.solids != 1:
raise SystemExit(f"holed planar Face push/pull should keep one solid: {stats}") 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 return result
@@ -94,6 +94,9 @@ def main() -> int:
scope_ids = tuple(plan.get("push_pull_scope_face_ids", ())) scope_ids = tuple(plan.get("push_pull_scope_face_ids", ()))
if set(scope_ids) != set(top_faces): if set(scope_ids) != set(top_faces):
raise SystemExit(f"expected push/pull scope {top_faces}, got {scope_ids}") 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": if plan["status"] == "blocked":
raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}") raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}")
@@ -17,6 +17,7 @@ from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.model import StepModel from step_editor.model import StepModel
from step_editor.step_io import _write_step from step_editor.step_io import _write_step
from step_editor.window_actions import WindowActionMixin 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): class _Probe(WindowActionMixin):
@@ -194,6 +195,183 @@ def main() -> int:
after_model=edited_cube, 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: with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_sphere_") as temp_dir:
sphere_path = Path(temp_dir) / "sphere.step" sphere_path = Path(temp_dir) / "sphere.step"
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path) _write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path)
@@ -299,6 +477,35 @@ def main() -> int:
after_model=edited_cone, 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( _expect_allowed(
"Non-Face edit solid-count change only warns", "Non-Face edit solid-count change only warns",
lambda: probe._verified_edit_quality_warnings( lambda: probe._verified_edit_quality_warnings(
+20
View File
@@ -86,6 +86,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"planar Face on curved Solid disables local-only deformation", "planar Face on curved Solid disables local-only deformation",
("verify_face_mixed_surface_guard.py",), ("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", "non-rectangular planar Face local edits",
("verify_face_nonrectangular_local_edit.py",), ("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", "property editor keeps generic Face edits out of cylindrical features",
("verify_property_editor_specs.py",), ("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", "Face edited values read back into property rows",
("verify_face_property_readback.py",), ("verify_face_property_readback.py",),
+65 -1
View File
@@ -3,6 +3,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import sys 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 PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path: 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: 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 best: tuple[float, int] | None = None
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
info = model.face_info(face_id) info = model.face_info(face_id)
if info.get("surface") != "plane": if info.get("surface") != "plane":
continue continue
center = _center(info) 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) best = (center[2], face_id)
if best is None: if best is None:
raise SystemExit("No planar Face found.") 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}") 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]: def _assert_first_level_topology(model: StepModel, face_id: int) -> dict[str, object]:
topology = model.face_first_level_topology(face_id) topology = model.face_first_level_topology(face_id)
if topology.get("topology_relation_depth") != 1: 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(value, 6.0, f"First-level side Face {index} center Z")
_assert_close(z_values[-1], 12.0, "Moved source Face 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"model={DEFAULT_MODEL}")
print(f"face_id={face_id}") print(f"face_id={face_id}")
print(f"topology={topology}") print(f"topology={topology}")
print(f"z_values_after_local_move={z_values}") 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")) print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0 return 0
+380 -6
View File
@@ -4,12 +4,15 @@ import sys
import tempfile import tempfile
from pathlib import Path 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 PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) 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.model import StepModel
from step_editor.step_io import _write_step from step_editor.step_io import _write_step
from step_editor.window_state import WindowStateMixin from step_editor.window_state import WindowStateMixin
@@ -35,6 +38,54 @@ def _write_cylinder(path: Path) -> None:
_write_step(cylinder, path) _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: def _first_planar_cap_face(model: StepModel) -> int:
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
info = model.face_info(face_id) 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") 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: def _assert_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked": if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked for a planar Face on a curved Solid: {plan}") 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") raise SystemExit("planar cylinder cap push/pull scope should remain available")
huge_push_plan = model.push_pull_plan(face_id, 50.0) huge_push_plan = model.push_pull_plan(face_id, 50.0)
if huge_push_plan.get("status") != "blocked": if huge_push_plan.get("status") == "blocked":
raise SystemExit(f"huge planar cylinder cap push/pull should be blocked early: {huge_push_plan}") 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) 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) 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: 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 "") huge_message = str(huge_push_plan.get("message") or "")
if "圆柱高度" not in huge_message and "cylindrical" not in huge_message.lower(): if "解析" not in huge_message and "analytic" not in huge_message.lower():
raise SystemExit(f"huge cap push/pull should explain the cylinder-height risk: {huge_push_plan}") 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) before_height = _bbox_height(model)
push_plan = model.push_pull_plan(face_id, 1.0) push_plan = model.push_pull_plan(face_id, 1.0)
@@ -158,6 +263,275 @@ def main() -> int:
f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, " f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, "
f"push_pull={push_result}" 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 return 0
+8
View File
@@ -46,6 +46,14 @@ def main() -> int:
if float(near_through.get("push_pull_inward_cut_ratio") or 0.0) < 0.85: 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}") 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) through = model.push_pull_plan(face_id, -10.0)
_assert_plan("through inward cut", through, "blocked", "blocked") _assert_plan("through inward cut", through, "blocked", "blocked")
message = str(through.get("message") or through.get("blockers") or "") message = str(through.get("message") or through.get("blockers") or "")
+2
View File
@@ -283,6 +283,8 @@ def main() -> int:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}") raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
if "Face result check:" not in result: if "Face result check:" not in result:
raise SystemExit(f"Face edit result message should include a result check, got: {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() after = model.stats()
if after.solids != before.solids: if after.solids != before.solids:
@@ -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())
@@ -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())
+218
View File
@@ -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())
+2
View File
@@ -330,6 +330,8 @@ def _run_blind_depth_case(target: float, tolerance: float) -> None:
verified_face, depth, error = _nearest_blind_depth(model, target) verified_face, depth, error = _nearest_blind_depth(model, target)
if error > tolerance: if error > tolerance:
raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}") 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("mode=blind_depth")
print(f"source_face={face_id}") print(f"source_face={face_id}")
print(f"bottom_face={bottom_face_id}") print(f"bottom_face={bottom_face_id}")
+146 -4
View File
@@ -6,7 +6,9 @@ import sys
import tempfile import tempfile
from pathlib import Path 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 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)) sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel from step_editor.model import StepModel
from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.step_io import _write_step from step_editor.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) _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: def _first_face_by_surface(model: StepModel, surface: str) -> int:
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
if model.face_info(face_id).get("surface") == surface: 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") 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: def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
candidates: list[tuple[int, int]] = [] candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)): for face_id in range(len(model.faces)):
@@ -270,7 +301,78 @@ def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float
print(f"matched_torus_radii={matches}") 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: 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( _run_worker_case(
label="面偏移(当前面)", label="面偏移(当前面)",
operation="move_face_plane_offset_local", operation="move_face_plane_offset_local",
@@ -384,8 +486,30 @@ def main() -> int:
def __init__(self) -> None: def __init__(self) -> None:
self.model = StepModel.load(DEFAULT_MODEL) self.model = StepModel.load(DEFAULT_MODEL)
self.step_path = DEFAULT_MODEL self.step_path = DEFAULT_MODEL
self.current_info_values = self.model.face_info(0)
probe = _IsolatedJobProbe() 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")) != [ if probe._isolated_edit_command(Path("request.json")) != [
sys.executable, sys.executable,
"-m", "-m",
@@ -445,9 +569,18 @@ def main() -> int:
) )
if "隔离子进程" not in str(result.get("message", "")): if "隔离子进程" not in str(result.get("message", "")):
raise SystemExit(f"isolated window job did not report isolated execution: {result}") raise SystemExit(f"isolated window job did not report isolated execution: {result}")
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: if probe.model.stats().solids != 1:
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}") raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
if not _logical_region_has_width(probe.model, 0, 25.0): 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") raise SystemExit("isolated window job did not preserve the original logical Face ID on the edited width")
print("isolated window job ok") print("isolated window job ok")
@@ -474,8 +607,17 @@ def main() -> int:
) )
if "隔离子进程" not in str(owning_result.get("message", "")): if "隔离子进程" not in str(owning_result.get("message", "")):
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}") raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0) owning_after_model = owning_result.get("after_model")
if not _logical_region_has_width(owning_probe.model, 0, 25.0): 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") raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
print("isolated owning window job ok") print("isolated owning window job ok")
_run_main_worker_entry_case() _run_main_worker_entry_case()
@@ -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())
+97 -3
View File
@@ -9,6 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.window_state import WindowStateMixin from step_editor.window_state import WindowStateMixin
from step_editor.ui_helpers import _format_value
class _PropertySpecProbe(WindowStateMixin): class _PropertySpecProbe(WindowStateMixin):
@@ -315,6 +316,10 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
def main() -> int: 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 = { plane_info = {
"surface": "plane", "surface": "plane",
"area": 100.0, "area": 100.0,
@@ -485,6 +490,68 @@ def main() -> int:
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature") _assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "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( slot_keys = _spec_keys(
{ {
"surface": "cylinder", "surface": "cylinder",
@@ -510,8 +577,7 @@ def main() -> int:
"slot/half-hole feature", "slot/half-hole feature",
) )
boss_keys = _spec_keys( boss_info = {
{
"surface": "cylinder", "surface": "cylinder",
"feature_guess": "boss/outer-round candidate", "feature_guess": "boss/outer-round candidate",
"diameter": 6.0, "diameter": 6.0,
@@ -527,9 +593,15 @@ def main() -> int:
"axis_center": (0.0, 0.0, 5.0), "axis_center": (0.0, 0.0, 5.0),
"feature_start_end_face_ids": (1,), "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_no_generic_face_leak(boss_keys, "boss feature")
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "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( fillet_keys = _spec_keys(
{ {
@@ -609,6 +681,28 @@ def main() -> int:
if surface_spec.get("current_text") != "圆锥面 / 拔模面": if surface_spec.get("current_text") != "圆锥面 / 拔模面":
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}") 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_target_change_detection()
_assert_holed_plane_local_scopes_disabled() _assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms() _assert_no_legacy_face_source_terms()
+3
View File
@@ -214,6 +214,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.edit_thread: QThread | None = None self.edit_thread: QThread | None = None
self.edit_worker: EditWorker | None = None self.edit_worker: EditWorker | None = None
self.pending_edit_context: dict[str, object] | 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_in_progress = False
self.scan_thread: QThread | None = None self.scan_thread: QThread | None = None
self.scan_worker: ScanWorker | None = None self.scan_worker: ScanWorker | None = None
+137
View File
@@ -64,7 +64,41 @@ from .geometry_utils import * # noqa: F403
class FeatureMixin: 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]: 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: try:
topology = self.cylindrical_feature_first_level_topology(face_id) topology = self.cylindrical_feature_first_level_topology(face_id)
except Exception as exc: except Exception as exc:
@@ -80,6 +114,7 @@ class FeatureMixin:
"first_level_adjacent_face_count": 0, "first_level_adjacent_face_count": 0,
"cylindrical_feature_side_face_ids": (face_id,), "cylindrical_feature_side_face_ids": (face_id,),
"cylindrical_feature_side_face_count": 1, "cylindrical_feature_side_face_count": 1,
**fact_fields,
} }
fields = { fields = {
@@ -121,6 +156,7 @@ class FeatureMixin:
0, 0,
), ),
} }
fields.update(fact_fields)
fields["first_level_edit_semantics"] = ( fields["first_level_edit_semantics"] = (
"Cylindrical feature edits currently use only first-level shared-edge topology: " "Cylindrical feature edits currently use only first-level shared-edge topology: "
"the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; " "the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; "
@@ -2394,6 +2430,8 @@ class FeatureMixin:
"solid_id": info["solid_id"], "solid_id": info["solid_id"],
"diameter": info.get("diameter"), "diameter": info.get("diameter"),
"radius": info.get("radius"), "radius": info.get("radius"),
"axis": info.get("axis"),
"axis_point": info.get("axis_point"),
"angular_span": info.get("angular_span"), "angular_span": info.get("angular_span"),
"height_estimate": scoped_info.get("height_estimate"), "height_estimate": scoped_info.get("height_estimate"),
"feature_type": feature.get("feature_type"), "feature_type": feature.get("feature_type"),
@@ -3067,11 +3105,110 @@ class FeatureMixin:
) )
return samples 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]: def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
face = self.faces[face_id] face = self.faces[face_id]
direction = surf.Plane().Axis().Direction() direction = surf.Plane().Axis().Direction()
axis_tuple = _dir_tuple(direction) axis_tuple = _dir_tuple(direction)
oriented_tuple = _oriented_dir_tuple(direction, face) 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 = { fallback = {
"outward_direction": oriented_tuple, "outward_direction": oriented_tuple,
"inward_direction": _neg_tuple(oriented_tuple), "inward_direction": _neg_tuple(oriented_tuple),
+6
View File
@@ -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])) return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale": if operation == "resize_shell_thickness_owning_scale":
return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1])) return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_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": if operation == "resize_cone_reference_radius":
return model.resize_conical_reference_radius(int(args[0]), float(args[1])) return model.resize_conical_reference_radius(int(args[0]), float(args[1]))
if operation == "resize_cone_semi_angle": if operation == "resize_cone_semi_angle":
+441 -32
View File
@@ -113,6 +113,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._same_domain_face_ids_cache: dict[int, list[int]] = {} self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {} self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_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._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -196,6 +197,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear() self._face_first_level_topology_cache.clear()
self._cylindrical_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._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None self._same_domain_internal_edge_ids_cache = None
@@ -290,6 +292,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._same_domain_face_ids_cache.clear() self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear() self._face_first_level_topology_cache.clear()
self._cylindrical_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._local_face_deform_readiness_cache.clear()
def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool: 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._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear() self._face_first_level_topology_cache.clear()
self._cylindrical_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._local_face_deform_readiness_cache.clear()
return True return True
@@ -349,12 +353,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_confidence"] = "unchecked" info["push_pull_confidence"] = "unchecked"
info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。" 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["push_pull_status"] = "candidate"
info["feature_type"] = "可推拉平面候选" info["feature_type"] = "可推拉平面候选"
info["feature_source_face_id"] = face_id info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,) info["feature_highlight_face_ids"] = (face_id,)
info["feature_edit_actions"] = "推拉平面" 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( info.update(
self._local_face_plane_size_info( self._local_face_plane_size_info(
face_id, face_id,
@@ -416,6 +431,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["feature_torus_major_radius"] = torus.MajorRadius() info["feature_torus_major_radius"] = torus.MajorRadius()
info["feature_torus_minor_radius"] = torus.MinorRadius() info["feature_torus_minor_radius"] = torus.MinorRadius()
info.update(self._recognition_summary_fields(info))
self._quick_face_info_cache[face_id] = dict(info) self._quick_face_info_cache[face_id] = dict(info)
return dict(info) return dict(info)
@@ -512,6 +528,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["axis"] = _dir_tuple(torus.Axis().Direction()) info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius() info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius() info["minor_radius"] = torus.MinorRadius()
info.update(self._recognition_summary_fields(info))
self._face_info_cache[face_id] = dict(info) self._face_info_cache[face_id] = dict(info)
return dict(info) return dict(info)
@@ -545,6 +562,267 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
except Exception: except Exception:
return None 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: def cached_feature_info(self, face_id: int) -> dict[str, object] | None:
cached = self._feature_info_cache.get(face_id) cached = self._feature_info_cache.get(face_id)
return dict(cached) if cached is not None else None 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) self._feature_info_cache[face_id] = dict(result)
return dict(result) return dict(result)
@@ -702,11 +981,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
{ {
"kind": "feature", "kind": "feature",
"feature_type": "环面候选", "feature_type": "环面候选",
"feature_type": prismatic_info.get("feature_type", "可推拉平面候选"),
"feature_source_face_id": face_id, "feature_source_face_id": face_id,
"feature_face_ids": (face_id,), "feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,),
"feature_highlight_face_ids": tuple(sorted(highlight_face_ids)),
"feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径", "feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": ( "feature_mode": (
@@ -1824,6 +2101,133 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._cylindrical_first_level_topology_cache[item] = dict(topology) self._cylindrical_first_level_topology_cache[item] = dict(topology)
return 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]: def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try: try:
boundary_wires = len(_explore(face, TopAbs_WIRE)) boundary_wires = len(_explore(face, TopAbs_WIRE))
@@ -2109,6 +2513,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
source_solid_id = self.face_solid_ids[face_id] source_solid_id = self.face_solid_ids[face_id]
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) 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) interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
plane = source_surf.Plane() plane = source_surf.Plane()
axis_point = plane.Location() axis_point = plane.Location()
@@ -2141,20 +2562,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
visited.add(candidate_id) visited.add(candidate_id)
queue.append(candidate_id) queue.append(candidate_id)
return sorted(visited) 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) return sorted(visited)
def connected_same_domain_face_ids(self, face_id: int) -> list[int]: 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) radius = max(float(cylinder.Radius()), 0.0)
diagonal = _shape_diagonal(self.shape) diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) 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) 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) source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir)
candidates: dict[int, tuple[float, float]] = {} candidates: dict[int, tuple[float, float]] = {}
for candidate_id, face in enumerate(self.faces): for candidate_id, face in enumerate(self.faces):
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: 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) visited.add(candidate_id)
queue.append(candidate_id) queue.append(candidate_id)
return sorted(visited) 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) return sorted(visited)
def _cylindrical_axis_range( def _cylindrical_axis_range(
+2644 -91
View File
File diff suppressed because it is too large Load Diff
+72 -2
View File
@@ -49,6 +49,25 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"curve", "curve",
"adjacent_face_ids", "adjacent_face_ids",
"adjacent_face_count", "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_message",
"push_pull_scope_face_ids", "push_pull_scope_face_ids",
"push_pull_scope_face_count", "push_pull_scope_face_count",
"push_pull_scope_area",
"push_pull_scope_note", "push_pull_scope_note",
"shell_region_kind", "shell_region_kind",
"shell_region_status", "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", "feature_guess",
"confidence", "confidence",
"material_vote_summary", "material_vote_summary",
@@ -372,6 +404,18 @@ INFO_LABELS = {
"feature_mode": "特征模式说明", "feature_mode": "特征模式说明",
"feature_type": "特征类型", "feature_type": "特征类型",
"feature_source_face_id": "特征来源 Face", "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": "零件数", "parts": "零件数",
"solids": "Solid 数", "solids": "Solid 数",
"faces": "Face 数", "faces": "Face 数",
@@ -389,6 +433,25 @@ INFO_LABELS = {
"curve": "曲线类型", "curve": "曲线类型",
"adjacent_face_ids": "相邻 Face", "adjacent_face_ids": "相邻 Face",
"adjacent_face_count": "相邻 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": "体积", "volume": "体积",
"surface_area": "表面积", "surface_area": "表面积",
"area": "面积", "area": "面积",
@@ -533,6 +596,7 @@ INFO_LABELS = {
"push_pull_message": "推拉说明", "push_pull_message": "推拉说明",
"push_pull_scope_face_ids": "推拉共面区域 Face", "push_pull_scope_face_ids": "推拉共面区域 Face",
"push_pull_scope_face_count": "推拉共面区域 Face 数", "push_pull_scope_face_count": "推拉共面区域 Face 数",
"push_pull_scope_area": "推拉共面区域面积",
"push_pull_scope_note": "推拉共面区域说明", "push_pull_scope_note": "推拉共面区域说明",
"shell_region_kind": "壳体/薄壁候选类型", "shell_region_kind": "壳体/薄壁候选类型",
"shell_region_status": "壳体/薄壁识别状态", "shell_region_status": "壳体/薄壁识别状态",
@@ -905,8 +969,14 @@ def _export_quality_text(info: dict[str, object]) -> str:
def _format_value(value: object) -> str: def _format_value(value: object) -> str:
if isinstance(value, float): if isinstance(value, float):
return _format_float(value) return _format_float(value)
if isinstance(value, tuple): if isinstance(value, (tuple, list)):
return "(" + ", ".join(_format_float(float(v)) for v in value) + ")" 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) return str(value)
+631 -11
View File
@@ -20,6 +20,15 @@ from PySide6.QtWidgets import (
from .model import StepModel from .model import StepModel
from .records import OperationRecord 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 .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker 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) 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: class WindowActionMixin:
def export_all(self) -> None: def export_all(self) -> None:
if self.model is None: if self.model is None:
@@ -339,7 +353,7 @@ class WindowActionMixin:
QMessageBox.critical(self, "距离无效", "请输入数字形式的面移动距离。") QMessageBox.critical(self, "距离无效", "请输入数字形式的面移动距离。")
return return
face_id = self.selected_face_id 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": if plan["status"] == "blocked":
QMessageBox.information(self, "不能推拉平面", str(plan["message"])) 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_material_depth": plan.get("push_pull_inward_material_depth"),
"push_pull_inward_cut_ratio": plan.get("push_pull_inward_cut_ratio"), "push_pull_inward_cut_ratio": plan.get("push_pull_inward_cut_ratio"),
"bbox_diagonal": plan.get("bbox_diagonal"), "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_kind="face",
target_id=face_id, target_id=face_id,
@@ -495,6 +527,20 @@ class WindowActionMixin:
if risk == "low": if risk == "low":
risk = "medium" risk = "medium"
warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。") 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"}: if status != "blocked" and risk in {"medium", "high"}:
status = "caution" status = "caution"
@@ -538,6 +584,10 @@ class WindowActionMixin:
"surface": surface, "surface": surface,
"area": info.get("area"), "area": info.get("area"),
"bbox_diagonal": bbox_diagonal, "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_material_depth": inward_material_depth,
"push_pull_inward_cut_ratio": inward_cut_ratio, "push_pull_inward_cut_ratio": inward_cut_ratio,
"outward_direction": outward_tuple, "outward_direction": outward_tuple,
@@ -554,8 +604,104 @@ class WindowActionMixin:
"push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_ids": tuple(scope_face_ids),
"push_pull_scope_face_count": len(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids),
"push_pull_scope_note": "使用当前显示/选中缓存生成快速预览;后台会重新计算真实推拉区域。", "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: def resize_shell_thickness(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -573,6 +719,8 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
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) plan = self.model.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked": if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整薄壁厚度", str(plan["message"])) QMessageBox.information(self, "不能调整薄壁厚度", str(plan["message"]))
@@ -782,6 +930,11 @@ class WindowActionMixin:
info = {} info = {}
if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id: if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id:
info = {} 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: if "surface" not in info and "diameter" not in info:
try: try:
info = dict(self.model.face_info(selected_face_id)) info = dict(self.model.face_info(selected_face_id))
@@ -789,6 +942,286 @@ class WindowActionMixin:
pass pass
return info 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( def _quick_cylinder_resize_plan(
self, self,
face_id: int, face_id: int,
@@ -982,6 +1415,8 @@ class WindowActionMixin:
"面高(整体)", "面高(整体)",
"面偏移(当前面)", "面偏移(当前面)",
"只移动当前Face", "只移动当前Face",
"圆柱高度调整",
"高度(整体)缩放所属对象",
} }
def _isolation_for_plan( def _isolation_for_plan(
@@ -1003,6 +1438,9 @@ class WindowActionMixin:
"move_face_center_local", "move_face_center_local",
"resize_shell_thickness", "resize_shell_thickness",
"resize_shell_thickness_owning_scale", "resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
"resize_cylindrical_boss_height",
"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",
@@ -1019,6 +1457,108 @@ class WindowActionMixin:
"reason": f"{risk}-risk-face-occ-edit", "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: def resize_hole(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -2413,7 +2953,7 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消圆柱凸台高度调整") self.statusBar().showMessage("已取消圆柱凸台高度调整")
return return
self._show_cylinder_boss_height_preview(face_id, target_height) self.clear_edit_preview(render=False)
def action(): def action():
return self.model.resize_cylindrical_boss_height(face_id, target_height) return self.model.resize_cylindrical_boss_height(face_id, target_height)
@@ -2451,6 +2991,11 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(
plan,
"resize_cylindrical_boss_height",
[face_id, target_height],
),
) )
def resize_cylinder_height(self) -> None: def resize_cylinder_height(self) -> None:
@@ -2527,6 +3072,7 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, 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: def resize_cylindrical_height_owning_scale(self) -> None:
@@ -2616,6 +3162,11 @@ class WindowActionMixin:
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=self._isolation_for_plan(
plan,
"resize_cylindrical_height_owning_scale",
[face_id, target_height],
),
) )
def suppress_hole(self) -> None: def suppress_hole(self) -> None:
@@ -4659,6 +5210,8 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
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) plan = self.model.face_area_local_resize_plan(face_id, target_area)
lines = [ lines = [
f"Face: {face_id}", f"Face: {face_id}",
@@ -4750,6 +5303,8 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
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) plan = self.model.face_size_local_resize_plan(face_id, target_size, axis_key)
lines = [ lines = [
f"Face: {face_id}", f"Face: {face_id}",
@@ -5010,6 +5565,8 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id face_id = self.selected_face_id
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) plan = self.model.face_plane_offset_local_plan(face_id, distance)
lines = [ lines = [
f"Face: {face_id}", f"Face: {face_id}",
@@ -5177,6 +5734,8 @@ class WindowActionMixin:
current_center[1] + vector[1], current_center[1] + vector[1],
current_center[2] + vector[2], current_center[2] + vector[2],
) )
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) plan = self.model.face_center_local_move_plan(face_id, target_center)
lines = [ lines = [
f"Face: {face_id}", f"Face: {face_id}",
@@ -6450,7 +7009,10 @@ class WindowActionMixin:
raise RuntimeError( raise RuntimeError(
f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}" f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}"
) from exc ) from exc
raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc raise RuntimeError(
f"编辑失败,模型已恢复到操作前状态:{exc}"
f"{self._edit_failure_diagnostics(context)}"
) from exc
model_polydata = None model_polydata = None
edge_polydata = None edge_polydata = None
try: try:
@@ -6522,22 +7084,38 @@ class WindowActionMixin:
) )
command = self._isolated_edit_command(request_path) command = self._isolated_edit_command(request_path)
self.isolated_edit_cancel_requested = False
process: subprocess.Popen[str] | None = None
try: try:
completed = subprocess.run( process = subprocess.Popen(
command, command,
cwd=project_root, cwd=project_root,
capture_output=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", 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: 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( raise RuntimeError(
f"隔离子进程执行超时,已终止危险计算;主程序和原模型保持不变。" f"隔离子进程执行超时,已终止危险计算;主程序和原模型保持不变。"
f" 超时时间: {timeout_seconds:g}s" f" 超时时间: {timeout_seconds:g}s"
f"{self._edit_failure_diagnostics(context)}"
) from exc ) 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_path = request_path.with_suffix(".response.json")
response: dict[str, object] = {} response: dict[str, object] = {}
@@ -6551,6 +7129,7 @@ class WindowActionMixin:
raise RuntimeError( raise RuntimeError(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。" "隔离子进程执行失败;主程序没有崩溃,原模型保持不变。"
f" 子进程返回码: {completed.returncode}. 错误: {error}" f" 子进程返回码: {completed.returncode}. 错误: {error}"
f"{self._edit_failure_diagnostics(context)}"
) )
if not output_path.exists(): if not output_path.exists():
raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。") raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。")
@@ -6576,14 +7155,13 @@ class WindowActionMixin:
after_quality, after_quality,
after_model=new_model, after_model=new_model,
) )
self.model = new_model
after_geometry: dict[str, object] = {} after_geometry: dict[str, object] = {}
model_polydata = None model_polydata = None
edge_polydata = None edge_polydata = None
try: try:
deflection = float(context.get("edit_result_deflection", 1.6)) deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection) model_polydata = new_model.build_face_polydata(deflection=deflection)
edge_polydata = self.model.build_edge_polydata( edge_polydata = new_model.build_edge_polydata(
deflection=deflection, deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)), 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_snapshot": after_snapshot,
"after_stats": after_stats, "after_stats": after_stats,
"after_part_stats": after_part_stats, "after_part_stats": after_part_stats,
"after_model": new_model,
"quality_warnings": quality_warnings, "quality_warnings": quality_warnings,
"after_geometry": after_geometry, "after_geometry": after_geometry,
"model_polydata": model_polydata, "model_polydata": model_polydata,
@@ -6611,6 +7190,35 @@ class WindowActionMixin:
return [sys.executable, "--isolated-edit-worker", str(request_path)] return [sys.executable, "--isolated-edit-worker", str(request_path)]
return [sys.executable, "-m", "step_editor.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( def _preserve_isolated_face_logical_id(
self, self,
model: StepModel, model: StepModel,
@@ -7285,6 +7893,9 @@ class WindowActionMixin:
QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。") QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。")
self.statusBar().showMessage("编辑结果无法识别") self.statusBar().showMessage("编辑结果无法识别")
return return
after_model = result.get("after_model")
if after_model is not None:
self.model = after_model
message = str(result["message"]) message = str(result["message"])
try: try:
record = self._make_operation_record( record = self._make_operation_record(
@@ -7347,11 +7958,20 @@ class WindowActionMixin:
if hasattr(self, "_is_ui_thread") and not self._is_ui_thread(): 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)) self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message))
return 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) self._end_edit_task(clear_preview=True)
if not close_after_cancel:
QMessageBox.critical(self, "操作失败", message) QMessageBox.critical(self, "操作失败", message)
self._clear_editable_candidates() self._clear_editable_candidates()
self._clear_cylinder_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: def _restore_failed_edit_snapshot(self, snapshot: object) -> str:
if self.model is None or not isinstance(snapshot, dict): if self.model is None or not isinstance(snapshot, dict):
+3
View File
@@ -214,6 +214,9 @@ class WindowCoreMixin:
return return
edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning()) 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 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("后台编辑正在计算,请等待当前操作完成后再关闭窗口。") self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
event.ignore() event.ignore()
return return
+236 -33
View File
@@ -30,6 +30,7 @@ PROPERTY_ACTION_COLUMN = 4
FEATURE_EDIT_SEMANTICS_KEYS = { FEATURE_EDIT_SEMANTICS_KEYS = {
"face_first_level_topology", "face_first_level_topology",
"cylindrical_feature_first_level_topology",
"slot_edit_semantics", "slot_edit_semantics",
"hole_edit_semantics", "hole_edit_semantics",
"boss_edit_semantics", "boss_edit_semantics",
@@ -160,6 +161,26 @@ class WindowStateMixin:
enriched["pick_position"] = pick_position enriched["pick_position"] = pick_position
return enriched 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]: def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None: if self.model is None:
return {} return {}
@@ -198,6 +219,70 @@ class WindowStateMixin:
"first_level_face_ids": topology.get("first_level_face_ids", (face_id,)), "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_face_count": topology.get("first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""), "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]: 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) root_info = self.model.feature_info(face_id)
if str(root_info.get("surface", "") or "") == "plane": if str(root_info.get("surface", "") or "") == "plane":
root_info.update(self._face_first_level_selection_fields(face_id)) 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]] = [] associated: list[dict[str, object]] = []
if detection_level in {"associated-only", "secondary"}: if detection_level in {"associated-only", "secondary"}:
try: try:
@@ -323,6 +410,10 @@ class WindowStateMixin:
), ),
} }
) )
try:
info.update(self.model._recognition_summary_fields(info))
except Exception:
pass
return info return info
def _current_feature_detection_level(self) -> str: 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_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet 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_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_slot_or_half_hole = ( is_slot_or_half_hole = (
is_hole_or_groove is_hole_or_groove
@@ -1970,6 +2093,62 @@ class WindowStateMixin:
) )
if has_face: 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_hole_or_groove:
if is_slot_or_half_hole: if is_slot_or_half_hole:
add_readonly_spec( add_readonly_spec(
@@ -2025,7 +2204,6 @@ class WindowStateMixin:
), ),
) )
elif is_plane or is_shell_candidate: elif is_plane or is_shell_candidate:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1: if topology_depth == 1:
same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0 same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("first_level_boundary_edge_count")) or 0 boundary_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")) current_boss_height = _float_or_none(action_info.get("same_domain_height_estimate"))
if current_boss_height is None: if current_boss_height is None:
current_boss_height = _float_or_none(action_info.get("height_estimate")) 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( add_scoped_spec(
key="boss_height", key="boss_height",
label="高度", label="高度",
current_raw=current_boss_height if current_boss_height is not None else "", current_raw=current_boss_height if current_boss_height is not None else "",
target_text=numeric_text(current_boss_height), target_text=numeric_text(current_boss_height),
scope_default="local", scope_default="owning" if boss_height_can_scale else "local",
scope_modes={ scope_modes={
"local": { "local": {
"label": "推拉端盖", "label": "推拉端盖",
"action": "resize_boss_height", "action": "resize_boss_height",
"target_attr": "boss_height_input", "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。", "enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可推拉的凸台端盖 Face。",
"disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", "disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。",
"range_hint": relative_range_hint(current_boss_height, 0.3, 0.8), "range_hint": relative_range_hint(current_boss_height, 0.3, 0.8),
@@ -3057,12 +3249,7 @@ class WindowStateMixin:
"label": "调整整个特征", "label": "调整整个特征",
"action": "resize_cylindrical_height_owning_scale", "action": "resize_cylindrical_height_owning_scale",
"target_attr": "boss_height_input", "target_attr": "boss_height_input",
"enabled": bool( "enabled": boss_height_can_scale,
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_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。", "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。",
"disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。",
"range_hint": ( "range_hint": (
@@ -3153,12 +3340,18 @@ class WindowStateMixin:
current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate")) current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate"))
if current_cylinder_height is None: if current_cylinder_height is None:
current_cylinder_height = _float_or_none(action_info.get("height_estimate")) 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( add_scoped_spec(
key="cylinder_height", key="cylinder_height",
label="高度", label="高度",
current_raw=current_cylinder_height if current_cylinder_height is not None else "", current_raw=current_cylinder_height if current_cylinder_height is not None else "",
target_text=numeric_text(current_cylinder_height), target_text=numeric_text(current_cylinder_height),
scope_default="local", scope_default="owning" if cylinder_height_can_scale else "local",
scope_modes={ scope_modes={
"local": { "local": {
"label": "推拉端盖", "label": "推拉端盖",
@@ -3173,12 +3366,7 @@ class WindowStateMixin:
"label": "调整整个特征", "label": "调整整个特征",
"action": "resize_cylindrical_height_owning_scale", "action": "resize_cylindrical_height_owning_scale",
"target_attr": "boss_height_input", "target_attr": "boss_height_input",
"enabled": bool( "enabled": cylinder_height_can_scale,
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_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。", "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。",
"disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。",
"range_hint": ( "range_hint": (
@@ -3295,6 +3483,14 @@ class WindowStateMixin:
used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"), used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"),
**positive_minimum(), **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: if is_cone:
current_reference_radius = _float_or_none(action_info.get("reference_radius")) current_reference_radius = _float_or_none(action_info.get("reference_radius"))
current_reference_diameter = ( current_reference_diameter = (
@@ -3303,7 +3499,10 @@ class WindowStateMixin:
reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability( reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability(
current_reference_radius 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( add_spec(
key="cone_reference_radius", key="cone_reference_radius",
label="参考半径", label="参考半径",
@@ -3313,7 +3512,7 @@ class WindowStateMixin:
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=reference_radius_enabled, enabled=reference_radius_enabled,
enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。", enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
disabled_tip=( disabled_tip=analytic_surface_disabled_tip(
reference_radius_disabled_reason reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考半径,不能直接修改。" or "当前圆锥面缺少稳定参考半径,不能直接修改。"
), ),
@@ -3332,9 +3531,12 @@ class WindowStateMixin:
target_text=numeric_text(current_reference_diameter), target_text=numeric_text(current_reference_diameter),
action="resize_cone_reference_radius", action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=current_reference_diameter is not None and reference_radius_supported, enabled=analytic_surface_enabled(
current_reference_diameter is not None,
reference_radius_supported,
),
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。", enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。",
disabled_tip=( disabled_tip=analytic_surface_disabled_tip(
reference_radius_disabled_reason reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考直径,不能直接修改。" or "当前圆锥面缺少稳定参考直径,不能直接修改。"
), ),
@@ -3356,6 +3558,7 @@ class WindowStateMixin:
current_reference_radius is not None current_reference_radius is not None
and current_semi_angle is not None and current_semi_angle is not None
and semi_angle_supported and semi_angle_supported
and analytic_surface_recognition_ready
) )
add_spec( add_spec(
key="cone_semi_angle_degrees", key="cone_semi_angle_degrees",
@@ -3366,7 +3569,7 @@ class WindowStateMixin:
target_attr="cone_reference_radius_input", target_attr="cone_reference_radius_input",
enabled=semi_angle_enabled, enabled=semi_angle_enabled,
enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。", enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
disabled_tip=( disabled_tip=analytic_surface_disabled_tip(
semi_angle_disabled_reason semi_angle_disabled_reason
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。" or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
), ),
@@ -3390,9 +3593,9 @@ class WindowStateMixin:
target_text=numeric_text(current_sphere_radius), target_text=numeric_text(current_sphere_radius),
action="resize_sphere_radius", action="resize_sphere_radius",
target_attr="sphere_radius_input", target_attr="sphere_radius_input",
enabled=current_sphere_radius is not None, enabled=analytic_surface_enabled(current_sphere_radius is not None),
enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。", enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。",
disabled_tip="当前球面缺少稳定半径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前球面缺少稳定半径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史半径参数。 {relative_range_hint(current_sphere_radius, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史半径参数。 {relative_range_hint(current_sphere_radius, 0.25, 0.6)}",
used=("radius",), used=("radius",),
@@ -3405,9 +3608,9 @@ class WindowStateMixin:
target_text=numeric_text(current_sphere_diameter), target_text=numeric_text(current_sphere_diameter),
action="resize_sphere_radius", action="resize_sphere_radius",
target_attr="sphere_radius_input", target_attr="sphere_radius_input",
enabled=current_sphere_diameter is not None, enabled=analytic_surface_enabled(current_sphere_diameter is not None),
enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放所属对象。", enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放所属对象。",
disabled_tip="当前球面缺少稳定直径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前球面缺少稳定直径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史直径参数。 {relative_range_hint(current_sphere_diameter, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史直径参数。 {relative_range_hint(current_sphere_diameter, 0.25, 0.6)}",
target_transform="diameter_to_radius", target_transform="diameter_to_radius",
@@ -3430,9 +3633,9 @@ class WindowStateMixin:
target_text=numeric_text(current_major_radius), target_text=numeric_text(current_major_radius),
action="resize_torus_major_radius", action="resize_torus_major_radius",
target_attr="torus_radius_input", target_attr="torus_radius_input",
enabled=current_major_radius is not None, enabled=analytic_surface_enabled(current_major_radius is not None),
enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。",
disabled_tip="当前环面缺少稳定主半径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定主半径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只改环面主半径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_radius, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只改环面主半径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_radius, 0.25, 0.6)}",
used=("major_radius", "feature_torus_major_radius"), used=("major_radius", "feature_torus_major_radius"),
@@ -3445,9 +3648,9 @@ class WindowStateMixin:
target_text=numeric_text(current_major_diameter), target_text=numeric_text(current_major_diameter),
action="resize_torus_major_radius", action="resize_torus_major_radius",
target_attr="torus_radius_input", target_attr="torus_radius_input",
enabled=current_major_diameter is not None, enabled=analytic_surface_enabled(current_major_diameter is not None),
enabled_tip="输入环面的目标主直径;程序会换算为主半径后整体缩放所属对象。", enabled_tip="输入环面的目标主直径;程序会换算为主半径后整体缩放所属对象。",
disabled_tip="当前环面缺少稳定主直径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定主直径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只改环面主直径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_diameter, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只改环面主直径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_diameter, 0.25, 0.6)}",
target_transform="diameter_to_radius", target_transform="diameter_to_radius",
@@ -3461,9 +3664,9 @@ class WindowStateMixin:
target_text=numeric_text(current_minor_radius), target_text=numeric_text(current_minor_radius),
action="resize_torus_minor_radius", action="resize_torus_minor_radius",
target_attr="torus_radius_input", target_attr="torus_radius_input",
enabled=current_minor_radius is not None, enabled=analytic_surface_enabled(current_minor_radius is not None),
enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。",
disabled_tip="当前环面缺少稳定小半径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定小半径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只改环面小半径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_radius, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只改环面小半径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_radius, 0.25, 0.6)}",
used=("minor_radius", "feature_torus_minor_radius"), used=("minor_radius", "feature_torus_minor_radius"),
@@ -3476,9 +3679,9 @@ class WindowStateMixin:
target_text=numeric_text(current_minor_diameter), target_text=numeric_text(current_minor_diameter),
action="resize_torus_minor_radius", action="resize_torus_minor_radius",
target_attr="torus_radius_input", target_attr="torus_radius_input",
enabled=current_minor_diameter is not None, enabled=analytic_surface_enabled(current_minor_diameter is not None),
enabled_tip="输入环面的目标小直径;程序会换算为小半径后整体缩放所属对象。", enabled_tip="输入环面的目标小直径;程序会换算为小半径后整体缩放所属对象。",
disabled_tip="当前环面缺少稳定小直径,不能直接修改。", disabled_tip=analytic_surface_disabled_tip("当前环面缺少稳定小直径,不能直接修改。"),
value_type="positive", value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只改环面小直径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_diameter, 0.25, 0.6)}", range_hint=f"这是整体缩放所属对象,不是只改环面小直径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_diameter, 0.25, 0.6)}",
target_transform="diameter_to_radius", target_transform="diameter_to_radius",