diff --git a/README.md b/README.md index f6f50c5..95f33ee 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,13 @@ python scripts\generate_cube_step.py python scripts\verify_face_edit_suite.py python scripts\verify_isolated_face_edit.py python scripts\verify_edge_edit_suite.py +python scripts\verify_edge_isolated_edit.py python scripts\verify_shell_thickness_resize.py python scripts\verify_analytic_surface_resize.py python scripts\verify_boss_resize.py python scripts\verify_hole_resize.py python scripts\verify_slot_resize.py +python scripts\verify_hole_slot_isolated_edit.py python scripts\verify_cylindrical_first_level_topology.py ``` @@ -124,8 +126,8 @@ git diff --check - 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以推拉,但“只改当前面局部尺寸/中心/偏移”会被标成受限修改。 -- 用户可以修改可控的特征,比如 Face 的面积、面宽/面高、中心、面偏移和薄壁厚度,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 +- 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/中心/偏移”会被标成受限修改。 +- 用户可以修改可控的特征,比如 Face 的面积、U向尺寸/V向尺寸、中心、偏移曲面和壳体厚度,并通过 `影响范围` 选择拉伸/切除、局部重建、移动特征或缩放特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 @@ -134,35 +136,39 @@ git diff --check ## 几何修改语义原则 -所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表面推拉、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 +所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 -所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 +所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、Face 拉伸/切除、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 -当前 Face 参数化修改已经接入隔离子进程执行,包括 `推拉当前面`、`面偏移(当前面)`、`面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)`、`薄壁厚度(当前面)`、`面积(整体)`、`面宽/面高(整体)`、`薄壁厚度(整体)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。其它还没接入隔离通道的 high-risk 圆角、倒角或复杂 fallback 仍会被稳定性保护阻止,后续按特征逐步开放。 +当前 Face 参数化修改已经接入隔离子进程执行,包括 `拉伸/切除`、`偏移曲面(局部重建)`、`面积(局部重建)`、`U向尺寸/V向尺寸(局部重建)`、`中心(局部重建)`、`壳体厚度(拉伸/切除)`、`面积(缩放特征)`、`U向尺寸/V向尺寸(缩放特征)`、`壳体厚度(缩放特征)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。仍未归类到明确 Face、孔槽或 Edge 语义的复杂 fallback 会继续被稳定性保护阻止,后续按特征逐步开放。 + +孔/槽一级关系的主要布尔/重建入口也开始复用同一条隔离执行通道,包括孔径、圆柱尺寸缩放、孔轴心、孔封堵、盲孔/盲槽深度、槽/半孔轴心、槽宽、槽深、圆弧长度、圆弧角度、长圆槽总长度和中心距。这样即使孔/槽局部重切在复杂 STEP 上失败、超时或触发 OCCT 崩溃,主界面和原模型也应保持不变。圆柱直径整体缩放和盲孔/盲槽深度整体缩放会在模型层做结果校验;如果结果虽然是有效 B-Rep,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。 + +Edge 一级关系的主要高风险入口也接入同一条隔离执行通道,包括修改 Edge 长度、直线 Edge 起点/中心/终点移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主/小半径、已有圆角半径、直线 Edge 新增圆角、对称倒角、不等距倒角和距离+角度倒角。Edge 操作仍然不是通用 CAD 历史特征编辑;复杂曲线、复杂圆角链、无法确认一级邻域或 OCCT 倒圆/倒角失败时会回滚或提示失败,但这些危险计算不应再拖垮主界面进程。 Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示底层 OCCT 异常。当前会尽量附带诊断信息:操作对象、Face 类型、目标值/目标位置、一级边界 Edge/Vertex、共享边相邻 Face、内孔/内边界数量、已识别的端盖专用路径,以及是否可能因为孔壁、槽底、台阶终点等二级/三级关系暂未传播而失败。 复杂大 STEP 里的多边界平面端盖不再在 UI 线程同步生成完整 `push_pull_plan`。界面只做轻量预检查并标记高风险,然后把完整一级关系识别、解析重建或快速阻止放到后台隔离进程里执行,避免用户点击 `参数化建模` 后先卡住几秒到几十秒。 -如果轻量预检查已经能判断出风险不可接受,比如复杂大模型里的多内孔端盖做向内推拉,界面会直接阻止并说明需要二级关系判断,不再提交隔离子进程等待失败返回。 +如果轻量预检查已经能判断出风险不可接受,比如复杂大模型里的多内孔端盖向内切除已经接近切穿,或需要判断孔底、槽底、台阶背后的二级关系,界面会直接阻止并说明原因,不再提交隔离子进程等待失败返回。浅范围内的多内孔端盖向内收缩会走后台/隔离子进程里的一级边界侧壁重建。 -复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面积(当前面)`、`面宽/面高(当前面)`、`中心(当前面)` 或 `面偏移(当前面)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`薄壁厚度(当前面)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和推拉距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 +复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面积(局部重建)`、`U向尺寸/V向尺寸(局部重建)`、`中心(局部重建)` 或 `偏移曲面(局部重建)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`壳体厚度(拉伸/切除)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和拉伸/切除距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 普通平面 Face 的核心修改路径现在还会在模型层做结果守门:执行后必须通过 B-Rep 校验、能重新找到目标 Face 的目标值,并且能重新识别该 Face 的一级共享边邻域。矩形、多边形 Face 会检查边界 Edge 和 Vertex;圆形端盖这类解析边界会允许 1 条闭合圆 Edge。圆锥参考半径/半角、球面半径、环面半径这类解析 Face 也会强制校验目标值和 B-Rep。若目标值没达到、一级邻域丢失或 B-Rep 无效,模型层会恢复操作前 shape,不把坏结果交给界面或导出流程。 -`推拉当前面` 对一次性较大的向外推拉不会按绝对距离一刀切阻止;如果距离相对当前 Face 或所属实体跨度较大,会标为高风险并走隔离子进程执行。这样可以允许用户做类似 CAD 里的大距离拉伸,同时如果 OCCT 布尔失败、超时或返回无效 B-Rep,主程序和原模型仍保持不变。 +`拉伸/切除` 对一次性较大的向外拉伸不会按绝对距离一刀切阻止;如果距离相对当前 Face 或所属实体跨度较大,会标为高风险并走隔离子进程执行。这样可以允许用户做类似 CAD 里的大距离拉伸,同时如果 OCCT 布尔失败、超时或返回无效 B-Rep,主程序和原模型仍保持不变。 -实心圆柱或同轴空心筒体的端盖推拉会优先走解析/局部重建:选中圆柱上/下底面或环形端面时,程序会识别相邻外圆柱面、内孔圆柱面和端盖一级关系。纯简单圆柱/筒体会直接重建新的完整高度;如果同一 Solid 上还有不碰端盖边界的侧向缺口、台阶等额外特征,向外推拉会只生成旧端盖到新端盖之间的局部延长段去补料,向内推拉会只切除旧端盖到新端盖之间的局部段,避免拿整根新圆柱/新筒体或通用平面 prism 和原模型做大范围布尔。同轴阶梯/盲孔式环形端盖允许内外圆柱侧面的旧深度不同;向外推拉时会保留旧侧面的复杂底部裁剪,只删除旧端盖,并补上旧端盖到新端盖之间的侧面段和新端盖,结果校验会优先检查新增端盖而不是全模型扫描。如果端盖边界本身连接了槽、缺口或台阶等额外一级相邻 Face,向外推拉会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长;向内推拉如果没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短;如果越过槽底/台阶内侧终点,会快速阻止并说明原因。无法确认同轴圆柱/筒体一级关系时,才退回隔离子进程里的通用推拉和回滚保护。 +实心圆柱或同轴空心筒体的端盖拉伸/切除会优先走解析/局部重建:选中圆柱上/下底面或环形端面时,程序会识别相邻外圆柱面、内孔圆柱面和端盖一级关系。纯简单圆柱/筒体会直接重建新的完整高度;如果同一 Solid 上还有不碰端盖边界的侧向缺口、台阶等额外特征,向外拉伸会只生成旧端盖到新端盖之间的局部延长段去补料,向内切除会只切除旧端盖到新端盖之间的局部段,避免拿整根新圆柱/新筒体或通用平面 prism 和原模型做大范围布尔。同轴阶梯/盲孔式环形端盖允许内外圆柱侧面的旧深度不同;向外拉伸时会保留旧侧面的复杂底部裁剪,只删除旧端盖,并补上旧端盖到新端盖之间的侧面段和新端盖,结果校验会优先检查新增端盖而不是全模型扫描。如果端盖边界本身连接了槽、缺口或台阶等额外一级相邻 Face,向外拉伸会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长;向内切除如果没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短;如果越过槽底/台阶内侧终点,会快速阻止并说明原因。无法确认同轴圆柱/筒体一级关系时,才退回隔离子进程里的通用拉伸/切除和回滚保护。 -带多个内孔/内边界的大平面端盖向外推拉时,如果不能归类成同轴圆柱/筒体端盖,会改走 `boundary-shell-rebuild`:移动当前端面,并把该端面的一级边界 Edge 沿推拉方向生成延长侧壁后局部缝合,避免复杂 STEP 退回 180s 通用布尔慢路径;向内推拉如果没有识别到安全的端盖缩短路径,或需要判断槽底、台阶终点、孔壁背后的二级/三级关系,会快速阻止并把原因写清楚。 +带多个内孔/内边界的大平面端盖如果不能归类成同轴圆柱/筒体端盖,会优先改走 `boundary-shell-rebuild`:移动当前端面,并把该端面的一级边界 Edge 沿拉伸/切除方向重建侧壁后局部缝合,避免复杂 STEP 退回 180s 通用布尔慢路径。向外拉伸会生成延长侧壁;浅范围向内收缩会重建缩短后的侧壁并保留内孔线圈;如果向内距离接近切穿,或需要判断槽底、台阶终点、孔壁背后的二级/三级关系,会快速阻止并把原因写清楚。 -选中普通完整圆柱侧面修改 `高度` 时,局部语义会换算成端盖推拉:执行后必须重新检测到同轴、同半径、目标高度的圆柱侧面,并且圆柱一级边界/相邻 Face 关系没有丢失,否则自动回滚。`调整整个特征` 语义会沿圆柱轴向缩放所属特征或 Solid,执行后按轴向跨度校验目标高度;这条语义可能影响同一对象上的孔距、台阶位置和其它轴向尺寸。 +选中普通完整圆柱侧面修改 `高度` 时,局部语义会换算成端盖拉伸/切除:执行后必须重新检测到同轴、同半径、目标高度的圆柱侧面,并且圆柱一级边界/相邻 Face 关系没有丢失,否则自动回滚。`缩放特征` 语义会沿圆柱轴向缩放所属特征或 Solid,执行后按轴向跨度校验目标高度;这条语义可能影响同一对象上的孔距、台阶位置和其它轴向尺寸。 产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。 -界面里的 `当前选中对象` 会优先显示 `编辑方式` 行,用来告诉你当前对象的修改会走哪类几何语义。比如孔径是同轴重切,槽/半孔是局部扇形槽或配对长圆槽重建,凸台是包络重建或端盖推拉,已有圆角是先移除旧圆角再重建。对于圆柱直径/半径,当前也开始提供独立的 `缩放所属对象` 行,让你明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 +界面里的 `当前选中对象` 会优先显示 `编辑方式` 行,用来告诉你当前对象的修改会走哪类几何语义。比如孔径是同轴重切,槽/半孔是局部扇形槽或配对长圆槽重建,凸台是包络重建或端盖拉伸/切除,已有圆角是先移除旧圆角再重建。对于圆柱直径/半径,当前也开始提供独立的 `缩放所属对象` 行,让你明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 ## 开源复用原则 @@ -193,10 +199,10 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | --- | --- | --- | --- | | Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | -| Face / 面 | 面积、面宽/面高、中心、面偏移、薄壁厚度;圆柱端盖或完整圆柱侧面的高度。 | 推拉当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | +| Face / 面 | 面积、U向尺寸/V向尺寸、中心、偏移曲面、壳体厚度;圆柱端盖或完整圆柱侧面的高度。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | | 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 | -| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或推拉端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | +| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或拉伸/切除端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | | 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角。 | 已有圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法。 | 复杂 blend、圆角链、支撑面不明确的圆角。 | | Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | @@ -206,14 +212,14 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | --- | --- | --- | -| Face | 平面 Face 可以改 `面积`、`面宽`、`面高`、`中心` 和 `面偏移`;这些行会在 `影响范围` 里选择 `只改当前面`、`推拉当前面`、`移动整个特征` 或 `调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `推拉当前面` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖推拉,或者选择沿轴向 `调整整个特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面宽/面高` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`面偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid;`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内推拉越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`推拉当前面` 的简单端盖路径或整体调整。 | -| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `影响范围` 选择 `只改孔/槽壁` 或 `调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | -| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度` 或 `调整整个特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | -| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁` 或 `调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | -| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`推拉端盖`、`移动凸台`、`调整整个特征` 或 `移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | -| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | +| Face | 平面 Face 可以改 `面积`、`U向尺寸`、`V向尺寸`、`中心` 和 `偏移曲面`;这些行会在 `影响范围` 里选择 `局部重建`、`拉伸/切除`、`移动特征` 或 `缩放特征`。识别到相对平面时还可以改 `壳体厚度`,并通过 `影响范围` 选择 `拉伸/切除` 或 `缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `U向尺寸/V向尺寸` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移曲面` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动特征` 会平移所属特征或 Solid;`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 | +| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `影响范围` 选择 `只改孔/槽壁` 或 `缩放特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`缩放特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | +| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度` 或 `缩放特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`缩放特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`缩放特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | +| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁` 或 `缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | +| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`拉伸/切除端盖`、`移动凸台`、`缩放特征` 或 `移动特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | +| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `缩放特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | -| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | +| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(缩放特征)` 或 `直径(缩放特征)`;环面可以改 `主半径(缩放特征)`、`主直径(缩放特征)`、`小半径(缩放特征)` 或 `小直径(缩放特征)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | @@ -238,18 +244,19 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 特征 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 -- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 +- 拉伸/切除后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回拉伸/切除前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 - 支持按当前选择模式输入 ID 直接选择零件 / Solid / Face / Edge / 特征。 -- Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 +- Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如拉伸/切除前侧壁是 558,拉伸/切除后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 +- Face / 特征的 `复制 ID` 也优先复制 `逻辑 Face ID`,属性区会同时显示 `逻辑选择 ID` 和 `当前拓扑 Face ID`;这样编辑后拓扑编号变化时,用户仍能用原来的逻辑 ID 回到同一个面区域。 - `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 - `特征` 模式现在会把点到的Face解释为几何特征候选: - - 平面会识别为可推拉平面候选。 - - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 + - 平面会识别为可拉伸/切除平面候选。 + - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算壳体局部区域厚度。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面Face,并一起高亮相关局部区域。 + - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。 - 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 @@ -270,20 +277,20 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 零件 / Solid 的包围盒、尺寸、体积、重心、表面积。 - Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 - 平面Face的几何法向和按拓扑方向修正后的法向。 - - 平面Face的推拉向外方向、向内方向和方向判断置信度。 + - 平面Face的拉伸/切除向外方向、向内方向和方向判断置信度。 - 圆柱Face的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。 - Edge 的参数范围、起点、终点、长度中心、包围盒。 - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 - 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划。 -- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 +- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持两点测量: - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 -- 特征模式会先显示特征来源Face、基础几何参数和当前可用的快速编辑入口;侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face和边界Edge等深层信息会在扫描候选、执行编辑计划或历史定位时再计算。 +- 特征模式会先显示特征来源Face、基础几何参数和当前可用的快速编辑入口;侧壁Face、同域圆柱范围、端面Face、疑似底面 Face、开口端相邻Face和边界Edge等深层信息会在扫描候选、执行编辑计划或历史定位时再计算。 - 支持导出当前完整模型为 STEP。 - 支持导出选中的零件为 STEP。 - 支持导出选中的Solid为 STEP。 @@ -297,7 +304,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 支持扫描可编辑对象: - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - - 可推拉的平面Face。 + - 可拉伸/切除的平面Face。 - 可调整孔径的圆柱Face候选。 - 可调整槽/半孔宽度、深度、圆弧长度和轴心坐标的局部圆柱孔/槽候选。 - 可调整盲孔/盲槽深度的圆柱Face候选。 @@ -307,8 +314,8 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变。 - 可调整圆Edge半径/直径的圆形或圆弧Edge候选。 - 表格会显示当前值、状态、风险、置信度和说明。 - - 盲孔/盲槽深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。 - - 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度/深度/圆弧长度调整、凸台高度调整、盲孔/盲槽深度调整、Edge倒圆、Edge倒角或修改Edge长度。 + - 盲孔/盲槽深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 Face轴向位置计算。 + - 点击表格行会直接选中对应Face或Edge,方便继续执行拉伸/切除、孔径调整、槽/半孔宽度/深度/圆弧长度调整、凸台高度调整、盲孔/盲槽深度调整、Edge倒圆、Edge倒角或修改Edge长度。 - 支持列出圆柱面候选特征。 - 圆柱面候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。 - 圆柱面候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 @@ -321,17 +328,17 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 圆柱面候选表格会显示直径、角度跨度、估算高度和置信度。 - 圆柱面候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。 - 圆柱面候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。 -- 当前版本支持平面Face推拉。 -- 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 -- 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 -- 推拉平面现在会从选中Face出发,自动查找共面且相接/重叠的相邻Face,并把它们作为同一片平面区域一起推拉。 +- 当前版本支持平面Face拉伸/切除。 +- 平面拉伸/切除会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 +- 拉伸/切除平面会先显示半透明预览体,表示拉伸/切除方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 +- 拉伸/切除平面现在会从选中Face出发,自动查找共面且相接/重叠的相邻Face,并把它们作为同一片平面区域一起拉伸/切除。 - 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。 - 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。 -- 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 -- Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 +- 拉伸/切除加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 +- Fuse/Cut 完成后会执行更强的拉伸/切除结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 - 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。 -- 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 -- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对Face尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。 +- 如果拉伸/切除本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 +- 拉伸/切除平面会先生成操作计划:检查距离是否为 0、方向置信度和相对Face尺寸的风险;能拉伸/切除时直接执行,不能拉伸/切除时会提示阻止原因。 - 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域Face,工具范围会优先覆盖整段同域圆柱。 - 当前版本支持盲孔/盲槽深度调整: - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 Face 的圆柱面候选开放。 @@ -339,7 +346,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 目标深度小于当前估算深度时,会从目标新底面到旧底面方向补料,让孔变浅。 - 调整盲孔深度会先显示半透明预览体:红色表示加深切削范围,绿色表示变浅补料范围。 - 孔深调整计划会记录当前深度、目标深度、变化比例、疑似底面、开口端和工具参数。 - - 当前深度会优先使用疑似底面Face在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一Solid内轴线附近的平面圆盘候选。 + - 当前深度会优先使用疑似底面 Face在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一Solid内轴线附近的平面圆盘候选。 - 当前版本支持槽/半孔候选结构化识别: - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`。 - 会估算槽宽、圆弧长度和槽深。 @@ -365,14 +372,14 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 当前版本支持长圆槽/槽孔总长度和中心距调整: - 你可以直接在当前选中对象表里修改 `槽孔总长度` 或 `槽孔中心距`,不需要先填写配对端 Face ID。 - 点击修改时,程序会自动寻找另一个半圆槽端;识别成功后保持槽宽不变,先补旧长圆槽体,再切出目标总长度。 - - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度、总长度和中心距调整会优先尝试使用这个手动配对端。 -- 当前版本支持薄壁/壳体厚度调整: + - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端 Face ID`,后续槽宽、槽深、圆弧长度、总长度和中心距调整会优先尝试使用这个手动配对端。 +- 当前版本支持壳体厚度调整: - 对已找到投影重叠相对平面的平面候选开放。 - - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。 - - 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。 - - 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。 - - 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。 - - 对简单全平面薄板/盒体,`调整整个特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。 + - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的拉伸/切除距离,保留相对平面不动。 + - 会先显示半透明拉伸/切除预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和拉伸/切除距离。 + - 这是局部 B-Rep 平面拉伸/切除近似,不是 CAD 壳命令或完整壳体参数编辑。 + - 当前选中对象表的 `壳体厚度` 会通过 `影响范围` 选择 `拉伸/切除` 或 `缩放特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。 + - 对简单全平面薄板/盒体,`缩放特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。 - 当前版本支持已有圆角/倒圆候选结构化识别: - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - 会估算已有圆角半径、圆弧角度和圆弧长度。 @@ -380,7 +387,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 - - 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。 + - 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `影响范围` 选择 `重建圆角` 或 `缩放特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。 - 当前版本支持圆柱孔封堵: - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 @@ -392,14 +399,14 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - - 当前选中对象表的 `直径` / `半径` 会通过 `影响范围` 选择 `只改凸台` 或 `调整整个特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。 + - 当前选中对象表的 `直径` / `半径` 会通过 `影响范围` 选择 `只改凸台` 或 `缩放特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 - 当前版本支持圆柱凸台高度和轴心坐标调整: - - 只对已识别为完整圆柱凸台、并找到可推拉端盖 Face 的候选开放。 - - 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖推拉距离。 + - 只对已识别为完整圆柱凸台、并找到可拉伸/切除端盖 Face 的候选开放。 + - 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖拉伸/切除距离。 - 输入目标轴心坐标后,程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。 - - 这不是 CAD 历史里的拉伸特征高度参数;端盖识别不稳定或推拉计划被阻止时会拒绝执行。 + - 这不是 CAD 历史里的拉伸特征高度参数;端盖识别不稳定或拉伸/切除计划被阻止时会拒绝执行。 - 当前版本支持直线Edge添加圆角: - 只对直线Edge开放。 - 会先检查边长、相邻Face数量和目标半径相对边长的比例。 @@ -420,14 +427,14 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - `长度基准` 行可以选择自动、中心、固定起点、固定终点。 - `长度` 行的 `影响范围` 可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属`。 - `只变当前Edge` 会强制使用局部边形变;如果当前Edge不满足简单全平面多面体条件,会直接阻止,不会偷偷改成整体缩放。 - - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面推拉;对正方体这类模型,会得到更像“变成长方体”的结果。如果找不到端面推拉路径,会直接阻止。 + - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面拉伸/切除;对正方体这类模型,会得到更像“变成长方体”的结果。如果找不到端面拉伸/切除路径,会直接阻止。 - `缩放所属` 会强制对所属特征或 Solid 做轴向/整体缩放,是高风险兜底语义,会影响其它尺寸。 - 对直线Edge,当前选中对象表会额外提供 `起点`、`中心` 和 `终点`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。 - 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。 - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 - - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 + - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面拉伸/切除操作。 - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge长度按比例换算成目标圆柱直径再执行局部编辑。 - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 - 椭圆 Edge 的 `椭圆主半径` / `椭圆小半径` 是单独的属性行:主半径会沿椭圆主轴方向单轴缩放,小半径会沿小轴方向单轴缩放;另一个半径方向尽量不动。 @@ -455,11 +462,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 当前版本支持圆柱孔/圆柱面扩大切削。 - 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 -- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动孔` 时,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽会走 `移动槽/半孔` 语义,避免整圆柱工具误补材料。把 `影响范围` 设为 `移动整个特征` 时,会平移所属特征或 Solid。 -- 当前版本支持孔/槽 `直径` / `半径` 的 `调整整个特征` 影响范围: +- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动孔` 时,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽会走 `移动槽/半孔` 语义,避免整圆柱工具误补材料。把 `影响范围` 设为 `移动特征` 时,会平移所属特征或 Solid。 +- 当前版本支持孔/槽 `直径` / `半径` 的 `缩放特征` 影响范围: - 这条路线不会重切孔壁,而是按目标直径或半径与当前值的比例均匀缩放所属特征或 Solid。 - 高度、厚度和同一对象上的其它尺寸会同比例变化。 - - 如果你只想让孔径变大/变小,优先在 `直径` 或 `半径` 行把 `影响范围` 设为 `只改孔/槽壁`;如果你的建模意图是让所属圆柱区域整体缩放,再设为 `调整整个特征`。 + - 如果你只想让孔径变大/变小,优先在 `直径` 或 `半径` 行把 `影响范围` 设为 `只改孔/槽壁`;如果你的建模意图是让所属圆柱区域整体缩放,再设为 `缩放特征`。 - 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 @@ -471,7 +478,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 实际切削优先使用有限长度 cutter,按选中圆柱面的 V 参数范围加少量余量生成,不再默认贯穿整个零件。 - 会沿圆柱轴线采样两端材料状态,初步区分通孔/开口端、盲孔和封闭端。 - 会把圆柱相邻平面按轴线位置分成起点端/终点端,并结合端部采样标记疑似盲孔底面;相邻端面缺失时,会尝试扫描轴线附近的平面圆盘候选作为兜底。 - - 孔径修改计划会记录疑似底面Face、开口端相邻Face和 cutter 底面保护状态。 + - 孔径修改计划会记录疑似底面 Face、开口端相邻Face和 cutter 底面保护状态。 - 对盲孔/封闭端,有限长度 cutter 会使用更小的端部余量,降低切深变长的风险。 - 支持撤销/重做编辑。 - 撤销/重做会先确认模型恢复成功,再移动历史栈;如果恢复失败,会尽量回到操作前状态。 @@ -518,8 +525,8 @@ vertices: 3262 已经有当前版本的初步实现,但还需要继续稳定和完善: - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 -- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底推拉或通过 `影响范围` 做轴向整体缩放,以及平面薄壁/壳体相对面厚度估算、局部厚度推拉和通过 `影响范围` 做厚度方向整体缩放;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 +- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底拉伸/切除或通过 `影响范围` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `影响范围` 做厚度方向整体缩放;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 - 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 @@ -534,7 +541,7 @@ vertices: 3262 建议下一步按这个顺序推进: -1. 先集中完成 Face 一级拓扑关系编辑:面积、面宽/面高、中心、面偏移和薄壁厚度,都要明确 `只改当前面`、`推拉当前面`、`移动整个特征`、`调整整个特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。 +1. 先集中完成 Face 一级拓扑关系编辑:面积、U向尺寸/V向尺寸、中心、偏移曲面和壳体厚度,都要明确 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。 2. Face 一级关系闭环稳定后,再集中做槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证。 3. 槽/孔一级关系稳定后,再集中做 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做完整,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback。 4. Edge 闭环稳定后,再集中做凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。 @@ -545,11 +552,11 @@ Face 阶段的当前验收口径: - 当前阶段的 `一级关系` 定义为:选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为当前阶段的自动传播范围。 - 选中平面 Face 时,`当前选中对象` 的参数表会显示 `一级关系` 说明行,直接列出当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并提示二级、三级关系暂不自动传播。 -- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、面宽/面高、中心、面偏移、薄壁厚度、推拉、局部变形禁用、目标值保护、属性回读和逻辑 Face ID 保持。 -- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积`、`面宽`、`面高`、`中心`、`面偏移` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 +- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、U向尺寸/V向尺寸、中心、偏移曲面、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。 +- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积`、`U向尺寸`、`V向尺寸`、`中心`、`偏移曲面` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 - `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。特征参数表也会显示圆柱特征的 `一级关系` 摘要,让用户知道当前孔/槽修改会围绕哪些直接邻域执行;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 -- 对不能稳定做 `只改当前面` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 +- 对不能稳定做 `局部重建` 的 Face,要禁用该影响范围,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。 - Face 一级关系闭环后,再进入槽/孔一级关系阶段;不要在 Face 还没有稳定解释和验证时提前分散到 Edge、圆锥/球/环面等对象。 ## 怎么使用 @@ -570,7 +577,7 @@ Face 阶段的当前验收口径: - `Solid`:选择实体。 - `Face`:选择面。 - `Edge`:选择边。 -- `特征`:按几何特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 +- `特征`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 - 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 @@ -601,7 +608,7 @@ Face 阶段的当前验收口径: - Face 模式下 `只显示选中` 会优先隔离当前命中的 Face,避免普通点选时触发同域面重计算;如果当前选择来自编辑计划、扫描候选或历史定位,并且已经解析出特征区域,则会使用这片已知区域。 - `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。 - `显示全部`:恢复显示完整模型。 -- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 +- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔拉伸/切除后的分面情况。 - `导出选中面区域` 会导出当前选中 Face;如果当前选择已经带有明确的特征区域或同域区域信息,则导出这片已知区域。 - Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 - 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 @@ -622,7 +629,7 @@ Face 阶段的当前验收口径: - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 -- `推拉平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `面偏移` 行里把 `影响范围` 选为 `推拉当前面` 来执行。 +- `拉伸/切除平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `偏移曲面` 行里把 `影响范围` 选为 `拉伸/切除` 来执行。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 - `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 - `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 @@ -672,25 +679,25 @@ Face 阶段的当前验收口径: 编辑: -- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 -- `面偏移`: +- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `拉伸/切除平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 +- `偏移曲面`: - 先选择一个平面 Face。 - - 在当前选中对象表里修改 `面偏移`,再用 `影响范围` 选择改法。 - - `面偏移` 是沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标。 - - `推拉当前面` 会把目标位置换算成这次要移动的距离,然后沿当前面的垂直方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。 - - `只改当前面` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 - - `移动整个特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。 - - 如果选中Face周围有共面且相接/重叠的碎面,`推拉当前面` 会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 - - 当前推拉方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 -- `面积` / `面宽` / `面高`: + - 在当前选中对象表里修改 `偏移曲面`,再用 `影响范围` 选择改法。 + - `偏移曲面` 是沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标。 + - `拉伸/切除` 会把目标位置换算成这次要移动的距离,然后沿当前面的垂直方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。 + - `局部重建` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 + - `移动特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。 + - 如果选中Face周围有共面且相接/重叠的碎面,`拉伸/切除` 会自动把这些Face合成同一片拉伸/切除区域,属性表和历史记录会显示 `拉伸/切除共面区域 Face 数`。 + - 当前拉伸/切除方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 +- `面积` / `U向尺寸` / `V向尺寸`: - 先选择一个 Face,在当前选中对象表里修改目标值,再用 `影响范围` 选择改法。 - - `只改当前面` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。 - - `调整整个特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 - - `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。 + - `局部重建` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。 + - `缩放特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 + - `U向尺寸` / `V向尺寸` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。 - `中心`: - 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `影响范围` 选择改法。 - - `只改当前面` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 - - `移动整个特征` 会把目标中心换算成平移向量,并平移这个 Face 所属的特征或 Solid;Face 自身形状和所属对象内部尺寸不变。 + - `局部重建` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 + - `移动特征` 会把目标中心换算成平移向量,并平移这个 Face 所属的特征或 Solid;Face 自身形状和所属对象内部尺寸不变。 - `孔直径` + `调整圆柱孔径`: - 先选择一个圆柱面。 - 输入新的直径。 @@ -700,18 +707,18 @@ Face 阶段的当前验收口径: - 程序会先生成切削计划,检查当前直径、目标直径、候选类型、材料投票和风险。 - 切削计划会显示直径变化量、直径变化比例,以及目标直径相对圆柱面估算高度的比例。 - 切削计划会显示端部类型、深度估算、起点端状态和终点端状态。 - - 切削计划会显示疑似底面Face、开口端相邻Face和底面保护说明;这些信息会写入操作历史。 + - 切削计划会显示疑似底面 Face、开口端相邻Face和底面保护说明;这些信息会写入操作历史。 - 如果目标直径小于等于 0,或几乎等于当前直径,程序会直接阻止。 - 如果目标直径变化过大,或者目标直径相对圆柱估算高度明显异常,程序会提高风险并要求确认。 - 缩小孔径当前版本只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。 - 缩小孔径属于高风险实验功能,因为它依赖“补料 + 重切”的布尔近似,不是 CAD 历史参数修改。 - - 如果当前Face的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 + - 如果当前 Face的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 - 切削计划会显示 cutter 策略、高度和余量;当前优先使用有限长度 cutter,减少误切其他区域。 - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 -- `直径` / `半径` 的 `调整整个特征`: - - 先选择一个圆柱 Face,然后在当前选中对象表里修改 `直径` 或 `半径`,并把 `影响范围` 设为 `调整整个特征`。 +- `直径` / `半径` 的 `缩放特征`: + - 先选择一个圆柱 Face,然后在当前选中对象表里修改 `直径` 或 `半径`,并把 `影响范围` 设为 `缩放特征`。 - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。 - 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。 - `槽/半孔宽度` + `调整槽/半孔宽度`: @@ -728,9 +735,9 @@ Face 阶段的当前验收口径: - 先选择一个槽/半孔候选 Face。 - 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 -- `槽宽` / `槽深` / `弧长` 的 `调整整个特征`: +- `槽宽` / `槽深` / `弧长` 的 `缩放特征`: - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改对应属性,并把 `影响范围` 设为 `调整整个特征`。 + - 在当前选中对象表里修改对应属性,并把 `影响范围` 设为 `缩放特征`。 - 程序会把目标槽宽、槽深或弧长换算成目标圆柱直径,然后按比例整体缩放所属特征或 Solid。 - 这不是局部重切槽;同一所属对象上的高度、厚度和其它尺寸也会跟随变化。 - `槽/半孔圆弧角度` / `槽/半孔开口角度`: @@ -743,27 +750,27 @@ Face 阶段的当前验收口径: - 在当前选中对象表里修改目标坐标,格式是 `X, Y, Z`,并把 `影响范围` 设为 `移动槽/半孔`。 - 程序会先填充旧的局部扇形槽,再在目标轴心位置按同宽度、同圆弧角度切出新槽。 - 这一步只移动选中的局部扇形槽面,不等于完整 CAD 草图里的槽整体迁移;长圆槽、链式槽和复杂槽需要确认结果,失败会回滚。 -- `轴心` 的 `移动整个特征`: +- `轴心` 的 `移动特征`: - 同样输入目标 X, Y, Z 坐标。 - - 把 `影响范围` 设为 `移动整个特征`。 + - 把 `影响范围` 设为 `移动特征`。 - 程序会把目标轴心换算成平移向量,并平移所属特征或 Solid;不会填旧槽,也不会切新槽。 - 这适合“整个对象搬动到新位置”的意图,不适合只想移动槽本身的场景。 -- `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距`: +- `槽孔配对端 Face ID` / `槽孔总长度` / `槽孔中心距`: - 对长圆槽或成对半圆槽,程序会在点击修改时自动寻找另一个槽端,避免选择对象时扫描过重。 - - 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`。 + - 如果自动配对失败,可以先在 `槽孔配对端 Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`。 - 修改 `槽孔总长度` 或 `槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。 -- `薄壁厚度` 的 `推拉当前面`: - - 先选择一个已识别到相对平面的薄壁/壳体平面 Face。 - - 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `推拉当前面`。 +- `壳体厚度` 的 `拉伸/切除`: + - 先选择一个已识别到相对平面的壳体平面 Face。 + - 在当前选中对象表里修改 `壳体厚度`,并把 `影响范围` 设为 `拉伸/切除`。 - 输入目标厚度后点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - - 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。 - - 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。 -- `薄壁厚度` 的 `调整整个特征`: - - 先选择一个已识别到相对平面的薄壁/壳体平面 Face。 - - 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `调整整个特征`。 - - 程序会沿薄壁厚度方向缩放所属特征或 Solid,让估算厚度接近目标值。 - - 这不是推拉当前面;同一对象上的孔距、台阶位置和其它厚度方向尺寸也会跟随变化。 + - 如果相对平面重叠率低、厚度变化过大或拉伸/切除方向不稳定,会提高风险或阻止执行。 + - 这一步仍是局部 B-Rep 平面拉伸/切除近似,不是完整 CAD 壳体特征参数编辑。 +- `壳体厚度` 的 `缩放特征`: + - 先选择一个已识别到相对平面的壳体平面 Face。 + - 在当前选中对象表里修改 `壳体厚度`,并把 `影响范围` 设为 `缩放特征`。 + - 程序会沿壳体厚度方向缩放所属特征或 Solid,让估算厚度接近目标值。 + - 这不是拉伸/切除;同一对象上的孔距、台阶位置和其它厚度方向尺寸也会跟随变化。 - `凸台直径` + `调整圆柱凸台直径`: - 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 @@ -771,36 +778,36 @@ Face 阶段的当前验收口径: - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 -- `直径` / `半径` 的 `调整整个特征`: +- `直径` / `半径` 的 `缩放特征`: - 先选择一个圆柱凸台 Face,然后在当前选中对象表里修改这一行。 - - 把 `影响范围` 设为 `调整整个特征`。 + - 把 `影响范围` 设为 `缩放特征`。 - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,不走凸台包络移除/重建路线。 - 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。 - `凸台高度`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `推拉端盖`,点击该行的 `可修改` 或点击 `参数化建模`。 - - 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。 -- `高度` 的 `调整整个特征`: + - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `拉伸/切除端盖`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 程序会尝试找到凸台外端盖 Face,并通过拉伸/切除端盖改变高度。 +- `高度` 的 `缩放特征`: - 先选择一个完整圆柱凸台或普通完整圆柱 Face。 - - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `调整整个特征`。 + - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `缩放特征`。 - 程序会沿圆柱轴向缩放所属特征或 Solid,让圆柱高度接近目标值。 - - 这不是端盖推拉;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 + - 这不是端盖拉伸/切除;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 - `轴心`: - 先选择一个完整圆柱凸台 Face。 - 在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动凸台`,点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 -- `轴心` 的 `移动整个特征`: +- `轴心` 的 `移动特征`: - 同样输入目标 X, Y, Z 坐标。 - - 把 `影响范围` 设为 `移动整个特征`。 + - 把 `影响范围` 设为 `移动特征`。 - 程序会整体平移凸台所属特征或 Solid;不会移除旧凸台,也不会补新凸台。 - 这适合移动整个所属对象,不适合只改凸台相对基体的位置。 - `轴心` 的 `移动孔`: - 先选择一个完整圆柱孔 Face。 - 在当前选中对象表里修改目标 X, Y, Z 坐标,并把 `影响范围` 设为 `移动孔`。 - 程序会先填补旧孔,再按同直径在目标轴心位置切出新孔。 -- `轴心` 的 `移动整个特征`: +- `轴心` 的 `移动特征`: - 同样输入目标 X, Y, Z 坐标。 - - 把 `影响范围` 设为 `移动整个特征`。 + - 把 `影响范围` 设为 `移动特征`。 - 程序会整体平移孔所属特征或 Solid;不会填旧孔,也不会切新孔。 - 这适合移动整个所属对象,不适合只改孔相对基体的位置。 - `封堵圆柱孔`: @@ -820,10 +827,10 @@ Face 阶段的当前验收口径: - 当前只支持 `hole/groove candidate`、端部类型为 `blind`、并且已经识别到 `疑似底面 Face` 的对象。 - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 - - 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。 -- `盲孔/盲槽深度` 的 `调整整个特征`: - - 先选择一个已经识别出底面的盲孔或盲槽圆柱面;自动底面不准时,可以先填写 `底面Face`。 - - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `调整整个特征`。 + - 操作历史会记录当前深度、目标深度、深度变化比例、底面 Face、开口端 Face和工具参数。 +- `盲孔/盲槽深度` 的 `缩放特征`: + - 先选择一个已经识别出底面的盲孔或盲槽圆柱面;自动底面不准时,可以先填写 `底面 Face`。 + - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `缩放特征`。 - 程序会沿孔/槽轴向缩放所属特征或 Solid,让深度接近目标值。 - 这不是加深切削,也不是变浅补料;同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化。 - `圆角半径` + `给Edge添加圆角`: @@ -843,9 +850,9 @@ Face 阶段的当前验收口径: - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 -- `圆角半径` / `圆角弧长` 的 `调整整个特征`: +- `圆角半径` / `圆角弧长` 的 `缩放特征`: - 先选择一个已有圆角/倒圆候选 Face。 - - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `影响范围` 设为 `调整整个特征`。 + - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `影响范围` 设为 `缩放特征`。 - 程序会按目标圆角半径或圆弧长度换算出的比例整体缩放所属特征或 Solid,不会移除旧圆角再重建。 - 这会连带改变同一对象上的其它尺寸。 - `倒角距离` + `给Edge添加倒角`: @@ -869,11 +876,11 @@ Face 阶段的当前验收口径: - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。 - 修改 `中心` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - 对 `assets/models/cube_10mm.step` 这类全平面多面体,选择 `只变当前Edge` 时会只移动被选Edge的端点并重建周边平面。这时正方体不会变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。 - - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面推拉,相关平行边会一起变化,结果更像从正方体变成长方体。 + - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面拉伸/切除,相关平行边会一起变化,结果更像从正方体变成长方体。 - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心”的局部形变路径。 - - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。 + - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面拉伸/切除后备路径,并计算需要移动的端面Face和拉伸/切除距离。 - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。 @@ -958,26 +965,28 @@ pythonocc-step-editor/ verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 - verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起推拉 - verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用只改当前面的局部变形,但保留向外推拉和向内切削 + verify_edge_isolated_edit.py # 通过 isolated worker 真实验证 Edge 一级编辑隔离执行 + verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起拉伸/切除 + verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用局部重建的局部变形,但保留向外拉伸和向内切削 verify_face_edit_suite.py # 一键运行 Face 专项修改验证 - verify_face_extreme_target_guards.py # 验证 Face 面积、面宽/面高、面偏移和薄壁厚度不能输入过大或过小的极端目标值 - verify_face_invalid_target_guards.py # 验证 Face 面积、面宽/面高 和薄壁厚度不能输入非数字、0 或负数 - verify_large_stepped_cap_push_pull.py # 验证大 STEP 里的同轴阶梯环形端盖推拉走局部 shell 重建而不是 180s 布尔慢路径 + verify_face_extreme_target_guards.py # 验证 Face 面积、U向尺寸/V向尺寸、偏移曲面和壳体厚度不能输入过大或过小的极端目标值 + verify_face_freeform_guard.py # 验证 B-spline 等自由曲面 Face 只显示只读限制说明,不暴露伪参数化编辑入口 + verify_face_invalid_target_guards.py # 验证 Face 面积、U向尺寸/V向尺寸 和壳体厚度不能输入非数字、0 或负数 + verify_large_stepped_cap_push_pull.py # 验证大 STEP 里的同轴阶梯环形端盖拉伸/切除走局部 shell 重建而不是 180s 布尔慢路径 verify_face_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面 - verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留推拉 - verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、面宽/面高、面偏移和推拉 + verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留拉伸/切除 + verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、U向尺寸/V向尺寸、偏移曲面和拉伸/切除 verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止 verify_first_level_fact_graph.py # 验证平面、孔、槽会输出统一一级拓扑事实包,且编辑计划会携带这份事实 verify_feature_recognition_summary.py # 验证平面、孔、槽候选会输出统一识别摘要、置信度、风险和证据 verify_face_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何 - verify_face_push_pull_limits.py # 验证 Face 向内推拉不会允许切穿实体厚度 + verify_face_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 的U向尺寸 和V向尺寸 方向不会混淆 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 - verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改 + verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 verify_cylindrical_first_level_topology.py # 临时生成孔/槽 STEP 并验证圆柱孔、盲孔、半圆槽、长圆槽的一级共边拓扑字段 step_editor/ @@ -990,7 +999,7 @@ pythonocc-step-editor/ info_panel.py # 属性面板、信息树和复制信息逻辑 model.py # StepModel 核心状态、拓扑索引、基础属性和 mixin 组合 model_types.py # PartNode、TopologyStats 等模型数据结构 - operations.py # 推拉、孔径、孔深、圆角、倒角、边长等实际编辑操作 + operations.py # 拉伸/切除、孔径、孔深、圆角、倒角、边长等实际编辑操作 polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成 records.py # 操作历史记录数据结构 step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出 @@ -1049,6 +1058,7 @@ python scripts\generate_cube_step.py python scripts\verify_face_edit_suite.py python scripts\verify_isolated_face_edit.py python scripts\verify_edge_edit_suite.py +python scripts\verify_edge_isolated_edit.py python scripts\verify_shell_thickness_resize.py python scripts\verify_analytic_surface_resize.py python scripts\verify_slot_resize.py @@ -1081,30 +1091,36 @@ git diff --check - `scripts/verify_edge_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。 - `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。 - `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。 -- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面宽/面高、矩形 Face 两个面内方向区分、面积、中心、面偏移、共面碎面一起推拉/切削、向内推拉切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、大 STEP 同轴阶梯环形端盖局部 shell 推拉、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、薄壁厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面偏移/面积/面宽/面高/薄壁厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 +- `scripts/verify_edge_isolated_edit.py`:通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证 Edge 长度、直线 Edge 起点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、直线 Edge 新增圆角、三类倒角和已有圆角半径修改可以在隔离执行通道里完成,输出 STEP 能重新加载并保持目标几何语义。 +- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、U向尺寸/V向尺寸、矩形 Face 两个面内方向区分、面积、中心、偏移曲面、共面碎面一起拉伸/切除/切削、向内切除切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、B-spline 自由曲面只读守卫、大 STEP 同轴阶梯环形端盖局部 shell 拉伸/切除、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、壳体厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及偏移曲面/面积/U向尺寸/V向尺寸/壳体厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 - `scripts/verify_face_edit_integrity_guards.py`:验证 Face 编辑结果不能丢 Solid、不能新增无效 B-Rep、不能只达到目标数值却破坏一级拓扑;若目标检查、一级拓扑计数或一级事实图校验失败,模型层会恢复编辑前快照。 - `scripts/verify_face_first_level_topology.py`:加载 `cube_10mm.step`,验证正方体上表面 Face 的一级关系只包含当前 Face、4 条边界 Edge、4 个边界 Vertex 和 4 个共享边相邻侧面;局部移动上表面后,目标 Face 的边界 Vertex 与共享边 Edge 会到达新高度,4 个一级侧面会跟随重建,二级底面保持不动。 -- `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 推拉,并确认推拉后原逻辑 Face ID 会定位到新生成的整片顶面。 -- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `只改当前面` 的局部面积、面宽/面高、中心和面偏移计划;同时确认 `推拉当前面` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 -- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `只改当前面` 的局部面积、中心和面偏移计划,属性提示会说明具体原因;同时确认 `推拉当前面` 仍可用并让圆柱高度增加。 -- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面积、面宽/面高 和薄壁厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数。 -- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面积、面宽/面高、面偏移和薄壁厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把面偏移移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。 -- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体面宽、局部/整体面积、局部/整体中心、局部/整体面偏移和推拉正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面。 -- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `只改当前面`、`调整整个特征`、`移动整个特征`、`推拉当前面` 语义,在中心、面积、面宽/面高 和面偏移场景下都能保持单 Solid。 -- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面宽/面高、面积、中心坐标和面偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `推拉当前面` 之间不会互相混淆。 -- `scripts/verify_face_noop_guards.py`:验证 Face 的推拉、面偏移、中心、面积、面宽/面高 和薄壁厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 -- `scripts/verify_first_level_fact_graph.py`:验证正方体平面 Face、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 推拉/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。 +- `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 拉伸/切除,并确认拉伸/切除后原逻辑 Face ID 会定位到新生成的整片顶面。 +- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `局部重建` 的局部面积、U向尺寸/V向尺寸、中心和偏移曲面计划;同时确认 `拉伸/切除` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 +- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的局部面积、中心和偏移曲面计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加。 + +- `scripts/verify_face_freeform_guard.py`:临时生成 B-spline 自由曲面 STEP,验证它会被识别为只读受限 Face,识别风险为 blocked;属性表只显示限制说明和只读诊断,不暴露面积、中心、偏移曲面、半径、半角等伪参数化编辑入口。 +- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面积、U向尺寸/V向尺寸 和壳体厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数。 +- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面积、U向尺寸/V向尺寸、偏移曲面和壳体厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把偏移曲面移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。 +- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体U向尺寸、局部/整体面积、局部/整体中心、局部/整体偏移曲面和拉伸/切除正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面。 +- `scripts/verify_selection_identity_ui.py`:验证编辑后当前拓扑 Face ID 改变时,Face / 特征标题会显示 `逻辑 Face ID(拓扑 Face ID)`,复制 ID 会优先复制可再次选择的逻辑 Face ID,避免把用户带到重建后的临时拓扑编号上。 +- `scripts/verify_face_feature_parameter_consistency.py`:加载默认模型 Face 594,验证轻量 `当前特征` 选择在 full feature 缓存前后都显示同一组基础 Face 参数,避免先改其它对象或触发深层识别后,同一个 Face 的属性列表突然缩水或膨胀。 +- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `局部重建`、`缩放特征`、`移动特征`、`拉伸/切除` 语义,在中心、面积、U向尺寸/V向尺寸 和偏移曲面场景下都能保持单 Solid。 +- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的U向尺寸/V向尺寸、面积、中心坐标和偏移曲面在 `当前面` 局部编辑、`整体` 所属对象编辑与 `拉伸/切除` 之间不会互相混淆。 +- `scripts/verify_face_noop_guards.py`:验证 Face 的拉伸/切除、偏移曲面、中心、面积、U向尺寸/V向尺寸 和壳体厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 +- `scripts/verify_first_level_fact_graph.py`:验证正方体平面 Face、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 拉伸/切除/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。 - `scripts/verify_feature_recognition_summary.py`:验证平面 Face、带内孔平面 Face、通孔圆柱面和槽/半孔候选都会输出统一的 `识别摘要`、候选类型、置信度、风险、证据项、`当前可改` 和 `当前受限修改`;这些摘要只使用已有识别结果生成,不额外触发深层几何扫描。 -- `scripts/verify_face_property_readback.py`:验证 Face 的面宽、面积、中心和面偏移编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `面偏移` 的 `只改当前面`、`移动整个特征` 和 `推拉当前面` 三种语义。 -- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内推拉时会估算当前面背后的材料厚度;浅切削允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked;向外一次性推拉较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。 -- `scripts/verify_face_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_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖推拉并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`调整整个特征` 的轴向缩放会按轴向跨度校验结果。 -- `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面向内推拉会快速阻止且说明二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 +- `scripts/verify_face_property_readback.py`:验证 Face 的U向尺寸、面积、中心和偏移曲面编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `偏移曲面` 的 `局部重建`、`移动特征` 和 `拉伸/切除` 三种语义。 +- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内切除时会估算当前面背后的材料厚度;浅范围切除允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked;向外一次性拉伸/切除较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。 +- `scripts/verify_face_ui_isolation_contract.py`:验证 Face、孔槽和 Edge 阶段使用的隔离操作集合、`WindowActionMixin._isolation_for_plan()` 的 UI 隔离列表,以及 `isolated_edit_worker` 真正支持的 worker 操作完全一致;脚本还会反向追踪属性表暴露的 Face 修改动作、孔槽按钮和 Edge 按钮是否最终进入隔离 worker,防止后续新增参数行时忘记接入后台隔离执行,导致 UI 卡死或主进程被危险 OCCT 计算拖崩。没有 pyocc 时可以先运行 `python scripts\verify_face_ui_isolation_contract.py --static-only` 做 AST 级契约检查。 +- `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `U向尺寸` 和 `V向尺寸` 两个方向上可以分别做 `局部重建` 与 `缩放特征`,另一个方向不会被误改,属性表也会读回新值。 +- `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖拉伸/切除并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`缩放特征` 的轴向缩放会按轴向跨度校验结果。 +- `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面浅范围向内收缩也走 `boundary-shell-rebuild`,深向内切除会快速阻止且说明材料厚度或二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 - `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 -- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的面偏移、面积、面宽/面高、中心和薄壁厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 -- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、面宽/面高 或面偏移编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `只改当前面` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 -- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 +- `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。 +- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移曲面、面积、U向尺寸/V向尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 +- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、U向尺寸/V向尺寸 或偏移曲面编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 +- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - `scripts/verify_cylindrical_first_level_topology.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,验证孔/槽类圆柱特征的修改计划会暴露一级共边拓扑关系,包括圆柱侧壁、边界 Edge、边界 Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。 @@ -1115,7 +1131,7 @@ git diff --check - `step_editor/window_state.py`:选择状态、按钮启用/禁用、撤销/重做和历史定位。 - `step_editor/model.py`:`StepModel` 核心状态和 mixin 组合。 - `step_editor/features.py`:可编辑对象和圆柱面/孔/槽/凸台/圆角等识别计划。 -- `step_editor/operations.py`:推拉、孔径、孔深、圆角、倒角、薄壁、边长等真实几何编辑。 +- `step_editor/operations.py`:拉伸/切除、孔径、孔深、圆角、倒角、壳体、边长等真实几何编辑。 - `step_editor/export.py`:STEP 导出和质量检查。 - `step_editor/polydata.py`:OCCT shape/face/edge 到 VTK polydata。 - `step_editor/widgets.py`:Qt 自定义小控件,例如不误吃滚轮的下拉框。 @@ -1133,14 +1149,14 @@ git diff --check - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 -- 编辑:面偏移、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。 -- 编辑语义:Face 的面偏移、面积、面宽/面高 和中心通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、薄壁、圆角和 Edge 长度也会通过 `影响范围` 或计划策略说明本次修改会局部重建、推拉、移动还是整体缩放。 +- 编辑:偏移曲面、面积、U向尺寸/V向尺寸、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。 +- 编辑语义:Face 的偏移曲面、面积、U向尺寸/V向尺寸 和中心通过 `影响范围` 选择“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、壳体、圆角和 Edge 长度也会通过 `影响范围` 或计划策略说明本次修改会局部重建、拉伸/切除、移动还是整体缩放。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 ### 编辑语义 -所有几何参数修改都必须先明确语义、影响范围和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分面推拉、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `影响范围` 提供显式改法选择;Face 当前把 `面偏移`、`面积`、`面宽`、`面高` 和 `中心` 显示为单行属性,并通过 `影响范围` 下拉框表达“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也通过 `影响范围` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `影响范围` 区分“局部重建/端盖推拉/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `影响范围` 区分“兜底重切/端盖推拉”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `影响范围` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `影响范围` 区分“局部切削/补料”和“轴向缩放所属对象”;薄壁厚度也通过 `影响范围` 区分“局部推拉当前薄壁平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `影响范围` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、薄壁厚度现在会显示并记录当前采用的编辑方式,后续还要继续把更多多语义场景做成用户可选策略。 +所有几何参数修改都必须先明确语义、影响范围和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `影响范围` 提供显式改法选择;Face 当前把 `偏移曲面`、`面积`、`U向尺寸`、`V向尺寸` 和 `中心` 显示为单行属性,并通过 `影响范围` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;孔/槽直径和半径也通过 `影响范围` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `影响范围` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `影响范围` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `影响范围` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `影响范围` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `影响范围` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `影响范围` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的编辑方式,后续还要继续把更多多语义场景做成用户可选策略。 必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。 @@ -1150,9 +1166,9 @@ git diff --check 当前支持的长度修改策略: -1. `auto`:自动策略。按局部边形变、端面推拉、相邻圆柱直径、几何缩放的顺序寻找可用路径。 +1. `auto`:自动策略。按局部边形变、端面拉伸/切除、相邻圆柱直径、几何缩放的顺序寻找可用路径。 2. `local-edge-only-deform`:只变当前Edge。直线Edge位于简单全平面多面体时,只移动被选Edge的端点并重建所属Solid/特征。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 -3. `move-edge-end-plane-by-push-pull`:移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉;端面和相关边跟随移动,相邻平面尽量保持垂直,对正方体会更像变成长方体。 +3. `move-edge-end-plane-by-push-pull`:移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面拉伸/切除;端面和相关边跟随移动,相邻平面尽量保持垂直,对正方体会更像变成长方体。 4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 5. `scale-owning-shape-from-edge`:缩放所属对象。圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 @@ -1168,17 +1184,17 @@ Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表 Face 一级关系基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,`face_first_level_topology()` 会返回 `topology_relation_depth=1`、4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并明确二级/三级关系暂不递归传播。将上表面中心沿 Z 方向从 `10` 移到 `12` 后,4 个一级侧面中心 Z 从 `5` 变为 `6`,二级底面中心 Z 仍为 `0`,说明当前局部 Face 编辑只处理一级共享边拓扑。 -Face 面偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `面偏移` 正向或负向改变 `1mm`。`只改当前面` 会走 `local-face-plane-offset-deform`,只移动当前 Face 顶点并重建相邻面;`移动整个特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`推拉当前面` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“面偏移”操作。 +Face 偏移曲面基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `偏移曲面` 正向或负向改变 `1mm`。`局部重建` 会走 `local-face-plane-offset-deform`,移动当前 Face 顶点并重建相邻面;`移动特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`拉伸/切除` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“偏移曲面”操作。 Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。 -Face 局部面宽/面高基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽` 或 `面高` 的影响范围设为 `只改当前面` 并从 `10` 改为 `15`,计划策略分别为 `local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后能检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 +Face 局部U向尺寸/V向尺寸基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `U向尺寸` 或 `V向尺寸` 的影响范围设为 `局部重建` 并从 `10` 改为 `15`,计划策略分别为 `local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后能检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 -Face 整体面宽/面高基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽` 或 `面高` 的影响范围设为 `调整整个特征` 并从 `10` 改为 `15`,计划策略分别为 `axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变化,同时避免通用仿射把平面盒子变成样条面。 +Face 整体U向尺寸/V向尺寸基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `U向尺寸` 或 `V向尺寸` 的影响范围设为 `缩放特征` 并从 `10` 改为 `15`,计划策略分别为 `axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变化,同时避免通用仿射把平面盒子变成样条面。 -Face 面积基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面积` 从 `100` 改为 `144`。`只改当前面` 会走 `local-face-area-only-deform`,只把当前 Face 在自身平面内缩放到 12mm x 12mm 并重建相邻面;`调整整个特征` 会走 `uniform-scale-face-area-fallback`,把所属对象整体等比缩放到所有 10mm x 10mm 面都变为约 12mm x 12mm。 +Face 面积基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面积` 从 `100` 改为 `144`。`局部重建` 会走 `local-face-area-only-deform`,只把当前 Face 在自身平面内缩放到 12mm x 12mm 并重建相邻面;`缩放特征` 会走 `uniform-scale-face-area-fallback`,把所属对象整体等比缩放到所有 10mm x 10mm 面都变为约 12mm x 12mm。 -Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将中心按 `(2, 0, 3)` 偏移。`只改当前面` 会走 `local-face-only-deform`,当前 Face 被移到目标中心并重建相邻面,Face 自身尺寸保持约 10mm x 10mm;`移动整个特征` 会走 `translate-part`,整个所属特征平移,所有面尺寸保持 10mm x 10mm。 +Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将中心按 `(2, 0, 3)` 偏移。`局部重建` 会走 `local-face-only-deform`,当前 Face 被移到目标中心并重建相邻面,Face 自身尺寸保持约 10mm x 10mm;`移动特征` 会走 `translate-part`,整个所属特征平移,所有面尺寸保持 10mm x 10mm。 孔/盲孔局部重建基线验证:`scripts/verify_hole_resize.py` 会临时生成一个通孔板件和一个盲孔块体。当前验证通过六条孔修改路线:通孔孔径 `6 -> 8`、通孔孔径 `6 -> 4`、通孔轴心偏移 `2mm`、完整通孔封堵、盲孔深度 `6 -> 8`、盲孔深度 `6 -> 4`。封堵会检查补料后仍是单 Solid、孔候选消失,并且体积接近完整板件;孔径缩小、孔轴心移动、封堵和盲孔变浅都会先补旧孔/旧底部再重切或补料;这些孔内补料 Fuse 必须关闭 OCCT glue,否则可能只生成一个插在孔里的独立圆柱 Solid,而不是和主体真正合并。 @@ -1188,7 +1204,9 @@ Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子和已有圆角盒子。当前验证通过五条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`。已有圆角修改走 defeature + refillet;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离。 -薄壁厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3` 和 `2 -> 1`。`推拉当前面` 会保持相对面不动,`调整整个特征` 会保持厚度中心基本不动。 +Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过真实 isolated worker 请求覆盖 Edge 长度、端点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、Edge 圆角、三类倒角和已有圆角半径修改。这个脚本的目标不是替代内存执行套件,而是确认 UI 交给 worker 的操作名和参数能在子进程里独立完成,避免危险 OCCT 倒圆、倒角或局部重建卡死主程序。 + +壳体厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3` 和 `2 -> 1`。`拉伸/切除` 会保持相对面不动,`缩放特征` 会保持厚度中心基本不动。 解析曲面基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过五条路线:简单圆锥参考半径 `4 -> 5`、简单圆锥半角 `11.3099° -> 16°`、球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;简单圆锥优先解析重建,嵌入式锥孔/沉孔的半角和参考半径由 `scripts/verify_cone_semi_angle_isolation.py` 覆盖局部重切与隔离执行;默认模型里的复杂浅锥/拔模面 `1° -> 10°` 和 `1° -> 50°` 会被提前禁用或阻断,避免生成无效 B-Rep。 @@ -1196,7 +1214,7 @@ Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临 新增基线验证:内存构造椭圆Edge,将长度放大 1.25 倍,计划策略为 `radial-affine`,通过预变换Edge长度把缩放比例从 `1.25` 微调到约 `1.25354`,结果边长贴合目标。另一个圆锥圆边先径向缩放后会变成 B-spline,再选这个平面 B-spline Edge 放大 1.1 倍,计划会走 `sampled edge best-fit plane`,采样偏差约 `3.6e-15`,结果边长贴合目标。 -后置校验基线:修改Edge长度完成后,结果消息会包含 `match`、`nearest_edge`、`nearest_length`、`target_error`、`relative_error`、`endpoint_error` 和校验范围;局部边形变、端面推拉和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length` 和 `target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 +后置校验基线:修改Edge长度完成后,结果消息会包含 `match`、`nearest_edge`、`nearest_length`、`target_error`、`relative_error`、`endpoint_error` 和校验范围;局部边形变、端面拉伸/切除和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length` 和 `target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 ### 当前边界 diff --git a/scripts/verify_associated_features.py b/scripts/verify_associated_features.py index 0b1d2a4..f243c0f 100644 --- a/scripts/verify_associated_features.py +++ b/scripts/verify_associated_features.py @@ -49,6 +49,7 @@ def main() -> int: state.selected_kind = "feature" state.selected_part_id = int(root["part_id"]) state.selected_solid_id = int(root["solid_id"]) + state.feature_detection_level = "associated-only" context = state._feature_context_info(SOURCE_FACE_ID) specs, _used = state._editable_property_specs(context) rows = state._feature_context_property_specs(specs, context) @@ -59,7 +60,7 @@ def main() -> int: } expected = { ("凸台/外圆候选 · 直径", 394, "resize_boss"), - ("凸台/外圆候选 · 高度", 394, "resize_boss_height"), + ("凸台/外圆候选 · 高度", 394, "resize_cylindrical_height_owning_scale"), ("圆柱孔候选 · 直径", 1591, "resize_hole"), ("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"), } diff --git a/scripts/verify_cylindrical_height_resize.py b/scripts/verify_cylindrical_height_resize.py index 60dcb58..db09abe 100644 --- a/scripts/verify_cylindrical_height_resize.py +++ b/scripts/verify_cylindrical_height_resize.py @@ -17,6 +17,7 @@ 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.operations import _is_effectively_full_cylinder from step_editor.step_io import _write_step @@ -113,6 +114,18 @@ def _assert_radii(model: StepModel, expected: tuple[float, ...], label: str) -> raise SystemExit(f"{label}: radii should be {expected_values}, got {radii}") +def _assert_split_same_domain_cylinder_identity() -> None: + if not _is_effectively_full_cylinder( + { + "angular_span": 3.141592653589793, + "same_domain_angular_span": 6.283185307179586, + } + ): + raise SystemExit("split same-domain cylinder should be treated as a full cylinder") + if _is_effectively_full_cylinder({"angular_span": 3.141592653589793}): + raise SystemExit("single partial cylinder should not be treated as a full cylinder") + + def _run_isolated_worker( input_path: Path, operation: str, @@ -263,6 +276,7 @@ def _run_prismatic_cap_push_pull_case(path: Path, distance: float, target_height def main() -> int: + _assert_split_same_domain_cylinder_identity() with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_") as temp_dir: root = Path(temp_dir) solid_path = root / "solid_cylinder.step" diff --git a/scripts/verify_edge_isolated_edit.py b/scripts/verify_edge_isolated_edit.py new file mode 100644 index 0000000..9e85217 --- /dev/null +++ b/scripts/verify_edge_isolated_edit.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +SCRIPTS_ROOT = PROJECT_ROOT / "scripts" +if str(SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPTS_ROOT)) + +from step_editor.model import StepModel +from verify_edge_coordinate_edit import ( + _add, + _distance, + _nearest_expected_edge, + _source_edge_frame, + _tuple3, +) +from verify_edge_length_resize import _edge_length, _line_edge_ids_near_length +from verify_edge_round_chamfer import ( + _cylindrical_faces_near_radius, + _first_adjacent_reference_face, + _first_editable_line_edge, + _first_existing_fillet_face, + _verify_chamfer_topology, + _write_box_model, + _write_filleted_box_model, +) +from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model +from verify_hole_resize import ( + _first_hole_face, + _nearest_hole_by_diameter, + _write_through_hole_model, +) +from verify_hole_slot_isolated_edit import _assert_one_solid, _run_worker, _write_request + + +DEFAULT_CUBE = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" + + +def _load_worker_output(output_path: Path, response: dict[str, object]) -> StepModel: + if not output_path.exists(): + raise SystemExit(f"isolated worker reported success but did not write output STEP: response={response}") + return StepModel.load(output_path) + + +def _verify_edge_length_isolated(temp_dir: Path) -> None: + input_path = DEFAULT_CUBE + output_path = temp_dir / "edge_length_out.step" + request_path = temp_dir / "edge_length_request.json" + model = StepModel.load(input_path) + edge_ids = _line_edge_ids_near_length(model, 10.0, 1e-5) + if not edge_ids: + raise SystemExit("no cube line Edge near length 10 was found") + edge_id = edge_ids[0] + target_length = 15.0 + + _write_request( + request_path, + input_path, + output_path, + "resize_general_edge_length", + [edge_id, target_length, "keep-start", "local-edge-only-deform"], + ) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + lengths = [_edge_length(result_model, candidate) for candidate in range(len(result_model.edges))] + nearest = min(lengths, key=lambda value: abs(value - target_length)) + if abs(nearest - target_length) > 1e-5: + raise SystemExit(f"isolated Edge length failed: nearest={nearest:g}, response={response}") + _assert_one_solid(result_model, "isolated Edge length") + print(f"isolated Edge length ok: edge={edge_id}, nearest={nearest:g}") + + +def _verify_edge_endpoint_isolated(temp_dir: Path) -> None: + input_path = DEFAULT_CUBE + output_path = temp_dir / "edge_endpoint_out.step" + request_path = temp_dir / "edge_endpoint_request.json" + model = StepModel.load(input_path) + edge_id, start, end, direction, _source_length = _source_edge_frame(model, 10.0, 1e-5) + target_start = _add(start, (direction[0] * -3.0, direction[1] * -3.0, direction[2] * -3.0)) + + _write_request(request_path, input_path, output_path, "move_edge_endpoint", [edge_id, "start", target_start]) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + matched_edge, endpoint_error, length_error = _nearest_expected_edge(result_model, target_start, end) + if endpoint_error > 1e-5 or length_error > 1e-5: + raise SystemExit( + f"isolated Edge endpoint failed: matched={matched_edge}, " + f"endpoint_error={endpoint_error:g}, length_error={length_error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated Edge endpoint") + print(f"isolated Edge endpoint ok: edge={edge_id}, matched={matched_edge}") + + +def _verify_edge_center_isolated(temp_dir: Path) -> None: + input_path = DEFAULT_CUBE + output_path = temp_dir / "edge_center_out.step" + request_path = temp_dir / "edge_center_request.json" + model = StepModel.load(input_path) + edge_id, start, end, _direction, _source_length = _source_edge_frame(model, 10.0, 1e-5) + current_center = _tuple3(model.edge_info(edge_id).get("length_center"), "source center") + delta = (0.0, 0.0, 2.0) + target_center = _add(current_center, delta) + target_start = _add(start, delta) + target_end = _add(end, delta) + + _write_request(request_path, input_path, output_path, "move_edge_center", [edge_id, target_center]) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + matched_edge, endpoint_error, length_error = _nearest_expected_edge(result_model, target_start, target_end) + if endpoint_error > 1e-5 or length_error > 1e-5: + raise SystemExit( + f"isolated Edge center failed: matched={matched_edge}, " + f"endpoint_error={endpoint_error:g}, length_error={length_error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated Edge center") + print(f"isolated Edge center ok: edge={edge_id}, matched={matched_edge}") + + +def _verify_circular_edge_axis_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "circular_edge_axis.step" + output_path = temp_dir / "circular_edge_axis_out.step" + request_path = temp_dir / "circular_edge_axis_request.json" + _write_through_hole_model(input_path) + model = StepModel.load(input_path) + hole_face_id = _first_hole_face(model, blind=False) + current_diameter = float(model.face_info(hole_face_id)["diameter"]) + edge_id = None + target_edge_center = None + target_axis_center = None + for candidate in range(len(model.edges)): + if model.edge_info(candidate).get("curve") != "circle": + continue + current_edge_center = _tuple3(model.edge_info(candidate).get("center"), f"circle Edge {candidate} center") + candidate_target = (current_edge_center[0] + 2.0, current_edge_center[1], current_edge_center[2]) + plan = model.circular_edge_axis_move_plan(candidate, candidate_target) + if plan.get("status") != "blocked": + edge_id = candidate + target_edge_center = candidate_target + target_axis_center = _tuple3(plan.get("target_axis_center"), "target axis center") + break + if edge_id is None or target_edge_center is None or target_axis_center is None: + raise SystemExit("no circular Edge with editable adjacent cylinder axis was found") + + _write_request(request_path, input_path, output_path, "move_circular_edge_axis_center", [edge_id, target_edge_center]) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + verified_face, diameter, center, error = _nearest_hole_by_diameter( + result_model, + current_diameter, + target_axis_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - current_diameter) > 1e-4: + raise SystemExit( + f"isolated circular Edge axis failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated circular Edge axis") + print(f"isolated circular Edge axis ok: edge={edge_id}, face={verified_face}") + + +def _verify_ellipse_edge_radius_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "ellipse_edge.step" + output_path = temp_dir / "ellipse_edge_out.step" + request_path = temp_dir / "ellipse_edge_request.json" + _write_ellipse_face_model(input_path) + model = StepModel.load(input_path) + edge_ids = _ellipse_edge_ids(model) + if not edge_ids: + raise SystemExit("no ellipse Edge was recognized") + edge_id = edge_ids[0] + target_radius = 7.5 + plan = model.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind="major") + + _write_request( + request_path, + input_path, + output_path, + "resize_ellipse_edge_axis_radius", + [edge_id, target_radius, "major"], + ) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + sampled = result_model._ellipse_edge_axis_radius_sampled_result(plan) + if sampled is None: + raise SystemExit(f"isolated ellipse Edge radius could not be sampled: response={response}") + verified_edge, value, other, error = sampled + if error > 5e-3 or abs(other - 2.0) > 5e-3: + raise SystemExit( + f"isolated ellipse Edge radius failed: edge={verified_edge}, " + f"value={value:g}, other={other:g}, error={error:g}, response={response}" + ) + print(f"isolated ellipse Edge radius ok: edge={edge_id}, verified={verified_edge}, value={value:g}") + + +def _verify_edge_fillet_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "edge_fillet.step" + output_path = temp_dir / "edge_fillet_out.step" + request_path = temp_dir / "edge_fillet_request.json" + _write_box_model(input_path) + model = StepModel.load(input_path) + edge_id = _first_editable_line_edge(model) + target_radius = 1.0 + + _write_request(request_path, input_path, output_path, "fillet_edge", [edge_id, target_radius]) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + matches = _cylindrical_faces_near_radius(result_model, target_radius, 2e-4) + if not matches: + raise SystemExit(f"isolated Edge fillet failed: response={response}") + _assert_one_solid(result_model, "isolated Edge fillet") + print(f"isolated Edge fillet ok: edge={edge_id}, matches={matches[:3]}") + + +def _verify_edge_chamfers_isolated(temp_dir: Path) -> None: + cases = ( + ("chamfer_edge", [1.0], "chamfer"), + ("chamfer_edge_asymmetric", [0.8, 1.2, None], "asymmetric_chamfer"), + ("chamfer_edge_distance_angle", [1.0, 45.0, None], "distance_angle_chamfer"), + ) + for operation, tail_args, label in cases: + input_path = temp_dir / f"{label}.step" + output_path = temp_dir / f"{label}_out.step" + request_path = temp_dir / f"{label}_request.json" + _write_box_model(input_path) + model = StepModel.load(input_path) + edge_id = _first_editable_line_edge(model) + reference_face_id = _first_adjacent_reference_face(model, edge_id) + args = [edge_id, *tail_args] + if operation != "chamfer_edge": + args[-1] = reference_face_id + before = model.stats() + _write_request(request_path, input_path, output_path, operation, args) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + after = result_model.stats() + _verify_chamfer_topology(label, edge_id, {"resize_strategy": operation}, before, after, str(response.get("message")), []) + _assert_one_solid(result_model, f"isolated {label}") + + +def _verify_existing_fillet_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "existing_fillet.step" + output_path = temp_dir / "existing_fillet_out.step" + request_path = temp_dir / "existing_fillet_request.json" + source_radius = 1.0 + target_radius = 1.5 + _write_filleted_box_model(input_path, source_radius) + model = StepModel.load(input_path) + face_id = _first_existing_fillet_face(model, source_radius, 2e-4) + + _write_request(request_path, input_path, output_path, "resize_existing_fillet", [face_id, target_radius]) + response = _run_worker(request_path) + result_model = _load_worker_output(output_path, response) + matches = _cylindrical_faces_near_radius(result_model, target_radius, 2e-4) + old_matches = _cylindrical_faces_near_radius(result_model, source_radius, 2e-4) + if not matches or old_matches: + raise SystemExit( + f"isolated existing fillet failed: matches={matches}, old_matches={old_matches}, response={response}" + ) + _assert_one_solid(result_model, "isolated existing fillet") + print(f"isolated existing fillet ok: face={face_id}, matches={matches[:3]}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_isolated_") as temp: + temp_dir = Path(temp) + _verify_edge_length_isolated(temp_dir) + _verify_edge_endpoint_isolated(temp_dir) + _verify_edge_center_isolated(temp_dir) + _verify_circular_edge_axis_isolated(temp_dir) + _verify_ellipse_edge_radius_isolated(temp_dir) + _verify_edge_fillet_isolated(temp_dir) + _verify_edge_chamfers_isolated(temp_dir) + _verify_existing_fillet_isolated(temp_dir) + print("Edge isolated edit suite passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_edge_round_chamfer.py b/scripts/verify_edge_round_chamfer.py index 409b23a..e3bb261 100644 --- a/scripts/verify_edge_round_chamfer.py +++ b/scripts/verify_edge_round_chamfer.py @@ -250,6 +250,9 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera plan = model.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise SystemExit(f"existing fillet plan was blocked: {plan['message']}") + support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ())) + if len(support_face_ids) < 2: + raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}") result = model.resize_existing_fillet(face_id, target_radius) after = model.stats() matches = _cylindrical_faces_near_radius(model, target_radius, tolerance) @@ -265,6 +268,7 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") + print(f"support_face_ids={support_face_ids}") print(f"source_radius={source_radius:.6f} target_radius={target_radius:.6f}") print(f"matched_faces={matches}") print(result.encode("ascii", "backslashreplace").decode("ascii")) diff --git a/scripts/verify_face_edit_suite.py b/scripts/verify_face_edit_suite.py index 349a0fc..5501da3 100644 --- a/scripts/verify_face_edit_suite.py +++ b/scripts/verify_face_edit_suite.py @@ -86,6 +86,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "planar Face on curved Solid disables local-only deformation", ("verify_face_mixed_surface_guard.py",), ), + ( + "freeform Face stays read-only with a clear blocker", + ("verify_face_freeform_guard.py",), + ), ( "cylindrical side Face height edits are checked", ("verify_cylindrical_height_resize.py",), @@ -130,6 +134,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "Face logical selection stays on the edited face", ("verify_face_logical_selection_retention.py",), ), + ( + "Face selection identity UI prefers logical IDs", + ("verify_selection_identity_ui.py",), + ), + ( + "Face feature parameters stay stable after full recognition cache", + ("verify_face_feature_parameter_consistency.py",), + ), ( "Face invalid positive targets are blocked", ("verify_face_invalid_target_guards.py",), diff --git a/scripts/verify_face_feature_parameter_consistency.py b/scripts/verify_face_feature_parameter_consistency.py new file mode 100644 index 0000000..6fc129d --- /dev/null +++ b/scripts/verify_face_feature_parameter_consistency.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.model import StepModel +from step_editor.window_state import WindowStateMixin, _feature_dimension_keys + + +MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" +FACE_ID = 594 +BASE_FACE_KEYS = ( + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", +) + + +class _Probe(WindowStateMixin): + pass + + +def _feature_rows(model: StepModel, face_id: int) -> tuple[str, ...]: + info = model.quick_face_info(face_id) + probe = object.__new__(_Probe) + probe.model = model + probe.operation_in_progress = False + probe.scan_in_progress = False + probe.load_in_progress = False + probe.selected_face_id = face_id + probe.selected_edge_id = None + probe.selected_kind = "feature" + probe.selected_part_id = int(info["part_id"]) + probe.selected_solid_id = int(info["solid_id"]) + probe.feature_detection_level = "current-only" + probe.manual_bottom_face_id = None + probe.manual_slot_pair_face_id = None + context = probe._feature_context_info(face_id) + specs, _used = probe._editable_property_specs(context) + rows = probe._feature_context_property_specs(specs, context) + return tuple(str(row.get("key")) for row in rows if row.get("parameter_role") == "dimension") + + +def _assert_contains(keys: tuple[str, ...], expected: tuple[str, ...], label: str) -> None: + missing = [key for key in expected if key not in keys] + if missing: + raise AssertionError(f"{label} missing {missing}, got {keys}") + + +def main() -> int: + model = StepModel.load(MODEL_PATH) + if FACE_ID >= len(model.faces): + raise AssertionError(f"test model has no Face {FACE_ID}") + + quick_info = model.quick_face_info(FACE_ID) + if quick_info.get("surface") != "plane": + raise AssertionError(f"Face {FACE_ID} should be planar in the baseline model: {quick_info}") + + before_cached_rows = _feature_rows(model, FACE_ID) + _assert_contains(before_cached_rows, BASE_FACE_KEYS, "Face 594 before full feature cache") + + full_info = model.feature_info(FACE_ID) + if full_info.get("shell_region_status") == "candidate": + dimension_keys = _feature_dimension_keys(full_info) + _assert_contains(dimension_keys, BASE_FACE_KEYS, "shell candidate feature dimensions") + if "shell_thickness_estimate" not in dimension_keys: + raise AssertionError(f"shell candidate should keep shell thickness as an extra dimension: {dimension_keys}") + + after_cached_rows = _feature_rows(model, FACE_ID) + _assert_contains(after_cached_rows, BASE_FACE_KEYS, "Face 594 after full feature cache") + if before_cached_rows != after_cached_rows: + raise AssertionError( + "Face 594 current-only feature rows changed after full recognition cache: " + f"before={before_cached_rows}, after={after_cached_rows}" + ) + + print("Face feature parameter consistency ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_face_freeform_guard.py b/scripts/verify_face_freeform_guard.py new file mode 100644 index 0000000..63e952c --- /dev/null +++ b/scripts/verify_face_freeform_guard.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +from OCC.Core.BRepAdaptor import BRepAdaptor_Surface +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace +from OCC.Core.GeomAbs import GeomAbs_BSplineSurface +from OCC.Core.GeomAPI import GeomAPI_PointsToBSplineSurface +from OCC.Core.TColgp import TColgp_Array2OfPnt +from OCC.Core.gp import gp_Pnt + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.model import StepModel +from step_editor.step_io import _write_step +from step_editor.window_state import WindowStateMixin + + +class _PropertySpecProbe(WindowStateMixin): + def __init__(self, face_id: int, selected_kind: str = "face") -> None: + self.model = object() + self.operation_in_progress = False + self.scan_in_progress = False + self.load_in_progress = False + self.selected_face_id = face_id + self.selected_edge_id = None + self.selected_kind = selected_kind + self.selected_part_id = 1 + self.selected_solid_id = -1 + self.manual_bottom_face_id = None + self.manual_slot_pair_face_id = None + + +def _write_b_spline_patch(path: Path) -> None: + points = TColgp_Array2OfPnt(1, 4, 1, 4) + for row in range(1, 5): + x = float(row - 1) * 10.0 + for column in range(1, 5): + y = float(column - 1) * 10.0 + z = 1.2 * ((row - 2.5) ** 2 - (column - 2.5) ** 2) + points.SetValue(row, column, gp_Pnt(x, y, z)) + surface = GeomAPI_PointsToBSplineSurface(points).Surface() + face = BRepBuilderAPI_MakeFace(surface, 1e-6).Face() + _write_step(face, path) + + +def _freeform_face_id(model: StepModel) -> int: + for face_id, face in enumerate(model.faces): + surf = BRepAdaptor_Surface(face) + if surf.GetType() == GeomAbs_BSplineSurface: + return face_id + raise SystemExit("B-spline Face was not found") + + +def _specs(info: dict[str, object], selected_kind: str = "face") -> list[dict[str, object]]: + probe = _PropertySpecProbe(int(info.get("face_id", 0)), selected_kind=selected_kind) + specs, _used = probe._editable_property_specs(info) + return specs + + +def _property_rows(info: dict[str, object], selected_kind: str = "face") -> list[dict[str, object]]: + probe = _PropertySpecProbe(int(info.get("face_id", 0)), selected_kind=selected_kind) + return probe._property_editor_specs(info, info) + + +def _assert_no_editable_actions(specs: list[dict[str, object]], label: str) -> None: + editable = [ + str(spec.get("key")) + for spec in specs + if bool(spec.get("editable")) and bool(spec.get("enabled")) and bool(spec.get("action")) + ] + if editable: + raise SystemExit(f"{label} should not expose editable actions for a freeform Face: {editable}") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="geom_param_freeform_guard_") as temp_dir: + path = Path(temp_dir) / "freeform_patch.step" + _write_b_spline_patch(path) + model = StepModel.load(path) + face_id = _freeform_face_id(model) + + quick_info = model.quick_face_info(face_id) + info = model.face_info(face_id) + for label, selected_info in (("quick", quick_info), ("full", info)): + if selected_info.get("surface") != "b-spline surface": + raise SystemExit(f"{label} info should identify a B-spline surface: {selected_info}") + if selected_info.get("freeform_face_status") != "blocked": + raise SystemExit(f"{label} info should block freeform Face editing: {selected_info}") + if selected_info.get("recognition_risk") != "blocked": + raise SystemExit(f"{label} recognition risk should be blocked: {selected_info}") + blockers = str(selected_info.get("freeform_face_blockers") or "") + if "自由曲面" not in blockers or "历史参数" not in blockers: + raise SystemExit(f"{label} blocker should explain the missing stable history parameter: {selected_info}") + + editable_specs = _specs(info) + editable_keys = {str(spec.get("key")) for spec in editable_specs} + if "freeform_surface_edit_semantics" not in editable_keys: + raise SystemExit(f"freeform Face should expose a read-only edit semantics row: {editable_specs}") + forbidden = { + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + "cone_reference_radius", + "sphere_radius", + "torus_major_radius", + } + leaked = sorted(forbidden & editable_keys) + if leaked: + raise SystemExit(f"freeform Face should not expose parametric edit rows: {leaked}") + _assert_no_editable_actions(editable_specs, "face mode editable specs") + + feature_rows = _property_rows(info, selected_kind="feature") + feature_keys = {str(spec.get("key")) for spec in feature_rows} + if "no_editable_feature_dimensions" not in feature_keys: + raise SystemExit(f"feature mode should say there are no reliable dimensions: {feature_rows}") + if "freeform_surface_edit_semantics" not in feature_keys: + raise SystemExit(f"feature mode should keep the freeform limitation explanation: {feature_rows}") + _assert_no_editable_actions(feature_rows, "feature mode rows") + + print( + "freeform Face guard ok: " + f"face_id={face_id}, surface={info.get('surface')}, " + f"risk={info.get('recognition_risk')}, specs={sorted(editable_keys)}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_face_mixed_surface_guard.py b/scripts/verify_face_mixed_surface_guard.py index aabceb8..2ab710d 100644 --- a/scripts/verify_face_mixed_surface_guard.py +++ b/scripts/verify_face_mixed_surface_guard.py @@ -202,7 +202,7 @@ def main() -> int: specs = _specs(face_id, info) semantics = _spec(specs, "face_edit_semantics") - if "不能只改当前面" not in str(semantics.get("current_text") or ""): + if "不能局部重建" not in str(semantics.get("current_text") or ""): raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}") if "曲面" not in str(semantics.get("disabled_tip") or ""): raise SystemExit(f"Face edit semantics tip should include the curved-owner blocker: {semantics}") diff --git a/scripts/verify_face_ui_isolation_contract.py b/scripts/verify_face_ui_isolation_contract.py index 2306ba0..0cc2884 100644 --- a/scripts/verify_face_ui_isolation_contract.py +++ b/scripts/verify_face_ui_isolation_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import ast import sys import time @@ -30,6 +31,41 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = { "resize_torus_radius", } +EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS = { + "resize_cylindrical_hole", + "resize_cylindrical_owning_scale", + "move_cylindrical_hole_axis", + "suppress_cylindrical_hole", + "resize_cylindrical_depth", + "resize_cylindrical_depth_owning_scale", + "move_cylindrical_slot_axis", + "resize_cylindrical_slot_width", + "resize_cylindrical_slot_depth", + "resize_cylindrical_slot_arc_length", + "resize_cylindrical_slot_angular_span", + "resize_cylindrical_slot_total_length", + "resize_cylindrical_slot_center_distance", +} + +EXPECTED_EDGE_ISOLATED_OPERATIONS = { + "resize_general_edge_length", + "move_edge_endpoint", + "move_edge_center", + "move_circular_edge_axis_center", + "resize_ellipse_edge_axis_radius", + "resize_existing_fillet", + "fillet_edge", + "chamfer_edge", + "chamfer_edge_asymmetric", + "chamfer_edge_distance_angle", +} + +EXPECTED_ISOLATED_OPERATIONS = ( + EXPECTED_FACE_ISOLATED_OPERATIONS + | EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS + | EXPECTED_EDGE_ISOLATED_OPERATIONS +) + def _source_tree(relative_path: str) -> ast.Module: return ast.parse((PROJECT_ROOT / relative_path).read_text(encoding="utf-8")) @@ -123,6 +159,37 @@ ACTION_IMPLEMENTATION_METHOD = { "resize_torus_minor_radius": "_resize_torus_radius", } +FEATURE_ACTION_TO_ISOLATED_OPERATION = { + "resize_hole": "resize_cylindrical_hole", + "resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale", + "move_cylindrical_hole_axis": "move_cylindrical_hole_axis", + "suppress_hole": "suppress_cylindrical_hole", + "resize_hole_depth": "resize_cylindrical_depth", + "resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale", + "move_cylindrical_slot_axis": "move_cylindrical_slot_axis", + "_resize_slot_metric_owning_scale": "resize_cylindrical_owning_scale", + "resize_slot_width": "resize_cylindrical_slot_width", + "resize_slot_depth": "resize_cylindrical_slot_depth", + "resize_slot_arc_length": "resize_cylindrical_slot_arc_length", + "resize_slot_angular_span": "resize_cylindrical_slot_angular_span", + "resize_slot_total_length": "resize_cylindrical_slot_total_length", + "resize_slot_center_distance": "resize_cylindrical_slot_center_distance", +} + +EDGE_ACTION_TO_ISOLATED_OPERATION = { + "resize_existing_fillet": "resize_existing_fillet", + "fillet_edge": "fillet_edge", + "chamfer_edge": "chamfer_edge", + "chamfer_edge_asymmetric": "chamfer_edge_asymmetric", + "chamfer_edge_distance_angle": "chamfer_edge_distance_angle", + "resize_edge_length": "resize_general_edge_length", + "resize_any_edge_length": "resize_general_edge_length", + "_resize_ellipse_edge_axis_radius": "resize_ellipse_edge_axis_radius", + "move_circular_edge_axis_center": "move_circular_edge_axis_center", + "move_edge_center_point": "move_edge_center", + "_move_edge_endpoint": "move_edge_endpoint", +} + def _property_spec_actions_for_keys(specs: list[dict[str, object]], keys: set[str]) -> set[str]: actions: set[str] = set() @@ -285,7 +352,7 @@ def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None 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: + if isolated_operation not in EXPECTED_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) @@ -296,17 +363,74 @@ def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None ) -def main() -> int: +def _assert_property_face_mapping_methods_are_isolated(actions_tree: ast.Module) -> None: + for action, isolated_operation in sorted(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION.items()): + if isolated_operation not in EXPECTED_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 _assert_hole_slot_actions_are_isolated(actions_tree: ast.Module) -> None: + for method_name, isolated_operation in sorted(FEATURE_ACTION_TO_ISOLATED_OPERATION.items()): + if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS: + raise SystemExit(f"{method_name} maps to non-worker isolated operation {isolated_operation!r}") + actual_operations = _method_isolation_operations(actions_tree, method_name) + if isolated_operation not in actual_operations: + raise SystemExit( + f"{method_name} should enter isolated operation {isolated_operation!r}; " + f"actual={sorted(actual_operations)}" + ) + + +def _assert_edge_actions_are_isolated(actions_tree: ast.Module) -> None: + for method_name, isolated_operation in sorted(EDGE_ACTION_TO_ISOLATED_OPERATION.items()): + if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS: + raise SystemExit(f"{method_name} maps to non-worker isolated operation {isolated_operation!r}") + actual_operations = _method_isolation_operations(actions_tree, method_name) + if isolated_operation not in actual_operations: + raise SystemExit( + f"{method_name} should enter isolated operation {isolated_operation!r}; " + f"actual={sorted(actual_operations)}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Verify UI/worker isolation contracts for risky geometry edits.") + parser.add_argument( + "--static-only", + action="store_true", + help="Only check AST-level operation contracts; skip OCCT-dependent property/model probes.", + ) + args = parser.parse_args(argv) + 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) + _assert_same("isolated worker operations", worker_ops, EXPECTED_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) + ui_ops = _constant_string_set(isolation_func, "isolated_geometry_operations") + _assert_same("UI isolation operations", ui_ops, EXPECTED_ISOLATED_OPERATIONS) _assert_same("UI/worker isolation operation contract", ui_ops, worker_ops) - _assert_property_face_actions_are_isolated(actions_tree) + if args.static_only: + _assert_property_face_mapping_methods_are_isolated(actions_tree) + else: + _assert_property_face_actions_are_isolated(actions_tree) + _assert_hole_slot_actions_are_isolated(actions_tree) + _assert_edge_actions_are_isolated(actions_tree) + + if args.static_only: + print( + "Static UI/worker isolation contract passed: " + f"{len(EXPECTED_ISOLATED_OPERATIONS)} operations are shared by UI and worker." + ) + return 0 from step_editor.window_actions import WindowActionMixin @@ -315,7 +439,7 @@ def main() -> int: probe = _Probe() for risk in ("low", "medium", "high"): - for operation in sorted(EXPECTED_FACE_ISOLATED_OPERATIONS): + for operation in sorted(EXPECTED_ISOLATED_OPERATIONS): isolation = probe._isolation_for_plan( {"risk": risk}, operation, @@ -330,13 +454,13 @@ def main() -> int: 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": + if str(isolation.get("reason") or "") != f"{risk}-risk-isolated-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"}, "resize_cylindrical_hole", [0, 20]) is None: + raise SystemExit("hole/slot feature operations should use the shared isolated geometry contract") if probe._isolation_for_plan({"risk": "high"}, "unknown_operation", []) is not None: raise SystemExit("unknown operations should not enter isolated execution") @@ -418,30 +542,35 @@ def main() -> int: 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) + outward_plan = multi_boundary_probe._push_pull_plan_for_action(multi_boundary_face_id, 32.5) + if abs(float(outward_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9: + raise SystemExit(f"multi-boundary outward quick plan should use the displayed Face offset: {outward_plan}") + if abs(float(outward_plan.get("target_plane_position") or 0.0) - 90.0) > 1e-9: + raise SystemExit(f"multi-boundary outward quick plan should target Face offset 90: {outward_plan}") + if tuple(float(item) for item in outward_plan.get("outward_direction", ())) != (0.0, -1.0, 0.0): + raise SystemExit(f"multi-boundary outward quick plan should use normal, not reversed oriented_normal: {outward_plan}") 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: + if inward_plan.get("status") != "caution": + raise SystemExit(f"multi-boundary shallow inward push/pull UI preflight should be caution: {inward_plan}") + if inward_plan.get("risk") not in {"medium", "high"}: + raise SystemExit(f"multi-boundary shallow inward push/pull should be medium/high risk: {inward_plan}") + if inward_plan.get("ui_quick_blocked_push_pull_plan"): + raise SystemExit(f"multi-boundary shallow inward push/pull should not be marked as blocked: {inward_plan}") + message = str(inward_plan.get("message") or "") + " " + str(inward_plan.get("warnings") or "") + if "多内孔" not in message or ("一级边界侧壁" not in message and "后台" not in message): raise SystemExit( - f"multi-boundary inward push/pull blocker should explain topology depth and Boolean risk: " + f"multi-boundary shallow inward push/pull should explain boundary-shell rebuild semantics: " 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}" - ) + if inward_plan.get("target_plane_position") != 56.5: + raise SystemExit(f"multi-boundary shallow inward push/pull should target 56.5: {inward_plan}") diagnostics = multi_boundary_probe._edit_failure_diagnostics( { - "operation_name": "推拉平面", + "operation_name": "拉伸/切除平面", "target": f"Face {multi_boundary_face_id}", "parameters": { "surface": inward_plan.get("surface"), @@ -467,19 +596,23 @@ def main() -> int: "operation": "push_pull_face", "args": [multi_boundary_face_id, -1.0], "timeout_seconds": 180.0, - "reason": "high-risk-face-occ-edit", + "reason": f"{inward_plan.get('risk')}-risk-isolated-occ-edit", }, } ) - for required in ("诊断信息", "操作: 推拉平面 / Face 594", "一级关系证据", "可能原因", "二级", "隔离保护"): + 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}") + isolation = multi_boundary_probe._isolation_for_plan(inward_plan, "push_pull_face", [multi_boundary_face_id, -1.0]) + if isolation is None: + raise SystemExit(f"multi-boundary shallow inward push/pull should enter isolated execution: {inward_plan}") + expected_reason = f"{inward_plan.get('risk')}-risk-isolated-occ-edit" + if str(isolation.get("reason") or "") != expected_reason: + raise SystemExit(f"multi-boundary shallow inward push/pull returned wrong isolation reason: {isolation}") print( "Face UI isolation contract ok: " - f"{len(EXPECTED_FACE_ISOLATED_OPERATIONS)} operations are shared by UI and worker." + f"{len(EXPECTED_ISOLATED_OPERATIONS)} operations are shared by UI and worker." ) return 0 diff --git a/scripts/verify_feature_parameter_policy.py b/scripts/verify_feature_parameter_policy.py index 5a41fed..5e6d258 100644 --- a/scripts/verify_feature_parameter_policy.py +++ b/scripts/verify_feature_parameter_policy.py @@ -53,9 +53,26 @@ def main() -> int: }, ("existing_fillet_radius_estimate",), ) + assert_keys( + {"surface": "plane"}, + ( + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + ), + ) assert_keys( {"surface": "plane", "shell_region_status": "candidate"}, - ("shell_thickness_estimate",), + ( + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + "shell_thickness_estimate", + ), ) assert_keys( { diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index e817b01..9d4148a 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -129,9 +129,9 @@ def _verify_holed_planar_summary(root: Path) -> None: _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 ready_actions, f"holed Face should expose push/pull as ready: {info}") _assert( - "当前面局部尺寸/中心/偏移" in limited_actions, + "局部重建尺寸/中心/偏移" in limited_actions, f"holed Face should expose local deformation as limited: {info}", ) diff --git a/scripts/verify_hole_slot_isolated_edit.py b/scripts/verify_hole_slot_isolated_edit.py new file mode 100644 index 0000000..523a154 --- /dev/null +++ b/scripts/verify_hole_slot_isolated_edit.py @@ -0,0 +1,1106 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +SCRIPTS_ROOT = PROJECT_ROOT / "scripts" +if str(SCRIPTS_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPTS_ROOT)) + +from step_editor.model import StepModel +from verify_hole_resize import ( + _axis_center, + _depth, + _first_hole_face, + _hole_face_ids, + _nearest_blind_depth, + _nearest_hole_by_diameter, + _volume, + _write_blind_hole_model, + _write_through_hole_model, +) +from verify_slot_resize import ( + _first_slot_face, + _nearest_axis_center, + _nearest_metric, + _nearest_obround_axis_pair, + _nearest_obround_total_length, + _slot_axis_center, + _slot_metric, + _write_half_round_slot_model, + _write_obround_slot_model, +) + + +def _run_worker(request_path: Path) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + check=False, + ) + response_path = request_path.with_suffix(".response.json") + if completed.returncode != 0: + stderr = completed.stderr.strip() + response = response_path.read_text(encoding="utf-8-sig") if response_path.exists() else "" + raise SystemExit( + f"isolated worker failed: returncode={completed.returncode}, stderr={stderr}, response={response}" + ) + if not response_path.exists(): + raise SystemExit("isolated worker did not write a response JSON file") + response = json.loads(response_path.read_text(encoding="utf-8-sig")) + if not response.get("ok"): + raise SystemExit(f"isolated worker returned an error response: {response}") + return response + + +def _run_worker_allow_failure(request_path: Path) -> tuple[int, dict[str, object]]: + completed = subprocess.run( + [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + check=False, + ) + response_path = request_path.with_suffix(".response.json") + if not response_path.exists(): + raise SystemExit( + f"isolated worker did not write a response JSON file; " + f"returncode={completed.returncode}, stderr={completed.stderr.strip()}" + ) + response = json.loads(response_path.read_text(encoding="utf-8-sig")) + return completed.returncode, response + + +def _write_request( + request_path: Path, + input_path: Path, + output_path: Path, + operation: str, + args: list[object], +) -> None: + 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", + ) + + +def _assert_one_solid(model: StepModel, label: str) -> None: + if model.stats().solids != 1: + raise SystemExit(f"{label} should keep one Solid: {model.stats()}") + + +def _logical_region_has_diameter(model: StepModel, logical_id: int, target_diameter: float) -> bool: + tolerance = max(abs(target_diameter) * 1e-4, 1e-5) + for face_id in model.face_ids_for_logical_id(logical_id): + try: + diameter = float(model.face_info(face_id).get("diameter")) + except (TypeError, ValueError): + continue + if abs(diameter - target_diameter) <= tolerance: + return True + return False + + +def _logical_region_has_slot_metric(model: StepModel, logical_id: int, key: str, target_value: float) -> bool: + tolerance = max(abs(target_value) * 1e-4, 1e-5) + for face_id in model.face_ids_for_logical_id(logical_id): + try: + feature = model.feature_info(face_id) + info = model.face_info(face_id) + value = feature.get(key) + if value is None: + value = info.get(key) + value = float(value) + except BaseException: + continue + if abs(value - target_value) <= tolerance: + return True + return False + + +def _logical_region_has_blind_depth(model: StepModel, logical_id: int, target_depth: float) -> bool: + tolerance = max(abs(target_depth) * 1e-4, 1e-5) + for face_id in model.face_ids_for_logical_id(logical_id): + try: + depth = _depth(model, face_id) + except BaseException: + continue + if abs(depth - target_depth) <= tolerance: + return True + return False + + +def _logical_region_has_hole_axis( + model: StepModel, + logical_id: int, + target_center: tuple[float, float, float], + target_diameter: float, +) -> bool: + for face_id in model.face_ids_for_logical_id(logical_id): + try: + center = _axis_center(model, face_id) + diameter = float(model.face_info(face_id).get("diameter")) + except BaseException: + continue + center_error = sum((center[index] - target_center[index]) ** 2 for index in range(3)) ** 0.5 + diameter_error = abs(diameter - target_diameter) + if center_error <= 1e-4 and diameter_error <= max(abs(target_diameter) * 1e-4, 1e-5): + return True + return False + + +def _logical_region_has_slot_axis( + model: StepModel, + logical_id: int, + target_center: tuple[float, float, float], +) -> bool: + for face_id in model.face_ids_for_logical_id(logical_id): + try: + center = _slot_axis_center(model, face_id) + except BaseException: + continue + center_error = sum((center[index] - target_center[index]) ** 2 for index in range(3)) ** 0.5 + if center_error <= 1e-4: + return True + return False + + +def _logical_region_contains_any(model: StepModel, logical_id: int, face_ids: tuple[int, ...]) -> bool: + logical_faces = set(model.face_ids_for_logical_id(logical_id)) + return any(face_id in logical_faces for face_id in face_ids) + + +def _window_probe_for(input_path: Path): + from step_editor.window_actions import WindowActionMixin + + class _WindowProbe(WindowActionMixin): + def __init__(self) -> None: + self.model = StepModel.load(input_path) + self.step_path = input_path + self.current_info_values = {} + self.selected_pick_position = None + self.isolated_edit_cancel_requested = False + self.active_isolated_edit_process = None + + return _WindowProbe() + + +def _verify_hole_diameter_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole.step" + output_path = temp_dir / "through_hole_out.step" + request_path = temp_dir / "hole_request.json" + _write_through_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=False) + target_center = _axis_center(model, face_id) + target_diameter = 8.0 + + _write_request(request_path, input_path, output_path, "resize_cylindrical_hole", [face_id, target_diameter]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, diameter, center, error = _nearest_hole_by_diameter( + result_model, + target_diameter, + target_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - target_diameter) > 1e-4: + raise SystemExit( + f"isolated hole resize verification failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated hole resize") + print(f"isolated hole resize ok: face={verified_face}, diameter={diameter:g}") + + +def _verify_hole_owning_scale_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole_scale.step" + output_path = temp_dir / "through_hole_scale_out.step" + request_path = temp_dir / "hole_scale_request.json" + _write_through_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=False) + target_center = _axis_center(model, face_id) + target_diameter = 8.0 + + _write_request(request_path, input_path, output_path, "resize_cylindrical_owning_scale", [face_id, target_diameter]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, diameter, center, error = _nearest_hole_by_diameter( + result_model, + target_diameter, + target_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - target_diameter) > 1e-4: + raise SystemExit( + f"isolated hole owning-scale verification failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated hole owning-scale") + print(f"isolated hole owning-scale ok: face={verified_face}, diameter={diameter:g}") + + +def _verify_hole_axis_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole_axis.step" + output_path = temp_dir / "through_hole_axis_out.step" + request_path = temp_dir / "hole_axis_request.json" + _write_through_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=False) + current_center = _axis_center(model, face_id) + target_center = (current_center[0] + 2.0, current_center[1], current_center[2]) + current_diameter = float(model.face_info(face_id)["diameter"]) + + _write_request(request_path, input_path, output_path, "move_cylindrical_hole_axis", [face_id, target_center]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, diameter, center, error = _nearest_hole_by_diameter( + result_model, + current_diameter, + target_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - current_diameter) > 1e-4: + raise SystemExit( + f"isolated hole axis move verification failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated hole axis move") + print(f"isolated hole axis move ok: face={verified_face}, center={center}") + + +def _verify_hole_suppress_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole_suppress.step" + output_path = temp_dir / "through_hole_suppress_out.step" + request_path = temp_dir / "hole_suppress_request.json" + _write_through_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=False) + before_volume = _volume(model) + + _write_request(request_path, input_path, output_path, "suppress_cylindrical_hole", [face_id]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + after_volume = _volume(result_model) + remaining_holes = _hole_face_ids(result_model) + full_box_volume = 30.0 * 20.0 * 8.0 + if remaining_holes: + raise SystemExit(f"isolated hole suppress left hole faces: {remaining_holes}, response={response}") + if abs(after_volume - full_box_volume) > 1e-3: + raise SystemExit( + f"isolated hole suppress volume failed: before={before_volume:g}, " + f"after={after_volume:g}, target={full_box_volume:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated hole suppress") + print(f"isolated hole suppress ok: volume={after_volume:g}") + + +def _verify_blind_depth_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "blind_hole.step" + output_path = temp_dir / "blind_hole_out.step" + request_path = temp_dir / "blind_depth_request.json" + _write_blind_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=True) + feature = model.feature_info(face_id) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None + target_depth = 8.0 + + _write_request(request_path, input_path, output_path, "resize_cylindrical_depth", [face_id, target_depth, bottom_face_id]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, depth, error = _nearest_blind_depth(result_model, target_depth) + if error > 1e-4: + raise SystemExit( + f"isolated blind depth verification failed: face={verified_face}, " + f"depth={depth:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated blind depth") + print(f"isolated blind depth ok: face={verified_face}, depth={depth:g}") + + +def _verify_blind_depth_owning_scale_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "blind_hole_scale.step" + output_path = temp_dir / "blind_hole_scale_out.step" + request_path = temp_dir / "blind_depth_scale_request.json" + _write_blind_hole_model(input_path) + model = StepModel.load(input_path) + face_id = _first_hole_face(model, blind=True) + feature = model.feature_info(face_id) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None + target_depth = 8.0 + + _write_request( + request_path, + input_path, + output_path, + "resize_cylindrical_depth_owning_scale", + [face_id, target_depth, bottom_face_id], + ) + returncode, response = _run_worker_allow_failure(request_path) + if returncode != 0: + error = str(response.get("error", "")) + if "rolled back" not in error and "回滚" not in error: + raise SystemExit(f"isolated blind depth owning-scale failed without rollback message: {response}") + print("isolated blind depth owning-scale guarded ok: result rejected and rolled back") + return + + if not response.get("ok"): + raise SystemExit(f"isolated blind depth owning-scale returned an error response: {response}") + result_model = StepModel.load(output_path) + verified_face, depth, error = _nearest_blind_depth(result_model, target_depth) + if error > 1e-4: + raise SystemExit( + f"isolated blind depth owning-scale verification failed: face={verified_face}, " + f"depth={depth:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated blind depth owning-scale") + print(f"isolated blind depth owning-scale ok: face={verified_face}, depth={depth:g}") + + +def _verify_slot_width_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "half_round_slot.step" + output_path = temp_dir / "half_round_slot_out.step" + request_path = temp_dir / "slot_request.json" + _write_half_round_slot_model(input_path) + model = StepModel.load(input_path) + face_id = _first_slot_face(model) + target_width = 8.0 + + _write_request(request_path, input_path, output_path, "resize_cylindrical_slot_width", [face_id, target_width, None]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, width, error = _nearest_metric(result_model, "slot_chord_width_estimate", target_width) + if error > 1e-4: + raise SystemExit( + f"isolated slot width verification failed: face={verified_face}, " + f"width={width:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated slot width resize") + print(f"isolated slot width resize ok: face={verified_face}, width={width:g}") + + +def _verify_slot_owning_scale_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "half_round_slot_scale.step" + output_path = temp_dir / "half_round_slot_scale_out.step" + request_path = temp_dir / "slot_scale_request.json" + _write_half_round_slot_model(input_path) + model = StepModel.load(input_path) + face_id = _first_slot_face(model) + target_diameter = 8.0 + target_width = 8.0 + + _write_request(request_path, input_path, output_path, "resize_cylindrical_owning_scale", [face_id, target_diameter]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, width, error = _nearest_metric(result_model, "slot_chord_width_estimate", target_width) + if error > 1e-4: + raise SystemExit( + f"isolated slot owning-scale verification failed: face={verified_face}, " + f"width={width:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated slot owning-scale") + print(f"isolated slot owning-scale ok: face={verified_face}, width={width:g}") + + +def _verify_slot_metric_isolated( + temp_dir: Path, + stem: str, + operation: str, + target_value: float, + metric_key: str, +) -> None: + input_path = temp_dir / f"{stem}.step" + output_path = temp_dir / f"{stem}_out.step" + request_path = temp_dir / f"{stem}_request.json" + _write_half_round_slot_model(input_path) + model = StepModel.load(input_path) + face_id = _first_slot_face(model) + + args: list[object] = [face_id, target_value] + if operation in { + "resize_cylindrical_slot_width", + "resize_cylindrical_slot_depth", + "resize_cylindrical_slot_arc_length", + }: + args.append(None) + _write_request(request_path, input_path, output_path, operation, args) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, value, error = _nearest_metric(result_model, metric_key, target_value) + if error > 1e-4: + raise SystemExit( + f"isolated {stem} verification failed: face={verified_face}, " + f"value={value:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, f"isolated {stem}") + print(f"isolated {stem} ok: face={verified_face}, value={value:g}") + + +def _verify_slot_axis_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "half_round_slot_axis.step" + output_path = temp_dir / "half_round_slot_axis_out.step" + request_path = temp_dir / "slot_axis_request.json" + _write_half_round_slot_model(input_path) + model = StepModel.load(input_path) + face_id = _first_slot_face(model) + current_center = _slot_axis_center(model, face_id) + target_center = (current_center[0], current_center[1] + 1.0, current_center[2]) + + _write_request(request_path, input_path, output_path, "move_cylindrical_slot_axis", [face_id, target_center]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, center, error = _nearest_axis_center(result_model, target_center) + if error > 1e-4: + raise SystemExit( + f"isolated slot axis move verification failed: face={verified_face}, " + f"center={center}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated slot axis move") + print(f"isolated slot axis move ok: face={verified_face}, center={center}") + + +def _verify_obround_slot_total_length_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "obround_total_length.step" + output_path = temp_dir / "obround_total_length_out.step" + request_path = temp_dir / "obround_total_length_request.json" + _write_obround_slot_model(input_path) + model = StepModel.load(input_path) + face_id, pair_face_id, _current_total_length, _current_center_distance, _ = _nearest_obround_total_length(model, 16.0) + target_total_length = 20.0 + + _write_request( + request_path, + input_path, + output_path, + "resize_cylindrical_slot_total_length", + [face_id, target_total_length, pair_face_id], + ) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length( + result_model, + target_total_length, + ) + if error > 1e-4: + raise SystemExit( + f"isolated obround total length verification failed: faces=({verified_face}, {verified_pair}), " + f"total_length={total_length:g}, center_distance={center_distance:g}, error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated obround total length") + print(f"isolated obround total length ok: faces=({verified_face}, {verified_pair}), total_length={total_length:g}") + + +def _verify_obround_slot_center_distance_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "obround_center_distance.step" + output_path = temp_dir / "obround_center_distance_out.step" + request_path = temp_dir / "obround_center_distance_request.json" + _write_obround_slot_model(input_path) + model = StepModel.load(input_path) + face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0) + slot_diameter = current_total_length - current_center_distance + target_center_distance = 14.0 + target_total_length = target_center_distance + slot_diameter + + _write_request( + request_path, + input_path, + output_path, + "resize_cylindrical_slot_center_distance", + [face_id, target_center_distance, pair_face_id], + ) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, verified_pair, total_length, center_distance, total_error = _nearest_obround_total_length( + result_model, + target_total_length, + ) + center_error = abs(center_distance - target_center_distance) + if center_error > 1e-4 or total_error > 1e-4: + raise SystemExit( + f"isolated obround center distance verification failed: faces=({verified_face}, {verified_pair}), " + f"total_length={total_length:g}, center_distance={center_distance:g}, " + f"center_error={center_error:g}, total_error={total_error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated obround center distance") + print( + "isolated obround center distance ok: " + f"faces=({verified_face}, {verified_pair}), center_distance={center_distance:g}" + ) + + +def _verify_obround_slot_axis_isolated(temp_dir: Path) -> None: + input_path = temp_dir / "obround_axis.step" + output_path = temp_dir / "obround_axis_out.step" + request_path = temp_dir / "obround_axis_request.json" + _write_obround_slot_model(input_path) + model = StepModel.load(input_path) + face_id, pair_face_id, _current_total_length, _current_center_distance, _ = _nearest_obround_total_length(model, 16.0) + current_center = _slot_axis_center(model, face_id) + pair_center = _slot_axis_center(model, pair_face_id) + target_center = (current_center[0], current_center[1] + 1.0, current_center[2]) + target_pair_center = (pair_center[0], pair_center[1] + 1.0, pair_center[2]) + + _write_request(request_path, input_path, output_path, "move_cylindrical_slot_axis", [face_id, target_center]) + response = _run_worker(request_path) + result_model = StepModel.load(output_path) + verified_face, verified_pair, center_1, center_2, error = _nearest_obround_axis_pair( + result_model, + target_center, + target_pair_center, + ) + if error > 1e-4: + raise SystemExit( + f"isolated obround axis move verification failed: faces=({verified_face}, {verified_pair}), " + f"centers=({center_1}, {center_2}), error={error:g}, response={response}" + ) + _assert_one_solid(result_model, "isolated obround axis move") + print(f"isolated obround axis move ok: faces=({verified_face}, {verified_pair}), error={error:g}") + + +def _verify_hole_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole_window.step" + _write_through_hole_model(input_path) + + probe = _window_probe_for(input_path) + face_id = _first_hole_face(probe.model, blind=False) + logical_id = probe.model.face_logical_id(face_id) + target_diameter = 8.0 + target_center = _axis_center(probe.model, face_id) + plan = probe.model.cylindrical_resize_plan(face_id, target_diameter) + context = { + "operation_name": "调整圆柱孔径", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "new_diameter": target_diameter, + "old_diameter": plan.get("current_diameter"), + "resize_strategy": plan.get("resize_strategy"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("hole resize should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("hole resize should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "resize_cylindrical_hole", + "args": [face_id, target_diameter], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window hole isolation should return after_model: {result}") + verified_face, diameter, center, error = _nearest_hole_by_diameter( + after_model, + target_diameter, + target_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - target_diameter) > 1e-4: + raise SystemExit( + f"window hole isolation verification failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, result={result}" + ) + if not _logical_region_has_diameter(after_model, logical_id, target_diameter): + raise SystemExit( + f"window hole isolation did not preserve logical Face {logical_id} on the resized hole" + ) + _assert_one_solid(after_model, "window hole isolation") + print(f"window hole isolation ok: logical={logical_id}, face={verified_face}, diameter={diameter:g}") + + +def _verify_hole_axis_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "through_hole_axis_window.step" + _write_through_hole_model(input_path) + + probe = _window_probe_for(input_path) + face_id = _first_hole_face(probe.model, blind=False) + logical_id = probe.model.face_logical_id(face_id) + current_center = _axis_center(probe.model, face_id) + current_diameter = float(probe.model.face_info(face_id)["diameter"]) + target_center = (current_center[0] + 2.0, current_center[1], current_center[2]) + plan = probe.model.cylindrical_axis_move_plan(face_id, target_center) + context = { + "operation_name": "move hole axis", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "current_center": current_center, + "target_center": target_center, + "current_diameter": current_diameter, + "resize_strategy": plan.get("resize_strategy"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("hole axis move should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("hole axis move should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "move_cylindrical_hole_axis", + "args": [face_id, target_center], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window hole-axis isolation should return after_model: {result}") + verified_face, diameter, center, error = _nearest_hole_by_diameter( + after_model, + current_diameter, + target_center, + blind=False, + ) + if error > 1e-4 or abs(diameter - current_diameter) > 1e-4: + raise SystemExit( + f"window hole-axis isolation verification failed: face={verified_face}, " + f"diameter={diameter:g}, center={center}, error={error:g}, result={result}" + ) + if not _logical_region_has_hole_axis(after_model, logical_id, target_center, current_diameter): + raise SystemExit( + f"window hole-axis isolation did not preserve logical Face {logical_id} on the moved hole" + ) + _assert_one_solid(after_model, "window hole-axis isolation") + print(f"window hole-axis isolation ok: logical={logical_id}, face={verified_face}, center={center}") + + +def _verify_slot_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "slot_window.step" + _write_half_round_slot_model(input_path) + + probe = _window_probe_for(input_path) + face_id = _first_slot_face(probe.model) + logical_id = probe.model.face_logical_id(face_id) + target_width = 8.0 + plan = probe.model.cylindrical_slot_resize_plan(face_id, target_width, "width") + context = { + "operation_name": "resize slot width", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "target_width": target_width, + "current_width": plan.get("slot_current_width"), + "resize_strategy": plan.get("resize_strategy"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("slot width should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("slot width should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "resize_cylindrical_slot_width", + "args": [face_id, target_width, None], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window slot isolation should return after_model: {result}") + verified_face, width, error = _nearest_metric(after_model, "slot_chord_width_estimate", target_width) + if error > 1e-4: + raise SystemExit( + f"window slot isolation verification failed: face={verified_face}, " + f"width={width:g}, error={error:g}, result={result}" + ) + if not _logical_region_has_slot_metric(after_model, logical_id, "slot_chord_width_estimate", target_width): + raise SystemExit( + f"window slot isolation did not preserve logical Face {logical_id} on the resized slot" + ) + _assert_one_solid(after_model, "window slot isolation") + print(f"window slot isolation ok: logical={logical_id}, face={verified_face}, width={width:g}") + + +def _verify_slot_axis_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "slot_axis_window.step" + _write_half_round_slot_model(input_path) + + probe = _window_probe_for(input_path) + face_id = _first_slot_face(probe.model) + logical_id = probe.model.face_logical_id(face_id) + current_center = _slot_axis_center(probe.model, face_id) + target_center = (current_center[0], current_center[1] + 1.0, current_center[2]) + plan = probe.model.cylindrical_slot_axis_move_plan(face_id, target_center) + context = { + "operation_name": "move slot axis", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "current_center": current_center, + "target_center": target_center, + "resize_strategy": plan.get("resize_strategy"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("slot axis move should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("slot axis move should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "move_cylindrical_slot_axis", + "args": [face_id, target_center], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window slot-axis isolation should return after_model: {result}") + verified_face, center, error = _nearest_axis_center(after_model, target_center) + if error > 1e-4: + raise SystemExit( + f"window slot-axis isolation verification failed: face={verified_face}, " + f"center={center}, error={error:g}, result={result}" + ) + if not _logical_region_has_slot_axis(after_model, logical_id, target_center): + raise SystemExit( + f"window slot-axis isolation did not preserve logical Face {logical_id} on the moved slot" + ) + _assert_one_solid(after_model, "window slot-axis isolation") + print(f"window slot-axis isolation ok: logical={logical_id}, face={verified_face}, center={center}") + + +def _verify_blind_depth_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "blind_depth_window.step" + _write_blind_hole_model(input_path) + + probe = _window_probe_for(input_path) + face_id = _first_hole_face(probe.model, blind=True) + logical_id = probe.model.face_logical_id(face_id) + feature = probe.model.feature_info(face_id) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None + target_depth = 8.0 + plan = probe.model.cylindrical_depth_plan(face_id, target_depth, bottom_face_id=bottom_face_id) + context = { + "operation_name": "resize blind hole depth", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "target_depth": target_depth, + "current_depth": plan.get("current_depth"), + "resize_strategy": plan.get("resize_strategy"), + "manual_bottom_face_id": bottom_face_id, + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("blind depth should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("blind depth should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "resize_cylindrical_depth", + "args": [face_id, target_depth, bottom_face_id], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window blind-depth isolation should return after_model: {result}") + verified_face, depth, error = _nearest_blind_depth(after_model, target_depth) + if error > 1e-4: + raise SystemExit( + f"window blind-depth isolation verification failed: face={verified_face}, " + f"depth={depth:g}, error={error:g}, result={result}" + ) + if not _logical_region_has_blind_depth(after_model, logical_id, target_depth): + raise SystemExit( + f"window blind-depth isolation did not preserve logical Face {logical_id} on the resized feature" + ) + _assert_one_solid(after_model, "window blind-depth isolation") + print(f"window blind-depth isolation ok: logical={logical_id}, face={verified_face}, depth={depth:g}") + + +def _verify_obround_total_length_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "obround_total_length_window.step" + _write_obround_slot_model(input_path) + + probe = _window_probe_for(input_path) + face_id, pair_face_id, _current_total_length, _current_center_distance, _ = _nearest_obround_total_length( + probe.model, + 16.0, + ) + logical_id = probe.model.face_logical_id(face_id) + target_total_length = 20.0 + plan = probe.model.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id) + context = { + "operation_name": "resize obround slot total length", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "target_total_length": target_total_length, + "slot_pair_face_id": pair_face_id, + "resize_strategy": plan.get("resize_strategy"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("obround total length should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("obround total length should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "resize_cylindrical_slot_total_length", + "args": [face_id, target_total_length, pair_face_id], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window obround-total-length isolation should return after_model: {result}") + verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length( + after_model, + target_total_length, + ) + if error > 1e-4: + raise SystemExit( + f"window obround-total-length isolation verification failed: " + f"faces=({verified_face}, {verified_pair}), total_length={total_length:g}, " + f"center_distance={center_distance:g}, error={error:g}, result={result}" + ) + if not _logical_region_contains_any(after_model, logical_id, (verified_face, verified_pair)): + raise SystemExit( + f"window obround-total-length isolation did not preserve logical Face {logical_id} " + "on the resized slot end" + ) + _assert_one_solid(after_model, "window obround-total-length isolation") + print( + "window obround-total-length isolation ok: " + f"logical={logical_id}, faces=({verified_face}, {verified_pair}), total_length={total_length:g}" + ) + + +def _verify_obround_axis_window_isolation_keeps_logical_id(temp_dir: Path) -> None: + input_path = temp_dir / "obround_axis_window.step" + _write_obround_slot_model(input_path) + + probe = _window_probe_for(input_path) + face_id, pair_face_id, _current_total_length, _current_center_distance, _ = _nearest_obround_total_length( + probe.model, + 16.0, + ) + logical_id = probe.model.face_logical_id(face_id) + current_center = _slot_axis_center(probe.model, face_id) + pair_center = _slot_axis_center(probe.model, pair_face_id) + target_center = (current_center[0], current_center[1] + 1.0, current_center[2]) + target_pair_center = (pair_center[0], pair_center[1] + 1.0, pair_center[2]) + plan = probe.model.cylindrical_slot_axis_move_plan(face_id, target_center) + context = { + "operation_name": "move obround slot axis", + "target": f"Face {face_id}", + "parameters": { + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "face_id": face_id, + "slot_pair_face_id": pair_face_id, + "target_center": target_center, + "target_pair_center": target_pair_center, + "resize_strategy": plan.get("resize_strategy"), + }, + "target_kind": "face", + "target_id": face_id, + "target_logical_id": logical_id, + "pick_position": None, + "show_same_domain_internal_edges": False, + "edit_result_deflection": 1.6, + } + if probe._context_is_face_parameter_edit(context): + raise SystemExit("obround axis move should not be treated as a generic Face parameter edit") + if not probe._context_should_preserve_face_logical_id(context): + raise SystemExit("obround axis move should still preserve the selected logical Face ID") + + result = probe._run_isolated_edit_job( + context=context, + isolation={ + "operation": "move_cylindrical_slot_axis", + "args": [face_id, target_center], + "timeout_seconds": 120.0, + }, + snapshot=probe.model.snapshot(), + before_stats=probe.model.stats(), + before_part_stats=probe.model.part_topology_stats(int(plan.get("part_id") or 1)), + before_quality=probe._edit_quality_info_or_none(probe.model, context, int(plan.get("part_id") or 1)), + before_geometry={}, + ) + after_model = result.get("after_model") + if not isinstance(after_model, StepModel): + raise SystemExit(f"window obround-axis isolation should return after_model: {result}") + verified_face, verified_pair, center_1, center_2, error = _nearest_obround_axis_pair( + after_model, + target_center, + target_pair_center, + ) + if error > 1e-4: + raise SystemExit( + f"window obround-axis isolation verification failed: faces=({verified_face}, {verified_pair}), " + f"centers=({center_1}, {center_2}), error={error:g}, result={result}" + ) + if not _logical_region_contains_any(after_model, logical_id, (verified_face, verified_pair)): + raise SystemExit( + f"window obround-axis isolation did not preserve logical Face {logical_id} on the moved slot end" + ) + _assert_one_solid(after_model, "window obround-axis isolation") + print(f"window obround-axis isolation ok: logical={logical_id}, faces=({verified_face}, {verified_pair})") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="geom_param_hole_slot_isolated_") as temp_name: + temp_dir = Path(temp_name) + _verify_hole_diameter_isolated(temp_dir) + _verify_hole_owning_scale_isolated(temp_dir) + _verify_hole_axis_isolated(temp_dir) + _verify_hole_suppress_isolated(temp_dir) + _verify_blind_depth_isolated(temp_dir) + _verify_blind_depth_owning_scale_isolated(temp_dir) + _verify_slot_width_isolated(temp_dir) + _verify_slot_owning_scale_isolated(temp_dir) + _verify_slot_metric_isolated( + temp_dir, + "slot_depth", + "resize_cylindrical_slot_depth", + 4.0, + "slot_sagitta_depth_estimate", + ) + _verify_slot_metric_isolated( + temp_dir, + "slot_arc_length", + "resize_cylindrical_slot_arc_length", + 12.0, + "slot_arc_length_estimate", + ) + _verify_slot_metric_isolated( + temp_dir, + "slot_angular_span", + "resize_cylindrical_slot_angular_span", + 2.2, + "slot_angular_span", + ) + _verify_slot_axis_isolated(temp_dir) + _verify_obround_slot_total_length_isolated(temp_dir) + _verify_obround_slot_center_distance_isolated(temp_dir) + _verify_obround_slot_axis_isolated(temp_dir) + _verify_hole_window_isolation_keeps_logical_id(temp_dir) + _verify_hole_axis_window_isolation_keeps_logical_id(temp_dir) + _verify_slot_window_isolation_keeps_logical_id(temp_dir) + _verify_slot_axis_window_isolation_keeps_logical_id(temp_dir) + _verify_blind_depth_window_isolation_keeps_logical_id(temp_dir) + _verify_obround_total_length_window_isolation_keeps_logical_id(temp_dir) + _verify_obround_axis_window_isolation_keeps_logical_id(temp_dir) + print("hole/slot isolated edit worker ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_isolated_face_edit.py b/scripts/verify_isolated_face_edit.py index 4c7fa52..d0eb984 100644 --- a/scripts/verify_isolated_face_edit.py +++ b/scripts/verify_isolated_face_edit.py @@ -374,49 +374,49 @@ def main() -> int: input_path=hollow_path, ) _run_worker_case( - label="面偏移(当前面)", + label="偏移曲面(局部重建)", operation="move_face_plane_offset_local", args=[0, 10.0], validator=_assert_plane_position, ) _run_worker_case( - label="面积(当前面)", + label="面积(局部重建)", operation="resize_face_area_local", args=[0, 225.0], validator=_assert_face_area, ) _run_worker_case( - label="面积(整体)", + label="面积(缩放特征)", operation="resize_face_area", args=[0, 400.0], validator=lambda label, model: _assert_face_area(label, model, 400.0), ) _run_worker_case( - label="面宽(当前面)", + label="U向尺寸(局部重建)", operation="resize_face_size_local", args=[0, 25.0, "width"], validator=_assert_face_width, ) _run_worker_case( - label="面高(当前面)", + label="V向尺寸(局部重建)", operation="resize_face_size_local", args=[0, 25.0, "height"], validator=_assert_face_height, ) _run_worker_case( - label="面宽(整体)", + label="U向尺寸(缩放特征)", operation="resize_face_size_owning_scale", args=[0, 25.0, "width"], validator=lambda label, model: _assert_face_width(label, model, 25.0), ) _run_worker_case( - label="面高(整体)", + label="V向尺寸(缩放特征)", operation="resize_face_size_owning_scale", args=[0, 25.0, "height"], validator=lambda label, model: _assert_face_height(label, model, 25.0), ) _run_worker_case( - label="中心(当前面)", + label="中心(局部重建)", operation="move_face_center_local", args=[0, [15.0, 5.0, 0.0]], validator=_assert_face_center, @@ -430,7 +430,7 @@ def main() -> int: if str(shell_plan.get("risk")) != "high": raise SystemExit(f"shell thickness isolation case should be high risk, got {shell_plan}") _run_worker_case( - label="薄壁厚度(当前面)", + label="壳体厚度(拉伸/切除)", operation="resize_shell_thickness", args=[shell_face_id, 4.0], validator=_assert_shell_thickness, @@ -440,7 +440,7 @@ def main() -> int: if str(shell_owning_plan.get("risk")) != "high": raise SystemExit(f"shell thickness owning isolation case should be high risk, got {shell_owning_plan}") _run_worker_case( - label="薄壁厚度(整体)", + label="壳体厚度(缩放特征)", operation="resize_shell_thickness_owning_scale", args=[shell_face_id, 4.0], validator=_assert_shell_thickness, @@ -534,16 +534,16 @@ def main() -> int: delattr(sys, "frozen") for title in ( - "面宽(当前面)", - "面高(当前面)", - "面宽(整体)", - "面高(整体)", - "薄壁厚度(整体)缩放所属对象", + "U向尺寸(局部重建)", + "V向尺寸(局部重建)", + "U向尺寸(缩放特征)", + "V向尺寸(缩放特征)", + "壳体厚度(缩放特征)缩放所属对象", ): if not probe._quick_edit_title_supports_isolation(title): raise SystemExit(f"{title}: quick edit title should support isolated execution") context = { - "operation_name": "面宽(当前面)", + "operation_name": "U向尺寸(局部重建)", "target": "Face 0", "parameters": {"part_id": 1, "face_id": 0}, "target_kind": "face", @@ -596,7 +596,7 @@ def main() -> int: owning_result = owning_probe._run_isolated_edit_job( context={ **context, - "operation_name": "面宽(整体)", + "operation_name": "U向尺寸(缩放特征)", }, isolation=owning_isolation, snapshot=owning_probe.model.snapshot(), @@ -616,7 +616,7 @@ def main() -> int: 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) + _assert_face_width("U向尺寸(整体窗口任务)", owning_after_model, 25.0) if not _logical_region_has_width(owning_after_model, 0, 25.0): raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width") print("isolated owning window job ok") diff --git a/scripts/verify_large_stepped_cap_push_pull.py b/scripts/verify_large_stepped_cap_push_pull.py index ec59d16..3642ea2 100644 --- a/scripts/verify_large_stepped_cap_push_pull.py +++ b/scripts/verify_large_stepped_cap_push_pull.py @@ -19,6 +19,11 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.model import StepModel +from step_editor.window_actions import WindowActionMixin + + +class _WindowActionProbe(WindowActionMixin): + pass def _wire_count(face) -> int: @@ -240,17 +245,54 @@ def main() -> int: 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": + 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-boundary cap shallow inward push/pull should be allowed by boundary shell rebuild: " 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: + if multi_inward_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild": raise SystemExit( - f"multi-boundary cap inward blocker should explain topology depth and slow Boolean risk: " + f"multi-boundary cap shallow inward should use boundary shell rebuild: {multi_inward_plan}" + ) + multi_inward_message = str(multi_inward_plan.get("message") or "") + if "向内收缩" not in multi_inward_message and "重建侧壁" not in multi_inward_message: + raise SystemExit( + f"multi-boundary cap shallow inward message should explain boundary shell semantics: " f"{multi_inward_plan}" ) + multi_inward_result_model = StepModel.load(path) + started = time.perf_counter() + multi_inward_result = multi_inward_result_model.push_pull_face(multi_face_id, -1.0) + multi_inward_result_elapsed = time.perf_counter() - started + if multi_inward_result_elapsed > 15.0: + raise SystemExit( + f"multi-boundary cap shallow inward push/pull took too long: " + f"{multi_inward_result_elapsed:.3f}s; result={multi_inward_result}" + ) + if "boundary-shell rebuild" not in multi_inward_result or "actual=56.5" not in multi_inward_result: + raise SystemExit( + f"multi-boundary cap shallow inward should move to 56.5 by boundary shell rebuild: {multi_inward_result}" + ) + if multi_inward_result_model.stats().solids != 1: + raise SystemExit(f"multi-boundary cap shallow inward should keep one Solid: {multi_inward_result_model.stats()}") + inward_retained_ids = multi_inward_result_model.face_ids_for_logical_id(multi_logical_id) + if len(inward_retained_ids) != 1: + raise SystemExit(f"multi-boundary cap shallow inward logical Face should follow the moved cap: {inward_retained_ids}") + inward_info = multi_inward_result_model.quick_face_info(inward_retained_ids[0]) + if int(inward_info.get("inner_boundary_wires", 0) or 0) < 5: + raise SystemExit(f"multi-boundary cap shallow inward should keep inner wires: {inward_info}") + + multi_deep_inward_plan = multi_model.push_pull_plan(multi_face_id, -77.5) + if multi_deep_inward_plan.get("status") != "blocked": + raise SystemExit( + f"multi-boundary cap deep inward push/pull should still be blocked: {multi_deep_inward_plan}" + ) + multi_deep_inward_message = str(multi_deep_inward_plan.get("message") or "") + if "材料厚度" not in multi_deep_inward_message and "二级关系" not in multi_deep_inward_message: + raise SystemExit( + f"multi-boundary cap deep inward blocker should explain cut-through or topology depth: " + f"{multi_deep_inward_plan}" + ) started = time.perf_counter() multi_result = multi_model.push_pull_face(multi_face_id, 34.5) @@ -351,6 +393,42 @@ def main() -> int: raise SystemExit( f"large multi-boundary cap isolated worker used the wrong path: {multi_isolated_message}" ) + isolated_output_model = StepModel.load(output_path) + probe = _WindowActionProbe() + direction = multi_plan.get("outward_direction") or multi_plan.get("plane_direction") + context = { + "operation_name": "拉伸/切除平面", + "target_kind": "face", + "target_id": multi_face_id, + "target_logical_id": multi_face_id, + "parameters": { + "part_id": multi_plan.get("part_id"), + "solid_id": multi_plan.get("solid_id"), + "surface": "plane", + "outward_direction": direction, + "current_plane_position": multi_plan.get("current_plane_position"), + "target_plane_position": multi_plan.get("target_plane_position"), + "bbox_diagonal": multi_plan.get("bbox_diagonal"), + "resize_strategy": multi_plan.get("resize_strategy"), + }, + } + blocker = probe._face_target_integrity_blocker(isolated_output_model, context) + if blocker: + raise SystemExit(f"window-layer isolated target check should accept the moved cap: {blocker}") + probe._preserve_isolated_face_logical_id(isolated_output_model, context, multi_isolated_message) + retained_after_isolation = isolated_output_model.face_ids_for_logical_id(multi_face_id) + if not retained_after_isolation: + raise SystemExit("window-layer isolated logical Face retention lost the moved cap") + if not probe._face_target_plane_position_matches( + isolated_output_model, + retained_after_isolation, + float(multi_plan["target_plane_position"]), + direction, + float(multi_plan["bbox_diagonal"]), + ): + raise SystemExit( + f"window-layer isolated logical Face retention points at the wrong cap: {retained_after_isolation}" + ) print( "large stepped cap push/pull ok: " diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index 3d3a5da..53c42c9 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -9,7 +9,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.window_state import WindowStateMixin -from step_editor.ui_helpers import _format_value +from step_editor.ui_helpers import INFO_LABELS, _format_value class _PropertySpecProbe(WindowStateMixin): @@ -181,7 +181,20 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[ def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]: - legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离") + legacy_terms = ( + "面内尺寸 1/2", + "面内尺寸 1", + "面内尺寸 2", + "面位置", + "偏移距离", + "面偏移", + "面宽", + "面高", + "推拉当前面", + "只改当前面", + "调整整个特征", + "移动整个特征", + ) hits: list[str] = [] if isinstance(value, dict): for key, child in value.items(): @@ -229,6 +242,13 @@ def _assert_no_legacy_face_source_terms() -> None: re.compile(r"面内尺寸 2"), re.compile(r"(? None: raise SystemExit("Face plane-offset push/pull scope should remain available for a holed planar Face") semantics = _spec(specs, "face_edit_semantics") - if "不能只改当前面" not in str(semantics.get("current_text") or ""): + if "不能局部重建" not in str(semantics.get("current_text") or ""): raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}") if "has inner boundary" not in str(semantics.get("disabled_tip") or ""): raise SystemExit(f"Face edit semantics tip should include the blocker: {semantics}") def main() -> int: + if INFO_LABELS.get("face_id") != "当前拓扑 Face ID": + raise SystemExit("raw face_id label should make clear that it is the current topological Face ID") + for key in ("selection_title", "selection_display_id", "selection_topological_face_id"): + if key not in INFO_LABELS: + raise SystemExit(f"{key} should have a user-facing label") + 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}") @@ -353,9 +379,9 @@ def main() -> int: }, "plane Face", ) - _assert_label(plane_specs, "local_face_width", "面宽") - _assert_label(plane_specs, "local_face_height", "面高") - _assert_label(plane_specs, "face_target_normal_position", "面偏移") + _assert_label(plane_specs, "local_face_width", "U向尺寸") + _assert_label(plane_specs, "local_face_height", "V向尺寸") + _assert_label(plane_specs, "face_target_normal_position", "偏移曲面") _assert_actionable_rows_first( plane_info, ( @@ -378,9 +404,18 @@ def main() -> int: for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"): if fragment not in topology_text: raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}") - if "face_target_normal_position" not in plane_feature_keys: + expected_plane_feature_keys = { + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + } + missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys + if missing_plane_feature_keys: raise SystemExit( - "plane feature mode should expose the current Face offset parameter; " + "plain plane feature mode should expose the same first-level editable Face parameters; " + f"missing {sorted(missing_plane_feature_keys)}, " f"got {sorted(plane_feature_keys)}" ) if "no_editable_feature_dimensions" in plane_feature_keys: @@ -470,6 +505,12 @@ def main() -> int: mode, ("5%", "5 倍", "会被阻止"), ) + shell_local = _scope_mode(shell_specs, "shell_thickness_estimate", "local") + if shell_local.get("label") != "拉伸/切除": + raise SystemExit(f"shell thickness local scope should expose an edit semantic, got {shell_local}") + shell_owning = _scope_mode(shell_specs, "shell_thickness_estimate", "owning") + if shell_owning.get("label") != "缩放特征": + raise SystemExit(f"shell thickness owning scope should expose an edit semantic, got {shell_owning}") cylinder_keys = _spec_keys( { @@ -549,9 +590,62 @@ def main() -> int: 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") != "调整整个特征": + if generic_height.get("scope_text") != "缩放特征": raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}") + split_full_cylinder_specs = _specs( + { + "surface": "cylinder", + "feature_guess": "", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 3.141592653589793, + "same_domain_angular_span": 6.283185307179586, + "is_full_cylinder": True, + "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), + } + ) + split_full_height_local = _scope_mode(split_full_cylinder_specs, "cylinder_height", "local") + if not bool(split_full_height_local.get("enabled")): + raise SystemExit( + "split same-domain full cylinder should keep local height push/pull enabled: " + f"{split_full_height_local}" + ) + + split_full_hole_keys = _spec_keys( + { + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "diameter": 6.0, + "radius": 3.0, + "angular_span": 3.141592653589793, + "same_domain_angular_span": 6.283185307179586, + "is_full_cylinder": True, + "height_estimate": 11.0, + "same_domain_height_estimate": 11.0, + "area": 188.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), + "slot_chord_width_estimate": 6.0, + "slot_sagitta_depth_estimate": 3.0, + "slot_arc_length_estimate": 9.42477796076938, + "feature_bottom_face_ids": (), + } + ) + _assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole") + if "slot_chord_width_estimate" in split_full_hole_keys: + raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}") + slot_keys = _spec_keys( { "surface": "cylinder", diff --git a/scripts/verify_selection_identity_ui.py b/scripts/verify_selection_identity_ui.py new file mode 100644 index 0000000..39eec5f --- /dev/null +++ b/scripts/verify_selection_identity_ui.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import sys +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 step_editor.info_panel import InfoPanelMixin +from step_editor.ui_helpers import INFO_LABELS +from step_editor.window_state import WindowStateMixin + + +class _SelectionProbe(InfoPanelMixin, WindowStateMixin): + pass + + +class _LogicalFaceModel: + def __init__(self, logical_id: int) -> None: + self.logical_id = logical_id + + def face_region_logical_id(self, _face_id: int) -> int: + return self.logical_id + + def face_logical_id(self, _face_id: int) -> int: + return self.logical_id + + +class _FeatureContextModel(_LogicalFaceModel): + faces = [object()] * 800 + face_part_ids = [1] * 800 + face_solid_ids = [0] * 800 + + def quick_face_info(self, face_id: int) -> dict[str, object]: + return { + "kind": "face", + "face_id": face_id, + "topological_face_id": face_id, + "logical_face_id": self.logical_id, + "surface": "plane", + "part_id": 1, + "solid_id": 0, + "area": 100.0, + "area_center": (5.0, 5.0, 0.0), + "local_face_width": 10.0, + "local_face_height": 10.0, + "feature_source_face_id": face_id, + "feature_highlight_face_ids": (face_id,), + } + + def cached_feature_info(self, _face_id: int) -> dict[str, object] | None: + return None + + def face_first_level_topology(self, face_id: int) -> dict[str, object]: + return { + "topology_relation_depth": 1, + "topology_relation_model": "STEP/B-Rep shared-edge first-level", + "topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces", + "topology_relation_boundary": "shared-edge", + "topology_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", + "same_domain_face_ids": (face_id,), + "same_domain_face_count": 1, + "same_domain_region_kind": "single-face", + "first_level_boundary_edge_ids": (1, 2, 3, 4), + "first_level_boundary_edge_count": 4, + "first_level_boundary_vertex_count": 4, + "first_level_adjacent_face_ids": (10, 11, 12, 13), + "first_level_adjacent_face_count": 4, + "first_level_face_ids": (face_id, 10, 11, 12, 13), + "first_level_face_count": 5, + "first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。", + } + + def face_first_level_facts(self, face_id: int, scope: str = "auto") -> dict[str, object]: + return { + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_status": "ready", + "first_level_fact_relation_depth": 1, + "first_level_fact_scope": scope, + "first_level_fact_relation_boundary": "shared-edge", + "first_level_fact_subject_face_ids": (face_id,), + "first_level_fact_subject_face_count": 1, + "first_level_fact_boundary_edge_count": 4, + "first_level_fact_boundary_vertex_count": 4, + "first_level_fact_adjacent_face_ids": (10, 11, 12, 13), + "first_level_fact_adjacent_face_count": 4, + "first_level_fact_included_face_count": 5, + "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "first_level_fact_summary": "一级事实=当前 Face、边界和共享边相邻 Face。", + } + + def _recognition_summary_fields(self, _info: dict[str, object]) -> dict[str, object]: + return {"recognition_summary": "识别摘要包含一级事实。"} + + +class _FeatureRefreshProbe(WindowStateMixin): + def __init__(self) -> None: + self.model = _FeatureContextModel(594) + self.selected_kind = "feature" + self.selected_face_id = 637 + self.selected_edge_id = None + self.selected_part_id = 1 + self.selected_solid_id = 0 + self.selected_pick_position = (1.0, 2.0, 3.0) + self.feature_detection_level = "current-only" + self.set_info_payload: dict[str, object] | None = None + self.highlighted_face_ids: tuple[int, ...] = () + self.synced_id: tuple[str, int] | None = None + + def set_info(self, info: dict[str, object]) -> None: + self.set_info_payload = dict(info) + self.current_info_values = dict(info) + + def _highlight_faces(self, face_ids: list[int] | tuple[int, ...] | None = None, **_kwargs) -> None: + self.highlighted_face_ids = tuple(int(item) for item in (face_ids or ())) + + def _update_selected_object_title(self) -> None: + return + + def _sync_id_picker(self, kind: str, target_id: int) -> None: + self.synced_id = (kind, int(target_id)) + + +def _probe(kind: str, selected_face_id: int, info: dict[str, object]) -> _SelectionProbe: + probe = object.__new__(_SelectionProbe) + probe.model = None + probe.selected_kind = kind + probe.selected_part_id = None + probe.selected_solid_id = None + probe.selected_face_id = selected_face_id + probe.selected_edge_id = None + probe.current_info_values = dict(info) + return probe + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> int: + for key in ("selection_title", "selection_display_id", "selection_topological_face_id"): + _assert(key in INFO_LABELS, f"{key} should have a readable UI label") + _assert(INFO_LABELS.get("face_id") == "当前拓扑 Face ID", "raw face_id should be labelled as topological") + + info = { + "feature_type": "可拉伸/切除平面候选", + "selection_display_id": 594, + "selection_topological_face_id": 637, + "face_region_logical_id": 594, + "logical_face_id": 594, + } + + feature_probe = _probe("feature", 637, info) + _assert( + feature_probe._selected_id_text() == "face 594", + f"feature copy ID should prefer logical Face ID, got {feature_probe._selected_id_text()!r}", + ) + feature_title = feature_probe._selected_object_title_suffix() + _assert( + "Face 594" in feature_title and "拓扑 637" in feature_title, + f"feature title should show logical and topological IDs: {feature_title}", + ) + + face_probe = _probe("face", 637, info) + _assert( + face_probe._selected_id_text() == "face 594", + f"Face copy ID should prefer logical Face ID, got {face_probe._selected_id_text()!r}", + ) + face_title = face_probe._selected_object_title_suffix() + _assert( + "Face 594" in face_title and "拓扑 637" in face_title, + f"Face title should show logical and topological IDs: {face_title}", + ) + + fallback_probe = _probe("feature", 637, {"face_region_logical_id": 594}) + _assert(fallback_probe._selected_id_text() == "face 594", "logical Face ID fallback should be copied") + + selection_only_probe = _probe("feature", 637, {"selection_display_id": 594}) + _assert( + selection_only_probe._selected_id_text() == "face 594", + "selection_display_id should be enough for copied Face ID", + ) + selection_only_title = selection_only_probe._selected_object_title_suffix() + _assert( + "Face 594" in selection_only_title and "拓扑 637" in selection_only_title, + f"selection_display_id should be enough for feature title: {selection_only_title}", + ) + + face_selection_only_probe = _probe("face", 637, {"selection_display_id": 594}) + face_selection_only_title = face_selection_only_probe._selected_object_title_suffix() + _assert( + "Face 594" in face_selection_only_title and "拓扑 637" in face_selection_only_title, + f"selection_display_id should be enough for Face title: {face_selection_only_title}", + ) + + raw_probe = _probe("feature", 637, {}) + _assert(raw_probe._selected_id_text() == "face 637", "topological Face ID fallback should still work") + + helper_probe = _probe("feature", 637, {}) + helper_probe.model = _LogicalFaceModel(594) + fields = helper_probe._selection_identity_fields(637, "特征来源 Face") + _assert(fields.get("selection_display_id") == 594, f"helper should expose logical Face ID: {fields}") + _assert( + fields.get("selection_topological_face_id") == 637, + f"helper should keep current topological Face ID: {fields}", + ) + _assert( + fields.get("selection_title") == "特征来源 Face 594(当前拓扑 Face 637)", + f"helper title should explain both IDs: {fields}", + ) + + refresh_probe = _FeatureRefreshProbe() + context = refresh_probe._feature_context_info(637) + _assert(context.get("selection_display_id") == 594, f"feature context should include logical ID: {context}") + _assert( + context.get("selection_topological_face_id") == 637, + f"feature context should include topological ID: {context}", + ) + refresh_probe._on_feature_detection_level_changed() + payload = refresh_probe.set_info_payload or {} + _assert(payload.get("selection_display_id") == 594, f"detection refresh should call set_info with identity: {payload}") + _assert(payload.get("pick_position") == (1.0, 2.0, 3.0), f"detection refresh should preserve pick: {payload}") + _assert(refresh_probe.highlighted_face_ids == (637,), f"detection refresh should rehighlight source Face") + + source_probe = _FeatureRefreshProbe() + source_probe.selected_face_id = 100 + source_probe.selected_pick_position = (9.0, 9.0, 9.0) + source_probe._activate_property_source_feature( + { + "source_face_id": 637, + "source_feature_info": { + "feature_type": "可拉伸/切除平面候选", + "part_id": 1, + "solid_id": 0, + "feature_highlight_face_ids": (637,), + }, + } + ) + source_payload = source_probe.set_info_payload or {} + _assert(source_probe.synced_id == ("Feature", 594), f"associated jump should sync logical ID: {source_probe.synced_id}") + _assert(source_payload.get("selection_display_id") == 594, f"associated jump should keep logical ID: {source_payload}") + _assert("pick_position" not in source_payload, f"associated jump should not reuse stale pick position: {source_payload}") + + print("selection identity UI ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step_editor/app.py b/step_editor/app.py index a43af79..3102670 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -151,6 +151,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edge_actor = None self.highlight_actor = None self.edge_highlight_actor = None + self.highlight_signature: tuple[object, ...] | None = None self.hover_face_actor = None self.hover_edge_actor = None self.hover_signature: tuple[str, int] | None = None @@ -238,6 +239,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.last_id_kind = "Feature" self.feature_detection_level = "current-only" self.property_editor_updating = False + self._control_state_cache: dict[int, tuple[bool, str]] = {} + self._selected_action_info_cache_key: tuple[object, ...] | None = None + self._selected_action_info_cache_value: dict[str, object] | None = None self.property_editor_specs: list[dict[str, object]] = [] self.property_table_expanded = False self.property_table_collapsed_rows = 6 @@ -835,7 +839,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.show_internal_edges_checkbox = QCheckBox("显示同域内部边") help_tip( self.show_internal_edges_checkbox, - "显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。", + "显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔拉伸/切除后常见的视觉接缝线。", ) self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled) view_layout.addWidget(self.show_internal_edges_checkbox) @@ -981,9 +985,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout = QGridLayout(edit_box) edit_layout.addWidget(QLabel("移动距离"), 0, 0) self.offset_input = QLineEdit("5.0") - help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") + help_tip(self.offset_input, "平面拉伸/切除距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") edit_layout.addWidget(self.offset_input, 0, 1) - self.push_button = QPushButton("推拉平面") + self.push_button = QPushButton("拉伸/切除平面") help_tip(self.push_button, "移动当前选中的平面区域:正数加料,负数切削。会先显示半透明预览,再后台执行。") self.push_button.clicked.connect(self.push_pull_face) edit_layout.addWidget(self.push_button, 1, 0, 1, 2) @@ -1081,10 +1085,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.face_area_input, "目标面面积。当前选中对象表会使用这个隐藏输入执行所属特征或 Solid 的均匀缩放。") self.face_width_input = QLineEdit("", edit_box) self.face_width_input.setVisible(False) - help_tip(self.face_width_input, "目标面宽。当前选中对象表会使用这个隐藏输入修改 Face 平面内第一个方向的尺寸。") + help_tip(self.face_width_input, "目标U向尺寸。当前选中对象表会使用这个隐藏输入修改 Face 平面内第一个方向的尺寸。") self.face_height_input = QLineEdit("", edit_box) self.face_height_input.setVisible(False) - help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸。") + help_tip(self.face_height_input, "目标V向尺寸。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸。") self.resize_boss_button = QPushButton("调整圆柱凸台直径") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") self.resize_boss_button.clicked.connect(self.resize_boss) @@ -1214,14 +1218,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length) edit_layout.addWidget(self.resize_edge_length_button, 19, 0, 1, 2) - edit_layout.addWidget(QLabel("薄壁厚度"), 20, 0) + edit_layout.addWidget(QLabel("壳体厚度"), 20, 0) self.shell_thickness_input = QLineEdit("") help_tip( self.shell_thickness_input, - "薄壁/壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。", + "壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。", ) edit_layout.addWidget(self.shell_thickness_input, 20, 1) - self.resize_shell_thickness_button = QPushButton("调整薄壁厚度") + self.resize_shell_thickness_button = QPushButton("调整壳体厚度") help_tip( self.resize_shell_thickness_button, "移动当前平面区域来达到目标厚度。当前版本基于相对平面估算,执行前会预览并可回滚。", diff --git a/step_editor/constants.py b/step_editor/constants.py index a1b633a..88c0ccb 100644 --- a/step_editor/constants.py +++ b/step_editor/constants.py @@ -42,6 +42,15 @@ SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__" FACE_SELECTION_FEATURE_INFO_SURFACES = {"plane", "cylinder", "cone", "sphere", "torus"} +FREEFORM_FACE_SURFACES = { + "bezier surface", + "b-spline surface", + "surface of revolution", + "surface of extrusion", + "offset surface", + "other surface", +} + CURVE_TYPES = { GeomAbs_Line: "line", GeomAbs_Circle: "circle", diff --git a/step_editor/features.py b/step_editor/features.py index 9fd8d95..290e669 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -536,7 +536,7 @@ class FeatureMixin: "confidence": item["confidence"], "note": ( "这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台高度。" - "执行时会推拉识别到的端盖 Face。" + "执行时会拉伸/切除识别到的端盖 Face。" ), } ) @@ -681,11 +681,11 @@ class FeatureMixin: confidence = "pending" risk = "medium" status = "caution" - note = "快速扫描:推拉方向会在选中Face或执行编辑前再详细判断。" + note = "快速扫描:拉伸/切除方向会在选中Face或执行编辑前再详细判断。" candidates.append( { "operation_key": "push_pull_plane", - "operation": "推拉平面", + "operation": "拉伸/切除平面", "target_kind": "face", "target_id": face_id, "face_id": face_id, @@ -710,7 +710,7 @@ class FeatureMixin: candidates.append( { "operation_key": "resize_shell_thickness", - "operation": "调整薄壁厚度", + "operation": "调整壳体厚度", "target_kind": "face", "target_id": face_id, "face_id": face_id, @@ -725,7 +725,7 @@ class FeatureMixin: "confidence": shell_confidence, "note": ( "快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度," - "执行时会移动当前平面区域来改变局部薄壁/壳体厚度。" + "执行时会移动当前平面区域来改变局部壳体厚度。" ), } ) @@ -2591,7 +2591,7 @@ class FeatureMixin: if not bottom_face_ids: return { "context_status": "blocked", - "context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面Face ID。", + "context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。", } cyl = surf.Cylinder() diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py index 244ae68..438b875 100644 --- a/step_editor/info_panel.py +++ b/step_editor/info_panel.py @@ -22,6 +22,8 @@ from .workers import EditWorker, ScanWorker class InfoPanelMixin: def set_info(self, info: dict[str, object]) -> None: + if hasattr(self, "_invalidate_selected_action_info_cache"): + self._invalidate_selected_action_info_cache() self.current_info_values = dict(info) self.current_info_text = _info_to_text(info) self.info_text.setPlainText(self.current_info_text) @@ -44,6 +46,8 @@ class InfoPanelMixin: self._update_action_states() def set_plain_info(self, text: str) -> None: + if hasattr(self, "_invalidate_selected_action_info_cache"): + self._invalidate_selected_action_info_cache() self.current_info_values = {} self.current_info_text = text self.info_tree.clear() @@ -262,6 +266,14 @@ class InfoPanelMixin: if self.selected_kind == "solid" and self.selected_solid_id is not None: return f"solid {self.selected_solid_id}" if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None: + for key in ("selection_display_id", "face_region_logical_id", "logical_face_id"): + value = self.current_info_values.get(key) + if value in {None, ""}: + continue + try: + return f"face {int(value)}" + except (TypeError, ValueError): + continue return f"face {self.selected_face_id}" if self.selected_kind == "edge" and self.selected_edge_id is not None: return f"edge {self.selected_edge_id}" diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index 60e67a0..bac49c6 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -8,6 +8,19 @@ from pathlib import Path from .model import StepModel +def _optional_int(value: object) -> int | None: + if value is None or value == "": + return None + return int(value) + + +def _point3(value: object, operation: str) -> tuple[float, float, float]: + point = list(value) if isinstance(value, (list, tuple)) else [] + if len(point) != 3: + raise ValueError(f"{operation} requires a 3D target center.") + return (float(point[0]), float(point[1]), float(point[2])) + + def _execute(model: StepModel, operation: str, args: list[object]) -> str: if operation == "push_pull_face": return model.push_pull_face(int(args[0]), float(args[1])) @@ -47,6 +60,73 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str: return model.resize_spherical_radius(int(args[0]), float(args[1])) if operation == "resize_torus_radius": return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2])) + if operation == "resize_cylindrical_hole": + return model.resize_cylindrical_hole(int(args[0]), float(args[1])) + if operation == "resize_cylindrical_owning_scale": + return model.resize_cylindrical_owning_scale(int(args[0]), float(args[1])) + if operation == "move_cylindrical_hole_axis": + return model.move_cylindrical_hole_axis(int(args[0]), _point3(args[1], operation)) + if operation == "suppress_cylindrical_hole": + return model.suppress_cylindrical_hole(int(args[0])) + if operation == "resize_cylindrical_depth": + bottom_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_depth(int(args[0]), float(args[1]), bottom_face_id=bottom_face_id) + if operation == "resize_cylindrical_depth_owning_scale": + bottom_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_depth_owning_scale(int(args[0]), float(args[1]), bottom_face_id=bottom_face_id) + if operation == "move_cylindrical_slot_axis": + return model.move_cylindrical_slot_axis(int(args[0]), _point3(args[1], operation)) + if operation == "resize_cylindrical_slot_width": + pair_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_slot_width(int(args[0]), float(args[1]), pair_face_id=pair_face_id) + if operation == "resize_cylindrical_slot_depth": + pair_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_slot_depth(int(args[0]), float(args[1]), pair_face_id=pair_face_id) + if operation == "resize_cylindrical_slot_arc_length": + pair_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_slot_arc_length(int(args[0]), float(args[1]), pair_face_id=pair_face_id) + if operation == "resize_cylindrical_slot_angular_span": + return model.resize_cylindrical_slot_angular_span(int(args[0]), float(args[1])) + if operation == "resize_cylindrical_slot_total_length": + pair_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_slot_total_length(int(args[0]), float(args[1]), pair_face_id=pair_face_id) + if operation == "resize_cylindrical_slot_center_distance": + pair_face_id = _optional_int(args[2]) if len(args) > 2 else None + return model.resize_cylindrical_slot_center_distance( + int(args[0]), + float(args[1]), + pair_face_id=pair_face_id, + ) + if operation == "resize_general_edge_length": + anchor_mode = str(args[2]) if len(args) > 2 else "auto" + strategy_mode = str(args[3]) if len(args) > 3 else "auto" + return model.resize_general_edge_length( + int(args[0]), + float(args[1]), + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) + if operation == "move_edge_endpoint": + return model.move_edge_endpoint(int(args[0]), str(args[1]), _point3(args[2], operation)) + if operation == "move_edge_center": + return model.move_edge_center(int(args[0]), _point3(args[1], operation)) + if operation == "move_circular_edge_axis_center": + return model.move_circular_edge_axis_center(int(args[0]), _point3(args[1], operation)) + if operation == "resize_ellipse_edge_axis_radius": + axis_kind = str(args[2]) if len(args) > 2 else "major" + return model.resize_ellipse_edge_axis_radius(int(args[0]), float(args[1]), axis_kind=axis_kind) + if operation == "resize_existing_fillet": + return model.resize_existing_fillet(int(args[0]), float(args[1])) + if operation == "fillet_edge": + return model.fillet_edge(int(args[0]), float(args[1])) + if operation == "chamfer_edge": + return model.chamfer_edge(int(args[0]), float(args[1])) + if operation == "chamfer_edge_asymmetric": + reference_face_id = _optional_int(args[3]) if len(args) > 3 else None + return model.chamfer_edge_asymmetric(int(args[0]), float(args[1]), float(args[2]), reference_face_id) + if operation == "chamfer_edge_distance_angle": + reference_face_id = _optional_int(args[3]) if len(args) > 3 else None + return model.chamfer_edge_distance_angle(int(args[0]), float(args[1]), float(args[2]), reference_face_id) raise ValueError(f"Unsupported isolated edit operation: {operation}") diff --git a/step_editor/model.py b/step_editor/model.py index 9f94881..000a8d7 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -74,7 +74,7 @@ from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge -from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .constants import CURVE_TYPES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .export import ExportMixin from .features import FeatureMixin from .operations import OperationMixin @@ -352,19 +352,20 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["normal"] = _dir_tuple(direction) info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["push_pull_confidence"] = "unchecked" - info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。" - cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf) - if cap_direction is not None: - info.update(cap_direction) + info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行拉伸/切除时会重新计算。" + # Keep ordinary selection cheap. Cylindrical-cap direction can + # scan adjacent surfaces and cost hundreds of ms on large STEP + # files; the full push/pull plan recomputes it when the user + # actually edits. info["push_pull_status"] = "candidate" - info["feature_type"] = "可推拉平面候选" + info["feature_type"] = "可拉伸/切除平面候选" info["feature_source_face_id"] = face_id info["feature_highlight_face_ids"] = (face_id,) - info["feature_edit_actions"] = "推拉平面" + info["feature_edit_actions"] = "拉伸/切除平面" 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 有内孔/内边界;请优先使用推拉当前面、孔或槽的专门修改入口。" + info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用拉伸/切除、孔或槽的专门修改入口。" else: info["local_face_deform_ready"] = True info["local_face_deform_face_count"] = 1 @@ -430,6 +431,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["minor_radius"] = torus.MinorRadius() info["feature_torus_major_radius"] = torus.MajorRadius() info["feature_torus_minor_radius"] = torus.MinorRadius() + elif surface_label in FREEFORM_FACE_SURFACES: + info.update(self._freeform_face_limit_fields(surface_label)) info.update(self._recognition_summary_fields(info)) self._quick_face_info_cache[face_id] = dict(info) @@ -528,10 +531,33 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["axis"] = _dir_tuple(torus.Axis().Direction()) info["major_radius"] = torus.MajorRadius() info["minor_radius"] = torus.MinorRadius() + elif str(info.get("surface") or "") in FREEFORM_FACE_SURFACES: + info.update(self._freeform_face_limit_fields(str(info.get("surface") or ""))) info.update(self._recognition_summary_fields(info)) self._face_info_cache[face_id] = dict(info) return dict(info) + def _freeform_face_limit_fields(self, surface: str) -> dict[str, object]: + blocker = ( + "当前 Face 是自由曲面/非基础解析曲面;STEP 里通常没有可直接修改的历史参数," + "当前一级阶段不开放面积、尺寸、半径或偏移类参数化修改。" + "请先保留为只读诊断,后续需要专门的曲面控制点或曲面替换语义。" + ) + return { + "feature_type": "自由曲面 Face(暂不支持参数化编辑)", + "feature_edit_actions": "只读诊断;不开放参数化修改", + "freeform_face_status": "blocked", + "freeform_face_risk": "blocked", + "freeform_face_blockers": blocker, + "local_face_deform_ready": False, + "local_face_deform_face_count": 1, + "local_face_deform_blocker": blocker, + "recognition_confidence": "low", + "recognition_risk": "blocked", + "recognition_blockers": blocker, + "note": f"{surface} 当前按自由曲面处理,不会伪装成平面、圆柱、圆锥、球面或环面参数。", + } + def face_surface_kind(self, face_id: int) -> str: if face_id < 0 or face_id >= len(self.faces): return "" @@ -588,12 +614,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd 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" + if surface in FREEFORM_FACE_SURFACES: + confidence = "low" 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", "薄壁厚度"), + ("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", "盲孔/盲槽深度"), @@ -611,8 +639,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd has_planar_push_pull_candidate = ( surface == "plane" and ( - "推拉" in feature_type_text - or "推拉" in feature_actions_text + "拉伸/切除" in feature_type_text + or "拉伸/切除" in feature_actions_text or str(info.get("push_pull_status") or "").strip() == "candidate" ) ) @@ -632,10 +660,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd for key in ( "risk", "first_level_topology_risk", + "freeform_face_risk", ): value = str(info.get(key) or "").strip() if risk_rank.get(value, -1) > risk_rank.get(risk, -1): risk = value + if surface in FREEFORM_FACE_SURFACES: + risk = "blocked" 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: @@ -696,11 +727,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd bottom_count = len(tuple(info.get("feature_bottom_face_ids") or ())) except TypeError: bottom_count = 0 - add("bottom_faces", f"疑似底面Face={bottom_count}") + 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", "找到相对平面/薄壁候选") + add("shell_opposite_face", "找到相对平面/壳体候选") ready_actions: list[str] = [] limited_actions: list[str] = [] @@ -724,13 +755,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add_action(limited_actions, label) if has_planar_push_pull_candidate: - add_action(ready_actions, "平面推拉") + add_action(ready_actions, "平面拉伸/切除") if has_local_face_deform: - add_action(ready_actions, "当前面面积/面宽/面高/中心/偏移") + add_action(ready_actions, "当前面面积/U向尺寸/V向尺寸/中心/偏移") elif str(info.get("local_face_deform_blocker") or "").strip(): - add_action(limited_actions, "当前面局部尺寸/中心/偏移") + add_action(limited_actions, "局部重建尺寸/中心/偏移") if has_shell_candidate: - add_action(ready_actions, "薄壁厚度") + add_action(ready_actions, "壳体厚度") if has_slot_candidate: add_action(ready_actions, "槽/半孔宽度/深度/弧长") if surface == "cone" and feature_type_text: @@ -1079,25 +1110,25 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info) prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info) if len(coplanar_face_ids) > 1: - scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。" + scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,拉伸/切除时会作为同一片平面区域处理。" else: - scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。" - edit_actions = "推拉平面" + scope_note = "当前 face 没有检测到可一起拉伸/切除的共面相接/重叠邻居。" + edit_actions = "拉伸/切除平面" if shell_info.get("shell_region_status") == "candidate": - edit_actions += ";调整薄壁/壳体厚度" + edit_actions += ";调整壳体厚度" if prismatic_info.get("prismatic_profile_status") == "candidate": edit_actions = "调整规则矩形轮廓长度/宽度" if prismatic_info.get("prismatic_extrusion_status") == "candidate": edit_actions += ";调整棱柱高度/凹槽深度" else: - edit_actions += ";沿法向推拉" + edit_actions += ";沿法向拉伸/切除" highlight_face_ids = set(coplanar_face_ids) highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids"))) result = dict(info) result.update( { "kind": "feature", - "feature_type": "可推拉平面候选", + "feature_type": "可拉伸/切除平面候选", "feature_source_face_id": face_id, "feature_face_ids": tuple(coplanar_face_ids), "feature_highlight_face_ids": tuple(coplanar_face_ids), @@ -1363,7 +1394,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd except Exception: return { "shell_region_status": "not-detected", - "shell_region_note": "无法读取当前平面,不能估算薄壁/壳体区域。", + "shell_region_note": "无法读取当前平面,不能估算壳体区域。", } if source_surf.GetType() != GeomAbs_Plane: return {} @@ -1382,7 +1413,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if source_interval is None: return { "shell_region_status": "not-detected", - "shell_region_note": "当前平面区域缺少稳定投影范围,不能估算薄壁/壳体厚度。", + "shell_region_note": "当前平面区域缺少稳定投影范围,不能估算壳体厚度。", } def interval_length(interval: tuple[float, float]) -> float: @@ -1440,7 +1471,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if best is None: return { "shell_region_status": "not-detected", - "shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体/薄壁厚度。", + "shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体厚度。", } thickness = float(best["shell_thickness_estimate"]) @@ -1454,7 +1485,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "shell_opposite_face_id": best["shell_opposite_face_id"], "shell_thickness_estimate": thickness, "shell_overlap_ratio_estimate": overlap_ratio, - "shell_region_note": "相对平面距离明显大于当前面的局部短边,不按薄壁厚度处理。", + "shell_region_note": "相对平面距离明显大于当前面的局部短边,不按壳体厚度处理。", } thin_ratio = thickness / diagonal if overlap_ratio >= 0.55 and thin_ratio <= 0.08: @@ -1471,7 +1502,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "shell_source_face_ids": tuple(valid_region_ids), **best, "shell_note": ( - "通过同一 solid 内投影重叠的相对平面估算薄壁/壳体厚度;" + "通过同一 solid 内投影重叠的相对平面估算壳体厚度;" "这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。" ), } @@ -1501,18 +1532,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd opening_face_ids = end_faces["opening_face_ids"] guess = str(info.get("feature_guess", "cylindrical face")) has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"]) + support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids)) material_toward = str(info.get("material_toward_axis", "") or "") material_away = str(info.get("material_away_axis", "") or "") + complete_cylinder_with_caps = combined_angular_span >= math.tau * 0.92 and has_two_axial_caps if ( guess == "round/fillet candidate" and has_two_axial_caps and material_toward == "inside" and "outside" in material_away + and (complete_cylinder_with_caps or len(support_face_ids_for_round) < 2) ): info = dict(info) info["feature_guess"] = "boss/outer-round candidate" info["confidence"] = "medium" - info["note"] = "partial cylinder has material inside its axis and explicit planar caps at both ends" + info["note"] = ( + "cocylindrical region has material inside its axis and explicit planar caps at both ends" + ) domain_info["feature_guess"] = info["feature_guess"] slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info) @@ -2261,7 +2297,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd { "local_face_deform_ready": False, "local_face_deform_face_count": 1, - "local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做“只改当前面”的局部重建。", + "local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做局部重建。", } ) solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] @@ -2273,7 +2309,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, - "local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放“只改当前面”的局部重建。", + "local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放局部重建。", } ) @@ -2296,7 +2332,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, - "local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放“只改当前面”。", + "local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放局部重建。", } ) wire_info = self._face_boundary_wire_info(face) @@ -2305,7 +2341,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, - "local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或推拉等专门修改方式。", + "local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或拉伸/切除等专门修改方式。", } ) try: diff --git a/step_editor/operations.py b/step_editor/operations.py index b32d8b1..5f35ebd 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -93,6 +93,22 @@ def _angle_degrees_or_none(value: object) -> float | None: return abs(math.degrees(radians)) +def _effective_cylinder_angular_span(info: dict[str, object]) -> float | None: + if bool(info.get("is_full_cylinder")): + return math.tau + values: list[float] = [] + for key in ("same_domain_angular_span", "angular_span"): + value = _float_or_none(info.get(key)) + if value is not None: + values.append(value) + return max(values) if values else None + + +def _is_effectively_full_cylinder(info: dict[str, object]) -> bool: + angular_span = _effective_cylinder_angular_span(info) + return angular_span is not None and angular_span >= math.tau * 0.92 + + def _result_value_error(actual: object, target: object) -> float: if isinstance(actual, tuple) and isinstance(target, tuple): if len(actual) != len(target): @@ -177,7 +193,7 @@ class OperationMixin: fields.update(fact_fields) fields["first_level_edit_semantics"] = ( "当前 Face 阶段只使用一级共享边拓扑:当前同域 Face 区域会作为编辑对象," - "直接相邻 Face 会跟随重建或作为推拉侧壁参与结果校验;二级/三级关系暂不递归传播。" + "直接相邻 Face 会跟随重建或作为拉伸/切除侧壁参与结果校验;二级/三级关系暂不递归传播。" ) return fields @@ -726,19 +742,19 @@ class OperationMixin: } ) else: - message = f"端面推拉路径不可用:{push_plan['message']}" + message = f"端面拉伸/切除路径不可用:{push_plan['message']}" if force_end_face: blockers.append(message) else: warnings.append(f"{message} 将尝试通用仿射缩放。") elif anchor_mode in {"keep-start", "keep-end"}: - message = f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径。" + message = f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面拉伸/切除路径。" if force_end_face: blockers.append(message) else: warnings.append(f"{message} 将尝试按该基准缩放所属对象。") elif force_end_face: - blockers.append("未找到可用于当前Edge的端面推拉路径。") + blockers.append("未找到可用于当前Edge的端面拉伸/切除路径。") elif not blockers and curve == "line" and anchor_mode == "center" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。") @@ -790,7 +806,7 @@ class OperationMixin: status = "caution" if transform_kind == "axis-affine": warnings.append( - "未找到可推拉端面;将沿该Edge的几何方向对所属 " + "未找到可拉伸/切除端面;将沿该Edge的几何方向对所属 " f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) else: @@ -835,7 +851,7 @@ class OperationMixin: if strategy == "local-edge-only-deform": message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。" elif strategy == "move-edge-end-plane-by-push-pull": - message = "可以通过端面推拉调整这条直线Edge长度。" + message = "可以通过端面拉伸/切除调整这条直线Edge长度。" elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。" elif strategy == "scale-owning-shape-from-edge": @@ -1268,7 +1284,7 @@ class OperationMixin: "保持垂直": "move-edge-end-plane-by-push-pull", "保持面垂直": "move-edge-end-plane-by-push-pull", "保持相邻面垂直": "move-edge-end-plane-by-push-pull", - "端面推拉": "move-edge-end-plane-by-push-pull", + "端面拉伸/切除": "move-edge-end-plane-by-push-pull", "整体尺寸变化": "move-edge-end-plane-by-push-pull", "变成长方体": "move-edge-end-plane-by-push-pull", "cylinder": "resize-adjacent-cylinder-from-circular-edge-length", @@ -1314,7 +1330,7 @@ class OperationMixin: end_face = plan.get("end_face_id") face_text = f" Face {end_face}" if end_face not in {None, ""} else "" return ( - f"移动端面:把长度变化转换为端面{face_text}推拉;端面和同一端面区域上的相关边会跟随," + f"移动端面:把长度变化转换为端面{face_text}拉伸/切除;端面和同一端面区域上的相关边会跟随," "相邻平面会尽量保持垂直,正方体这类模型会更像变成长方体。" ) if strategy == "resize-adjacent-cylinder-from-circular-edge-length": @@ -1583,16 +1599,20 @@ class OperationMixin: feature = self.feature_info(face_id) except Exception: feature = {} + face_feature_info = {**face_info, **feature} axis_data = self._cylindrical_face_axis_mid_center(face_id, feature) if axis_data is None: notes.append(f"相邻圆柱Face {face_id} 缺少稳定中间轴心。") continue target_axis_center = _tuple_add(axis_data["current_axis_center"], movement) - angular_span = _float_or_none(face_info.get("angular_span")) + angular_span = _effective_cylinder_angular_span(face_feature_info) slot_kind = str(feature.get("slot_kind") or face_info.get("slot_kind") or "") if feature_guess == "hole/groove candidate": - if slot_kind == "partial-cylindrical-groove" or (angular_span is not None and angular_span < math.tau * 0.92): + if not _is_effectively_full_cylinder(face_feature_info) and ( + slot_kind == "partial-cylindrical-groove" + or (angular_span is not None and angular_span < math.tau * 0.92) + ): mode_order = ("slot", "hole") else: mode_order = ("hole", "slot") @@ -2426,7 +2446,7 @@ class OperationMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 Face 不是平面,不能执行推拉平面。", + "message": "当前选中的 Face 不是平面,不能执行拉伸/切除平面。", "blockers": "当前选中的 Face 不是平面。", "warnings": "", "face_id": face_id, @@ -2448,9 +2468,9 @@ class OperationMixin: except Exception: pass if len(scope_face_ids) > 1: - scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" + scope_note = f"将一起拉伸/切除 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" else: - scope_note = "只推拉当前Face。" + scope_note = "只拉伸/切除当前 Face。" direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) @@ -2502,20 +2522,20 @@ class OperationMixin: if distance_abs <= 1e-9: status = "blocked" risk = "blocked" - blockers.append("推拉距离为 0,不需要修改。") + blockers.append("拉伸/切除距离为 0,不需要修改。") if direction_confidence != "high": risk = _max_risk(risk, "medium") - warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。") + warnings.append("拉伸/切除方向判断置信度较低,可能不是期望的内外方向。") if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 5.0: risk = "blocked" status = "blocked" - blockers.append("推拉距离超过当前 Face 尺寸的 5 倍,容易生成过大布尔体或影响无关几何。") + blockers.append("拉伸/切除距离超过当前 Face 尺寸的 5 倍,容易生成过大布尔体或影响无关几何。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: risk = _max_risk(risk, "high") - warnings.append("推拉距离超过当前Face包围盒对角线的 20%,容易导致布尔失败或大范围变形。") + warnings.append("拉伸/切除距离超过当前 Face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: risk = _max_risk(risk, "medium") - warnings.append("推拉距离相对当前Face尺寸偏大,请确认预览范围。") + warnings.append("拉伸/切除距离相对当前 Face 尺寸偏大,请确认预览范围。") if inward_cut_ratio is not None and inward_material_depth is not None: depth_tolerance = max( @@ -2555,13 +2575,13 @@ class OperationMixin: risk = _max_risk(risk, "high") warnings.append( "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" - "本次向外推拉会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长,避免通用大布尔长时间计算。" + "本次向外拉伸会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长,避免通用大布尔长时间计算。" ) elif cap_method == "cap-profile-prism": risk = _max_risk(risk, "high") warnings.append( "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" - "本次向内推拉没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短。" + "本次向内切除没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短。" ) else: status = "blocked" @@ -2577,7 +2597,7 @@ class OperationMixin: blockers.append( "已识别为圆柱/筒体端盖,但端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" f"{limit_text}" - "本次向内推拉会越过这些开口/台阶的内侧终点,当前版本还不能判断槽底或台阶是否应一起移动。" + "本次向内切除会越过这些开口/台阶的内侧终点,当前版本还不能判断槽底或台阶是否应一起移动。" "已快速阻止,避免留下残余台阶、长时间布尔或丢失这些特征。" ) simple_cap_rebuild = self._simple_cylindrical_cap_extension_rebuild_available( @@ -2596,18 +2616,18 @@ class OperationMixin: if distance < 0 and growth_ratio is not None and growth_ratio < 0.2: risk = _max_risk(risk, "high") warnings.append( - "当前圆柱/筒体端面向内推拉后剩余高度低于原高度的 20%;" + "当前圆柱/筒体端面向内切除后剩余高度低于原高度的 20%;" "已识别端盖一级关系,执行时会优先切除旧端盖到新端盖之间的局部段。" ) elif distance < 0: if str(cap_extension_info.get("cap_extension_method") or "") == "cap-profile-prism": warnings.append( - "已识别圆柱/筒体端盖一级关系,向内推拉会优先走端盖真实轮廓切削," + "已识别圆柱/筒体端盖一级关系,向内切除会优先走端盖真实轮廓切削," "避免通用大范围 prism 切削过慢。" ) else: warnings.append( - "已识别圆柱/筒体端盖一级关系,向内推拉会优先切除旧端盖到新端盖之间的局部段," + "已识别圆柱/筒体端盖一级关系,向内切除会优先切除旧端盖到新端盖之间的局部段," "避免通用平面 prism 切削过慢。" ) elif growth_ratio is not None and growth_ratio > 3.0 and simple_cap_rebuild: @@ -2626,12 +2646,12 @@ class OperationMixin: elif distance_to_height_ratio is not None and distance_to_height_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append( - "当前圆柱端面推拉距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。" + "当前圆柱端面拉伸/切除距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。" ) elif radius is not None and radius > 0 and distance_abs > radius * 4.0: risk = _max_risk(risk, "high") warnings.append( - "当前圆柱端面推拉距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" + "当前圆柱端面拉伸/切除距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" ) boundary_shell_extension_info: dict[str, object] | None = None @@ -2644,10 +2664,16 @@ class OperationMixin: ) if boundary_shell_extension_info is not None: risk = _max_risk(risk, "medium") - warnings.append( - "已识别为带内孔/多边界的平面端盖;本次向外推拉会移动当前端面," - "并沿所有一级边界 Edge 生成延长侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" - ) + if distance > 0.0: + warnings.append( + "已识别为带内孔/多边界的平面端盖;本次向外拉伸会移动当前端面," + "并沿所有一级边界 Edge 生成延长侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" + ) + else: + warnings.append( + "已识别为带内孔/多边界的平面端盖;本次向内收缩会移动当前端面," + "并沿所有一级边界 Edge 重建侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" + ) if ( status != "blocked" @@ -2667,7 +2693,7 @@ class OperationMixin: f"内边界 {inner_wires} 个,一级边界 Edge {first_level_edges} 条," f"共享边一级相邻 Face {first_level_adjacent} 个。" "但没有识别到可安全向内切削的端盖一级关系。" - "向内推拉这类面通常需要判断孔壁、槽底、台阶内侧终点或其它相邻特征是否一起移动," + "向内切除这类面通常需要判断孔壁、槽底、台阶内侧终点或其它相邻特征是否一起移动," "这已经涉及一级相邻面背后的二级关系;当前阶段只自动处理一级关系。" "已快速阻止,避免进入通用 OCCT 布尔后长时间卡住、超时或生成无效 B-Rep。" ) @@ -2682,7 +2708,7 @@ class OperationMixin: ): risk = _max_risk(risk, "high") warnings.append( - "本次向外推拉距离相对当前 Face 或所属实体跨度较大,可能需要较长 OCCT 布尔计算;" + "本次向外拉伸距离相对当前 Face 或所属实体跨度较大,可能需要较长 OCCT 布尔计算;" "程序会在隔离子进程中执行,并在失败时保持原模型不变。" ) elif ( @@ -2692,7 +2718,7 @@ class OperationMixin: ): risk = _max_risk(risk, "high") warnings.append( - "本次向外推拉距离已经超过所属实体当前法向跨度的 60%,布尔延长可能较慢;" + "本次向外拉伸距离已经超过所属实体当前法向跨度的 60%,布尔延长可能较慢;" "建议优先小幅修改。" ) @@ -2703,7 +2729,7 @@ class OperationMixin: elif warnings: message = " ".join(warnings) else: - message = "可以尝试推拉该平面。" + message = "可以尝试拉伸/切除该平面。" plane_origin = _tuple_or_none(info.get("plane_origin")) outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) @@ -2779,34 +2805,22 @@ class OperationMixin: cap_extension_info.get("cap_profile_prism_retract_limit_parameter") if cap_extension_info is not None else None ), "planar_cap_extension_kind": ( - boundary_shell_extension_info.get("cap_extension_kind") - if boundary_shell_extension_info is not None - else None + (boundary_shell_extension_info or {}).get("cap_extension_kind") ), "planar_cap_extension_method": ( - boundary_shell_extension_info.get("cap_extension_method") - if boundary_shell_extension_info is not None - else None + (boundary_shell_extension_info or {}).get("cap_extension_method") ), - "planar_cap_boundary_edge_count": ( - boundary_shell_extension_info.get("cap_boundary_edge_count") - if boundary_shell_extension_info is not None - else 0 + "planar_cap_boundary_edge_count": int( + (boundary_shell_extension_info or {}).get("cap_boundary_edge_count") or 0 ), - "planar_cap_bridge_face_count": ( - boundary_shell_extension_info.get("bridge_face_count") - if boundary_shell_extension_info is not None - else 0 + "planar_cap_bridge_face_count": int( + (boundary_shell_extension_info or {}).get("bridge_face_count") or 0 ), - "planar_cap_inner_boundary_wires": ( - boundary_shell_extension_info.get("inner_boundary_wires") - if boundary_shell_extension_info is not None - else 0 + "planar_cap_inner_boundary_wires": int( + (boundary_shell_extension_info or {}).get("inner_boundary_wires") or 0 ), - "planar_cap_adjacent_face_count": ( - boundary_shell_extension_info.get("adjacent_face_count") - if boundary_shell_extension_info is not None - else 0 + "planar_cap_adjacent_face_count": int( + (boundary_shell_extension_info or {}).get("adjacent_face_count") or 0 ), "outward_direction": outward_direction, "direction_confidence": direction_confidence, @@ -2843,7 +2857,7 @@ class OperationMixin: except (TypeError, ValueError): distance = 0.0 frame = None - blockers = ["目标面偏移必须是数字。"] + blockers = ["目标偏移曲面必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] @@ -2851,7 +2865,7 @@ class OperationMixin: if face_id < 0 or face_id >= len(self.faces): blockers.append(f"Unknown face id {face_id}") if frame is None: - blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按面偏移只移动当前 Face。") + blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移曲面(局部重建)。") current_center = None target_center = None @@ -2891,8 +2905,8 @@ class OperationMixin: "target_face_center": target_center, "face_center_move_vector": move_vector, "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "按面偏移只移动当前Face", - "edit_semantics": "把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 并重建相邻平面。", + "edit_strategy_label": "偏移曲面(局部重建)", + "edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。", } plan = self.face_center_local_move_plan(face_id, target_center) @@ -2904,10 +2918,10 @@ class OperationMixin: "plane_direction": plane_direction, "plane_offset_distance": distance, "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "按面偏移只移动当前Face", + "edit_strategy_label": "偏移曲面(局部重建)", "edit_semantics": ( - "把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 的顶点并重建相邻平面;" - "不推拉加料/切削,也不平移所属对象。" + "把目标偏移曲面换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" + "不拉伸/切除加料/切削,也不平移所属对象。" ), } ) @@ -2919,7 +2933,7 @@ class OperationMixin: except (TypeError, ValueError): distance = 0.0 frame = None - blockers = ["目标面偏移必须是数字。"] + blockers = ["目标偏移曲面必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] @@ -2930,7 +2944,7 @@ class OperationMixin: else: info = self.face_info(face_id) if frame is None: - blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按面偏移平移所属对象。") + blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移曲面平移所属对象。") plane_origin = None plane_direction = None @@ -2973,8 +2987,8 @@ class OperationMixin: "translation_vector": vector, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", - "edit_strategy_label": "按面偏移平移所属对象", - "edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。", + "edit_strategy_label": "按偏移曲面平移所属对象", + "edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。", } plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector) @@ -2983,7 +2997,7 @@ class OperationMixin: if diagonal > 1e-9: distance_ratio = abs(distance) / diagonal if distance_ratio > 5.0: - blocker = "目标面偏移需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。" + blocker = "目标偏移曲面需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。" blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] blockers.append(blocker) plan["status"] = "blocked" @@ -3003,8 +3017,8 @@ class OperationMixin: "face_offset_distance_ratio": distance_ratio, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", - "edit_strategy_label": "按面偏移平移所属对象", - "edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。", + "edit_strategy_label": "按偏移曲面平移所属对象", + "edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", } ) return plan @@ -3185,7 +3199,7 @@ class OperationMixin: info = self.feature_info(face_id) blockers: list[str] = [] warnings: list[str] = [ - "薄壁/壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。" + "壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。" ] risk = "low" status = "ready" @@ -3195,7 +3209,7 @@ class OperationMixin: target_thickness = float(target_thickness) except (TypeError, ValueError): target_thickness = 0.0 - blockers.append("目标薄壁厚度必须是数字。") + blockers.append("目标壳体厚度必须是数字。") current_thickness = float(info.get("shell_thickness_estimate") or 0.0) signed_thickness = float(info.get("shell_signed_thickness") or 0.0) delta_thickness = target_thickness - current_thickness @@ -3205,21 +3219,21 @@ class OperationMixin: opposite_face_id = int(info.get("shell_opposite_face_id", -1)) if info.get("surface") != "plane": - blockers.append("当前选中 Face 不是平面,不能调整薄壁/壳体厚度。") + blockers.append("当前选中 Face 不是平面,不能调整壳体厚度。") if info.get("shell_region_status") != "candidate": - blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。"))) + blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对壳体平面。"))) if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9: - blockers.append("当前薄壁厚度估算无效。") + blockers.append("当前壳体厚度估算无效。") if target_thickness <= 1e-9: - blockers.append("目标薄壁厚度必须大于 0。") + blockers.append("目标壳体厚度必须大于 0。") if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6): blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。") if current_thickness > 1e-9: thickness_scale = target_thickness / current_thickness if thickness_scale < 0.05: - blockers.append("目标薄壁厚度会把当前厚度缩到 5% 以下,容易生成退化薄壁或无效几何。") + blockers.append("目标壳体厚度会把当前厚度缩到 5% 以下,容易生成退化壳体或无效几何。") elif thickness_scale > 5.0: - blockers.append("目标薄壁厚度会把当前厚度放大到 5 倍以上,容易导致推拉布尔失败或大范围变形。") + blockers.append("目标壳体厚度会把当前厚度放大到 5 倍以上,容易导致拉伸/切除布尔失败或大范围变形。") normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) @@ -3228,7 +3242,7 @@ class OperationMixin: movement_alignment = 0.0 if not blockers: if normal is None or outward is None: - blockers.append("当前平面缺少稳定法向或推拉方向,不能换算厚度修改。") + blockers.append("当前平面缺少稳定法向或拉伸/切除方向,不能换算厚度修改。") else: sign = 1.0 if signed_thickness >= 0.0 else -1.0 toward_opposite = _tuple_scale(normal, sign) @@ -3239,7 +3253,7 @@ class OperationMixin: else: movement_alignment = abs(_tuple_dot(desired_unit, outward)) if movement_alignment < 0.82: - blockers.append("当前平面推拉方向与薄壁厚度方向不匹配,暂不执行自动厚度修改。") + blockers.append("当前平面拉伸/切除方向与壳体厚度方向不匹配,暂不执行自动厚度修改。") else: push_pull_distance = _tuple_dot(desired_movement, outward) @@ -3247,13 +3261,13 @@ class OperationMixin: delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9) if confidence == "low": risk = _max_risk(risk, "high") - warnings.append("薄壁/壳体相对面识别置信度较低。") + warnings.append("壳体相对面识别置信度较低。") elif confidence == "medium": risk = _max_risk(risk, "medium") - warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。") + warnings.append("壳体相对面识别置信度为 medium,执行后请检查周边。") if overlap_ratio < 0.25: risk = _max_risk(risk, "high") - warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。") + warnings.append("相对平面投影重叠率较低,可能不是稳定壳体区域。") elif overlap_ratio < 0.55: risk = _max_risk(risk, "medium") warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") @@ -3282,7 +3296,7 @@ class OperationMixin: status = "caution" message = " ".join(warnings) else: - message = "可以通过推拉当前平面区域调整薄壁/壳体厚度。" + message = "可以通过拉伸/切除当前平面区域调整壳体厚度。" return { "status": status, @@ -3401,14 +3415,14 @@ class OperationMixin: tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ - "只移动当前 Face 会移动这个 Face 的顶点并重建周边平面;相邻面可能变斜或被拆成三角面。" + "`中心(局部重建)` 会移动这个 Face 的顶点并重建周边平面;相邻面可能变斜或被拆成三角面。" ] risk = "low" status = "ready" first_level_fields = self._face_first_level_plan_fields(face_id) if info.get("surface") != "plane": - blockers.append("当前 Face 不是平面,不能执行“只移动当前Face”。") + blockers.append("当前 Face 不是平面,不能执行“中心(局部重建)”。") if current_center is None: blockers.append("当前 Face 缺少稳定中心坐标。") if target is None: @@ -3448,7 +3462,7 @@ class OperationMixin: if not solid_faces: blockers.append("局部 Face 移动不可用:所属 Solid 没有可重建 Face。") if face_count > 128: - blockers.append("局部 Face 移动暂只对较简单的平面实体开放,复杂模型请使用推拉或整体平移。") + blockers.append("局部 Face 移动暂只对较简单的平面实体开放,复杂模型请使用拉伸/切除或整体平移。") for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: @@ -3465,7 +3479,7 @@ class OperationMixin: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, - operation_label="只移动当前 Face", + operation_label="中心(局部重建)", ) ) if not blockers: @@ -3483,7 +3497,7 @@ class OperationMixin: status = "caution" message = " ".join(warnings) else: - message = "可以只移动当前平面 Face,并让相邻平面按新的顶点位置重建。" + message = "可以执行中心(局部重建),并让相邻平面按新的顶点位置重建。" return { "status": status, @@ -3508,9 +3522,9 @@ class OperationMixin: "local_face_deform_moved_point_count": len(source_points), "part_solid_count": part_solid_count, "resize_strategy": "local-face-only-deform", - "edit_strategy_label": "只移动当前Face", + "edit_strategy_label": "中心(局部重建)", "edit_semantics": ( - "只移动当前 Face 的顶点,周边相邻面按新顶点重建;这不是平移所属对象,也不是面面积缩放。" + "移动当前 Face 的顶点,周边相邻面按新顶点重建;这不是平移所属对象,也不是面积缩放。" ), } @@ -3530,7 +3544,7 @@ class OperationMixin: tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ - "只缩放当前 Face 面积会在该平面内移动当前 Face 的顶点,并重建相邻平面。" + "`面积(局部重建)` 会在该平面内移动当前 Face 的顶点,并重建相邻平面。" ] risk = "low" status = "ready" @@ -3542,7 +3556,7 @@ class OperationMixin: target_area = 0.0 blockers.append("目标面面积必须是数字。") if info.get("surface") != "plane": - blockers.append("当前 Face 不是平面,不能执行“只缩放当前Face面积”。") + blockers.append("当前 Face 不是平面,不能执行“面积(局部重建)”。") if current_area is None or current_area <= 1e-9: blockers.append("当前 Face 缺少稳定面积。") if center is None: @@ -3609,7 +3623,7 @@ class OperationMixin: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, - operation_label="只缩放当前 Face 面积", + operation_label="面积(局部重建)", ) ) if not blockers: @@ -3636,7 +3650,7 @@ class OperationMixin: status = "caution" message = " ".join(warnings) else: - message = "可以只缩放当前平面 Face 的面积,并让相邻平面按新的顶点位置重建。" + message = "可以执行面积(局部重建),并让相邻平面按新的顶点位置重建。" return { "status": status, @@ -3662,7 +3676,7 @@ class OperationMixin: "local_face_deform_moved_point_count": len(point_targets), "part_solid_count": part_solid_count, "resize_strategy": "local-face-area-only-deform", - "edit_strategy_label": "只缩放当前Face面积", + "edit_strategy_label": "面积(局部重建)", "edit_semantics": ( "围绕当前 Face 中心在该平面内缩放这个 Face 的顶点,周边相邻面按新顶点重建;" "这不是缩放所属对象。" @@ -3674,7 +3688,7 @@ class OperationMixin: raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" - axis_label = "面高" if axis_key == "height" else "面宽" + axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) @@ -3686,7 +3700,7 @@ class OperationMixin: tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ - f"{axis_label}(当前面)会只沿选中 Face 自身平面内的一个方向缩放当前 Face 顶点,并重建相邻平面。" + f"{axis_label}(局部重建)会只沿选中 Face 自身平面内的一个方向缩放当前 Face 顶点,并重建相邻平面。" ] risk = "low" status = "ready" @@ -3698,7 +3712,7 @@ class OperationMixin: target_size = 0.0 blockers.append(f"目标{axis_label}必须是数字。") if info.get("surface") != "plane": - blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(当前面)”。") + blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(局部重建)”。") if center is None: blockers.append("当前 Face 缺少稳定中心坐标。") if target_size <= 1e-9: @@ -3773,7 +3787,7 @@ class OperationMixin: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, - operation_label=f"{axis_label}(当前面)", + operation_label=f"{axis_label}(局部重建)", ) ) if not blockers: @@ -3836,7 +3850,7 @@ class OperationMixin: "local_face_deform_distance_hint": abs(delta_size), "part_solid_count": part_solid_count, "resize_strategy": f"local-face-{axis_key}-only-deform", - "edit_strategy_label": f"{axis_label}(当前面)", + "edit_strategy_label": f"{axis_label}(局部重建)", "edit_semantics": ( f"围绕当前 Face 中心,只沿 Face 平面内的{axis_label}方向缩放该 Face 顶点;" "另一方向尺寸保持不主动缩放,周边相邻面按新顶点重建;这不是缩放所属对象。" @@ -3848,7 +3862,7 @@ class OperationMixin: raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" - axis_label = "面高" if axis_key == "height" else "面宽" + axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) @@ -3860,7 +3874,7 @@ class OperationMixin: tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ - f"{axis_label}(整体)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。" + f"{axis_label}(缩放特征)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。" ] risk = "medium" first_level_fields = self._face_first_level_plan_fields(face_id) @@ -3871,7 +3885,7 @@ class OperationMixin: target_size = 0.0 blockers.append(f"目标{axis_label}必须是数字。") if info.get("surface") != "plane": - blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(整体)”。") + blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(缩放特征)”。") if center is None: blockers.append("当前 Face 缺少稳定中心坐标,不能确定缩放基准。") if target_size <= 1e-9: @@ -3924,7 +3938,7 @@ class OperationMixin: target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" if not blockers: - warnings.append(f"当前会缩放所属{target_label},不是只移动当前 Face 顶点。") + warnings.append(f"当前会缩放所属{target_label},不是局部重建当前 Face 顶点。") owning_rebuild_mode = "affine-transform" point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () @@ -4007,14 +4021,14 @@ class OperationMixin: "face_height_direction": height_dir, "bbox_diagonal": diagonal, "resize_strategy": f"axis-scale-owning-shape-from-face-{axis_key}", - "edit_strategy_label": f"{axis_label}(整体)", + "edit_strategy_label": f"{axis_label}(缩放特征)", "edit_semantics": ( f"围绕当前 Face 中心,沿 Face 平面内的{axis_label}方向对所属{target_label}做单向仿射缩放;" "同一对象上的其它几何会跟随变化,这不是只改当前 Face。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", - "affine_transform_label": f"按当前Face{axis_label}方向缩放所属{target_label}", + "affine_transform_label": f"按当前 Face{axis_label}方向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它特征间距。", "affine_axis_point": center, "affine_axis_direction": axis_dir, @@ -4049,7 +4063,7 @@ class OperationMixin: blockers: list[str] = [] edit_label = "凸台高度" if require_boss else "圆柱高度" warnings: list[str] = [ - f"{edit_label}调整会推拉识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。" + f"{edit_label}调整会拉伸/切除识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。" ] risk = "low" @@ -4057,8 +4071,9 @@ class OperationMixin: blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。") if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate": blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。") - angular_span = float(info.get("angular_span", 0.0)) - if angular_span < math.tau * 0.92: + cylinder_identity = {**info, **feature} + angular_span = _effective_cylinder_angular_span(cylinder_identity) or 0.0 + if not _is_effectively_full_cylinder(cylinder_identity): blockers.append(f"{edit_label}调整当前版本只支持接近完整圆柱的圆柱面。") if target_height <= 1e-9: blockers.append(f"目标{edit_label}必须大于 0。") @@ -4128,7 +4143,7 @@ class OperationMixin: continue push_plan = self.push_pull_plan(cap_face_id, push_pull_distance) if push_plan["status"] == "blocked": - warnings.append(f"{endpoint_label} Face {cap_face_id} 不能推拉:{push_plan.get('message', '')}") + warnings.append(f"{endpoint_label} Face {cap_face_id} 不能拉伸/切除:{push_plan.get('message', '')}") continue candidate_risk = str(push_plan.get("risk", "medium")) score = ( @@ -4161,7 +4176,7 @@ class OperationMixin: selected_cap: dict[str, object] = {} if not blockers: if not cap_candidates: - blockers.append("没有找到可推拉的圆柱端盖 Face,暂不能直接调整凸台高度。") + blockers.append("没有找到可拉伸/切除的圆柱端盖 Face,暂不能直接调整凸台高度。") else: cap_candidates.sort(key=lambda item: item[0]) selected_cap = cap_candidates[0][1] @@ -4179,7 +4194,7 @@ class OperationMixin: message = " ".join(warnings) else: status = "ready" - message = f"可以通过推拉圆柱端盖调整{edit_label}。" + message = f"可以通过拉伸/切除圆柱端盖调整{edit_label}。" return { "status": status, @@ -4211,9 +4226,9 @@ class OperationMixin: if require_boss else "push-pull-cylindrical-end-cap-to-target-height" ), - "edit_strategy_label": "端盖推拉调整高度" if require_boss else "圆柱端盖推拉调整高度", + "edit_strategy_label": "端盖拉伸/切除调整高度" if require_boss else "圆柱端盖拉伸/切除调整高度", "edit_semantics": ( - f"通过推拉识别到的{edit_label}端盖 Face 改变高度;这是局部端面移动,不是整体缩放。" + f"通过拉伸/切除识别到的{edit_label}端盖 Face 改变高度;这是局部端面移动,不是整体缩放。" ), **selected_cap, } @@ -4310,7 +4325,7 @@ class OperationMixin: info = self.feature_info(face_id) blockers: list[str] = [] warnings: list[str] = [ - "该方式会沿薄壁/壳体厚度方向缩放所属特征或 Solid;它不是推拉当前平面,也不是局部壳命令参数。" + "该方式会沿壳体厚度方向缩放所属特征或 Solid;它不是拉伸/切除当前平面,也不是局部壳命令参数。" ] risk = "medium" first_level_fields = self._face_first_level_plan_fields(face_id) @@ -4319,53 +4334,53 @@ class OperationMixin: target_thickness = float(target_thickness) except (TypeError, ValueError): target_thickness = 0.0 - blockers.append("目标薄壁厚度必须是数字。") + blockers.append("目标壳体厚度必须是数字。") current_thickness = _float_or_none(info.get("shell_thickness_estimate")) signed_thickness = _float_or_none(info.get("shell_signed_thickness")) normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) plane_origin = _tuple_or_none(info.get("plane_origin")) if info.get("surface") != "plane": - blockers.append("当前选中 Face 不是平面,不能按薄壁厚度整体缩放。") + blockers.append("当前选中 Face 不是平面,不能按壳体厚度整体缩放。") if info.get("shell_region_status") != "candidate": - blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。"))) + blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对壳体平面。"))) if current_thickness is None or current_thickness <= 1e-9: - blockers.append("当前薄壁厚度估算无效。") + blockers.append("当前壳体厚度估算无效。") if signed_thickness is None or abs(signed_thickness) <= 1e-9: - blockers.append("当前薄壁厚度方向无效。") + blockers.append("当前壳体厚度方向无效。") if normal is None: blockers.append("当前平面缺少稳定法向,不能按厚度方向整体缩放。") if target_thickness <= 1e-9: - blockers.append("目标薄壁厚度必须大于 0。") + blockers.append("目标壳体厚度必须大于 0。") current_value = float(current_thickness or 0.0) scale = target_thickness / max(current_value, 1e-9) delta_thickness = target_thickness - current_value delta_ratio = abs(delta_thickness) / max(current_value, 1e-9) if current_thickness is not None and abs(delta_thickness) <= max(current_thickness * 1e-6, 1e-6): - blockers.append("目标薄壁厚度与当前值几乎相同,不需要修改。") + blockers.append("目标壳体厚度与当前值几乎相同,不需要修改。") if scale < 0.05: - blockers.append("目标薄壁厚度会把所属对象沿厚度方向缩放到当前值的 5% 以下,容易生成退化几何。") + blockers.append("目标壳体厚度会把所属对象沿厚度方向缩放到当前值的 5% 以下,容易生成退化几何。") elif scale > 5.0: - blockers.append("目标薄壁厚度会把所属对象沿厚度方向放大到当前值的 5 倍以上,风险过高。") + blockers.append("目标壳体厚度会把所属对象沿厚度方向放大到当前值的 5 倍以上,风险过高。") if delta_ratio > 0.75: risk = _max_risk(risk, "high") - warnings.append("薄壁厚度变化超过 75%,会明显影响所属对象上的其它厚度方向尺寸。") + warnings.append("壳体厚度变化超过 75%,会明显影响所属对象上的其它厚度方向尺寸。") elif delta_ratio > 0.3: - warnings.append("薄壁厚度变化超过 30%,修改后请重点检查壁厚和相邻特征。") + warnings.append("壳体厚度变化超过 30%,修改后请重点检查壁厚和相邻特征。") confidence = str(info.get("shell_confidence", "low")) overlap_ratio = _float_or_none(info.get("shell_overlap_ratio_estimate")) if confidence == "low": risk = _max_risk(risk, "high") - warnings.append("薄壁/壳体相对面识别置信度较低。") + warnings.append("壳体相对面识别置信度较低。") elif confidence == "medium": risk = _max_risk(risk, "medium") - warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。") + warnings.append("壳体相对面识别置信度为 medium,执行后请检查周边。") if overlap_ratio is not None: if overlap_ratio < 0.25: risk = _max_risk(risk, "high") - warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。") + warnings.append("相对平面投影重叠率较低,可能不是稳定壳体区域。") elif overlap_ratio < 0.55: risk = _max_risk(risk, "medium") warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") @@ -4379,19 +4394,19 @@ class OperationMixin: ) scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if scale_center is None: - blockers.append("当前薄壁区域缺少稳定缩放中心,不能整体缩放所属对象。") + blockers.append("当前壳体区域缺少稳定缩放中心,不能整体缩放所属对象。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: - blockers.append("找不到当前薄壁区域所属特征。") + blockers.append("找不到当前壳体区域所属特征。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" - warnings.append(f"当前会沿薄壁厚度方向缩放所属{target_label};同一对象上的其它尺寸会跟随变化。") + warnings.append(f"当前会沿壳体厚度方向缩放所属{target_label};同一对象上的其它尺寸会跟随变化。") owning_rebuild_mode = "affine-transform" point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () @@ -4466,14 +4481,14 @@ class OperationMixin: "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), "resize_strategy": "axis-scale-owning-shape-from-shell-thickness", - "edit_strategy_label": "薄壁厚度(整体)", + "edit_strategy_label": "壳体厚度(缩放特征)", "edit_semantics": ( - "按目标薄壁厚度和当前厚度的比例,沿厚度方向缩放所属特征或 Solid;" - "这会改变同一对象上的其它尺寸,不是局部推拉当前平面。" + "按目标壳体厚度和当前厚度的比例,沿厚度方向缩放所属特征或 Solid;" + "这会改变同一对象上的其它尺寸,不是局部拉伸/切除当前平面。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", - "affine_transform_label": f"按薄壁厚度方向缩放所属{target_label}", + "affine_transform_label": f"按壳体厚度方向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它尺寸。", "affine_axis_point": scale_center, "affine_axis_direction": normal or (0.0, 0.0, 1.0), @@ -4565,7 +4580,7 @@ class OperationMixin: ) blockers: list[str] = [] warnings: list[str] = [ - "该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是推拉某个端盖,也不是只修改单个圆柱面。" + "该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是拉伸/切除某个端盖,也不是只修改单个圆柱面。" ] risk = "medium" @@ -4599,8 +4614,9 @@ class OperationMixin: if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") - angular_span = _float_or_none(info.get("angular_span")) - if angular_span is not None and angular_span < math.tau * 0.92: + cylinder_identity = {**info, **feature} + angular_span = _effective_cylinder_angular_span(cylinder_identity) + if angular_span is not None and not _is_effectively_full_cylinder(cylinder_identity): risk = _max_risk(risk, "high") warnings.append("当前圆柱面不是完整圆柱;轴向整体缩放会影响所属对象,但不等于稳定的局部槽/半孔高度编辑。") if current_height is None or current_height <= 1e-9: @@ -4680,10 +4696,10 @@ class OperationMixin: "axis_point": axis_point, "scale_center": scale_center, "resize_strategy": "axis-scale-owning-shape-from-cylinder-height", - "edit_strategy_label": "高度(整体)", + "edit_strategy_label": "高度(缩放特征)", "edit_semantics": ( "按目标圆柱高度和当前高度的比例,沿圆柱轴向缩放所属特征或 Solid;" - "这会改变同一对象上的其它轴向尺寸,不是端盖推拉。" + "这会改变同一对象上的其它轴向尺寸,不是端盖拉伸/切除。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", @@ -4760,7 +4776,7 @@ class OperationMixin: if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") if str(info.get("feature_guess", "")) != "hole/groove candidate": - blockers.append("深度(整体)当前只对孔/槽候选开放。") + blockers.append("深度(缩放特征)当前只对孔/槽候选开放。") if target_depth <= 1e-9: blockers.append("目标盲孔/盲槽深度必须大于 0。") @@ -4818,8 +4834,9 @@ class OperationMixin: elif delta_ratio > 0.3: warnings.append("盲孔/盲槽深度变化超过 30%,修改后请重点检查相邻特征和壁厚。") - angular_span = _float_or_none(info.get("angular_span")) - if angular_span is not None and angular_span < math.tau * 0.92: + cylinder_identity = {**info, **feature} + angular_span = _effective_cylinder_angular_span(cylinder_identity) + if angular_span is not None and not _is_effectively_full_cylinder(cylinder_identity): risk = _max_risk(risk, "high") warnings.append("当前是局部圆柱槽/半孔;整体缩放会改变所属对象,不等于稳定的局部槽底调整。") @@ -4867,7 +4884,7 @@ class OperationMixin: "depth_current_bottom_point": context.get("depth_current_bottom_point"), "depth_current_depth_source": context.get("depth_current_depth_source", ""), "resize_strategy": "axis-scale-owning-shape-from-blind-depth", - "edit_strategy_label": "盲孔/盲槽深度(整体)", + "edit_strategy_label": "盲孔/盲槽深度(缩放特征)", "edit_semantics": ( "按目标盲孔/盲槽深度和当前深度的比例,沿孔/槽轴向缩放所属特征或 Solid;" "这会改变同一对象上的其它轴向尺寸,不是局部切削或补料。" @@ -4897,14 +4914,33 @@ class OperationMixin: ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - self._apply_edge_length_affine_transform(plan) + self._remember_face_target_logical_id(plan, face_id) + part_id = int(plan.get("part_id", self.face_part_ids[face_id])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + self._apply_edge_length_affine_transform(plan) + verification = self._verify_cylindrical_depth_result(plan, part_id) + if not verification.get("matched"): + detail = str(verification.get("detail", "blind cylindrical depth verification failed")) + raise RuntimeError( + "Blind cylindrical depth owning-scale returned a shape, but no target-depth blind " + f"cylindrical feature was detected after the edit; rolled back to the previous model. {detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( "Blind cylindrical depth resize completed by axis owning-shape scaling: " f"face {face_id}, " f"depth={float(plan['current_depth']):g}->{float(plan['target_depth']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}." ) def cylindrical_owning_scale_plan(self, face_id: int, target_diameter: float) -> dict[str, object]: @@ -4927,6 +4963,11 @@ class OperationMixin: current_radius = _float_or_none(info.get("radius")) axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + topology_fields = ( + self._cylindrical_feature_first_level_plan_fields(face_id) + if info.get("surface") == "cylinder" + else {} + ) if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") if current_diameter is None or current_diameter <= 1e-9: @@ -5002,6 +5043,8 @@ class OperationMixin: "scale_center": scale_center, "axis_point": axis_point, "axis": axis_direction, + "cutter_axis_point": axis_point, + "cutter_axis_direction": axis_direction, "resize_strategy": "uniform-scale-owning-shape-from-cylinder-diameter", "edit_strategy_label": "按圆柱直径缩放所属对象", "edit_semantics": ( @@ -5017,20 +5060,40 @@ class OperationMixin: "affine_anchor_source": "cylinder axis center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, + **topology_fields, } def resize_cylindrical_owning_scale(self, face_id: int, target_diameter: float) -> str: plan = self.cylindrical_owning_scale_plan(face_id, target_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - self._apply_edge_length_affine_transform(plan) + self._remember_face_target_logical_id(plan, face_id) + part_id = int(plan.get("part_id", self.face_part_ids[face_id])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + old_part_shape = part.shape + verification: dict[str, object] = {} + try: + self._apply_edge_length_affine_transform(plan) + verification = self._verify_cylindrical_resize_result(plan, part_id) + if not verification.get("matched"): + detail = str(verification.get("detail", "cylindrical diameter verification failed")) + raise RuntimeError( + "Cylindrical owning-scale returned a shape, but no target-diameter cylindrical Face " + f"was detected after the edit; rolled back to the previous model. {detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise return ( "Cylindrical diameter resize completed by uniform owning-shape scaling: " f"face {face_id}, " f"diameter={float(plan['current_diameter']):g}->{float(plan['target_diameter']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}." ) def conical_reference_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]: @@ -7036,7 +7099,7 @@ class OperationMixin: expected_vertices = int(plan.get("first_level_boundary_vertex_count", 0) or 0) expected_adjacent = int(plan.get("first_level_adjacent_face_count", 0) or 0) expected_inner_wires = int(plan.get("selected_inner_boundary_wires", 0) or 0) - matched_info = self.face_info(matched_face_id) + matched_info = self.quick_face_info(matched_face_id) boundary_wires = int(matched_info.get("boundary_wires", 0) or 0) inner_boundary_wires = int(matched_info.get("inner_boundary_wires", 0) or 0) expected_scope_area = _float_or_none(plan.get("push_pull_scope_area")) @@ -7184,7 +7247,7 @@ class OperationMixin: f"{area_summary}" f"fact_subject_faces={fact_subject}, fact_included_faces={fact_included}." ) - return self._face_edit_result_summary(plan) + first_level_summary + return self._face_edit_result_summary(plan, check) + first_level_summary def _run_checked_face_edit( self, @@ -7200,8 +7263,13 @@ class OperationMixin: self._restore_face_edit_snapshot(snapshot) raise RuntimeError(f"Face edit failed and the model was restored: {exc}") from exc - def _face_edit_result_summary(self, plan: dict[str, object]) -> str: - check = self._face_edit_result_check(plan) + def _face_edit_result_summary( + self, + plan: dict[str, object], + check: dict[str, object] | None = None, + ) -> str: + if check is None: + check = self._face_edit_result_check(plan) if check is None: return "Face result check unavailable." if int(check.get("face_id", -1)) < 0: @@ -7243,14 +7311,14 @@ class OperationMixin: metric = "area" target = target_area tolerance = max(abs(target_area) * 0.02, 1e-4) - getter = lambda face_id: _float_or_none(self.face_info(face_id).get("area")) + getter = lambda face_id: _float_or_none(self.quick_face_info(face_id).get("area")) elif target_size is not None and target_size > 0: axis_key = "height" if str(plan.get("face_size_axis") or "").lower() == "height" else "width" info_key = "local_face_height" if axis_key == "height" else "local_face_width" metric = info_key target = target_size tolerance = max(abs(target_size) * 0.02, 1e-4) - getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key)) + getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) elif target_center is not None: metric = "center" target = target_center @@ -7258,7 +7326,7 @@ class OperationMixin: tolerance = max(reference * 1e-5, 1e-4) def center_getter(face_id: int) -> tuple[float, float, float] | None: - info = self.face_info(face_id) + info = self.quick_face_info(face_id) return _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) getter = center_getter @@ -7351,9 +7419,9 @@ class OperationMixin: target = value tolerance = max(abs(value) * ratio, floor) if info_key == "semi_angle_degrees": - getter = lambda face_id: _angle_degrees_or_none(self.face_info(face_id).get("semi_angle")) + getter = lambda face_id: _angle_degrees_or_none(self.quick_face_info(face_id).get("semi_angle")) else: - getter = lambda face_id, key=info_key: _float_or_none(self.face_info(face_id).get(key)) + getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) break if getter is None or target is None: @@ -7366,6 +7434,9 @@ class OperationMixin: preferred_ids.extend(self.face_ids_for_logical_id(logical_id)) except Exception: pass + plan_face_id = _int_or_none(plan.get("face_id")) + if plan_face_id is not None: + preferred_ids.append(plan_face_id) if preferred_ids: preferred_best: dict[str, object] | None = None for face_id in preferred_ids: @@ -8840,7 +8911,7 @@ class OperationMixin: ): return ( "已识别为带槽口/台阶等额外一级相邻面的端盖,但无法把它安全简化成解析重建;" - "继续走通用 OCCT 布尔很容易长时间卡住。已快速阻止本次大距离推拉," + "继续走通用 OCCT 布尔很容易长时间卡住。已快速阻止本次大距离拉伸/切除," "请先缩小修改距离,或后续用更明确的槽/台阶一级关系编辑入口处理。" ) @@ -8856,7 +8927,7 @@ class OperationMixin: ): return ( "当前 Face 有多个内孔/内边界,并且所属 STEP 实体很复杂;程序没有识别到可安全解析重建的单一拉伸体。" - "继续使用通用布尔推拉很可能再次超时。已快速阻止本次大距离推拉;" + "继续使用通用布尔拉伸/切除很可能再次超时。已快速阻止本次大距离拉伸/切除;" "简单空心圆柱、多个贯穿孔圆柱和普通板件端盖已经会走快速解析重建," "这种复杂大实体需要先做更明确的特征隔离/一级关系编辑。" ) @@ -8869,7 +8940,7 @@ class OperationMixin: outward: tuple[float, float, float], scope_face_ids: Iterable[int] | None = None, ) -> dict[str, object] | None: - if float(distance) <= 1e-9: + if abs(float(distance)) <= 1e-9: return None if face_id < 0 or face_id >= len(self.faces): return None @@ -8919,7 +8990,7 @@ class OperationMixin: return { "cap_extension_kind": "multi-boundary-planar-cap", "cap_extension_method": "boundary-shell-rebuild", - "cap_operation": "extend", + "cap_operation": "extend" if float(distance) > 0.0 else "retract", "cap_scope_face_ids": tuple(cap_scope_face_ids), "cap_boundary_edge_ids": tuple(boundary_edge_ids), "cap_boundary_edge_count": len(boundary_edge_ids), @@ -8939,8 +9010,8 @@ class OperationMixin: distance: float, outward: tuple[float, float, float], ) -> TopoDS_Shape: - if float(distance) <= 1e-9: - raise RuntimeError("Planar cap boundary-shell rebuild currently supports outward extension only.") + if abs(float(distance)) <= 1e-9: + raise RuntimeError("Planar cap boundary-shell rebuild requires a non-zero distance.") part_id = self.face_part_ids[face_id] solid_id = self.face_solid_ids[face_id] part = self.part_by_id(part_id) @@ -9285,7 +9356,7 @@ class OperationMixin: return solid_candidate if cylindrical_adjacent_face_ids != adjacent_face_id_set: return None - if any(float(candidate.get("angular_span") or 0.0) < math.tau * 0.92 for _score, candidate in candidates): + if any(not _is_effectively_full_cylinder(candidate) for _score, candidate in candidates): return None if any( float(candidate.get("cap_center_axis_distance") or 0.0) @@ -10872,7 +10943,7 @@ class OperationMixin: if before_solids and after_solids != before_solids: raise RuntimeError( "槽/半孔轴心移动结果改变了 Solid 数量,当前版本已回滚," - "避免把一个实体拆成多个独立实体。请改用“轴心(整体)”或槽孔总长度/槽宽等更稳定参数。" + "避免把一个实体拆成多个独立实体。请改用“轴心(缩放特征)”或槽孔总长度/槽宽等更稳定参数。" ) part.shape = result self.refresh_topology() diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 17f4c4f..c1cb92b 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -18,6 +18,9 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "file", "part_id", "solid_id", + "selection_title", + "selection_display_id", + "selection_topological_face_id", "logical_face_id", "face_region_logical_id", "topological_face_id", @@ -392,10 +395,13 @@ INFO_LABELS = { "file": "文件", "part_id": "零件 ID", "solid_id": "Solid ID", + "selection_title": "当前选中对象", + "selection_display_id": "逻辑选择 ID", + "selection_topological_face_id": "当前拓扑 Face ID", "logical_face_id": "逻辑 Face ID", "face_region_logical_id": "面区域逻辑 ID", "topological_face_id": "拓扑 Face ID", - "face_id": "Face ID", + "face_id": "当前拓扑 Face ID", "face_region_ids": "面区域 Face", "face_region_count": "面区域 Face 数", "edge_id": "Edge ID", @@ -544,24 +550,24 @@ INFO_LABELS = { "center_of_mass": "重心", "surface_center": "表面积中心", "area_center": "面积中心", - "local_face_width": "当前面宽", - "local_face_height": "当前面高", - "local_face_width_direction": "面宽 方向", - "local_face_height_direction": "面高 方向", - "local_face_size_center": "面宽/面高 中心", - "face_size_axis": "面宽/面高 方向", - "face_size_label": "面宽/面高 名称", - "current_face_width": "当前面宽", - "target_face_width": "目标面宽", - "current_face_height": "当前面高", - "target_face_height": "目标面高", - "current_face_size": "当前面宽/面高", - "target_face_size": "目标面宽/面高", - "face_size_delta": "面宽/面高 变化量", - "face_size_delta_ratio": "面宽/面高 变化比例", - "face_size_scale": "面宽/面高 缩放比例", - "face_size_center": "面宽/面高 缩放中心", - "face_size_axis_direction": "面宽/面高 缩放方向", + "local_face_width": "U向尺寸", + "local_face_height": "V向尺寸", + "local_face_width_direction": "U向方向", + "local_face_height_direction": "V向方向", + "local_face_size_center": "U/V 尺寸中心", + "face_size_axis": "U/V 尺寸方向", + "face_size_label": "U/V 尺寸名称", + "current_face_width": "当前 U向尺寸", + "target_face_width": "目标 U向尺寸", + "current_face_height": "当前 V向尺寸", + "target_face_height": "目标 V向尺寸", + "current_face_size": "当前 U/V 尺寸", + "target_face_size": "目标 U/V 尺寸", + "face_size_delta": "U/V 尺寸变化量", + "face_size_delta_ratio": "U/V 尺寸变化比例", + "face_size_scale": "U/V 尺寸缩放比例", + "face_size_center": "U/V 尺寸缩放中心", + "face_size_axis_direction": "U/V 尺寸缩放方向", "owning_face_size_rebuild_mode": "所属对象尺寸重建模式", "length_center": "长度中心", "bbox_min": "包围盒最小点", @@ -574,7 +580,7 @@ INFO_LABELS = { "end_face_id": "端面 Face", "end_face_label": "端面位置", "end_face_plane_distance": "端面匹配距离", - "push_pull_distance": "端面推拉距离", + "push_pull_distance": "拉伸/切除距离", "normal": "几何法向", "oriented_normal": "拓扑修正法向", "plane_origin": "平面原点", @@ -585,37 +591,37 @@ INFO_LABELS = { "rotation_axis": "旋转轴", "end_face_outward_direction": "端面向外方向", "desired_movement_vector": "目标移动向量", - "push_pull_outward_direction": "推拉向外方向", - "push_pull_inward_direction": "推拉向内方向", + "push_pull_outward_direction": "拉伸向外方向", + "push_pull_inward_direction": "切除向内方向", "push_pull_plus_side": "原始法向侧", "push_pull_minus_side": "反向法向侧", - "push_pull_confidence": "推拉方向置信度", - "push_pull_note": "推拉方向说明", - "push_pull_status": "推拉状态", - "push_pull_risk": "推拉风险", - "push_pull_message": "推拉说明", - "push_pull_scope_face_ids": "推拉共面区域 Face", - "push_pull_scope_face_count": "推拉共面区域 Face 数", - "push_pull_scope_area": "推拉共面区域面积", - "push_pull_scope_note": "推拉共面区域说明", - "shell_region_kind": "壳体/薄壁候选类型", - "shell_region_status": "壳体/薄壁识别状态", - "shell_confidence": "壳体/薄壁置信度", - "shell_thickness": "薄壁厚度", - "shell_source_face_ids": "壳体/薄壁源平面 Face", + "push_pull_confidence": "拉伸/切除方向置信度", + "push_pull_note": "拉伸/切除方向说明", + "push_pull_status": "拉伸/切除状态", + "push_pull_risk": "拉伸/切除风险", + "push_pull_message": "拉伸/切除说明", + "push_pull_scope_face_ids": "拉伸/切除共面区域 Face", + "push_pull_scope_face_count": "拉伸/切除共面区域 Face 数", + "push_pull_scope_area": "拉伸/切除共面区域面积", + "push_pull_scope_note": "拉伸/切除共面区域说明", + "shell_region_kind": "壳体候选类型", + "shell_region_status": "壳体识别状态", + "shell_confidence": "壳体置信度", + "shell_thickness": "壳体厚度", + "shell_source_face_ids": "壳体源平面 Face", "shell_opposite_face_id": "相对平面 Face", - "shell_thickness_estimate": "薄壁厚度估算", - "shell_current_thickness": "当前薄壁厚度", - "shell_target_thickness": "目标薄壁厚度", - "shell_delta_thickness": "薄壁厚度变化量", - "shell_delta_ratio": "薄壁厚度变化比例", - "shell_signed_thickness": "有符号薄壁厚度", + "shell_thickness_estimate": "壳体厚度估算", + "shell_current_thickness": "当前壳体厚度", + "shell_target_thickness": "目标壳体厚度", + "shell_delta_thickness": "壳体厚度变化量", + "shell_delta_ratio": "壳体厚度变化比例", + "shell_signed_thickness": "有符号壳体厚度", "shell_overlap_ratio_estimate": "相对平面重叠率估算", "shell_opposite_normal_dot": "相对平面法向点积", - "shell_desired_movement_vector": "薄壁目标移动向量", - "shell_movement_alignment": "薄壁移动方向匹配度", - "shell_note": "壳体/薄壁识别说明", - "shell_region_note": "壳体/薄壁识别说明", + "shell_desired_movement_vector": "壳体目标移动向量", + "shell_movement_alignment": "壳体移动方向匹配度", + "shell_note": "壳体识别说明", + "shell_region_note": "壳体识别说明", "u_range": "U 参数范围", "v_range": "V 参数范围", "first_parameter": "起始参数", @@ -700,6 +706,9 @@ INFO_LABELS = { "existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算", "existing_fillet_note": "已有圆角识别说明", "feature_edit_actions": "当前可用操作", + "freeform_face_status": "自由曲面状态", + "freeform_face_risk": "自由曲面风险", + "freeform_face_blockers": "自由曲面限制", "resize_status": "切削状态", "resize_strategy": "编辑策略", "edit_strategy_label": "编辑策略说明", @@ -940,7 +949,7 @@ def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]: warnings.append( "目标零件Solid数发生变化:" f"{before_part_stats.solids} -> {after_part_stats.solids}。" - "如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。" + "如果这是一次局部拉伸/切除/孔径修改,请重点检查导出后是否仍是一体实体。" ) return warnings diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index db26799..c28a151 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -342,7 +342,7 @@ class WindowActionMixin: def push_pull_face(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再执行新的推拉。"): + if self._edit_busy("请等待当前编辑完成后再执行新的拉伸/切除。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择 Face", "请先选择一个平面 Face。") @@ -356,8 +356,8 @@ class WindowActionMixin: plan = self._push_pull_plan_for_action(face_id, distance) if plan["status"] == "blocked": - QMessageBox.information(self, "不能推拉平面", str(plan["message"])) - self.statusBar().showMessage("推拉平面已阻止") + QMessageBox.information(self, "不能拉伸/切除平面", str(plan["message"])) + self.statusBar().showMessage("拉伸/切除平面已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -369,7 +369,7 @@ class WindowActionMixin: ) result = QMessageBox.question( self, - "确认推拉平面", + "确认拉伸/切除平面", ( f"Face: {face_id}\n" f"本次移动距离: {_format_value(distance)}\n" @@ -387,7 +387,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消推拉平面") + self.statusBar().showMessage("已取消拉伸/切除平面") return isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]) if isolation is None: @@ -400,7 +400,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="推拉平面", + operation_name="拉伸/切除平面", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -466,7 +466,7 @@ class WindowActionMixin: outward = info.get("push_pull_outward_direction") if not isinstance(outward, (tuple, list)) or len(outward) != 3: - outward = info.get("oriented_normal") or info.get("normal") + outward = info.get("normal") if not isinstance(outward, (tuple, list)) or len(outward) != 3: return { "status": "blocked", @@ -526,10 +526,23 @@ class WindowActionMixin: else: if risk == "low": 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: + shallow_complex_cap_rebuild = ( + inward_cut_ratio is not None + and inward_cut_ratio < 0.6 + and inner_wires > 0 + ) + complex_multi_boundary_cap = status != "blocked" and model_face_count > 600 and inner_wires > 1 + if complex_multi_boundary_cap and inward_cut_ratio is None: + risk = self._max_quick_risk(risk, "high") + warnings.append( + "当前是复杂大模型里的多内孔/多边界平面端盖,快速缓存里没有完整材料厚度;" + "将交给后台/隔离子进程重新计算一级边界侧壁重建计划。若发现接近切穿或需要二级关系传播," + "模型层会快速阻止并保持原模型不变。" + ) + elif complex_multi_boundary_cap and not shallow_complex_cap_rebuild: status = "blocked" risk = "blocked" boundary_edges = _compact_plan_value(info.get("first_level_boundary_edge_count", "未知")) @@ -537,10 +550,16 @@ class WindowActionMixin: blockers.append( f"当前 Face {face_id} 是复杂大模型里的多内孔/多边界平面端盖:" f"内边界 {inner_wires} 个,一级边界 Edge {boundary_edges} 条," - f"共享边一级相邻 Face {adjacent_faces} 个。向内推拉这类面需要判断孔壁、" + f"共享边一级相邻 Face {adjacent_faces} 个。向内切除这类面需要判断孔壁、" "槽底或台阶背后的二级关系是否一起变化;当前阶段只自动处理一级关系。" "已在界面预检查阶段阻止,避免进入通用 OCCT 布尔后长时间卡住。" ) + elif complex_multi_boundary_cap and shallow_complex_cap_rebuild: + risk = self._max_quick_risk(risk, "medium") + warnings.append( + "当前是复杂大模型里的多内孔/多边界平面端盖,但本次向内切除没有接近切穿;" + "将交给后台/隔离子进程移动当前端面并重建一级边界侧壁,完成后校验目标位置和内边界。" + ) if status != "blocked" and risk in {"medium", "high"}: status = "caution" @@ -594,7 +613,7 @@ class WindowActionMixin: "current_plane_position": current_plane_position, "target_plane_position": target_plane_position, "resize_strategy": "push-pull-planar-face-region", - "edit_strategy_label": "沿面法向推拉当前平面区域", + "edit_strategy_label": "沿面法向拉伸/切除当前平面区域", "edit_semantics": ( "沿当前 Face 的法向移动平面区域;正向通常表示向外加料,负向表示向内切削。" "相邻侧面会跟随重建,但这不是平移整个所属对象,也不是只改面积。" @@ -603,7 +622,7 @@ class WindowActionMixin: "direction_note": info.get("push_pull_note", "使用当前选中 Face 的缓存方向,完整检查在后台执行。"), "push_pull_scope_face_ids": tuple(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")), @@ -665,10 +684,10 @@ class WindowActionMixin: plan["quick_plan_status"] = quick_plan.get("status") plan["quick_plan_risk"] = quick_plan.get("risk") plan["message"] = ( - "为避免复杂 STEP 在界面线程生成完整推拉计划时卡顿,本次只做快速预检查;" + "为避免复杂 STEP 在界面线程生成完整拉伸/切除计划时卡顿,本次只做快速预检查;" "真正的一级关系识别、风险判断、解析重建或快速阻止会在后台隔离进程中完成。" ) - plan.setdefault("edit_strategy_label", "后台计算完整推拉计划") + plan.setdefault("edit_strategy_label", "后台计算完整拉伸/切除计划") plan.setdefault( "edit_semantics", "界面先提交后台任务;子进程会按当前 Face 的一级拓扑关系决定是解析重建、局部重建还是阻止。", @@ -691,7 +710,7 @@ class WindowActionMixin: { "status": "blocked", "risk": "blocked", - "message": f"无法生成完整推拉计划:{exc}", + "message": f"无法生成完整拉伸/切除计划:{exc}", "model_plan_error": str(exc), "quick_plan_status": quick_plan.get("status"), "quick_plan_risk": quick_plan.get("risk"), @@ -705,17 +724,17 @@ class WindowActionMixin: def resize_shell_thickness(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再调整薄壁厚度。"): + if self._edit_busy("请等待当前编辑完成后再调整壳体厚度。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的薄壁/壳体平面候选。") + QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的壳体平面候选。") return if not hasattr(self, "shell_thickness_input"): return try: target_thickness = float(self.shell_thickness_input.text()) except ValueError: - QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标薄壁厚度。") + QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标壳体厚度。") return face_id = self.selected_face_id @@ -723,8 +742,8 @@ class WindowActionMixin: if plan is None: plan = self.model.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整薄壁厚度", str(plan["message"])) - self.statusBar().showMessage("薄壁厚度调整已阻止") + QMessageBox.information(self, "不能调整壳体厚度", str(plan["message"])) + self.statusBar().showMessage("壳体厚度调整已阻止") return if plan["risk"] != "low": @@ -732,7 +751,7 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认调整薄壁厚度", + "确认调整壳体厚度", ( f"face: {plan['face_id']}\n" f"当前厚度估算: {_format_value(plan['shell_current_thickness'])}\n" @@ -742,7 +761,7 @@ class WindowActionMixin: f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}\n" f"相对平面重叠率: {_format_percent(plan.get('shell_overlap_ratio_estimate'))}\n" f"置信度: {_format_value(plan.get('shell_confidence', ''))}\n" - f"当前面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"当前 Face 拉伸/切除距离: {_format_value(plan.get('push_pull_distance', ''))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -757,7 +776,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消薄壁厚度调整") + self.statusBar().showMessage("已取消壳体厚度调整") return isolation = self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness]) @@ -771,7 +790,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="调整薄壁厚度", + operation_name="调整壳体厚度", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -809,20 +828,20 @@ class WindowActionMixin: def resize_shell_thickness_owning_scale(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再执行薄壁厚度(整体)。"): + if self._edit_busy("请等待当前编辑完成后再执行壳体厚度(缩放特征)。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的薄壁/壳体平面候选。") + QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的壳体平面候选。") return if not hasattr(self, "shell_thickness_input"): return try: target_thickness = float(self.shell_thickness_input.text()) except ValueError: - QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标薄壁厚度。") + QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标壳体厚度。") return if target_thickness <= 0: - QMessageBox.critical(self, "目标厚度无效", "目标薄壁厚度必须大于 0。") + QMessageBox.critical(self, "目标厚度无效", "目标壳体厚度必须大于 0。") return face_id = self.selected_face_id @@ -840,14 +859,14 @@ class WindowActionMixin: f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}", - "当前语义: 沿薄壁/壳体厚度方向整体缩放所属特征或 Solid;不是推拉当前平面区域。", + "当前语义: 沿壳体厚度方向整体缩放所属特征或 Solid;不是拉伸/切除当前平面区域。", ] if not self._confirm_quick_edit_plan( - "薄壁厚度(整体)缩放所属对象", + "壳体厚度(缩放特征)缩放所属对象", plan, lines, - "薄壁厚度(整体)已阻止", - "已取消薄壁厚度(整体)", + "壳体厚度(缩放特征)已阻止", + "已取消壳体厚度(缩放特征)", ): return self.clear_edit_preview(render=False) @@ -857,7 +876,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="薄壁厚度(整体)缩放所属对象", + operation_name="壳体厚度(缩放特征)缩放所属对象", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -967,7 +986,7 @@ class WindowActionMixin: base = "当前 Face 位于复杂大 STEP 中,并且有内孔、内边界或复杂边界;不适合做只改当前 Face 的局部变形。" return ( f"{base} 为避免在界面线程生成完整局部重建计划时卡顿,已在轻量预检查阶段阻止;" - "请改用 `推拉当前面`、孔/槽专门入口、`移动整个特征` 或 `调整整个特征`。" + "请改用 `拉伸/切除`、孔/槽专门入口、`移动特征` 或 `缩放特征`。" ) def _quick_blocked_local_face_plan( @@ -1026,8 +1045,8 @@ class WindowActionMixin: face_id, blocker, resize_strategy="local-face-area-only-deform", - edit_strategy_label="只缩放当前Face面积", - edit_semantics="只移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + edit_strategy_label="面积(局部重建)", + edit_semantics="移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", info=info, extra={ "current_area": current_area, @@ -1054,12 +1073,12 @@ class WindowActionMixin: 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 "面宽" + axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" 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_strategy_label=f"{axis_label}(局部重建)", edit_semantics="只沿当前 Face 的一个面内方向移动边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", info=info, extra={ @@ -1099,8 +1118,8 @@ class WindowActionMixin: face_id, blocker, resize_strategy="local-face-only-deform", - edit_strategy_label="只移动当前Face", - edit_semantics="只移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", + edit_strategy_label="中心(局部重建)", + edit_semantics="移动当前 Face 的边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", info=info, extra={ "current_face_center": current_center, @@ -1134,7 +1153,7 @@ class WindowActionMixin: face_id, blocker, resize_strategy="local-face-plane-offset-deform", - edit_strategy_label="面偏移(当前面)", + edit_strategy_label="偏移曲面(局部重建)", edit_semantics="按当前面垂直方向移动 Face 边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", info=info, extra={ @@ -1168,10 +1187,10 @@ class WindowActionMixin: if target_thickness <= 0: return self._quick_blocked_local_face_plan( face_id, - "目标薄壁厚度必须大于 0。", + "目标壳体厚度必须大于 0。", resize_strategy="push-pull-shell-source-plane-to-target-thickness", - edit_strategy_label="薄壁厚度(当前面)", - edit_semantics="移动当前平面区域以接近目标薄壁厚度。", + edit_strategy_label="壳体厚度(拉伸/切除)", + edit_semantics="移动当前平面区域以接近目标壳体厚度。", info=info, extra={ "shell_current_thickness": current, @@ -1183,15 +1202,15 @@ class WindowActionMixin: 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 的薄壁相对面识别会放到后台隔离进程里执行,避免主界面先卡住。", - "执行前请确认这是想修改的局部薄壁区域,而不是孔/槽或台阶端面。", + "复杂大 STEP 的壳体相对面识别会放到后台隔离进程里执行,避免主界面先卡住。", + "执行前请确认这是想修改的局部壳体区域,而不是孔/槽或台阶端面。", ] return { "status": "caution", "risk": "high", "message": ( "为避免在界面线程扫描复杂模型里的相对面,本次只做快速预检查;" - "真正的薄壁相对面识别、推拉距离计算和结果校验会在后台隔离进程中完成。" + "真正的壳体相对面识别、拉伸/切除距离计算和结果校验会在后台隔离进程中完成。" ), "warnings": ";".join(warnings), "blockers": "", @@ -1213,10 +1232,10 @@ class WindowActionMixin: "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": "复杂薄壁计划延后到后台隔离进程重新计算。", + "push_pull_scope_note": "复杂壳体计划延后到后台隔离进程重新计算。", "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", - "edit_strategy_label": "薄壁厚度(当前面)", - "edit_semantics": "后台识别相对平面后,把当前平面区域推拉到目标薄壁厚度;失败会保持原模型不变。", + "edit_strategy_label": "壳体厚度(拉伸/切除)", + "edit_semantics": "后台识别相对平面后,把当前平面区域拉伸/切除到目标壳体厚度;失败会保持原模型不变。", "quick_preflight": True, "ui_deferred_model_plan": True, "ui_deferred_shell_thickness_plan": True, @@ -1406,17 +1425,17 @@ class WindowActionMixin: def _quick_edit_title_supports_isolation(self, title: str) -> bool: return title in { - "薄壁厚度(整体)缩放所属对象", - "只缩放当前Face面积", - "面面积(整体)", - "面宽(当前面)", - "面高(当前面)", - "面宽(整体)", - "面高(整体)", - "面偏移(当前面)", - "只移动当前Face", + "壳体厚度(缩放特征)缩放所属对象", + "面积(局部重建)", + "面面积(缩放特征)", + "U向尺寸(局部重建)", + "V向尺寸(局部重建)", + "U向尺寸(缩放特征)", + "V向尺寸(缩放特征)", + "偏移曲面(局部重建)", + "中心(局部重建)", "圆柱高度调整", - "高度(整体)缩放所属对象", + "高度(缩放特征)缩放所属对象", } def _isolation_for_plan( @@ -1428,7 +1447,7 @@ class WindowActionMixin: timeout_seconds: float = 180.0, ) -> dict[str, object] | None: risk = str(plan.get("risk", "")) - isolated_face_operations = { + isolated_geometry_operations = { "push_pull_face", "move_face_plane_offset_local", "resize_face_area_local", @@ -1445,8 +1464,31 @@ class WindowActionMixin: "resize_cone_semi_angle", "resize_sphere_radius", "resize_torus_radius", + "resize_cylindrical_hole", + "resize_cylindrical_owning_scale", + "move_cylindrical_hole_axis", + "suppress_cylindrical_hole", + "resize_cylindrical_depth", + "resize_cylindrical_depth_owning_scale", + "move_cylindrical_slot_axis", + "resize_cylindrical_slot_width", + "resize_cylindrical_slot_depth", + "resize_cylindrical_slot_arc_length", + "resize_cylindrical_slot_angular_span", + "resize_cylindrical_slot_total_length", + "resize_cylindrical_slot_center_distance", + "resize_general_edge_length", + "move_edge_endpoint", + "move_edge_center", + "move_circular_edge_axis_center", + "resize_ellipse_edge_axis_radius", + "resize_existing_fillet", + "fillet_edge", + "chamfer_edge", + "chamfer_edge_asymmetric", + "chamfer_edge_distance_angle", } - if operation not in isolated_face_operations: + if operation not in isolated_geometry_operations: return None if risk not in {"low", "medium", "high"}: return None @@ -1454,7 +1496,7 @@ class WindowActionMixin: "operation": operation, "args": args, "timeout_seconds": timeout_seconds, - "reason": f"{risk}-risk-face-occ-edit", + "reason": f"{risk}-risk-isolated-occ-edit", } def _edit_failure_diagnostics(self, context: dict[str, object]) -> str: @@ -1594,6 +1636,7 @@ class WindowActionMixin: "已取消圆柱孔径调整", ): return + isolation = self._isolation_for_plan(plan, "resize_cylindrical_hole", [face_id, diameter]) self.clear_edit_preview(render=False) def action(): @@ -1670,6 +1713,7 @@ class WindowActionMixin: "已取消圆柱尺寸缩放所属对象", ): return + isolation = self._isolation_for_plan(plan, "resize_cylindrical_owning_scale", [face_id, diameter]) self.clear_edit_preview(render=False) def action(): @@ -1715,6 +1759,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def move_cylindrical_hole_axis(self) -> None: @@ -1755,6 +1800,11 @@ class WindowActionMixin: "已取消圆柱孔轴心坐标修改", ): return + isolation = self._isolation_for_plan( + plan, + "move_cylindrical_hole_axis", + [face_id, list(target_center)], + ) self.clear_edit_preview(render=False) def action(): @@ -1799,6 +1849,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def move_cylindrical_slot_axis(self) -> None: @@ -1841,6 +1892,11 @@ class WindowActionMixin: "已取消槽/半孔轴心坐标修改", ): return + isolation = self._isolation_for_plan( + plan, + "move_cylindrical_slot_axis", + [face_id, list(target_center)], + ) self.clear_edit_preview(render=False) def action(): @@ -1889,6 +1945,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_width_owning_scale(self) -> None: @@ -1909,7 +1966,7 @@ class WindowActionMixin: "arc_length": ("弧长", "slot_arc_length_input", "slot_arc_length_estimate"), } label, input_attr, current_key = labels.get(mode, labels["width"]) - operation_name = f"{label}(整体)" + operation_name = f"{label}(缩放特征)" if self._edit_busy(f"请等待当前编辑完成后再执行{operation_name}。"): return if self.selected_face_id is None: @@ -1976,6 +2033,7 @@ class WindowActionMixin: f"已取消{operation_name}", ): return + isolation = self._isolation_for_plan(plan, "resize_cylindrical_owning_scale", [face_id, target_diameter]) self.clear_edit_preview(render=False) def action(): @@ -2033,6 +2091,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_width(self) -> None: @@ -2095,6 +2154,11 @@ class WindowActionMixin: "已取消槽/半孔宽度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_width", + [face_id, target_width, pair_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -2149,6 +2213,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_depth(self) -> None: @@ -2214,6 +2279,11 @@ class WindowActionMixin: "已取消槽/半孔深度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_depth", + [face_id, target_depth, pair_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -2270,6 +2340,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_arc_length(self) -> None: @@ -2337,6 +2408,11 @@ class WindowActionMixin: "已取消槽/半孔圆弧长度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_arc_length", + [face_id, target_arc_length, pair_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -2399,6 +2475,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_angular_span(self) -> None: @@ -2462,6 +2539,11 @@ class WindowActionMixin: "已取消槽/半孔圆弧角度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_angular_span", + [face_id, target_angular_span], + ) self.clear_edit_preview(render=False) def action(): @@ -2513,6 +2595,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_total_length(self) -> None: @@ -2563,6 +2646,11 @@ class WindowActionMixin: "已取消槽孔总长度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_total_length", + [face_id, target_total_length, pair_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -2606,6 +2694,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_slot_center_distance(self) -> None: @@ -2656,6 +2745,11 @@ class WindowActionMixin: "已取消槽孔中心距调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_slot_center_distance", + [face_id, target_center_distance, pair_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -2701,6 +2795,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_boss(self) -> None: @@ -2896,6 +2991,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_boss_height(self) -> None: @@ -2935,7 +3031,7 @@ class WindowActionMixin: f"目标凸台高度: {_format_value(plan['target_height'])}\n" f"高度变化量: {_format_value(plan['delta_height'])}\n" f"高度变化比例: {_format_percent(plan['height_delta_ratio'])}\n" - f"端盖推拉距离: {_format_value(plan.get('push_pull_distance'))}\n" + f"端盖拉伸/切除距离: {_format_value(plan.get('push_pull_distance'))}\n" f"候选判断: {plan.get('feature_guess')}\n" f"置信度: {plan.get('confidence')}\n" f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" @@ -2943,7 +3039,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会推拉识别到的凸台端盖,真实修改当前 B-Rep 几何。\n\n" + "继续操作会拉伸/切除识别到的凸台端盖,真实修改当前 B-Rep 几何。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -3023,9 +3119,9 @@ class WindowActionMixin: f"当前圆柱高度: {_format_value(plan.get('current_height'))}", f"目标圆柱高度: {_format_value(plan.get('target_height'))}", f"高度变化量: {_format_value(plan.get('delta_height'))}", - f"端盖推拉距离: {_format_value(plan.get('push_pull_distance'))}", + f"端盖拉伸/切除距离: {_format_value(plan.get('push_pull_distance'))}", f"候选判断: {plan.get('feature_guess')}", - "当前版本会推拉识别到的圆柱端盖,不依赖 CAD 原始建模历史。", + "当前版本会拉伸/切除识别到的圆柱端盖,不依赖 CAD 原始建模历史。", ] if not self._confirm_quick_edit_plan( "圆柱高度调整", @@ -3078,7 +3174,7 @@ class WindowActionMixin: def resize_cylindrical_height_owning_scale(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再执行高度(整体)。"): + if self._edit_busy("请等待当前编辑完成后再执行高度(缩放特征)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择圆柱面", "请先选择一个可测高度的圆柱 Face。") @@ -3105,14 +3201,14 @@ class WindowActionMixin: f"缩放比例: {_format_value(plan.get('affine_scale'))}", f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", f"轴向: {_format_value(plan.get('affine_axis_direction'))}", - "当前语义: 沿圆柱轴向整体缩放所属特征或 Solid;不是推拉单个端盖。", + "当前语义: 沿圆柱轴向整体缩放所属特征或 Solid;不是拉伸/切除单个端盖。", ] if not self._confirm_quick_edit_plan( - "高度(整体)缩放所属对象", + "高度(缩放特征)缩放所属对象", plan, lines, - "高度(整体)已阻止", - "已取消高度(整体)", + "高度(缩放特征)已阻止", + "已取消高度(缩放特征)", ): return self.clear_edit_preview(render=False) @@ -3122,7 +3218,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="高度(整体)缩放所属对象", + operation_name="高度(缩放特征)缩放所属对象", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -3220,6 +3316,7 @@ class WindowActionMixin: return face_id = self.selected_face_id + isolation = self._isolation_for_plan(plan, "suppress_cylindrical_hole", [face_id]) self._show_cylinder_suppress_preview(face_id) def action(): @@ -3438,6 +3535,11 @@ class WindowActionMixin: "已取消盲孔/盲槽深度调整", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_depth", + [face_id, target_depth, bottom_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -3484,12 +3586,13 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_hole_depth_owning_scale(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再执行盲孔/盲槽深度(整体)。"): + if self._edit_busy("请等待当前编辑完成后再执行盲孔/盲槽深度(缩放特征)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择圆柱面", "请先选择一个盲孔或盲槽的圆柱面。") @@ -3529,13 +3632,18 @@ class WindowActionMixin: if bottom_face_id is not None: lines.append(f"手动底面 Face: {bottom_face_id}") if not self._confirm_quick_edit_plan( - "盲孔/盲槽深度(整体)缩放所属对象", + "盲孔/盲槽深度(缩放特征)缩放所属对象", plan, lines, - "盲孔/盲槽深度(整体)已阻止", - "已取消盲孔/盲槽深度(整体)", + "盲孔/盲槽深度(缩放特征)已阻止", + "已取消盲孔/盲槽深度(缩放特征)", ): return + isolation = self._isolation_for_plan( + plan, + "resize_cylindrical_depth_owning_scale", + [face_id, target_depth, bottom_face_id], + ) self.clear_edit_preview(render=False) def action(): @@ -3547,7 +3655,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="盲孔/盲槽深度(整体)缩放所属对象", + operation_name="盲孔/盲槽深度(缩放特征)缩放所属对象", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -3588,6 +3696,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def resize_existing_fillet(self) -> None: @@ -3606,8 +3715,7 @@ class WindowActionMixin: face_id = self.selected_face_id plan = self.model.existing_fillet_resize_plan(face_id, target_radius) - if self._block_unisolated_high_risk_operation("已阻止高风险已有圆角修改", plan): - return + isolation = self._isolation_for_plan(plan, "resize_existing_fillet", [face_id, target_radius]) if plan["status"] == "blocked": QMessageBox.information(self, "不能修改已有圆角", str(plan["message"])) self.statusBar().showMessage("已有圆角半径修改已阻止") @@ -3682,6 +3790,7 @@ class WindowActionMixin: }, target_kind="face", target_id=face_id, + isolation=isolation, ) def fillet_edge(self) -> None: @@ -3700,8 +3809,7 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_fillet_plan(edge_id, radius) - if self._block_unisolated_high_risk_operation("已阻止高风险边圆角", plan): - return + isolation = self._isolation_for_plan(plan, "fillet_edge", [edge_id, radius]) if plan["status"] == "blocked": QMessageBox.information(self, "不能添加圆角", str(plan["message"])) self.statusBar().showMessage("添加圆角已阻止") @@ -3764,6 +3872,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def chamfer_edge(self) -> None: @@ -3782,8 +3891,7 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_chamfer_plan(edge_id, distance) - if self._block_unisolated_high_risk_operation("已阻止高风险边倒角", plan): - return + isolation = self._isolation_for_plan(plan, "chamfer_edge", [edge_id, distance]) if plan["status"] == "blocked": QMessageBox.information(self, "不能添加倒角", str(plan["message"])) self.statusBar().showMessage("添加倒角已阻止") @@ -3846,6 +3954,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def chamfer_edge_asymmetric(self) -> None: @@ -3874,8 +3983,11 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) - if self._block_unisolated_high_risk_operation("已阻止高风险不等距倒角", plan): - return + isolation = self._isolation_for_plan( + plan, + "chamfer_edge_asymmetric", + [edge_id, distance1, distance2, reference_face_id], + ) lines = [ f"Edge: {edge_id}", f"Edge长度: {_format_value(plan.get('edge_length'))}", @@ -3929,6 +4041,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def chamfer_edge_distance_angle(self) -> None: @@ -3957,8 +4070,11 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) - if self._block_unisolated_high_risk_operation("已阻止高风险距离+角度倒角", plan): - return + isolation = self._isolation_for_plan( + plan, + "chamfer_edge_distance_angle", + [edge_id, distance, angle_degrees, reference_face_id], + ) lines = [ f"Edge: {edge_id}", f"Edge长度: {_format_value(plan.get('edge_length'))}", @@ -4011,6 +4127,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def resize_edge_length(self) -> None: @@ -4036,6 +4153,11 @@ class WindowActionMixin: anchor_mode=anchor_mode, strategy_mode=strategy_mode, ) + isolation = self._isolation_for_plan( + plan, + "resize_general_edge_length", + [edge_id, target_length, anchor_mode, strategy_mode], + ) if plan["status"] == "blocked": QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) self.statusBar().showMessage("Edge长度修改已阻止") @@ -4062,7 +4184,7 @@ class WindowActionMixin: f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\n" f"端面Face: {_format_value(plan.get('end_face_id', ''))}\n" f"端面位置: {_format_value(plan.get('end_face_label', ''))}\n" - f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"端面拉伸/切除距离: {_format_value(plan.get('push_pull_distance', ''))}\n" f"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" @@ -4074,7 +4196,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "当前会优先只移动被选直线Edge的端点并重建周边平面;没有明确局部路径时会尝试端面推拉、相邻圆柱直径编辑或几何缩放 fallback。" + "当前会优先只移动被选直线Edge的端点并重建周边平面;没有明确局部路径时会尝试端面拉伸/切除、相邻圆柱直径编辑或几何缩放 fallback。" "这不是通用参数化Edge长度编辑。\n\n" "确定继续吗?" ), @@ -4171,6 +4293,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def resize_any_edge_length(self) -> None: @@ -4196,6 +4319,11 @@ class WindowActionMixin: anchor_mode=anchor_mode, strategy_mode=strategy_mode, ) + isolation = self._isolation_for_plan( + plan, + "resize_general_edge_length", + [edge_id, target_length, anchor_mode, strategy_mode], + ) if plan["status"] == "blocked": QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) self.statusBar().showMessage("Edge长度修改已阻止") @@ -4223,7 +4351,7 @@ class WindowActionMixin: f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n" f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\n" f"端面Face: {_format_value(plan.get('end_face_id', ''))}\n" - f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"端面拉伸/切除距离: {_format_value(plan.get('push_pull_distance', ''))}\n" f"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" @@ -4334,6 +4462,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def resize_ellipse_edge_major_radius(self) -> None: @@ -4363,6 +4492,11 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind=axis_kind) + isolation = self._isolation_for_plan( + plan, + "resize_ellipse_edge_axis_radius", + [edge_id, target_radius, axis_kind], + ) if plan["status"] == "blocked": QMessageBox.information(self, f"不能修改椭圆Edge{axis_label}", str(plan["message"])) self.statusBar().showMessage(f"椭圆Edge{axis_label}修改已阻止") @@ -4431,6 +4565,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def _edge_length_anchor_mode_from_ui(self) -> str: @@ -4485,6 +4620,7 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.circular_edge_axis_move_plan(edge_id, target_center) + isolation = self._isolation_for_plan(plan, "move_circular_edge_axis_center", [edge_id, target_center]) lines = [ f"Edge: {edge_id}", f"当前圆心: {_format_value(plan.get('current_edge_center'))}", @@ -4547,6 +4683,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def move_edge_center_point(self) -> None: @@ -4569,6 +4706,7 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_center_move_plan(edge_id, target_center) + isolation = self._isolation_for_plan(plan, "move_edge_center", [edge_id, target_center]) lines = [ f"Edge: {edge_id}", f"Current center: {_format_value(plan.get('current_edge_center'))}", @@ -4621,6 +4759,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def _move_edge_endpoint(self, endpoint_role: str) -> None: @@ -4638,6 +4777,7 @@ class WindowActionMixin: edge_id = self.selected_edge_id plan = self.model.edge_endpoint_move_plan(edge_id, endpoint_role, target_point) + isolation = self._isolation_for_plan(plan, "move_edge_endpoint", [edge_id, endpoint_role, target_point]) title = "移动Edge起点" if endpoint_role == "start" else "移动Edge终点" lines = [ f"Edge: {edge_id}", @@ -4698,6 +4838,7 @@ class WindowActionMixin: }, target_kind="edge", target_id=edge_id, + isolation=isolation, ) def resize_cone_reference_radius(self) -> None: @@ -4860,7 +5001,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修改圆锥半角(整体)", + operation_name="修改圆锥半角(缩放特征)", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -4899,7 +5040,7 @@ class WindowActionMixin: def resize_sphere_radius(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再修改球面(整体)。"): + if self._edit_busy("请等待当前编辑完成后再修改球面(缩放特征)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择球面", "请先选择一个球面 Face 或球面特征。") @@ -4916,8 +5057,8 @@ class WindowActionMixin: plan = self.model.spherical_radius_plan(face_id, target_radius) isolation = self._isolation_for_plan(plan, "resize_sphere_radius", [face_id, target_radius]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"])) - self.statusBar().showMessage("球面(整体)修改已阻止") + QMessageBox.information(self, "不能修改球面(缩放特征)", str(plan["message"])) + self.statusBar().showMessage("球面(缩放特征)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -4929,7 +5070,7 @@ class WindowActionMixin: ) result = QMessageBox.question( self, - "确认修改球面(整体)", + "确认修改球面(缩放特征)", ( f"Face: {face_id}\n" f"当前半径: {_format_value(plan.get('current_radius'))}\n" @@ -4950,7 +5091,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消球面(整体)修改") + self.statusBar().showMessage("已取消球面(缩放特征)修改") return self.clear_edit_preview(render=False) @@ -4959,7 +5100,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修改球面半径(整体)", + operation_name="修改球面半径(缩放特征)", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -5000,7 +5141,7 @@ class WindowActionMixin: def _resize_torus_radius(self, mode: str) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再修改环面(整体)。"): + if self._edit_busy("请等待当前编辑完成后再修改环面(缩放特征)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择环面", "请先选择一个环面 Face 或环面特征。") @@ -5014,13 +5155,13 @@ class WindowActionMixin: return mode_key = "major" if mode == "major" else "minor" - mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)" + mode_label = "主半径(缩放特征)" if mode_key == "major" else "小半径(缩放特征)" face_id = self.selected_face_id plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key) isolation = self._isolation_for_plan(plan, "resize_torus_radius", [face_id, target_radius, mode_key]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"])) - self.statusBar().showMessage("环面(整体)修改已阻止") + QMessageBox.information(self, "不能修改环面(缩放特征)", str(plan["message"])) + self.statusBar().showMessage("环面(缩放特征)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -5055,7 +5196,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消环面(整体)修改") + self.statusBar().showMessage("已取消环面(缩放特征)修改") return self.clear_edit_preview(render=False) @@ -5196,7 +5337,7 @@ class WindowActionMixin: def resize_face_area_local(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再只缩放当前Face面积。"): + if self._edit_busy("请等待当前编辑完成后再执行面积(局部重建)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5225,11 +5366,11 @@ class WindowActionMixin: f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", ] if not self._confirm_quick_edit_plan( - "只缩放当前Face面积", + "面积(局部重建)", plan, lines, - "只缩放当前Face面积已阻止", - "已取消只缩放当前Face面积", + "面积(局部重建)已阻止", + "已取消面积(局部重建)", ): return self.clear_edit_preview(render=False) @@ -5239,7 +5380,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="只缩放当前Face面积", + operation_name="面积(局部重建)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5287,8 +5428,8 @@ class WindowActionMixin: if self.model is None: return axis_key = "height" if str(axis).lower() == "height" else "width" - axis_label = "面高" if axis_key == "height" else "面宽" - if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(当前面)。"): + axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(局部重建)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5308,11 +5449,11 @@ class WindowActionMixin: plan = self.model.face_size_local_resize_plan(face_id, target_size, axis_key) lines = [ f"Face: {face_id}", - f"修改对象: {axis_label}(当前面)", - f"当前面宽: {_format_value(plan.get('current_face_width'))}", - f"目标面宽: {_format_value(plan.get('target_face_width'))}", - f"当前面高: {_format_value(plan.get('current_face_height'))}", - f"目标面高: {_format_value(plan.get('target_face_height'))}", + f"修改对象: {axis_label}(局部重建)", + f"当前U向尺寸: {_format_value(plan.get('current_face_width'))}", + f"目标U向尺寸: {_format_value(plan.get('target_face_width'))}", + f"当前V向尺寸: {_format_value(plan.get('current_face_height'))}", + f"目标V向尺寸: {_format_value(plan.get('target_face_height'))}", f"变化量: {_format_value(plan.get('face_size_delta'))}", f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", f"缩放比例: {_format_value(plan.get('face_size_scale'))}", @@ -5322,11 +5463,11 @@ class WindowActionMixin: f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", ] if not self._confirm_quick_edit_plan( - f"{axis_label}(当前面)", + f"{axis_label}(局部重建)", plan, lines, - f"{axis_label}(当前面)已阻止", - f"已取消{axis_label}(当前面)", + f"{axis_label}(局部重建)已阻止", + f"已取消{axis_label}(局部重建)", ): return self.clear_edit_preview(render=False) @@ -5336,7 +5477,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name=f"修改{axis_label}(当前面)", + operation_name=f"修改{axis_label}(局部重建)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5381,8 +5522,8 @@ class WindowActionMixin: if self.model is None: return axis_key = "height" if str(axis).lower() == "height" else "width" - axis_label = "面高" if axis_key == "height" else "面宽" - if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(整体)。"): + axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(缩放特征)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5400,11 +5541,11 @@ class WindowActionMixin: plan = self.model.face_size_owning_scale_plan(face_id, target_size, axis_key) lines = [ f"Face: {face_id}", - f"修改对象: {axis_label}(整体)", - f"当前面宽: {_format_value(plan.get('current_face_width'))}", - f"目标面宽: {_format_value(plan.get('target_face_width'))}", - f"当前面高: {_format_value(plan.get('current_face_height'))}", - f"目标面高: {_format_value(plan.get('target_face_height'))}", + f"修改对象: {axis_label}(缩放特征)", + f"当前U向尺寸: {_format_value(plan.get('current_face_width'))}", + f"目标U向尺寸: {_format_value(plan.get('target_face_width'))}", + f"当前V向尺寸: {_format_value(plan.get('current_face_height'))}", + f"目标V向尺寸: {_format_value(plan.get('target_face_height'))}", f"变化量: {_format_value(plan.get('face_size_delta'))}", f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", f"缩放比例: {_format_value(plan.get('face_size_scale'))}", @@ -5417,11 +5558,11 @@ class WindowActionMixin: f"影响说明: {_format_value(plan.get('affine_transform_note'))}", ] if not self._confirm_quick_edit_plan( - f"{axis_label}(整体)", + f"{axis_label}(缩放特征)", plan, lines, - f"{axis_label}(整体)已阻止", - f"已取消{axis_label}(整体)", + f"{axis_label}(缩放特征)已阻止", + f"已取消{axis_label}(缩放特征)", ): return self.clear_edit_preview(render=False) @@ -5431,7 +5572,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name=f"修改{axis_label}(整体)", + operation_name=f"修改{axis_label}(缩放特征)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5484,7 +5625,7 @@ class WindowActionMixin: def move_selected_face_plane_position_by_translation(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再按面偏移平移所属对象。"): + if self._edit_busy("请等待当前编辑完成后再按偏移曲面平移所属对象。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5492,14 +5633,14 @@ class WindowActionMixin: try: distance = float(self.offset_input.text()) except (AttributeError, ValueError): - QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。") + QMessageBox.critical(self, "偏移曲面无效", "请输入数字形式的目标偏移曲面。") return face_id = self.selected_face_id plan = self.model.face_plane_offset_owning_translation_plan(face_id, distance) if plan["status"] == "blocked": - QMessageBox.information(self, "不能按面偏移平移", str(plan["message"])) - self.statusBar().showMessage("面偏移整体平移已阻止") + QMessageBox.information(self, "不能按偏移曲面平移", str(plan["message"])) + self.statusBar().showMessage("偏移曲面整体平移已阻止") return current_position = float(plan.get("current_plane_position", 0.0) or 0.0) target_position = float(plan.get("target_plane_position", current_position) or current_position) @@ -5511,7 +5652,7 @@ class WindowActionMixin: target_position, plane_direction, ): - self.statusBar().showMessage("已取消面偏移整体平移") + self.statusBar().showMessage("已取消偏移曲面整体平移") return self.clear_edit_preview(render=False) @@ -5522,7 +5663,7 @@ class WindowActionMixin: moved_id = plan.get("solid_id") if moved_kind == "solid" else plan.get("part_id") self._run_edit_action( action, - operation_name="面偏移(整体)", + operation_name="偏移曲面(移动特征)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5543,8 +5684,8 @@ class WindowActionMixin: "translate_warnings": plan.get("warnings"), "translate_blockers": plan.get("blockers"), "resize_strategy": "translate-owning-shape-from-plane-offset", - "edit_strategy_label": "按面偏移平移所属对象", - "edit_semantics": "把目标面偏移换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。", + "edit_strategy_label": "按偏移曲面平移所属对象", + "edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", }, target_kind="face", target_id=face_id, @@ -5553,7 +5694,7 @@ class WindowActionMixin: def move_selected_face_plane_position_local(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再按面偏移只移动当前Face。"): + if self._edit_busy("请等待当前编辑完成后再执行偏移曲面(局部重建)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5561,7 +5702,7 @@ class WindowActionMixin: try: distance = float(self.offset_input.text()) except (AttributeError, ValueError): - QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。") + QMessageBox.critical(self, "偏移曲面无效", "请输入数字形式的目标偏移曲面。") return face_id = self.selected_face_id @@ -5570,8 +5711,8 @@ class WindowActionMixin: plan = self.model.face_plane_offset_local_plan(face_id, distance) lines = [ f"Face: {face_id}", - f"当前面偏移: {_format_value(plan.get('current_plane_position'))}", - f"目标面偏移: {_format_value(plan.get('target_plane_position'))}", + f"当前偏移曲面: {_format_value(plan.get('current_plane_position'))}", + f"目标偏移曲面: {_format_value(plan.get('target_plane_position'))}", f"平面方向: {_format_value(plan.get('plane_direction'))}", f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", @@ -5581,11 +5722,11 @@ class WindowActionMixin: f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", ] if not self._confirm_quick_edit_plan( - "面偏移(当前面)", + "偏移曲面(局部重建)", plan, lines, - "面偏移(当前面)已阻止", - "已取消面偏移(当前面)", + "偏移曲面(局部重建)已阻止", + "已取消偏移曲面(局部重建)", ): return self.clear_edit_preview(render=False) @@ -5595,7 +5736,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="面偏移(当前面)", + operation_name="偏移曲面(局部重建)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5617,10 +5758,10 @@ class WindowActionMixin: "local_face_deform_face_count": plan.get("local_face_deform_face_count"), "local_face_deform_moved_point_count": plan.get("local_face_deform_moved_point_count"), "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "按面偏移只移动当前Face", + "edit_strategy_label": "偏移曲面(局部重建)", "edit_semantics": ( - "把目标面偏移换算成沿当前面垂直方向的移动量,只移动当前 Face 的顶点并重建相邻平面;" - "不推拉加料/切削,也不平移所属对象。" + "把目标偏移曲面换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" + "不拉伸/切除加料/切削,也不平移所属对象。" ), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), @@ -5713,7 +5854,7 @@ class WindowActionMixin: def move_selected_face_center_local(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再只移动当前Face。"): + if self._edit_busy("请等待当前编辑完成后再执行中心(局部重建)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5727,7 +5868,7 @@ class WindowActionMixin: face_id = self.selected_face_id current_center = self._selected_face_center_for_translation(face_id) if current_center is None: - QMessageBox.information(self, "不能只移动当前Face", "当前 Face 缺少稳定中心坐标。") + QMessageBox.information(self, "不能执行中心(局部重建)", "当前 Face 缺少稳定中心坐标。") return target_center = ( current_center[0] + vector[0], @@ -5739,7 +5880,7 @@ class WindowActionMixin: plan = self.model.face_center_local_move_plan(face_id, target_center) lines = [ f"Face: {face_id}", - f"当前Face中心: {_format_value(plan.get('current_face_center'))}", + f"当前 Face 中心: {_format_value(plan.get('current_face_center'))}", f"目标Face中心: {_format_value(plan.get('target_face_center'))}", f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", @@ -5749,11 +5890,11 @@ class WindowActionMixin: f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", ] if not self._confirm_quick_edit_plan( - "只移动当前Face", + "中心(局部重建)", plan, lines, - "只移动当前Face已阻止", - "已取消只移动当前Face", + "中心(局部重建)已阻止", + "已取消中心(局部重建)", ): return self.clear_edit_preview(render=False) @@ -5763,7 +5904,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="只移动当前Face", + operation_name="中心(局部重建)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -6012,7 +6153,7 @@ class WindowActionMixin: f"移动对象: {_format_value(plan.get('target_kind', ''))}\n" f"零件: {_format_value(plan.get('part_id', ''))}\n" f"Solid: {_format_value(plan.get('solid_id', ''))}\n" - f"当前Face中心: {_format_value(current_center)}\n" + f"当前 Face 中心: {_format_value(current_center)}\n" f"目标Face中心: {_format_value(target_center)}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n" @@ -6043,23 +6184,23 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认面偏移整体平移", + "确认偏移曲面整体平移", ( f"Face: {face_id}\n" f"移动对象: {_format_value(plan.get('target_kind', ''))}\n" f"零件: {_format_value(plan.get('part_id', ''))}\n" f"Solid: {_format_value(plan.get('solid_id', ''))}\n" - f"当前面偏移: {_format_value(current_position)}\n" - f"目标面偏移: {_format_value(target_position)}\n" + f"当前偏移曲面: {_format_value(current_position)}\n" + f"目标偏移曲面: {_format_value(target_position)}\n" f"平面方向: {_format_value(plane_direction)}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n" - f"编辑策略: 按面偏移平移所属对象\n" - f"编辑方式: 沿当前面垂直方向平移所属特征或 Solid;不推拉当前面,不切削,也不补料。\n" + f"编辑策略: 按偏移曲面平移所属对象\n" + f"编辑方式: 沿当前面垂直方向平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "如果你想改变厚度或把这个面推出/切入,请在“面偏移”这一行把影响范围选为“推拉当前面”。确定继续吗?" + "如果你想改变厚度或把这个面推出/切入,请在“偏移曲面”这一行把影响范围选为“拉伸/切除”。确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, @@ -6920,7 +7061,7 @@ class WindowActionMixin: return target_logical_id = self._edit_target_logical_id(target_kind, target_id) result_deflection = float(getattr(self, "edit_result_deflection", 1.6)) - if operation_name == "推拉平面": + if operation_name == "拉伸/切除平面": result_deflection = max(result_deflection, 2.4) context = { "operation_name": operation_name, @@ -7017,7 +7158,8 @@ class WindowActionMixin: edge_polydata = None try: deflection = float(context.get("edit_result_deflection", 1.6)) - model_polydata = self.model.build_face_polydata(deflection=deflection) + face_polydata = self.model.build_face_polydata(deflection=deflection) + model_polydata = _smooth_surface_polydata(face_polydata) edge_polydata = self.model.build_edge_polydata( deflection=deflection, show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) @@ -7160,7 +7302,8 @@ class WindowActionMixin: edge_polydata = None try: deflection = float(context.get("edit_result_deflection", 1.6)) - model_polydata = new_model.build_face_polydata(deflection=deflection) + face_polydata = new_model.build_face_polydata(deflection=deflection) + model_polydata = _smooth_surface_polydata(face_polydata) edge_polydata = new_model.build_edge_polydata( deflection=deflection, show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)), @@ -7225,7 +7368,7 @@ class WindowActionMixin: context: dict[str, object], result_message: str, ) -> None: - if not self._context_is_face_parameter_edit(context): + if not self._context_should_preserve_face_logical_id(context): return target_logical_id = context.get("target_logical_id") if target_logical_id is None: @@ -7238,16 +7381,39 @@ class WindowActionMixin: logical_id = int(target_logical_id) except (TypeError, ValueError): return - if not (0 <= face_id < len(model.faces)): - return - try: - face_ids = model.face_region_ids(face_id) or [face_id] - except Exception: - face_ids = [face_id] - try: - model.assign_logical_face_region_exclusive(logical_id, face_ids) - except Exception: - return + candidate_ids: list[int] = [] + if 0 <= face_id < len(model.faces): + candidate_ids.append(face_id) + parameters = context.get("parameters") + parameters = parameters if isinstance(parameters, dict) else {} + target_position = _float_or_none(parameters.get("target_plane_position")) + plane_direction = ( + _unit_triple_or_none(parameters.get("plane_direction")) + or _unit_triple_or_none(parameters.get("outward_direction")) + ) + if target_position is not None and plane_direction is not None: + candidate_ids.extend( + self._face_ids_at_plane_position( + model, + target_position, + plane_direction, + _float_or_none(parameters.get("bbox_diagonal")), + self._edit_integrity_int_or_none(parameters.get("part_id")), + self._edit_integrity_int_or_none(parameters.get("solid_id")), + ) + ) + for candidate_id in candidate_ids: + if not (0 <= int(candidate_id) < len(model.faces)): + continue + try: + face_ids = model.face_region_ids(int(candidate_id)) or [int(candidate_id)] + except Exception: + face_ids = [int(candidate_id)] + try: + model.assign_logical_face_region_exclusive(logical_id, face_ids) + return + except Exception: + continue def _edit_context_part_id(self, context: dict[str, object]) -> int | None: if self.model is None: @@ -7397,7 +7563,7 @@ class WindowActionMixin: target_width = _float_or_none(parameters.get("target_face_width")) target_height = _float_or_none(parameters.get("target_face_height")) if target_width is not None or target_height is not None: - checks.append(("面宽/面高", self._face_target_size_matches(model, face_ids, target_width, target_height))) + checks.append(("U向尺寸/V向尺寸", self._face_target_size_matches(model, face_ids, target_width, target_height))) target_center = _triple_or_none(parameters.get("target_face_center")) if target_center is not None: @@ -7411,7 +7577,7 @@ class WindowActionMixin: if target_position is not None and plane_direction is not None: checks.append( ( - "面偏移", + "偏移曲面", self._face_target_plane_position_matches( model, face_ids, @@ -7426,7 +7592,7 @@ class WindowActionMixin: if target_thickness is not None and target_thickness > 0: checks.append( ( - "薄壁厚度", + "壳体厚度", self._face_target_shell_thickness_matches(model, face_ids, target_thickness), ) ) @@ -7585,6 +7751,10 @@ class WindowActionMixin: result = filtered(candidates) if result: + if surface == "plane" and parameters.get("target_plane_position") not in {"", None}: + for face_id in filtered(range(len(model.faces))): + if face_id not in result: + result.append(face_id) return result if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value: result = filtered(range(len(model.faces))) @@ -7668,9 +7838,11 @@ class WindowActionMixin: tolerance = max((bbox_diagonal or 1.0) * 1e-4, abs(target_position) * 1e-5, 1e-4) for face_id in face_ids: try: - info = model.face_info(face_id) + info = model.quick_face_info(face_id) except Exception: continue + if str(info.get("surface") or "") != "plane": + continue origin = _triple_or_none(info.get("plane_origin")) if origin is None: continue @@ -7679,6 +7851,36 @@ class WindowActionMixin: return True return False + def _face_ids_at_plane_position( + self, + model: StepModel, + target_position: float, + direction: tuple[float, float, float], + bbox_diagonal: float | None, + part_id: int | None, + solid_id: int | None, + ) -> list[int]: + tolerance = max((bbox_diagonal or 1.0) * 1e-4, abs(target_position) * 1e-5, 1e-4) + matches: list[int] = [] + for face_id in range(len(model.faces)): + if part_id is not None and int(model.face_part_ids[face_id]) != part_id: + continue + if solid_id is not None and solid_id >= 0 and int(model.face_solid_ids[face_id]) != solid_id: + continue + try: + info = model.quick_face_info(face_id) + except Exception: + continue + if str(info.get("surface") or "") != "plane": + continue + origin = _triple_or_none(info.get("plane_origin")) + if origin is None: + continue + position = sum(origin[index] * direction[index] for index in range(3)) + if abs(position - target_position) <= tolerance: + matches.append(face_id) + return matches + def _face_target_shell_thickness_matches( self, model: StepModel, @@ -7779,14 +7981,53 @@ class WindowActionMixin: return True operation = str(context.get("operation_name") or "") target_kind = str(context.get("target_kind") or "") - return target_kind in {"face", "feature"} and any( - token in operation for token in ("Face", "面", "薄壁", "推拉", "圆锥", "球面", "环面") + face_target_keys = ( + "target_area", + "target_face_width", + "target_face_height", + "target_face_center", + "target_plane_position", + "shell_target_thickness", + "target_reference_radius", + "target_semi_angle_degrees", + "embedded_cone_target_small_radius", + "embedded_cone_target_large_radius", + "target_radius", + "target_major_radius", + "target_minor_radius", ) + if target_kind in {"face", "feature"} and any(parameters.get(key) not in {"", None} for key in face_target_keys): + return True + return target_kind in {"face", "feature"} and any( + token in operation + for token in ( + "Face", + "面", + "面积", + "中心", + "U向尺寸", + "V向尺寸", + "偏移曲面", + "壳体", + "拉伸/切除", + "圆锥", + "球面", + "环面", + ) + ) + + def _context_should_preserve_face_logical_id(self, context: dict[str, object]) -> bool: + if self._context_is_face_parameter_edit(context): + return True + target_kind = str(context.get("target_kind") or "") + if target_kind not in {"face", "feature"}: + return False + return context.get("target_id") not in {"", None} and context.get("target_logical_id") not in {"", None} def _begin_edit_task(self, operation_name: str) -> None: self.operation_in_progress = True self._update_action_states() - if "推拉" in operation_name: + if "拉伸/切除" in operation_name: progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。" elif "孔径" in operation_name: progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步红/绿预览。" @@ -7799,7 +8040,7 @@ class WindowActionMixin: elif "槽/半孔圆弧长度" in operation_name: progress_text = f"{operation_name} 正在后台计算;圆弧长度会在后台换算并重建孔/槽几何。" elif "凸台高度" in operation_name: - progress_text = f"{operation_name} 正在后台计算,预览表示将推拉的凸台端盖范围。" + progress_text = f"{operation_name} 正在后台计算,预览表示将拉伸/切除的凸台端盖范围。" elif "凸台" in operation_name: progress_text = f"{operation_name} 正在后台计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围。" elif "封堵" in operation_name: @@ -7814,20 +8055,20 @@ class WindowActionMixin: elif "倒角" in operation_name: progress_text = f"{operation_name} 正在后台计算,橙色预览表示目标边和倒角距离范围。" elif "边长" in operation_name or "Edge长度" in operation_name: - progress_text = f"{operation_name} 正在后台计算,预览表示局部边形变、端面推拉或后备策略后的目标范围。" - elif "只缩放当前Face面积" in operation_name: + progress_text = f"{operation_name} 正在后台计算,预览表示局部边形变、端面拉伸/切除或后备策略后的目标范围。" + elif "面积(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会在面内移动所选 Face 顶点并重建相邻平面。" - elif "面宽(整体)" in operation_name or "面高(整体)" in operation_name: + elif "U向尺寸(缩放特征)" in operation_name or "V向尺寸(缩放特征)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会沿选中 Face 的平面内方向缩放所属特征或 Solid,其它几何会跟随变化。" - elif "面宽(当前面)" in operation_name or "面高(当前面)" in operation_name: + elif "U向尺寸(局部重建)" in operation_name or "V向尺寸(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。" - elif "面偏移(当前面)" in operation_name: + elif "偏移曲面(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向移动所选 Face 顶点并重建相邻平面。" - elif "面偏移(整体)" in operation_name: - progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向平移所属特征或 Solid,不推拉当前面。" + elif "偏移曲面(移动特征)" in operation_name: + progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向平移所属特征或 Solid,不拉伸/切除。" elif "Face中心" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不做单面局部扭曲。" - elif "只移动当前Face" in operation_name: + elif "中心(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会移动所选 Face 顶点并重建相邻平面。" elif "按轴心平移" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不重切孔/槽/凸台。" diff --git a/step_editor/window_core.py b/step_editor/window_core.py index d9d8894..2ae07d1 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -149,9 +149,9 @@ class WindowCoreMixin: self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start) self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end) if hasattr(self.interactor, "SetDesiredUpdateRate"): - self.interactor.SetDesiredUpdateRate(24.0) + self.interactor.SetDesiredUpdateRate(60.0) if hasattr(self.interactor, "SetStillUpdateRate"): - self.interactor.SetStillUpdateRate(0.2) + self.interactor.SetStillUpdateRate(1.0) light = vtk.vtkLight() light.SetLightTypeToHeadlight() @@ -252,7 +252,8 @@ class WindowCoreMixin: "show_internal_edges": show_internal_edges, } if build_polydata: - result["model_polydata"] = new_model.build_face_polydata(deflection=deflection) + face_polydata = new_model.build_face_polydata(deflection=deflection) + result["model_polydata"] = _smooth_surface_polydata(face_polydata) result["edge_polydata"] = new_model.build_edge_polydata( deflection=deflection, show_same_domain_internal_edges=show_internal_edges, @@ -297,8 +298,8 @@ class WindowCoreMixin: @Slot(object) def _start_initial_load_worker(self, expected_path: Path) -> None: # Qt/VTK/OCCT visualization objects are not safe to construct from this - # worker path in the current PySide build. Keep the old entry point as a - # main-thread fallback so stale signal paths cannot crash the process. + # worker path in the current PySide build. Keep this as a main-thread + # fallback so stale signal paths cannot crash the process. self._run_deferred_initial_load(expected_path) def _load_step_sync( @@ -348,7 +349,7 @@ class WindowCoreMixin: if polydata is None: return None copied = vtk.vtkPolyData() - copied.DeepCopy(polydata) + copied.ShallowCopy(polydata) return copied def _detach_worker_polydata_result(self, result: dict[str, object]) -> dict[str, object]: @@ -1027,8 +1028,14 @@ class WindowCoreMixin: self.renderer.RemoveAllViewProps() self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.scene_isolated = False + self.highlight_signature = None - self.model_polydata = _smooth_surface_polydata(model_polydata) + point_normals = None + try: + point_normals = model_polydata.GetPointData().GetNormals() + except Exception: + point_normals = None + self.model_polydata = model_polydata if point_normals is not None else _smooth_surface_polydata(model_polydata) self.model_face_id_array = self.model_polydata.GetCellData().GetArray("face_id") self.model_part_id_array = self.model_polydata.GetCellData().GetArray("part_id") self.model_solid_id_array = self.model_polydata.GetCellData().GetArray("solid_id") @@ -1569,7 +1576,7 @@ class WindowCoreMixin: elif action == "resize_shell_thickness": self._set_selection_mode("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}") + self.statusBar().showMessage(f"已选择可调整壳体厚度候选Face {target_id}") elif action == "suppress_cylinder": self._set_selection_mode("Feature") self.select_feature(int(target_id)) @@ -1601,7 +1608,7 @@ class WindowCoreMixin: else: self._set_selection_mode("Face") self.select_face(int(target_id)) - self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}") + self.statusBar().showMessage(f"已选择可拉伸/切除平面Face {target_id}") def select_by_id(self, kind: str | None = None) -> None: if self.model is None: @@ -1712,18 +1719,23 @@ class WindowCoreMixin: highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id] else: highlight_face_ids = [face_id] - logical_id = int(info.get("logical_face_id", face_id)) + selection_fields = self._selection_identity_fields( + face_id, + "特征来源 Face" if feature_mode else "Face", + ) + logical_id = int(selection_fields["selection_display_id"]) input_info = dict(info) + input_info.update(selection_fields) self._sync_cylindrical_edit_inputs(input_info) self.selected_part_id = int(info["part_id"]) self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None self._highlight_faces(face_ids=highlight_face_ids or [face_id]) self._show_pick_marker(pick_position) - self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id) + self._sync_id_picker("Feature" if feature_mode else "Face", logical_id) self.set_info(self._with_pick_info(input_info, pick_position)) self._update_action_states() - raw_note = f"(拓扑Face {face_id})" if not feature_mode and logical_id != face_id else "" - message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源Face {face_id}" + raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else "" + message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}" self.statusBar().showMessage(self._selection_status(message, pick_position)) def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None: @@ -1733,6 +1745,9 @@ class WindowCoreMixin: return info = self._feature_context_info(face_id) info["kind"] = "feature" + selection_fields = self._selection_identity_fields(face_id, "特征来源 Face") + logical_id = int(selection_fields["selection_display_id"]) + info.update(selection_fields) self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "feature" self.selected_face_id = face_id @@ -1743,11 +1758,12 @@ class WindowCoreMixin: highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) self._highlight_faces(face_ids=highlight_face_ids or [face_id]) self._show_pick_marker(pick_position) - self._sync_id_picker("Feature", face_id) + self._sync_id_picker("Feature", logical_id) self.set_info(self._with_pick_info(info, pick_position)) feature_type = str(info.get("feature_type", "特征候选")) self._update_action_states() - self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position)) + raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else "" + self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", pick_position)) def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: fillet_radius_suggestion: str | None = None @@ -1927,8 +1943,13 @@ class WindowCoreMixin: def _highlight_faces(self, face_ids=None, part_ids=None) -> None: if self.model is None: return + face_key = _int_tuple_or_none(face_ids) + part_key = _int_tuple_or_none(part_ids) + signature = ("faces", face_key, part_key) + if signature == getattr(self, "highlight_signature", None) and self.highlight_actor is not None: + return self._clear_highlight() - polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=True) + polydata = self._cached_face_overlay_polydata(face_ids=face_key, part_ids=part_key, smooth=True) if polydata is None: return mapper = vtk.vtkPolyDataMapper() @@ -1945,12 +1966,16 @@ class WindowCoreMixin: actor.GetProperty().SetLineWidth(2) self._offset_overlay_actor_toward_camera(actor, scale=0.00035) self.highlight_actor = actor + self.highlight_signature = signature self.renderer.AddActor(actor) self.render_window.Render() def _highlight_edge(self, edge_id: int) -> None: if self.model is None: return + signature = ("edge", int(edge_id)) + if signature == getattr(self, "highlight_signature", None) and self.edge_highlight_actor is not None: + return self._clear_highlight() polydata = self._cached_edge_overlay_polydata(edge_id) if polydata is None: @@ -1965,6 +1990,7 @@ class WindowCoreMixin: actor.GetProperty().SetDiffuse(0.8) actor.GetProperty().SetLineWidth(5) self.edge_highlight_actor = actor + self.highlight_signature = signature self.renderer.AddActor(actor) self.render_window.Render() @@ -2090,6 +2116,7 @@ class WindowCoreMixin: if self.pick_marker_actor is not None: self.renderer.RemoveActor(self.pick_marker_actor) self.pick_marker_actor = None + self.highlight_signature = None def clear_edit_preview(self, render: bool = True) -> None: if self.edit_preview_timer is not None: @@ -2138,7 +2165,7 @@ class WindowCoreMixin: if polydata is None: raise RuntimeError("当前显示网格里没有可复用的选中 Face 预览数据") except Exception as exc: - self.statusBar().showMessage(f"推拉预览不可用:{exc}") + self.statusBar().showMessage(f"拉伸/切除预览不可用:{exc}") return if distance >= 0: color = (0.0, 0.86, 0.34) @@ -2172,9 +2199,9 @@ class WindowCoreMixin: if polydata is None: polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False) if polydata is None: - raise RuntimeError("当前显示网格里没有可复用的薄壁预览数据") + raise RuntimeError("当前显示网格里没有可复用的壳体预览数据") except Exception as exc: - self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}") + self.statusBar().showMessage(f"壳体厚度调整预览不可用:{exc}") return movement = _triple_or_none(plan.get("shell_desired_movement_vector")) if isinstance(plan, dict) else None if movement is None and isinstance(plan, dict): diff --git a/step_editor/window_state.py b/step_editor/window_state.py index d42c189..ff88e5f 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -15,7 +15,7 @@ from PySide6.QtWidgets import ( ) from PySide6.QtGui import QColor -from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY +from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY from .model import StepModel from .records import OperationRecord from .ui_helpers import * # noqa: F403 @@ -36,6 +36,7 @@ FEATURE_EDIT_SEMANTICS_KEYS = { "boss_edit_semantics", "existing_fillet_edit_semantics", "analytic_surface_edit_semantics", + "freeform_surface_edit_semantics", "face_edit_semantics", } @@ -53,11 +54,28 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: keys.append("shell_thickness_estimate") return tuple(keys) if action_info.get("shell_region_status") == "candidate": - return ("shell_thickness_estimate",) - return ("face_target_normal_position",) + return ( + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + "shell_thickness_estimate", + ) + return ( + "area", + "local_face_width", + "local_face_height", + "face_center_position", + "face_target_normal_position", + ) if surface == "cylinder": - is_partial = angular_span is not None and angular_span < math.tau * 0.92 + is_partial = ( + not _is_effectively_full_cylinder(action_info) + and angular_span is not None + and angular_span < math.tau * 0.92 + ) if feature_guess == "hole/groove candidate": if is_partial: return ( @@ -81,6 +99,16 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: return () +def _is_effectively_full_cylinder(action_info: dict[str, object]) -> bool: + if bool(action_info.get("is_full_cylinder")): + return True + for key in ("same_domain_angular_span", "angular_span"): + angular_span = _float_or_none(action_info.get(key)) + if angular_span is not None and angular_span >= math.tau * 0.92: + return True + return False + + def _record_message_field(message: str | None, key: str) -> str | None: if not message: return None @@ -127,12 +155,18 @@ class WindowStateMixin: self.selected_face_id = None self.selected_edge_id = None self.selected_pick_position = None + self._invalidate_selected_action_info_cache() if clear_info: self._clear_current_object_info() if clear_highlight: self._clear_highlight() - self._update_selected_object_title() - self._update_action_states() + if clear_info or clear_highlight: + self._update_selected_object_title() + self._update_action_states() + + def _invalidate_selected_action_info_cache(self) -> None: + self._selected_action_info_cache_key = None + self._selected_action_info_cache_value = None def _clear_current_object_info(self) -> None: self.current_info_values = {} @@ -161,6 +195,25 @@ class WindowStateMixin: enriched["pick_position"] = pick_position return enriched + def _selection_identity_fields(self, face_id: int, title_prefix: str = "Face") -> dict[str, object]: + logical_id = int(face_id) + if self.model is not None: + try: + logical_id = int(self.model.face_region_logical_id(face_id)) + except Exception: + try: + logical_id = int(self.model.face_logical_id(face_id)) + except Exception: + logical_id = int(face_id) + title = f"{title_prefix} {logical_id}" + if logical_id != int(face_id): + title += f"(当前拓扑 Face {face_id})" + return { + "selection_title": title, + "selection_display_id": logical_id, + "selection_topological_face_id": int(face_id), + } + def _first_level_fact_selection_fields(self, face_id: int, scope: str = "auto") -> dict[str, object]: if self.model is None: return {} @@ -197,7 +250,7 @@ class WindowStateMixin: "first_level_boundary_vertex_count": 0, "first_level_adjacent_face_count": 0, "first_level_topology_note": ( - "当前 Face 的一级拓扑关系暂时无法确认;只改当前面的局部重建会在计划阶段再次检查。" + "当前 Face 的一级拓扑关系暂时无法确认;局部重建会在计划阶段再次检查。" ), } return { @@ -293,9 +346,13 @@ class WindowStateMixin: surface = str(fallback_info.get("surface", "") or "") if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES: return dict(fallback_info) - try: - cached_info = self.model.cached_feature_info(face_id) - except Exception: + allow_cached_info = self._current_feature_detection_level() != "current-only" + if allow_cached_info: + try: + cached_info = self.model.cached_feature_info(face_id) + except Exception: + cached_info = None + else: cached_info = None if cached_info is not None: info = cached_info @@ -308,13 +365,17 @@ class WindowStateMixin: "快速选择信息;不会在选中时扫描同域面、端盖或底面,点击具体修改时会再计算完整编辑计划。", ) if surface == "plane": - info.setdefault("feature_type", "可推拉平面候选") - info.setdefault("feature_edit_actions", "推拉平面") + info.setdefault("feature_type", "可拉伸/切除平面候选") + info.setdefault("feature_edit_actions", "拉伸/切除平面") elif surface == "cylinder": feature_guess = str(info.get("feature_guess", "") or "") angular_span = _float_or_none(info.get("angular_span")) if feature_guess == "hole/groove candidate": - if angular_span is not None and angular_span < math.tau * 0.92: + if ( + not _is_effectively_full_cylinder(info) + and angular_span is not None + and angular_span < math.tau * 0.92 + ): info.setdefault("feature_type", "槽/半孔候选") info.setdefault("feature_edit_actions", "调整槽/半孔宽度、深度、圆弧参数;完整槽孔配对会在执行时计算") else: @@ -410,6 +471,7 @@ class WindowStateMixin: ), } ) + info.update(self._selection_identity_fields(face_id, "特征来源 Face")) try: info.update(self.model._recognition_summary_fields(info)) except Exception: @@ -432,9 +494,13 @@ class WindowStateMixin: info = self._feature_context_info(self.selected_face_id) except Exception: return - self.current_info_values = dict(info) - self.current_info_text = "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items()) - self._refresh_property_editor() + if hasattr(self, "set_info"): + self.set_info(self._with_pick_info(info, self.selected_pick_position)) + else: + self._invalidate_selected_action_info_cache() + self.current_info_values = dict(info) + self.current_info_text = _info_to_text(info) + self._refresh_property_editor() if hasattr(self, "_highlight_faces"): self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]) self._update_selected_object_title() @@ -769,20 +835,14 @@ class WindowStateMixin: is_cone = has_face and surface == "cone" and "reference_radius" in action_info is_sphere = has_face and surface == "sphere" and "radius" in action_info is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info - is_generic_surface = has_face and surface in { - "bezier surface", - "b-spline surface", - "surface of revolution", - "surface of extrusion", - "offset surface", - "other surface", - } + is_generic_surface = has_face and surface in FREEFORM_FACE_SURFACES is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate" is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate" - is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92 + is_full_cylinder = _is_effectively_full_cylinder(action_info) is_slot_or_half_hole = ( is_hole_or_groove + and not is_full_cylinder and angular_span is not None and angular_span < math.tau * 0.92 and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None @@ -812,28 +872,28 @@ class WindowStateMixin: self._set_control_state( self.offset_input, can_push, - "输入平面 Face 的推拉距离。", - "请先选择一个可推拉的平面 Face。", + "输入平面 Face 的拉伸/切除距离。", + "请先选择一个可拉伸/切除的平面 Face。", ) self._set_control_state( self.push_button, can_push, - "对当前平面 Face 执行推拉。", - "请先选择一个平面 Face,或在特征模式下选择可推拉平面候选。", + "对当前平面 Face 执行拉伸/切除。", + "请先选择一个平面 Face,或在特征模式下选择可拉伸/切除平面候选。", ) if hasattr(self, "resize_shell_thickness_button"): self._set_control_state( self.shell_thickness_input, can_resize_shell, - "输入薄壁/壳体局部区域的目标厚度。", - "请先选择一个已识别到相对平面的薄壁/壳体平面候选。", + "输入壳体局部区域的目标厚度。", + "请先选择一个已识别到相对平面的壳体平面候选。", ) self._set_control_state( self.resize_shell_thickness_button, can_resize_shell, - "按目标厚度推拉当前薄壁/壳体平面区域。", - "请先选择一个已识别到相对平面的薄壁/壳体平面候选。", + "按目标厚度拉伸/切除当前壳体平面区域。", + "请先选择一个已识别到相对平面的壳体平面候选。", ) self._set_control_state( self.hole_diameter_input, @@ -1064,12 +1124,19 @@ class WindowStateMixin: self._update_property_apply_state(has_model) def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None: - widget.setEnabled(enabled) tip = enabled_tip if enabled else disabled_tip + cache = getattr(self, "_control_state_cache", None) + cache_key = id(widget) + state = (bool(enabled), str(tip)) + if isinstance(cache, dict) and cache.get(cache_key) == state: + return + widget.setEnabled(enabled) if hasattr(self, "_set_help_tip"): self._set_help_tip(widget, tip) else: widget.setToolTip(tip) + if isinstance(cache, dict): + cache[cache_key] = state def _current_selection_mode(self) -> str: if not hasattr(self, "mode_combo"): @@ -1118,15 +1185,25 @@ class WindowStateMixin: confidence_suffix = f"(置信度:{confidence_label})" if confidence_label else "" related_count = int(self.current_info_values.get("associated_feature_count", 0) or 0) related_suffix = f" · 关联 {related_count} 项" if related_count else "" - return f"{feature_label}{confidence_suffix} · 来源 Face {self.selected_face_id}{related_suffix}" + logical_id = self.current_info_values.get("selection_display_id") + if logical_id in {None, ""}: + logical_id = self.current_info_values.get("face_region_logical_id") + if logical_id in {None, ""}: + logical_id = self.current_info_values.get("logical_face_id") + source_suffix = f"来源 Face {self.selected_face_id}" + if logical_id not in {None, "", self.selected_face_id}: + source_suffix = f"来源 Face {logical_id}(拓扑 {self.selected_face_id})" + return f"{feature_label}{confidence_suffix} · {source_suffix}{related_suffix}" if self.selected_kind == "feature" and self.selected_face_id is not None: feature_label = self._selected_feature_label() if feature_label: return f"{feature_label} {self.selected_face_id}(来源Face)" return f"特征 {self.selected_face_id}(来源Face)" if self.selected_kind == "face" and self.selected_face_id is not None: - logical_id = self.current_info_values.get("face_region_logical_id") - if logical_id is None: + logical_id = self.current_info_values.get("selection_display_id") + if logical_id in {None, ""}: + logical_id = self.current_info_values.get("face_region_logical_id") + if logical_id in {None, ""}: logical_id = self.current_info_values.get("logical_face_id") feature_label = self._selected_feature_label() feature_suffix = f"(识别为{feature_label})" if feature_label else "" @@ -1146,8 +1223,9 @@ class WindowStateMixin: feature_guess = str(info.get("feature_guess", "") or "") if surface == "cylinder" and feature_guess == "hole/groove candidate": angular_span = _float_or_none(info.get("angular_span")) - if info.get("slot_kind") == "partial-cylindrical-groove" or ( - angular_span is not None and angular_span < math.tau * 0.92 + if not _is_effectively_full_cylinder(info) and ( + info.get("slot_kind") == "partial-cylindrical-groove" + or (angular_span is not None and angular_span < math.tau * 0.92) ): return "槽/半孔候选" return "圆柱孔/槽候选" @@ -1156,7 +1234,7 @@ class WindowStateMixin: if surface == "cylinder" and feature_guess == "round/fillet candidate": return "圆角/倒圆候选" if surface == "plane" and info.get("push_pull_status"): - return "可推拉平面候选" + return "可拉伸/切除平面候选" return "" def _clear_property_editor(self) -> None: @@ -1695,14 +1773,7 @@ class WindowStateMixin: is_cone = has_face and surface == "cone" and "reference_radius" in action_info is_sphere = has_face and surface == "sphere" and "radius" in action_info is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info - is_generic_surface = has_face and surface in { - "bezier surface", - "b-spline surface", - "surface of revolution", - "surface of extrusion", - "offset surface", - "other surface", - } + is_generic_surface = has_face and surface in FREEFORM_FACE_SURFACES is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate" is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate" @@ -1738,9 +1809,10 @@ class WindowStateMixin: ) 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 = _is_effectively_full_cylinder(action_info) is_slot_or_half_hole = ( is_hole_or_groove + and not is_full_cylinder and angular_span is not None and angular_span < math.tau * 0.92 and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None @@ -1841,7 +1913,7 @@ class WindowStateMixin: return "格式为 X, Y, Z;建议先小幅试改并查看预览。" face_linear_hard_limit = "目标值低于当前值的 5% 或高于当前值的 5 倍会被阻止。" - face_area_hard_limit = "如果目标面积会让面宽/面高缩到当前 5% 以下或放大到 5 倍以上,会被阻止。" + face_area_hard_limit = "如果目标面积会让 U向尺寸/V向尺寸缩到当前 5% 以下或放大到 5 倍以上,会被阻止。" face_offset_hard_limit = "如果目标位置与当前位置差距远超当前模型尺寸,会被阻止。" face_center_hard_limit = "如果目标中心与当前中心距离超过所属对象尺寸的 5 倍,会被阻止。" @@ -1892,7 +1964,7 @@ class WindowStateMixin: def face_local_disabled_tip(base: str) -> str: if local_face_deform_ready or not local_face_deform_blocker: return base - return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}" + return f"{base} 当前不能局部重建的原因:{local_face_deform_blocker}" def cone_semi_angle_capability(current_angle_degrees: float | None) -> tuple[bool, str]: if current_angle_degrees is None: @@ -2168,7 +2240,7 @@ class WindowStateMixin: text="孔:同轴重切孔壁;移动轴心会先填旧孔再切新孔。", tip=( "孔径/半径不是缩放整个模型,而是在当前孔轴线上做受限布尔重切;" - "轴心选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动整个特征”时" + "轴心选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动特征”时" "会移动整个所属对象;盲孔/盲槽深度会按底面方向切削或补料。" ), ) @@ -2176,10 +2248,10 @@ class WindowStateMixin: add_readonly_spec( key="boss_edit_semantics", label="编辑方式", - text="凸台:直径重建包络;高度推拉端盖;轴心先移除再补新凸台。", + text="凸台:直径重建包络;高度拉伸/切除端盖;轴心先移除再补新凸台。", tip=( "凸台直径会通过局部布尔补料或移除旧包络后重建目标圆柱;" - "凸台高度会推拉识别到的端盖 Face;凸台轴心会移除旧凸台包络后在目标轴心补出同直径凸台;" + "凸台高度会拉伸/切除识别到的端盖 Face;凸台轴心会移除旧凸台包络后在目标轴心补出同直径凸台;" "带“平移所属对象”的轴心坐标会移动整个所属对象。" ), ) @@ -2203,6 +2275,22 @@ class WindowStateMixin: "不是只替换单个曲面的历史参数;执行前需要确认影响范围。" ), ) + elif is_generic_surface: + surface_name = _format_info_value("surface", surface) + blocker = str( + action_info.get("freeform_face_blockers") + or action_info.get("local_face_deform_blocker") + or "当前一级阶段不开放自由曲面参数化编辑。" + ) + add_readonly_spec( + key="freeform_surface_edit_semantics", + label="编辑方式", + text=f"{surface_name}:暂不支持参数化编辑。", + tip=( + f"{blocker} 当前不会把自由曲面的面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;" + "后续需要单独设计控制点编辑、曲面替换或重拟合曲面的语义。" + ), + ) elif is_plane or is_shell_candidate: if topology_depth == 1: same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0 @@ -2223,18 +2311,19 @@ class WindowStateMixin: ), tip=topology_tip, ) - face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。" + face_semantics_text = "Face:先改目标值,再用“影响范围”选择局部重建、拉伸/切除、移动特征或缩放特征。" face_semantics_tip = ( - "面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;" - "面偏移是沿当前面垂直方向测到的位置。影响范围决定这次修改只作用在当前 Face," - "还是推拉加料/切削,或带动所属特征或 Solid。" + "面积是 Face 的几何面积;U向尺寸/V向尺寸是这个 Face 自身参数方向上的两个投影尺寸;" + "偏移曲面使用 Creo 曲面 Offset 语义,表示沿当前 Face 法向得到的目标位置。" + "影响范围决定这次修改是局部重建当前 Face、按拉伸/切除加减材料," + "还是移动或缩放所属特征/Solid。" ) if is_plane and not local_face_deform_ready: - face_semantics_text = "Face:当前面暂不能只改当前面,可用推拉或整体策略。" + face_semantics_text = "Face:当前暂不能局部重建,可用拉伸/切除、移动特征或缩放特征。" face_semantics_tip = ( - "此 Face 不适合做“只改当前面”的局部顶点重建;" + "此 Face 不适合做局部顶点重建;" f"原因:{local_face_deform_blocker or '当前拓扑不满足局部重建条件。'}" - "可优先使用“推拉当前面”、孔/槽专门入口,或选择移动/调整整个特征。" + "可优先使用“拉伸/切除”、孔/槽专门入口,或选择移动/缩放所属特征。" ) add_readonly_spec( key="face_edit_semantics", @@ -2268,7 +2357,7 @@ class WindowStateMixin: scope_default="local", scope_modes={ "local": { - "label": "只改当前面", + "label": "局部重建", "action": "resize_face_area_local", "target_attr": "face_area_input", "enabled": bool( @@ -2279,29 +2368,29 @@ class WindowStateMixin: and current_face_center is not None ), "enabled_tip": ( - "输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点," + "输入目标面积;程序只在当前 Face 平面内缩放该 Face 的顶点," "相邻面会按新边界重建。" ), "disabled_tip": face_local_disabled_tip( - "只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。" + "只有带稳定面积和中心坐标的平面 Face 才能尝试局部重建面积。" ), "range_hint": ( - "只改当前面时,相邻面可能自然变斜。当前只对简单全平面实体开放," + "局部重建时,相邻面可能自然变斜。当前只对简单全平面实体开放," f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}" ), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_face_area", "target_attr": "face_area_input", "enabled": current_face_area is not None and current_face_area > 0, "enabled_tip": ( - "输入目标面积;程序会用这个面作为参考,均匀缩放它所属的特征或 Solid," + "输入目标面积;程序会用该 Face 作为参考,均匀缩放它所属的特征或 Solid," "让该面的面积接近目标值。" ), "disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。", "range_hint": ( - "调整整个特征会影响同一对象上的其它尺寸。" + "缩放特征会影响同一对象上的其它尺寸。" f" {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}" ), }, @@ -2314,22 +2403,22 @@ class WindowStateMixin: current_face_width = _float_or_none(action_info.get("local_face_width")) current_face_height = _float_or_none(action_info.get("local_face_height")) face_size_tip = ( - "这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度," - "不是面积,也不是模型整体高度。只修改当前 Face 顶点,相邻面会按新顶点重建。" + "这里的 U向尺寸/V向尺寸是选中 Face 在自身参数方向上的投影长度," + "不是面积,也不是模型整体高度。局部重建会移动当前 Face 顶点,相邻面会按新顶点重建。" ) face_size_owner_tip = ( - "这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度," + "这里的 U向尺寸/V向尺寸是选中 Face 在自身参数方向上的投影长度," "不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。" ) add_scoped_spec( key="local_face_width", - label="面宽", + label="U向尺寸", current_raw=current_face_width if current_face_width is not None else "", target_text=numeric_text(current_face_width), scope_default="local", scope_modes={ "local": { - "label": "只改当前面", + "label": "局部重建", "action": "resize_face_width_local", "target_attr": "face_width_input", "enabled": bool( @@ -2338,14 +2427,14 @@ class WindowStateMixin: and current_face_width > 0 and current_face_center is not None ), - "enabled_tip": f"输入目标面宽;{face_size_tip}", + "enabled_tip": f"输入目标 U向尺寸;{face_size_tip}", "disabled_tip": face_local_disabled_tip( - "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。" + "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改 U向尺寸。" ), "range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}", }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_face_width_owning_scale", "target_attr": "face_width_input", "enabled": bool( @@ -2354,8 +2443,8 @@ class WindowStateMixin: and current_face_center is not None and (self.selected_part_id is not None or self.selected_solid_id is not None) ), - "enabled_tip": f"输入目标面宽;{face_size_owner_tip}", - "disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按这个方向缩放所属对象。", + "enabled_tip": f"输入目标 U向尺寸;{face_size_owner_tip}", + "disabled_tip": "当前 Face 缺少稳定 U向尺寸、中心坐标或所属对象,不能按这个方向缩放所属对象。", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}", }, }, @@ -2365,13 +2454,13 @@ class WindowStateMixin: ) add_scoped_spec( key="local_face_height", - label="面高", + label="V向尺寸", current_raw=current_face_height if current_face_height is not None else "", target_text=numeric_text(current_face_height), scope_default="local", scope_modes={ "local": { - "label": "只改当前面", + "label": "局部重建", "action": "resize_face_height_local", "target_attr": "face_height_input", "enabled": bool( @@ -2380,14 +2469,14 @@ class WindowStateMixin: and current_face_height > 0 and current_face_center is not None ), - "enabled_tip": f"输入目标面高;{face_size_tip}", + "enabled_tip": f"输入目标 V向尺寸;{face_size_tip}", "disabled_tip": face_local_disabled_tip( - "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。" + "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改 V向尺寸。" ), "range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}", }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_face_height_owning_scale", "target_attr": "face_height_input", "enabled": bool( @@ -2396,8 +2485,8 @@ class WindowStateMixin: and current_face_center is not None and (self.selected_part_id is not None or self.selected_solid_id is not None) ), - "enabled_tip": f"输入目标面高;{face_size_owner_tip}", - "disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按这个方向缩放所属对象。", + "enabled_tip": f"输入目标 V向尺寸;{face_size_owner_tip}", + "disabled_tip": "当前 Face 缺少稳定 V向尺寸、中心坐标或所属对象,不能按这个方向缩放所属对象。", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}", }, }, @@ -2413,19 +2502,19 @@ class WindowStateMixin: scope_default="local", scope_modes={ "local": { - "label": "只改当前面", + "label": "局部重建", "action": "move_selected_face_center_local", "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "enabled": bool(is_plane and local_face_deform_ready and current_face_center is not None), "enabled_tip": ( "输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;" - "程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。" + "程序会移动当前 Face 的顶点,并让相邻平面按新顶点重建。" ), "disabled_tip": face_local_disabled_tip( - "只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。" + "只有带稳定中心坐标的平面 Face 才能尝试局部重建中心位置。" ), "range_hint": ( - "只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。" + "局部重建时,相邻面可能自然变斜,非共面面可能被拆成三角面。" f"{translation_hint()} {face_center_hard_limit}" ), "target_transform": "target_center_to_translation", @@ -2433,7 +2522,7 @@ class WindowStateMixin: **face_vector_move_limit(current_face_center), }, "owning": { - "label": "移动整个特征", + "label": "移动特征", "action": "move_selected_face_center", "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "enabled": current_face_center is not None @@ -2444,7 +2533,7 @@ class WindowStateMixin: ), "disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。", "range_hint": ( - "移动整个特征不会改变当前对象形状,但同一对象会整体搬动。" + "移动特征不会改变当前对象形状,但同一对象会整体搬动。" f"{translation_hint()} {face_center_hard_limit}" ), "target_transform": "target_center_to_translation", @@ -2482,28 +2571,28 @@ class WindowStateMixin: ) add_scoped_spec( key="face_target_normal_position", - label="面偏移", + label="偏移曲面", current_raw=current_plane_position if current_plane_position is not None else "", target_text=numeric_text(current_plane_position), scope_default="push_pull", scope_modes={ "push_pull": { - "label": "推拉当前面", + "label": "拉伸/切除", "action": "push_pull_face", "target_attr": "offset_input", "enabled": current_plane_position is not None, - "enabled_tip": "输入目标面偏移;程序会沿当前面的垂直方向移动面,自动加料或切削。", - "disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。", + "enabled_tip": "输入偏移曲面的目标位置;程序会沿当前 Face 法向执行拉伸或切除。", + "disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置拉伸/切除。", "range_hint": ( - "面偏移是沿当前面垂直方向测量的目标值,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。" - "程序会把目标面偏移自动换算成本次需要移动的距离。" + "偏移曲面是沿当前 Face 法向测量的目标位置,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。" + "程序会把目标位置自动换算成本次拉伸/切除距离。" f"{face_offset_hard_limit}" ), "target_transform": "plane_target_position_to_offset", "transform_context": {"current_plane_position": current_plane_position}, }, "local": { - "label": "只改当前面", + "label": "局部重建", "action": "move_selected_face_plane_position_local", "target_attr": "offset_input", "enabled": bool( @@ -2513,14 +2602,14 @@ class WindowStateMixin: and current_face_center is not None ), "enabled_tip": ( - "输入目标面偏移;程序只把当前 Face 沿当前面的垂直方向移动到该目标值," + "输入偏移曲面的目标位置;程序只把当前 Face 沿法向移动到该目标值," "并让相邻平面按新顶点重建。" ), "disabled_tip": face_local_disabled_tip( - "当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。" + "当前平面缺少稳定方向、基准点或中心,不能按偏移曲面局部重建当前 Face。" ), "range_hint": ( - "只改当前面不会自动加料/切削,相邻面可能自然变斜," + "局部重建不会自动加料/切削,相邻面可能自然变斜," "非共面面可能被拆成三角面。" f"{translation_hint()} {face_offset_hard_limit}" ), @@ -2528,7 +2617,7 @@ class WindowStateMixin: "transform_context": {"current_plane_position": current_plane_position}, }, "owning": { - "label": "移动整个特征", + "label": "移动特征", "action": "move_selected_face_plane_position_by_translation", "target_attr": "offset_input", "enabled": bool( @@ -2537,12 +2626,12 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入目标面偏移;程序会沿当前面的垂直方向平移所属特征或 Solid," + "输入偏移曲面的目标位置;程序会沿当前 Face 法向平移所属特征或 Solid," "当前面形状和所属对象内部尺寸不变。" ), - "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。", + "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按偏移曲面移动所属特征。", "range_hint": ( - "这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。" + "这是移动所属对象,不是拉伸/切除当前 Face;如果想改变形状或厚度,请选择“拉伸/切除”。" f"{translation_hint()} {face_offset_hard_limit}" ), "target_transform": "plane_target_position_to_offset", @@ -2559,22 +2648,22 @@ class WindowStateMixin: shell_origin = _triple_or_none(action_info.get("plane_origin")) add_scoped_spec( key="shell_thickness_estimate", - label="薄壁厚度", + label="壳体厚度", current_raw=current if current is not None else "", target_text=numeric_text(current), scope_default="local", scope_modes={ "local": { - "label": "推拉当前面", + "label": "拉伸/切除", "action": "resize_shell_thickness", "target_attr": "shell_thickness_input", "enabled": current is not None, - "enabled_tip": "输入薄壁/壳体局部区域的目标厚度;程序会推拉当前平面来改变与相对面的距离。", - "disabled_tip": "当前平面没有稳定识别到可修改的薄壁厚度。", + "enabled_tip": "输入壳体特征的目标厚度;程序会移动当前平面来改变与相对面的距离。", + "disabled_tip": "当前平面没有稳定识别到可修改的壳体厚度。", "range_hint": relative_range_hint(current, 0.35, 0.8, face_linear_hard_limit), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_shell_thickness_owning_scale", "target_attr": "shell_thickness_input", "enabled": bool( @@ -2587,13 +2676,13 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入目标薄壁厚度;程序会沿厚度方向缩放所属特征或 Solid," + "输入目标壳体厚度;程序会沿厚度方向缩放所属特征或 Solid," "同一对象上的其它厚度方向尺寸会跟随变化。" ), - "disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。", + "disabled_tip": "当前壳体候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度缩放所属特征。", "range_hint": ( - "这是整体缩放所属对象,不是推拉当前平面;" - f"如果只想移动当前薄壁平面区域,请把影响范围设为“推拉当前面”。 {relative_range_hint(current, 0.2, 0.5, face_linear_hard_limit)}" + "这是缩放所属对象,不是单独移动当前平面;" + f"如果只想移动当前壳体平面区域,请把影响范围设为“拉伸/切除”。 {relative_range_hint(current, 0.2, 0.5, face_linear_hard_limit)}" ), }, }, @@ -2625,7 +2714,7 @@ class WindowStateMixin: "max_value": diameter_upper, }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -2660,7 +2749,7 @@ class WindowStateMixin: "max_value": diameter_upper * 0.5 if diameter_upper is not None else None, }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -2715,7 +2804,7 @@ class WindowStateMixin: "range_hint": translation_hint(), }, "owning": { - "label": "移动整个特征", + "label": "移动特征", "action": "move_selected_axis_center_by_translation", "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "enabled": bool( @@ -2769,7 +2858,7 @@ class WindowStateMixin: "range_hint": translation_hint(), }, "owning": { - "label": "移动整个特征", + "label": "移动特征", "action": "move_selected_axis_center_by_translation", "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "enabled": bool( @@ -2826,7 +2915,7 @@ class WindowStateMixin: "range_hint": relative_range_hint(current_slot, 0.35, 1.0), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_slot_width_owning_scale", "target_attr": "slot_width_input", "enabled": bool(current_slot is not None and slot_owning_scale_ready), @@ -2857,7 +2946,7 @@ class WindowStateMixin: "range_hint": relative_range_hint(current_slot_depth, 0.35, 1.0), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_slot_depth_owning_scale", "target_attr": "slot_depth_input", "enabled": bool(current_slot_depth is not None and slot_owning_scale_ready), @@ -2888,7 +2977,7 @@ class WindowStateMixin: "range_hint": relative_range_hint(current_slot_arc, 0.35, 1.0), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_slot_arc_length_owning_scale", "target_attr": "slot_arc_length_input", "enabled": bool(current_slot_arc is not None and slot_owning_scale_ready), @@ -2955,7 +3044,7 @@ class WindowStateMixin: slot_pair_target_text = manual_slot_pair_text add_spec( key="slot_pair_face_id", - label="配对端Face", + label="配对端 Face", current_raw=slot_pair_target_text, target_text=slot_pair_target_text, action="set_manual_slot_pair_face", @@ -3010,7 +3099,7 @@ class WindowStateMixin: bottom_target_text = manual_bottom_text or (str(auto_bottom_face_id) if auto_bottom_face_id is not None else "") add_spec( key="hole_bottom_face_id", - label="底面Face", + label="底面 Face", current_raw=bottom_target_text, target_text=bottom_target_text, action="set_manual_hole_bottom_face", @@ -3056,7 +3145,7 @@ class WindowStateMixin: "range_hint": relative_range_hint(current_depth, 0.35, 1.0), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_hole_depth_owning_scale", "target_attr": "hole_depth_input", "enabled": bool( @@ -3100,7 +3189,7 @@ class WindowStateMixin: "range_hint": relative_range_hint(current_boss, 0.3, 0.8), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3135,7 +3224,7 @@ class WindowStateMixin: "target_transform": "radius_to_diameter", }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3189,7 +3278,7 @@ class WindowStateMixin: "range_hint": translation_hint(), }, "owning": { - "label": "移动整个特征", + "label": "移动特征", "action": "move_selected_axis_center_by_translation", "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), "enabled": bool( @@ -3237,20 +3326,20 @@ class WindowStateMixin: scope_default="owning" if boss_height_can_scale else "local", scope_modes={ "local": { - "label": "推拉端盖", + "label": "拉伸/切除端盖", "action": "resize_boss_height", "target_attr": "boss_height_input", "enabled": boss_height_can_local, - "enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可推拉的凸台端盖 Face。", + "enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可拉伸/切除的凸台端盖 Face。", "disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", "range_hint": relative_range_hint(current_boss_height, 0.3, 0.8), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_height_owning_scale", "target_attr": "boss_height_input", "enabled": boss_height_can_scale, - "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。", + "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是拉伸/切除凸台端盖。", "disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "range_hint": ( "这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。" @@ -3284,7 +3373,7 @@ class WindowStateMixin: "range_hint": "高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。", }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3319,7 +3408,7 @@ class WindowStateMixin: "target_transform": "radius_to_diameter", }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3354,20 +3443,20 @@ class WindowStateMixin: scope_default="owning" if cylinder_height_can_scale else "local", scope_modes={ "local": { - "label": "推拉端盖", + "label": "拉伸/切除端盖", "action": "resize_cylinder_height", "target_attr": "boss_height_input", "enabled": bool(is_full_cylinder and current_cylinder_height is not None), - "enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并确认可推拉的圆柱端盖 Face。", + "enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并确认可拉伸/切除的圆柱端盖 Face。", "disabled_tip": "当前圆柱面不是完整圆柱,或缺少稳定高度信息。", "range_hint": relative_range_hint(current_cylinder_height, 0.3, 0.8), }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_height_owning_scale", "target_attr": "boss_height_input", "enabled": cylinder_height_can_scale, - "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。", + "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是拉伸/切除单个端盖。", "disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", "range_hint": ( "这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。" @@ -3413,7 +3502,7 @@ class WindowStateMixin: "range_hint": fillet_hint, }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3458,7 +3547,7 @@ class WindowStateMixin: "transform_context": {"angular_span": current_fillet_span}, }, "owning": { - "label": "调整整个特征", + "label": "缩放特征", "action": "resize_cylindrical_owning_scale", "target_attr": "hole_diameter_input", "enabled": bool( @@ -3588,7 +3677,7 @@ class WindowStateMixin: current_sphere_diameter = _float_or_none(action_info.get("diameter")) add_spec( key="sphere_radius", - label="半径(整体)", + label="半径(缩放特征)", current_raw=current_sphere_radius if current_sphere_radius is not None else "", target_text=numeric_text(current_sphere_radius), action="resize_sphere_radius", @@ -3603,7 +3692,7 @@ class WindowStateMixin: ) add_spec( key="sphere_diameter", - label="直径(整体)", + label="直径(缩放特征)", current_raw=current_sphere_diameter if current_sphere_diameter is not None else "", target_text=numeric_text(current_sphere_diameter), action="resize_sphere_radius", @@ -3628,7 +3717,7 @@ class WindowStateMixin: ) add_spec( key="torus_major_radius", - label="主半径(整体)", + label="主半径(缩放特征)", current_raw=current_major_radius if current_major_radius is not None else "", target_text=numeric_text(current_major_radius), action="resize_torus_major_radius", @@ -3643,7 +3732,7 @@ class WindowStateMixin: ) add_spec( key="torus_major_diameter", - label="主直径(整体)", + label="主直径(缩放特征)", current_raw=current_major_diameter if current_major_diameter is not None else "", target_text=numeric_text(current_major_diameter), action="resize_torus_major_radius", @@ -3659,7 +3748,7 @@ class WindowStateMixin: ) add_spec( key="torus_minor_radius", - label="小半径(整体)", + label="小半径(缩放特征)", current_raw=current_minor_radius if current_minor_radius is not None else "", target_text=numeric_text(current_minor_radius), action="resize_torus_minor_radius", @@ -3674,7 +3763,7 @@ class WindowStateMixin: ) add_spec( key="torus_minor_diameter", - label="小直径(整体)", + label="小直径(缩放特征)", current_raw=current_minor_diameter if current_minor_diameter is not None else "", target_text=numeric_text(current_minor_diameter), action="resize_torus_minor_radius", @@ -4373,11 +4462,20 @@ class WindowStateMixin: tooltip = f"{tooltip}\n\n{range_hint}" widget.setProperty("changed", bool(row_changed and not validation_error)) widget.setProperty("invalid", bool(validation_error)) - widget.setToolTip(tooltip) - widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor) - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.setEnabled(enabled) + button_state = ( + widget.text(), + bool(enabled), + bool(row_changed and not validation_error), + bool(validation_error), + tooltip, + ) + if getattr(widget, "_geom_param_button_state", None) != button_state: + widget.setToolTip(tooltip) + widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor) + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.setEnabled(enabled) + setattr(widget, "_geom_param_button_state", button_state) if not hasattr(self, "apply_property_button"): return changed = self._changed_property_rows() @@ -4612,16 +4710,19 @@ class WindowStateMixin: info = self._feature_context_info(source_face_id) else: info = dict(info) + info.update(self._selection_identity_fields(source_face_id, "特征来源 Face")) + logical_id = int(info["selection_display_id"]) self.selected_kind = "feature" self.selected_face_id = source_face_id self.selected_edge_id = None self.selected_part_id = int(info.get("part_id", self.model.face_part_ids[source_face_id])) self.selected_solid_id = int(info.get("solid_id", self.model.face_solid_ids[source_face_id])) - self.current_info_values = dict(info) - self.current_info_text = "\n".join( - f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items() - ) - self._sync_id_picker("Feature", source_face_id) + if hasattr(self, "set_info"): + self.set_info(self._with_pick_info(info, None)) + else: + self.current_info_values = dict(info) + self.current_info_text = _info_to_text(info) + self._sync_id_picker("Feature", logical_id) if hasattr(self, "_highlight_faces"): self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [source_face_id]) self._update_selected_object_title() @@ -4836,7 +4937,7 @@ class WindowStateMixin: if transform == "plane_target_position_to_offset": current_position = _float_or_none(context.get("current_plane_position")) if current_position is None: - raise ValueError("当前平面缺少稳定法向位置,不能换算推拉距离。") + raise ValueError("当前平面缺少稳定法向位置,不能换算拉伸/切除距离。") return value - current_position if transform == "degrees_to_radians": if value <= 0: @@ -4979,30 +5080,64 @@ class WindowStateMixin: def _selected_action_info(self) -> dict[str, object]: if self.model is None: return {} + cache_key = ( + id(self.model), + self.selected_kind, + self.selected_part_id, + self.selected_solid_id, + self.selected_face_id, + self.selected_edge_id, + id(getattr(self, "current_info_values", None)), + getattr(self, "feature_detection_level", "current-only"), + ) + if ( + getattr(self, "_selected_action_info_cache_key", None) == cache_key + and getattr(self, "_selected_action_info_cache_value", None) is not None + ): + return dict(self._selected_action_info_cache_value) current_info = dict(getattr(self, "current_info_values", {}) or {}) current_face_id = _int_or_none( current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id"))) ) + result: dict[str, object] if ( self.selected_face_id is not None and current_face_id == self.selected_face_id and str(current_info.get("surface", "") or "") ): - return self._feature_info_for_selected_face(self.selected_face_id, current_info) + result = self._feature_info_for_selected_face(self.selected_face_id, current_info) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result try: if self.selected_kind == "feature" and self.selected_face_id is not None: - return self._feature_info_for_selected_face( + result = self._feature_info_for_selected_face( self.selected_face_id, self.model.quick_face_info(self.selected_face_id), ) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result if self.selected_kind == "face" and self.selected_face_id is not None: info = self.model.quick_face_info(self.selected_face_id) - return self._feature_info_for_selected_face(self.selected_face_id, info) + result = self._feature_info_for_selected_face(self.selected_face_id, info) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result if self.selected_kind == "edge" and self.selected_edge_id is not None: - return self.model.edge_info(self.selected_edge_id) + result = self.model.edge_info(self.selected_edge_id) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result except Exception: - return dict(self.current_info_values) - return dict(self.current_info_values) + result = dict(self.current_info_values) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result + result = dict(self.current_info_values) + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return result def _locate_operation_record(self, record: OperationRecord) -> str: if self.model is None: @@ -5019,6 +5154,12 @@ class WindowStateMixin: if record.target_kind == "feature" else self._feature_info_for_selected_face(resolved_face_id, self.model.face_info(resolved_face_id)) ) + info.update( + self._selection_identity_fields( + resolved_face_id, + "特征来源 Face" if record.target_kind == "feature" else "Face", + ) + ) self.selected_kind = "feature" if record.target_kind == "feature" else "face" self.selected_face_id = resolved_face_id self.selected_part_id = int(info["part_id"]) @@ -5281,18 +5422,13 @@ class WindowStateMixin: "cone", "sphere", "torus", - "bezier surface", - "b-spline surface", - "surface of revolution", - "surface of extrusion", - "offset surface", - "other surface", + *FREEFORM_FACE_SURFACES, }: return surface operation_name = str(record.operation_name or "") if any(token in operation_name for token in ("孔", "圆柱", "槽", "凸台", "圆角", "倒圆")): return "cylinder" - if any(token in operation_name for token in ("推拉", "薄壁", "壳体")): + if any(token in operation_name for token in ("拉伸/切除", "壳体", "壳体")): return "plane" if self._record_target_diameter(record) is not None: return "cylinder"