diff --git a/.gitignore b/.gitignore index e6a4033..3093218 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,7 @@ dist/ assets/screenshots/ +教学.txt + .DS_Store Thumbs.db diff --git a/GeometryParametric.spec b/GeometryParametric.spec new file mode 100644 index 0000000..2e06dd1 --- /dev/null +++ b/GeometryParametric.spec @@ -0,0 +1,55 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [('assets', 'assets')] +binaries = [] +hiddenimports = [] +tmp_ret = collect_all('OCC') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('vtkmodules') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('PySide6') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='GeometryParametric', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='GeometryParametric', +) diff --git a/README.md b/README.md index 3cf0dc3..c96848f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ python scripts\generate_cube_step.py ```powershell python scripts\verify_edge_length_resize.py +python scripts\verify_edge_round_chamfer.py +python scripts\verify_shell_thickness_resize.py +python scripts\verify_analytic_surface_resize.py +python scripts\verify_face_resize_semantics.py --strategy local --axis width --target-size 15 +python scripts\verify_face_resize_semantics.py --strategy owning --axis width --target-size 15 +python scripts\verify_boss_resize.py +python scripts\verify_hole_resize.py +python scripts\verify_slot_resize.py ``` 11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行: @@ -96,13 +104,31 @@ git diff --check - 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的特征,比如平面推拉和目标法向位置、孔径/半径调整、完整圆柱孔轴心坐标修改、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度和槽孔总长度调整、盲孔/盲槽深度调整、凸台直径/半径/高度/轴心坐标调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度调整、圆锥半角、环面直径、Edge倒圆、Edge倒角、圆Edge半径/直径、修改Edge长度和直线Edge起点/终点坐标。 +- 用户可以修改可控的特征,比如面偏移、偏移距离、面积、面宽/面高、中心,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 +## 几何修改语义原则 + +所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表面推拉、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 + +所以每个可编辑行都应尽量说明本次采用的策略、影响范围和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、面推拉、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 + +产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 + +设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。 + +界面里的 `当前选中对象` 会优先显示 `编辑方式` 行,用来告诉你当前对象的修改会走哪类几何语义。比如孔径是同轴重切,槽/半孔是局部扇形槽或配对长圆槽重建,凸台是包络重建或端盖推拉,已有圆角是先移除旧圆角再重建。对于圆柱直径/半径,当前也开始提供独立的 `缩放所属对象` 行,让你明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 + +## 开源复用原则 + +三维几何计算要优先复用成熟开源库和几何内核,不要自己从零实现已经可靠存在的底层能力。当前项目已经通过 `pythonocc` 复用 OpenCascade,用它处理 STEP 读写、B-Rep 拓扑、布尔、倒角/圆角、曲面和基础几何计算;后续如果发现合适的开源库,也可以接入用于网格、约束求解、几何分析或文件格式转换。 + +但不能直接从 GitHub 随便复制功能代码。引入外部项目之前必须先看许可证、维护状态、依赖体积、Windows/Conda 兼容性,以及它是否能接进本项目的选择状态、属性表、确认窗口、后台执行、失败回滚、操作历史和验证脚本。MIT/BSD/Apache 这类宽松许可证通常更容易复用;GPL/AGPL 代码要非常谨慎,避免影响整个项目授权。优先原则是“复用库和算法能力,少复制产品代码;能通过依赖调用就不要粘贴实现”。 + ## 最小系统先实现什么 最小系统目标是先把完整工作链路打通: @@ -118,20 +144,34 @@ git diff --check - 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 +## 能改哪些,一分钟版 + +如果你只想快速判断一个 STEP 模型现在能不能改,先看这张表;更细的参数和风险在下一节。 + +| 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | +| --- | --- | --- | --- | +| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | +| Face / 面 | 平面偏移、只移动当前面、整体平移所属特征、面积、面宽/面高、中心、薄壁厚度。 | 推拉当前平面;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面、带复杂内孔的面、复杂壳体局部区域。 | +| 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | +| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 | +| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或推拉端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | +| 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角。 | 已有圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法。 | 复杂 blend、圆角链、支撑面不明确的圆角。 | +| Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | + ## 当前可修改范围 这张表描述的是当前版本已经接入到界面或编辑链路里的能力。它们都是基于 STEP 的 B-Rep 结果几何做局部重建,不等于原 CAD 软件里的完整建模历史参数。 | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | --- | --- | --- | -| 平面 Face | `Face目标法向位置`、`Face本次偏移量`;识别到相对平面时可以改薄壁厚度。 | `Face面积` 目前只读;自由曲面、复杂壳体局部区域和带复杂边界的面不能直接按面积修改。 | -| 圆柱孔 / 圆柱面 | 孔或圆柱直径、半径;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚。 | -| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助。 | -| 槽 / 半孔 | 可以改槽/半孔宽度、深度、圆弧长度、圆弧角度和开口角度;长圆槽识别到成对半圆端时,可以改槽孔总长度和中心距,也可以手动填写配对端 Face ID。 | 复杂草图槽、非圆柱槽、槽的整体迁移/轴心移动、链式槽和跨多个不规则面的槽还不是稳定能力。 | -| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心坐标;普通完整圆柱也有高度兜底修改。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建。 | -| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改半径或圆弧长度,使用先移除旧圆角再重建新圆角的路线。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚。 | -| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败。 | -| 圆锥 / 球 / 环面 | 圆锥可以改参考半径、直径或半角;球可以改半径或直径;环面可以改主/副半径或直径。 | 这些属于几何缩放或局部重建能力,不是原始特征历史恢复;复杂相邻拓扑可能失败。 | +| Face | 平面 Face 可以改 `面偏移`、`偏移距离`、`面积`、`面宽`、`面高` 和 `中心`;这些行会在 `影响范围` 里选择 `只改当前面`、`推拉当前面`、`移动整个特征` 或 `调整整个特征`。识别到相对平面时还可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`。 | `推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid;`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、复杂壳体局部区域和带内孔/复杂边界的面仍不能稳定做局部尺寸编辑。 | +| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `影响范围` 选择 `只改孔/槽壁` 或 `调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | +| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度` 或 `调整整个特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | +| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁` 或 `调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | +| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `影响范围` 里选择 `只改凸台`、`推拉端盖`、`移动凸台`、`调整整个特征` 或 `移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | +| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | +| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径,并通过 `影响范围` 选择 `自动`、`相邻圆柱` 或 `缩放所属`;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | +| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)`、`参考直径(整体)` 或 `半角(整体)`;球面可以改 `半径(整体)` 或 `直径(整体)`;环面可以改 `主半径(整体)`、`主直径(整体)`、`小半径(整体)` 或 `小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | @@ -155,7 +195,7 @@ git diff --check - Edge - 特征 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 -- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。 +- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 - 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 @@ -192,18 +232,18 @@ git diff --check - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 -- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 +- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划。 - 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 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。 -- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 +- 支持导出选中的Face为 STEP;如果当前编辑计划、扫描候选或历史定位已经解析出明确特征区域,也可以导出这片相关区域。 - 支持导出选中特征区域为 STEP。 - 支持导出选中的Edge为 STEP。 - 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 @@ -215,7 +255,7 @@ git diff --check - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - 可推拉的平面Face。 - 可调整孔径的圆柱Face候选。 - - 可调整槽/半孔宽度、深度和圆弧长度的局部圆柱孔/槽候选。 + - 可调整槽/半孔宽度、深度、圆弧长度和轴心坐标的局部圆柱孔/槽候选。 - 可调整盲孔/盲槽深度的圆柱Face候选。 - 可调整圆柱凸台直径和高度的凸台候选。 - 可添加新圆角的直线Edge候选。 @@ -279,13 +319,15 @@ git diff --check - 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。 - 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。 - 当前版本支持长圆槽/槽孔总长度和中心距调整: - - 自动识别到成对半圆槽端时,程序会保持槽宽不变,先补旧长圆槽体,再切出目标总长度。 - - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度和总长度调整会优先尝试使用这个手动配对端。 -- 当前版本支持薄壁/壳体局部厚度调整: + - 你可以直接在当前选中对象表里修改 `槽孔总长度` 或 `槽孔中心距`,不需要先填写配对端 Face ID。 + - 点击修改时,程序会自动寻找另一个半圆槽端;识别成功后保持槽宽不变,先补旧长圆槽体,再切出目标总长度。 + - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度、总长度和中心距调整会优先尝试使用这个手动配对端。 +- 当前版本支持薄壁/壳体厚度调整: - 对已找到投影重叠相对平面的平面候选开放。 - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。 - 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。 - 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。 + - 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面` 或 `调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。 - 当前版本支持已有圆角/倒圆候选结构化识别: - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - 会估算已有圆角半径、圆弧角度和圆弧长度。 @@ -293,6 +335,7 @@ git diff --check - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 + - 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `影响范围` 选择 `重建圆角` 或 `调整整个特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。 - 当前版本支持圆柱孔封堵: - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 @@ -304,6 +347,7 @@ git diff --check - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 + - 当前选中对象表的 `直径` / `半径` 会通过 `影响范围` 选择 `只改凸台` 或 `调整整个特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 - 当前版本支持圆柱凸台高度和轴心坐标调整: @@ -326,18 +370,23 @@ git diff --check - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 - 当前版本支持修改Edge长度: - - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 - - 对圆形/圆弧Edge,当前选中对象表会额外提供圆Edge半径和圆Edge直径可编辑行,内部会换算为等比例Edge目标长度。 - - 可以选择Edge长度基准:自动、中心、固定起点、固定终点。 - - 对直线Edge,当前选中对象表会额外提供 `Edge起点坐标`、`Edge中心坐标` 和 `Edge终点坐标`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 + - 选中Edge后,`长度` 会自动填入当前Edge长度。 + - 对圆形/圆弧Edge,当前选中对象表会额外提供圆边半径和圆边直径可编辑行,内部会换算为等比例Edge长度,并通过 `影响范围` 选择自动、相邻圆柱或缩放所属。 + - `长度基准` 行可以选择自动、中心、固定起点、固定终点。 + - `长度` 行的 `影响范围` 可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属`。 + - `只变当前Edge` 会强制使用局部边形变;如果当前Edge不满足简单全平面多面体条件,会直接阻止,不会偷偷改成整体缩放。 + - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面推拉;对正方体这类模型,会得到更像“变成长方体”的结果。如果找不到端面推拉路径,会直接阻止。 + - `缩放所属` 会强制对所属特征或 Solid 做轴向/整体缩放,是高风险兜底语义,会影响其它尺寸。 + - 对直线Edge,当前选中对象表会额外提供 `起点`、`中心` 和 `终点`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。 - 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。 - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 - - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑。 + - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge长度按比例换算成目标圆柱直径再执行局部编辑。 - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 - - 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 + - 椭圆 Edge 的 `椭圆主半径` / `椭圆小半径` 是单独的属性行:主半径会沿椭圆主轴方向单轴缩放,小半径会沿小轴方向单轴缩放;另一个半径方向尽量不动。 + - 如果直接改椭圆 Edge 的 `长度`,或选择其它可稳定采样为平面的 B-spline / Bezier 等非直线边,仍会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 - 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 @@ -361,7 +410,11 @@ git diff --check - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 当前版本支持圆柱孔/圆柱面扩大切削。 - 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 -- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `孔轴心坐标`,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽暂不放行这条路线,避免整圆柱工具误补材料。 +- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动孔` 时,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽会走 `移动槽/半孔` 语义,避免整圆柱工具误补材料。把 `影响范围` 设为 `移动整个特征` 时,会平移所属特征或 Solid。 +- 当前版本支持孔/槽 `直径` / `半径` 的 `调整整个特征` 影响范围: + - 这条路线不会重切孔壁,而是按目标直径或半径与当前值的比例均匀缩放所属特征或 Solid。 + - 高度、厚度和同一对象上的其它尺寸会同比例变化。 + - 如果你只想让孔径变大/变小,优先在 `直径` 或 `半径` 行把 `影响范围` 设为 `只改孔/槽壁`;如果你的建模意图是让所属圆柱区域整体缩放,再设为 `调整整个特征`。 - 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 @@ -421,7 +474,7 @@ vertices: 3262 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、长圆槽总长度/中心距调整和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底推拉或通过 `影响范围` 做轴向整体缩放,以及平面薄壁/壳体相对面厚度估算、局部厚度推拉和通过 `影响范围` 做厚度方向整体缩放;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 - 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 @@ -437,11 +490,12 @@ vertices: 3262 建议下一步按这个顺序推进: 1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。 -2. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 -3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。 -4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 -5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。 -6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 +2. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略,优先覆盖 Edge 长度、Face 编辑、孔/槽、凸台和圆角。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。 +3. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整(含整体缩放)、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 +4. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。 +5. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 +6. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。 +7. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 ## 怎么使用 @@ -489,11 +543,11 @@ vertices: 3262 左侧 `显示` 用来临时缩小三维视图范围: - `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。 -- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。 +- Face 模式下 `只显示选中` 会优先隔离当前命中的 Face,避免普通点选时触发同域面重计算;如果当前选择来自编辑计划、扫描候选或历史定位,并且已经解析出特征区域,则会使用这片已知区域。 - `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。 - `显示全部`:恢复显示完整模型。 - `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 -- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。 +- `导出选中面区域` 会导出当前选中 Face;如果当前选择已经带有明确的特征区域或同域区域信息,则导出这片已知区域。 - Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 - 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 @@ -513,7 +567,7 @@ vertices: 3262 - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 -- `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移` 和 `推拉平面` 使用。 +- `推拉平面` 行表示这个Face是平面,可以配合 `偏移距离` 和 `推拉平面` 使用。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 - `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 - `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 @@ -529,8 +583,8 @@ vertices: 3262 选择对象后: - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 -- 在 `Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 -- 如果处于 `特征` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 +- 在 `Face` 模式下,普通点选只做轻量拾取和单个对象高亮,保证旋转、悬停和选中更流畅;同域面、端盖、底面和槽端配对等深层识别会延后到扫描候选、执行编辑计划或历史定位时进行。 +- 如果处于 `特征` 模式,普通点选会先高亮命中的基础对象;扫描候选、执行编辑计划或历史定位时,程序会尽量解析并高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 - 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 @@ -542,7 +596,7 @@ vertices: 3262 - `复制信息` 会复制当前信息面板里的完整文本。 - 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 - 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。 -- 如果选中直线Edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `Edge目标长度` 填成当前Edge长度。 +- 如果选中直线Edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `长度` 填成当前Edge长度。 圆柱面候选: @@ -564,7 +618,7 @@ vertices: 3262 编辑: - 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 -- `Face偏移` + `推拉平面`: +- `偏移距离` + `推拉平面`: - 先选择一个平面Face。 - 输入偏移距离。 - 正数表示沿程序判断的外法向加料。 @@ -577,6 +631,21 @@ vertices: 3262 - 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。 - 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 - 当前实现仍使用布尔加/减验证推拉路线。 +- `面偏移`: + - 先选择一个平面 Face。 + - 在当前选中对象表里修改目标面偏移,再用 `影响范围` 选择改法。 + - `推拉当前面` 会沿当前推拉方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。 + - `只改当前面` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 + - `移动整个特征` 会沿当前平面方向平移所属特征或 Solid;形状和内部尺寸不变。 +- `面积` / `面宽` / `面高`: + - 先选择一个 Face,在当前选中对象表里修改目标值,再用 `影响范围` 选择改法。 + - `只改当前面` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。 + - `调整整个特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 + - `面宽` / `面高` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是全局 X/Y/Z 包围盒尺寸。 +- `中心`: + - 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `影响范围` 选择改法。 + - `只改当前面` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 + - `移动整个特征` 会把目标中心换算成平移向量,并平移这个 Face 所属的特征或 Solid;Face 自身形状和所属对象内部尺寸不变。 - `孔直径` + `调整圆柱孔径`: - 先选择一个圆柱面。 - 输入新的直径。 @@ -596,6 +665,10 @@ vertices: 3262 - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 +- `直径` / `半径` 的 `调整整个特征`: + - 先选择一个圆柱 Face,然后在当前选中对象表里修改 `直径` 或 `半径`,并把 `影响范围` 设为 `调整整个特征`。 + - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。 + - 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。 - `槽/半孔宽度` + `调整槽/半孔宽度`: - 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 @@ -610,22 +683,42 @@ vertices: 3262 - 先选择一个槽/半孔候选 Face。 - 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 +- `槽宽` / `槽深` / `弧长` 的 `调整整个特征`: + - 先选择一个槽/半孔候选 Face。 + - 在当前选中对象表里修改对应属性,并把 `影响范围` 设为 `调整整个特征`。 + - 程序会把目标槽宽、槽深或弧长换算成目标圆柱直径,然后按比例整体缩放所属特征或 Solid。 + - 这不是局部重切槽;同一所属对象上的高度、厚度和其它尺寸也会跟随变化。 - `槽/半孔圆弧角度` / `槽/半孔开口角度`: - 先选择一个槽/半孔候选 Face。 - 在当前选中对象表里修改角度值,角度输入单位是度。 - 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角`。 - 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 +- `轴心` 的 `移动槽/半孔`: + - 先选择一个槽/半孔候选 Face。 + - 在当前选中对象表里修改目标坐标,格式是 `X, Y, Z`,并把 `影响范围` 设为 `移动槽/半孔`。 + - 程序会先填充旧的局部扇形槽,再在目标轴心位置按同宽度、同圆弧角度切出新槽。 + - 这一步只移动选中的局部扇形槽面,不等于完整 CAD 草图里的槽整体迁移;长圆槽、链式槽和复杂槽需要确认结果,失败会回滚。 +- `轴心` 的 `移动整个特征`: + - 同样输入目标 X, Y, Z 坐标。 + - 把 `影响范围` 设为 `移动整个特征`。 + - 程序会把目标轴心换算成平移向量,并平移所属特征或 Solid;不会填旧槽,也不会切新槽。 + - 这适合“整个对象搬动到新位置”的意图,不适合只想移动槽本身的场景。 - `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距`: - - 对长圆槽或成对半圆槽,程序会自动寻找另一个槽端。 + - 对长圆槽或成对半圆槽,程序会在点击修改时自动寻找另一个槽端,避免选择对象时扫描过重。 - 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`。 - 修改 `槽孔总长度` 或 `槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。 -- `薄壁厚度` + `调整薄壁厚度`: - - 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入。 - - 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值。 - - 输入目标厚度后点击 `调整薄壁厚度`。 +- `薄壁厚度` 的 `推拉当前面`: + - 先选择一个已识别到相对平面的薄壁/壳体平面 Face。 + - 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `推拉当前面`。 + - 输入目标厚度后点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。 - 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。 +- `薄壁厚度` 的 `调整整个特征`: + - 先选择一个已识别到相对平面的薄壁/壳体平面 Face。 + - 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `调整整个特征`。 + - 程序会沿薄壁厚度方向缩放所属特征或 Solid,让估算厚度接近目标值。 + - 这不是推拉当前面;同一对象上的孔距、台阶位置和其它厚度方向尺寸也会跟随变化。 - `凸台直径` + `调整圆柱凸台直径`: - 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 @@ -633,14 +726,38 @@ vertices: 3262 - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 +- `直径` / `半径` 的 `调整整个特征`: + - 先选择一个圆柱凸台 Face,然后在当前选中对象表里修改这一行。 + - 把 `影响范围` 设为 `调整整个特征`。 + - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,不走凸台包络移除/重建路线。 + - 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。 - `凸台高度`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `凸台高度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `推拉端盖`,点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。 -- `凸台轴心坐标`: +- `高度` 的 `调整整个特征`: + - 先选择一个完整圆柱凸台或普通完整圆柱 Face。 + - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `调整整个特征`。 + - 程序会沿圆柱轴向缩放所属特征或 Solid,让圆柱高度接近目标值。 + - 这不是端盖推拉;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 +- `轴心`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `凸台轴心坐标`,点击该行的 `应用` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动凸台`,点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 +- `轴心` 的 `移动整个特征`: + - 同样输入目标 X, Y, Z 坐标。 + - 把 `影响范围` 设为 `移动整个特征`。 + - 程序会整体平移凸台所属特征或 Solid;不会移除旧凸台,也不会补新凸台。 + - 这适合移动整个所属对象,不适合只改凸台相对基体的位置。 +- `轴心` 的 `移动孔`: + - 先选择一个完整圆柱孔 Face。 + - 在当前选中对象表里修改目标 X, Y, Z 坐标,并把 `影响范围` 设为 `移动孔`。 + - 程序会先填补旧孔,再按同直径在目标轴心位置切出新孔。 +- `轴心` 的 `移动整个特征`: + - 同样输入目标 X, Y, Z 坐标。 + - 把 `影响范围` 设为 `移动整个特征`。 + - 程序会整体平移孔所属特征或 Solid;不会填旧孔,也不会切新孔。 + - 这适合移动整个所属对象,不适合只改孔相对基体的位置。 - `封堵圆柱孔`: - 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。 - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 @@ -648,10 +765,10 @@ vertices: 3262 - 通过检查后,会显示绿色半透明补料体预览。 - 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。 - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。 -- `孔深度` + `调整盲孔深度`: +- `盲孔/盲槽深度` 的 `改底面深度`: - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - - 选中符合条件的盲孔/盲槽后,`孔深度` 会自动填入一个比当前估算深度略大的建议值。 - - 输入目标深度后点击 `调整盲孔深度`。 + - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `改底面深度`。 + - 输入目标深度后点击该行的 `可修改` 或点击 `参数化建模`。 - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 @@ -659,6 +776,11 @@ vertices: 3262 - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 - 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。 +- `盲孔/盲槽深度` 的 `调整整个特征`: + - 先选择一个已经识别出底面的盲孔或盲槽圆柱面;自动底面不准时,可以先填写 `底面Face`。 + - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `调整整个特征`。 + - 程序会沿孔/槽轴向缩放所属特征或 Solid,让深度接近目标值。 + - 这不是加深切削,也不是变浅补料;同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化。 - `圆角半径` + `给Edge添加圆角`: - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。 - 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。 @@ -668,15 +790,19 @@ vertices: 3262 - 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。 - 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 -- `圆角半径` + `修改已有圆角半径`: +- `圆角半径` / `圆角弧长` 的 `重建圆角`: - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 - - 输入目标半径后点击 `修改已有圆角半径`。 - - 也可以在当前选中对象表里修改 `已有圆角圆弧长度`,程序会按当前圆弧角度换算成目标半径后执行同一条修改路线。 + - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `影响范围` 设为 `重建圆角`。 + - 输入目标值后点击该行的 `可修改` 或点击 `参数化建模`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 +- `圆角半径` / `圆角弧长` 的 `调整整个特征`: + - 先选择一个已有圆角/倒圆候选 Face。 + - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `影响范围` 设为 `调整整个特征`。 + - 程序会按目标圆角半径或圆弧长度换算出的比例整体缩放所属特征或 Solid,不会移除旧圆角再重建。 + - 这会连带改变同一对象上的其它尺寸。 - `倒角距离` + `给Edge添加倒角`: - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。 - 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。 @@ -689,18 +815,19 @@ vertices: 3262 - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - 当前支持对称距离倒角、两侧不同距离倒角和距离+角度倒角。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 -- `Edge目标长度` + `修改Edge长度`: +- `长度` + `影响范围`: - 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。 - - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 - - 右侧Edge长度基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - - 把它改成目标长度后点击 `修改Edge长度`。 - - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `Edge起点坐标`、`Edge中心坐标` 或 `Edge终点坐标`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。 - - 修改 `Edge中心坐标` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - - 对 `assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面。 - - 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。 + - 选中Edge后,`长度` 会自动填入当前Edge长度。 + - 当前选中对象表中的 `长度基准` 行可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 + - `长度` 行的 `影响范围` 下拉框可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱` 或 `缩放所属`。 + - 把 `长度` 改成目标值后点击这一行的按钮。 + - 如果选中的是直线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中心坐标”的局部形变路径。 + - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心”的局部形变路径。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。 - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 @@ -752,7 +879,7 @@ vertices: 3262 - `导出当前完整 STEP`:导出当前完整模型。 - `导出选中零件`:导出当前选中的零件。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。 -- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。 +- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。 - `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。 - `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 @@ -775,7 +902,15 @@ pythonocc-step-editor/ screenshots/ # 调试截图和问题截图,默认不提交 scripts/ generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 + verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证解析曲面整体缩放 + verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改 + verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 + verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 + verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 + verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 + verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证薄壁厚度局部/整体修改 + verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 step_editor/ __init__.py # 包导出 app.py # 主窗口初始化、UI 搭建和程序启动 @@ -840,6 +975,10 @@ python main.py python main.py assets\models\cube_10mm.step python scripts\generate_cube_step.py python scripts\verify_edge_length_resize.py +python scripts\verify_edge_round_chamfer.py +python scripts\verify_shell_thickness_resize.py +python scripts\verify_analytic_surface_resize.py +python scripts\verify_slot_resize.py python main.py --smoke-test ``` @@ -862,7 +1001,14 @@ git diff --check - `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。 +- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证圆锥参考半径、球面半径、环面主半径和环面小半径整体缩放。 +- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 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_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的 `当前面` 局部尺寸编辑和 `整体` 所属对象尺寸编辑不会互相混淆。 +- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路。 +- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证薄壁厚度局部推拉和薄壁厚度整体缩放两种语义。 +- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。 - `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 - `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。 @@ -886,28 +1032,62 @@ git diff --check ### 当前关键能力 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 -- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。 -- 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 -- 编辑:平面推拉和目标法向位置、孔径/半径、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度、槽孔总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心坐标、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度当前版本修改、圆锥半角、环面直径、Edge圆角、Edge倒角、圆Edge半径/直径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积当前作为只读属性展示,不再伪装成直接可修改参数。 +- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 +- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 +- 编辑:面偏移、偏移距离、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高和中心现在统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 +### 编辑语义 + +所有几何参数修改都必须先明确语义、影响范围和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分面推拉、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `影响范围` 提供显式改法选择;Face 当前把 `面偏移`、`面积`、`面宽`、`面高` 和 `中心` 显示为单行属性,并通过 `影响范围` 下拉框表达“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也通过 `影响范围` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `影响范围` 区分“局部重建/端盖推拉/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `影响范围` 区分“兜底重切/端盖推拉”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `影响范围` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `影响范围` 区分“局部切削/补料”和“轴向缩放所属对象”;薄壁厚度也通过 `影响范围` 区分“局部推拉当前薄壁平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `影响范围` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、薄壁厚度现在会显示并记录当前采用的编辑方式,后续还要继续把更多多语义场景做成用户可选策略。 + +必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。 + ### 修改Edge长度 这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。 -当前策略顺序: +当前支持的长度修改策略: -1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 -2. `local-edge-endpoint-deform`:直线Edge起点/终点坐标直接修改,同样只在简单全平面多面体上移动单个端点并重建周边平面。 -3. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 +1. `auto`:自动策略。按局部边形变、端面推拉、相邻圆柱直径、几何缩放的顺序寻找可用路径。 +2. `local-edge-only-deform`:只变当前Edge。直线Edge位于简单全平面多面体时,只移动被选Edge的端点并重建所属Solid/特征。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 +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,风险高,会影响其它尺寸。 +5. `scale-owning-shape-from-edge`:缩放所属对象。圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 + +直线Edge起点/终点/中心坐标编辑是另一类显式语义:`local-edge-endpoint-deform` 和 `local-edge-center-deform` 会在简单全平面多面体上移动端点或整条Edge,并重建周边平面。 确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。 最近一次基线验证:在 `assets/models/cube_10mm.step` 上选第一条直线Edge,将 10mm 改为 15mm,计划策略为 `local-edge-only-deform`,内存执行成功。执行后边长分布为 `10:9, 11.1803:2, 14.1421:1, 15:1`,Face数从 6 变 7,说明没有整体拉成长方体,而是局部重建并拆分了一个非共面面。 +移动端面基线验证:在同一个立方体模型上将第一条 10mm 直线 Edge 改为 15mm,并强制选择 `move-edge-end-plane-by-push-pull`、`keep-start`,内存执行成功。执行后 Face 数仍为 6、Edge 数仍为 12,边长分布为 `10:8, 15:4`,说明相关端面和同向边一起移动,结果更接近长方体语义。 + +Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。 + +Face 局部面偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `面偏移` 的影响范围设为 `只改当前面` 并改变 `1mm`,执行路径为 `local-face-plane-offset-deform`,实际复用局部 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`,内存执行成功。执行后检测到一个 `15 x 10`、面积 `150` 的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 + +Face 整体面宽基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面宽` 的影响范围设为 `调整整个特征` 并从 `10` 改为 `15`,计划策略为 `axis-scale-owning-shape-from-face-width`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该方向整体跟随变宽,同时避免通用仿射把平面盒子变成样条面。 + +孔/盲孔局部重建基线验证:`scripts/verify_hole_resize.py` 会临时生成一个通孔板件和一个盲孔块体。当前验证通过六条孔修改路线:通孔孔径 `6 -> 8`、通孔孔径 `6 -> 4`、通孔轴心偏移 `2mm`、完整通孔封堵、盲孔深度 `6 -> 8`、盲孔深度 `6 -> 4`。封堵会检查补料后仍是单 Solid、孔候选消失,并且体积接近完整板件;孔径缩小、孔轴心移动、封堵和盲孔变浅都会先补旧孔/旧底部再重切或补料;这些孔内补料 Fuse 必须关闭 OCCT glue,否则可能只生成一个插在孔里的独立圆柱 Solid,而不是和主体真正合并。 + +圆柱凸台局部重建基线验证:`scripts/verify_boss_resize.py` 会临时生成一个底板加圆柱凸台的 STEP。当前验证通过四条凸台修改路线:凸台直径 `6 -> 8`、凸台直径 `6 -> 4`、凸台高度 `5 -> 7`、凸台轴心偏移 `2mm`。直径缩小和轴心移动会先移除旧凸台包络,再按目标直径或目标轴心补新凸台;凸台补料/重建 Fuse 和旧包络 Cut 都必须显式关闭 OCCT glue,否则旧凸台可能没切掉,或补出的凸台变成独立 Solid。验证脚本会断言修改后仍是单 Solid,并且直径/轴心修改不会顺手改变凸台高度。 + +槽/半孔局部重建基线验证:`scripts/verify_slot_resize.py` 会临时生成一个 30 x 20 x 10 的块体并在上表面切出半径 3 的半圆槽,还会生成一个 40 x 24 x 6 的长圆槽板件。当前验证通过八条局部槽修改路线:槽宽 `6 -> 8`、槽深 `3 -> 4`、弧长 `9.42478 -> 12`、弧角 `180° -> 126.051°`、半圆槽轴心偏移 `1mm`、长圆槽整槽轴心偏移 `1mm`、槽孔总长度 `16 -> 20`、两端中心距 `10 -> 14`。槽宽/槽深/弧长会保持当前圆弧角度并换算目标圆柱直径;弧角会先补旧扇形槽,再按目标角度重切;半圆槽轴心会先填旧槽再按同尺寸切新槽,并丢弃极小布尔毛刺;长圆槽轴心会填旧胶囊槽再整体切到新位置;槽孔总长度和中心距会保持槽宽不变并调整两端中心距。槽扇形和长圆槽胶囊工具体必须使用真实圆弧边拉伸,不能用多边形近似,否则结果会变成小平面,后续识别不到目标圆柱槽面。 + +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` 的薄板。当前验证通过两条路线:`薄壁厚度` 的 `推拉当前面` 把厚度 `2 -> 3`,以及 `薄壁厚度` 的 `调整整个特征` 做厚度方向整体缩放。局部路线会保持相对面不动,整体路线会保持厚度中心基本不动。 + +解析曲面整体缩放基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过四条路线:圆锥参考半径 `4 -> 5`、球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;圆锥径向仿射后 OCCT 可能把面转成 B-spline,所以脚本用上下端盖直径和高度校验几何结果。 + +椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine` 或 `ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。 + 新增基线验证:内存构造椭圆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长度是否贴近目标值。 diff --git a/scripts/verify_analytic_surface_resize.py b/scripts/verify_analytic_surface_resize.py new file mode 100644 index 0000000..ec15276 --- /dev/null +++ b/scripts/verify_analytic_surface_resize.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCone, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus + +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 + + +def _write_cone_model(path: Path) -> None: + shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape() + _write_step(shape, path) + + +def _write_sphere_model(path: Path) -> None: + shape = BRepPrimAPI_MakeSphere(5.0).Shape() + _write_step(shape, path) + + +def _write_torus_model(path: Path) -> None: + shape = BRepPrimAPI_MakeTorus(8.0, 2.0).Shape() + _write_step(shape, path) + + +def _first_face_by_surface(model: StepModel, surface: str) -> int: + for face_id in range(len(model.faces)): + if model.face_info(face_id).get("surface") == surface: + return face_id + raise SystemExit(f"no {surface} Face was recognized") + + +def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int, float]: + best: tuple[int, float, float] | None = None + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "sphere": + continue + radius = float(info.get("radius") or 0.0) + score = abs(radius - target_radius) + if best is None or score < best[2]: + best = (face_id, radius, score) + if best is None: + raise SystemExit("no sphere Face remained after edit") + return best[0], best[1] + + +def _nearest_torus_radii( + model: StepModel, + target_major: float, + target_minor: float, +) -> tuple[int, float, float]: + best: tuple[int, float, float, float] | None = None + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "torus": + continue + major = float(info.get("major_radius") or 0.0) + minor = float(info.get("minor_radius") or 0.0) + score = abs(major - target_major) + abs(minor - target_minor) + if best is None or score < best[3]: + best = (face_id, major, minor, score) + if best is None: + raise SystemExit("no torus Face remained after edit") + return best[0], best[1], best[2] + + +def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool: + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + size = info.get("bbox_size") + if not isinstance(size, tuple) or len(size) != 3: + continue + dx, dy, dz = (float(size[0]), float(size[1]), float(size[2])) + if dz > max(tolerance * 10.0, 1e-5): + continue + if abs(dx - target_diameter) <= tolerance and abs(dy - target_diameter) <= tolerance: + return True + return False + + +def _bbox_z_size(model: StepModel) -> float: + size = model.geometry_stats().get("bbox_size") + if not isinstance(size, tuple) or len(size) != 3: + raise SystemExit("model bbox_size is missing") + return float(size[2]) + + +def _run_cone_case(target_reference_radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_cone_") as temp_dir: + model_path = Path(temp_dir) / "cone.step" + _write_cone_model(model_path) + model = StepModel.load(model_path) + face_id = _first_face_by_surface(model, "cone") + before = model.stats() + info = model.face_info(face_id) + current_reference_radius = float(info.get("reference_radius") or 0.0) + current_top_radius = 2.0 + scale = target_reference_radius / current_reference_radius + target_top_radius = current_top_radius * scale + plan = model.conical_reference_radius_plan(face_id, target_reference_radius) + if plan["status"] == "blocked": + raise SystemExit(f"cone plan was blocked: {plan['message']}") + result = model.resize_conical_reference_radius(face_id, target_reference_radius) + after = model.stats() + if after.solids != before.solids: + raise SystemExit(f"cone resize changed solid count: before={before.solids}, after={after.solids}") + if abs(_bbox_z_size(model) - 10.0) > tolerance: + raise SystemExit(f"cone height changed unexpectedly: z_size={_bbox_z_size(model):g}") + if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance): + raise SystemExit(f"cone bottom cap diameter was not resized to {target_reference_radius * 2.0:g}") + if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance): + raise SystemExit(f"cone top cap diameter was not resized to {target_top_radius * 2.0:g}") + print("mode=cone_reference_radius") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_reference_radius={current_reference_radius:.6f} target_reference_radius={target_reference_radius:.6f}") + print(f"target_top_radius={target_top_radius:.6f}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_sphere_case(target_radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir: + model_path = Path(temp_dir) / "sphere.step" + _write_sphere_model(model_path) + model = StepModel.load(model_path) + face_id = _first_face_by_surface(model, "sphere") + before = model.stats() + current_radius = float(model.face_info(face_id).get("radius") or 0.0) + plan = model.spherical_radius_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise SystemExit(f"sphere plan was blocked: {plan['message']}") + result = model.resize_spherical_radius(face_id, target_radius) + after = model.stats() + verified_face, radius = _nearest_sphere_radius(model, target_radius) + if after.solids != before.solids: + raise SystemExit(f"sphere resize changed solid count: before={before.solids}, after={after.solids}") + if abs(radius - target_radius) > tolerance: + raise SystemExit(f"sphere radius verification failed: target={target_radius:g}, value={radius:g}") + print("mode=sphere_radius") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_radius={current_radius:.6f} target_radius={target_radius:.6f}") + print(f"verified_face={verified_face} value={radius:.6f}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_torus_case(mode: str, target_radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix=f"geom_param_torus_{mode}_") as temp_dir: + model_path = Path(temp_dir) / "torus.step" + _write_torus_model(model_path) + model = StepModel.load(model_path) + face_id = _first_face_by_surface(model, "torus") + before = model.stats() + info = model.face_info(face_id) + current_major = float(info.get("major_radius") or 0.0) + current_minor = float(info.get("minor_radius") or 0.0) + current = current_major if mode == "major" else current_minor + scale = target_radius / current + target_major = current_major * scale + target_minor = current_minor * scale + plan = model.toroidal_radius_plan(face_id, target_radius, mode) + if plan["status"] == "blocked": + raise SystemExit(f"torus {mode} plan was blocked: {plan['message']}") + result = model.resize_toroidal_radius(face_id, target_radius, mode) + after = model.stats() + verified_face, major, minor = _nearest_torus_radii(model, target_major, target_minor) + if after.solids != before.solids: + raise SystemExit(f"torus {mode} resize changed solid count: before={before.solids}, after={after.solids}") + if abs(major - target_major) > tolerance or abs(minor - target_minor) > tolerance: + raise SystemExit( + f"torus {mode} verification failed: target=({target_major:g}, {target_minor:g}), " + f"value=({major:g}, {minor:g})" + ) + print(f"mode=torus_{mode}_radius") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_major={current_major:.6f} current_minor={current_minor:.6f}") + print(f"target_major={target_major:.6f} target_minor={target_minor:.6f}") + print(f"verified_face={verified_face} major={major:.6f} minor={minor:.6f}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify analytic curved surface resize operations.") + parser.add_argument( + "--mode", + default="all", + choices=["all", "cone", "sphere", "torus_major", "torus_minor"], + ) + parser.add_argument("--cone-reference-radius", type=float, default=5.0) + parser.add_argument("--sphere-radius", type=float, default=6.25) + parser.add_argument("--torus-major-radius", type=float, default=10.0) + parser.add_argument("--torus-minor-radius", type=float, default=3.0) + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + modes = ["cone", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode] + for mode in modes: + if mode == "cone": + _run_cone_case(args.cone_reference_radius, args.tolerance) + elif mode == "sphere": + _run_sphere_case(args.sphere_radius, args.tolerance) + elif mode == "torus_major": + _run_torus_case("major", args.torus_major_radius, args.tolerance) + elif mode == "torus_minor": + _run_torus_case("minor", args.torus_minor_radius, args.tolerance) + else: + raise SystemExit(f"unsupported mode: {mode}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_boss_resize.py b/scripts/verify_boss_resize.py new file mode 100644 index 0000000..758fd6d --- /dev/null +++ b/scripts/verify_boss_resize.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import argparse +import math +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.geometry_utils import _finalize_boolean_result +from step_editor.model import StepModel +from step_editor.step_io import _write_step + + +def _write_boss_model(path: Path) -> None: + base = BRepPrimAPI_MakeBox(30.0, 20.0, 6.0).Shape() + axis = gp_Ax2(gp_Pnt(15.0, 10.0, 6.0), gp_Dir(0.0, 0.0, 1.0)) + boss = BRepPrimAPI_MakeCylinder(axis, 3.0, 5.0).Shape() + fuse = BRepAlgoAPI_Fuse(base, boss) + shape = _finalize_boolean_result(fuse, "verify boss model fuse", use_glue=False) + _write_step(shape, path) + + +def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return math.sqrt( + (left[0] - right[0]) ** 2 + + (left[1] - right[1]) ** 2 + + (left[2] - right[2]) ** 2 + ) + + +def _boss_face_ids(model: StepModel) -> list[int]: + face_ids: list[int] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + if str(info.get("feature_guess", "")) != "boss/outer-round candidate": + continue + span = float(info.get("angular_span") or 0.0) + if span < math.tau * 0.92: + continue + face_ids.append(face_id) + return face_ids + + +def _first_boss_face(model: StepModel) -> int: + candidates = _boss_face_ids(model) + if not candidates: + raise SystemExit("no near-full cylindrical boss face was recognized") + return candidates[0] + + +def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: + info = model.face_info(face_id) + axis_point = info.get("axis_point") + axis = info.get("axis") + v_range = info.get("same_domain_v_range") or info.get("v_range") + if not isinstance(axis_point, tuple) or not isinstance(axis, tuple) or not isinstance(v_range, tuple): + raise SystemExit(f"axis center data is missing on Face {face_id}") + v_mid = (float(v_range[0]) + float(v_range[1])) * 0.5 + return ( + float(axis_point[0]) + float(axis[0]) * v_mid, + float(axis_point[1]) + float(axis[1]) * v_mid, + float(axis_point[2]) + float(axis[2]) * v_mid, + ) + + +def _diameter(model: StepModel, face_id: int) -> float: + value = model.face_info(face_id).get("diameter") + if value is None: + raise SystemExit(f"diameter is missing on Face {face_id}") + return float(value) + + +def _height(model: StepModel, face_id: int) -> float: + feature = model.feature_info(face_id) + value = feature.get("same_domain_height_estimate") + if value is None: + value = model.face_info(face_id).get("height_estimate") + if value is None: + raise SystemExit(f"height is missing on Face {face_id}") + return float(value) + + +def _nearest_boss( + model: StepModel, + target_diameter: float, + target_center: tuple[float, float, float] | None = None, +) -> tuple[int, float, float, tuple[float, float, float], float]: + best: tuple[int, float, float, tuple[float, float, float], float] | None = None + for face_id in _boss_face_ids(model): + diameter = _diameter(model, face_id) + height = _height(model, face_id) + center = _axis_center(model, face_id) + diameter_error = abs(diameter - target_diameter) + center_error = _distance(center, target_center) if target_center is not None else 0.0 + score = diameter_error + center_error + if best is None or score < best[4]: + best = (face_id, diameter, height, center, score) + if best is None: + raise SystemExit("no cylindrical boss face remained after edit") + return best + + +def _run_diameter_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_boss_diam_") as temp_dir: + model_path = Path(temp_dir) / "boss.step" + _write_boss_model(model_path) + model = StepModel.load(model_path) + face_id = _first_boss_face(model) + before = model.stats() + center = _axis_center(model, face_id) + current_height = _height(model, face_id) + plan = model.cylindrical_boss_resize_plan(face_id, target) + if plan["status"] == "blocked": + raise SystemExit(f"diameter plan was blocked: {plan['message']}") + result = model.resize_cylindrical_boss(face_id, target) + after = model.stats() + verified_face, diameter, height, verified_center, _ = _nearest_boss(model, target, center) + error = abs(diameter - target) + height_error = abs(height - current_height) + if after.solids != before.solids: + raise SystemExit(f"diameter changed solid count: before={before.solids}, after={after.solids}") + if error > tolerance: + raise SystemExit(f"diameter verification failed: target={target:g}, value={diameter:g}, error={error:g}") + if height_error > tolerance: + raise SystemExit( + f"diameter changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}" + ) + print("mode=diameter") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target:.6f} value={diameter:.6f} height={height:.6f} center={verified_center} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_axis_center_case(offset: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_boss_axis_") as temp_dir: + model_path = Path(temp_dir) / "boss.step" + _write_boss_model(model_path) + model = StepModel.load(model_path) + face_id = _first_boss_face(model) + before = model.stats() + current_center = _axis_center(model, face_id) + current_diameter = _diameter(model, face_id) + current_height = _height(model, face_id) + target_center = (current_center[0] + offset, current_center[1], current_center[2]) + plan = model.cylindrical_boss_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise SystemExit(f"axis_center plan was blocked: {plan['message']}") + result = model.move_cylindrical_boss_axis(face_id, target_center) + after = model.stats() + verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter, target_center) + center_error = _distance(center, target_center) + height_error = abs(height - current_height) + if after.solids != before.solids: + raise SystemExit(f"axis_center changed solid count: before={before.solids}, after={after.solids}") + if center_error > tolerance or abs(diameter - current_diameter) > tolerance: + raise SystemExit( + f"axis_center verification failed: target={target_center}, center={center}, " + f"center_error={center_error:g}, diameter={diameter:g}" + ) + if height_error > tolerance: + raise SystemExit( + f"axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}" + ) + print("mode=axis_center") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target_center} value={center} diameter={diameter:.6f} height={height:.6f} error={center_error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_height_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir: + model_path = Path(temp_dir) / "boss.step" + _write_boss_model(model_path) + model = StepModel.load(model_path) + face_id = _first_boss_face(model) + before = model.stats() + current_diameter = _diameter(model, face_id) + plan = model.cylindrical_boss_height_plan(face_id, target) + if plan["status"] == "blocked": + raise SystemExit(f"height plan was blocked: {plan['message']}") + result = model.resize_cylindrical_boss_height(face_id, target) + after = model.stats() + verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter) + error = abs(height - target) + if after.solids != before.solids: + raise SystemExit(f"height changed solid count: before={before.solids}, after={after.solids}") + if error > tolerance: + raise SystemExit(f"height verification failed: target={target:g}, value={height:g}, error={error:g}") + print("mode=height") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target:.6f} value={height:.6f} diameter={diameter:.6f} center={center} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify cylindrical boss edit operations.") + parser.add_argument( + "--mode", + default="all", + choices=["all", "diameter", "diameter_shrink", "height", "axis_center"], + help="Boss edit mode to verify.", + ) + parser.add_argument("--diameter", type=float, default=8.0) + parser.add_argument("--diameter-shrink", type=float, default=4.0) + parser.add_argument("--height", type=float, default=7.0) + parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.") + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + cases = ( + [ + ("diameter", args.diameter), + ("diameter_shrink", args.diameter_shrink), + ("height", args.height), + ("axis_center", args.axis_center), + ] + if args.mode == "all" + else [(args.mode, getattr(args, args.mode.replace("-", "_")))] + ) + for mode, target in cases: + if mode in {"diameter", "diameter_shrink"}: + _run_diameter_case(float(target), args.tolerance) + elif mode == "height": + _run_height_case(float(target), args.tolerance) + elif mode == "axis_center": + _run_axis_center_case(float(target), args.tolerance) + else: + raise SystemExit(f"unsupported mode: {mode}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_edge_length_resize.py b/scripts/verify_edge_length_resize.py index 7ff6e7b..62a98ed 100644 --- a/scripts/verify_edge_length_resize.py +++ b/scripts/verify_edge_length_resize.py @@ -35,11 +35,23 @@ def _length_distribution(model: StepModel) -> dict[float, int]: def main() -> int: - parser = argparse.ArgumentParser(description="Verify cube edge-length local deformation.") + parser = argparse.ArgumentParser(description="Verify cube edge-length resize semantics.") parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.") parser.add_argument("--source-length", type=float, default=10.0, help="Current line edge length to search for.") parser.add_argument("--target-length", type=float, default=15.0, help="Target edge length to apply.") parser.add_argument("--anchor", default="keep-start", choices=["auto", "center", "keep-start", "keep-end"]) + parser.add_argument( + "--strategy", + default="local-edge-only-deform", + choices=[ + "auto", + "local-edge-only-deform", + "move-edge-end-plane-by-push-pull", + "scale-owning-shape-from-edge", + ], + help="Requested Edge length edit semantics.", + ) + parser.add_argument("--expect-strategy", default="", help="Expected resolved resize strategy.") parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.") args = parser.parse_args() @@ -50,12 +62,25 @@ def main() -> int: edge_id = edge_ids[0] before = model.stats() - plan = model.general_edge_length_plan(edge_id, args.target_length, anchor_mode=args.anchor) + plan = model.general_edge_length_plan( + edge_id, + args.target_length, + anchor_mode=args.anchor, + strategy_mode=args.strategy, + ) strategy = str(plan.get("resize_strategy", "")) - if strategy != "local-edge-only-deform": - raise SystemExit(f"expected local-edge-only-deform, got {strategy or ''}") + expected_strategy = args.expect_strategy or args.strategy + if expected_strategy == "auto": + expected_strategy = strategy + if strategy != expected_strategy: + raise SystemExit(f"expected {expected_strategy}, got {strategy or ''}") - result = model.resize_general_edge_length(edge_id, args.target_length, anchor_mode=args.anchor) + result = model.resize_general_edge_length( + edge_id, + args.target_length, + anchor_mode=args.anchor, + strategy_mode=args.strategy, + ) after = model.stats() lengths = [_edge_length(model, item) for item in range(len(model.edges))] nearest = min(lengths, key=lambda value: abs(value - args.target_length)) @@ -65,6 +90,7 @@ def main() -> int: print(f"model={Path(args.model)}") print(f"edge_id={edge_id}") + print(f"requested_strategy={args.strategy}") print(f"strategy={strategy}") print(f"anchor_mode={args.anchor}") print(f"start_move={plan.get('local_edge_deform_start_move')}") diff --git a/scripts/verify_edge_round_chamfer.py b/scripts/verify_edge_round_chamfer.py new file mode 100644 index 0000000..409b23a --- /dev/null +++ b/scripts/verify_edge_round_chamfer.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import argparse +import math +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepAdaptor import BRepAdaptor_Curve +from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet +from OCC.Core.BRepGProp import brepgprop +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCC.Core.GeomAbs import GeomAbs_Line +from OCC.Core.GProp import GProp_GProps +from OCC.Core.TopoDS import TopoDS_Shape, topods +from OCC.Extend.TopologyUtils import TopologyExplorer + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.geometry_utils import _finalize_builder_result +from step_editor.model import StepModel +from step_editor.step_io import _write_step + + +def _write_box_model(path: Path) -> None: + shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() + _write_step(shape, path) + + +def _first_line_edge(shape: TopoDS_Shape, minimum_length: float = 5.0) -> TopoDS_Shape: + for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Line: + continue + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + if props.Mass() >= minimum_length: + return topods.Edge(edge) + raise SystemExit("no line edge found in generated box") + + +def _write_filleted_box_model(path: Path, radius: float) -> None: + shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() + maker = BRepFilletAPI_MakeFillet(shape) + maker.Add(float(radius), _first_line_edge(shape)) + result = _finalize_builder_result(maker, "verify source box fillet") + _write_step(result, path) + + +def _first_editable_line_edge(model: StepModel) -> int: + candidates: list[tuple[float, int]] = [] + for edge_id in range(len(model.edges)): + info = model.edge_info(edge_id) + if info.get("curve") != "line": + continue + if int(info.get("adjacent_face_count") or 0) < 2: + continue + length = float(info.get("length") or 0.0) + if length <= 1e-9: + continue + candidates.append((-length, edge_id)) + if not candidates: + raise SystemExit("no editable line Edge was recognized") + candidates.sort() + return candidates[0][1] + + +def _first_adjacent_reference_face(model: StepModel, edge_id: int) -> int: + adjacent_face_ids = tuple(model.edge_info(edge_id).get("adjacent_face_ids") or ()) + if not adjacent_face_ids: + raise SystemExit(f"Edge {edge_id} has no adjacent Face for chamfer reference") + return int(adjacent_face_ids[0]) + + +def _cylindrical_faces_near_radius( + model: StepModel, + radius: float, + tolerance: float, + *, + require_fillet_guess: bool = False, +) -> list[tuple[int, float, float, str]]: + matches: list[tuple[int, float, float, str]] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + value = float(info.get("radius") or 0.0) + if abs(value - radius) > tolerance: + continue + guess = str(info.get("feature_guess", "")) + if require_fillet_guess and guess != "round/fillet candidate": + continue + matches.append((face_id, value, float(info.get("angular_span") or 0.0), guess)) + return matches + + +def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: float) -> int: + matches = _cylindrical_faces_near_radius(model, radius, tolerance, require_fillet_guess=True) + if matches: + return matches[0][0] + loose_matches = _cylindrical_faces_near_radius(model, radius, tolerance) + detail = ", ".join( + f"Face {face_id}: radius={value:g}, span={span:g}, guess={guess}" + for face_id, value, span, guess in loose_matches[:8] + ) + raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or ''}") + + +def _run_fillet_case(radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_fillet_") as temp_dir: + model_path = Path(temp_dir) / "box.step" + _write_box_model(model_path) + model = StepModel.load(model_path) + edge_id = _first_editable_line_edge(model) + before = model.stats() + plan = model.edge_fillet_plan(edge_id, radius) + if plan["status"] == "blocked": + raise SystemExit(f"fillet plan was blocked: {plan['message']}") + result = model.fillet_edge(edge_id, radius) + after = model.stats() + matches = _cylindrical_faces_near_radius(model, radius, tolerance) + if after.solids != before.solids: + raise SystemExit(f"fillet changed solid count: before={before.solids}, after={after.solids}") + if not matches: + raise SystemExit(f"fillet verification failed: no cylindrical face near radius {radius:g}") + print("mode=fillet") + print(f"edge_id={edge_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"target_radius={radius:.6f}") + print(f"matched_faces={matches}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _verify_chamfer_topology( + mode: str, + edge_id: int, + plan: dict[str, object], + before, + after, + result: str, + extra_lines: list[str], +) -> None: + if after.solids != before.solids: + raise SystemExit(f"{mode} changed solid count: before={before.solids}, after={after.solids}") + if after.faces <= before.faces: + raise SystemExit(f"{mode} did not add a visible planar face: before={before.faces}, after={after.faces}") + if after.edges <= before.edges: + raise SystemExit(f"{mode} did not add expected boundary edges: before={before.edges}, after={after.edges}") + print(f"mode={mode}") + print(f"edge_id={edge_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + for line in extra_lines: + print(line) + print(f"face_delta={after.faces - before.faces} edge_delta={after.edges - before.edges}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_chamfer_case(distance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_chamfer_") as temp_dir: + model_path = Path(temp_dir) / "box.step" + _write_box_model(model_path) + model = StepModel.load(model_path) + edge_id = _first_editable_line_edge(model) + before = model.stats() + plan = model.edge_chamfer_plan(edge_id, distance) + if plan["status"] == "blocked": + raise SystemExit(f"chamfer plan was blocked: {plan['message']}") + result = model.chamfer_edge(edge_id, distance) + after = model.stats() + _verify_chamfer_topology( + "chamfer", + edge_id, + plan, + before, + after, + result, + [f"target_distance={distance:.6f}"], + ) + + +def _run_asymmetric_chamfer_case(distance1: float, distance2: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_asym_chamfer_") as temp_dir: + model_path = Path(temp_dir) / "box.step" + _write_box_model(model_path) + model = StepModel.load(model_path) + edge_id = _first_editable_line_edge(model) + reference_face_id = _first_adjacent_reference_face(model, edge_id) + before = model.stats() + plan = model.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) + if plan["status"] == "blocked": + raise SystemExit(f"asymmetric chamfer plan was blocked: {plan['message']}") + result = model.chamfer_edge_asymmetric(edge_id, distance1, distance2, reference_face_id) + after = model.stats() + _verify_chamfer_topology( + "asymmetric_chamfer", + edge_id, + plan, + before, + after, + result, + [ + f"target_distance1={distance1:.6f}", + f"target_distance2={distance2:.6f}", + f"reference_face_id={reference_face_id}", + ], + ) + + +def _run_distance_angle_chamfer_case(distance: float, angle_degrees: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_da_chamfer_") as temp_dir: + model_path = Path(temp_dir) / "box.step" + _write_box_model(model_path) + model = StepModel.load(model_path) + edge_id = _first_editable_line_edge(model) + reference_face_id = _first_adjacent_reference_face(model, edge_id) + before = model.stats() + plan = model.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) + if plan["status"] == "blocked": + raise SystemExit(f"distance-angle chamfer plan was blocked: {plan['message']}") + result = model.chamfer_edge_distance_angle(edge_id, distance, angle_degrees, reference_face_id) + after = model.stats() + _verify_chamfer_topology( + "distance_angle_chamfer", + edge_id, + plan, + before, + after, + result, + [ + f"target_distance={distance:.6f}", + f"target_angle_degrees={angle_degrees:.6f}", + f"reference_face_id={reference_face_id}", + ], + ) + + +def _run_existing_fillet_case(source_radius: float, target_radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_") as temp_dir: + model_path = Path(temp_dir) / "filleted_box.step" + _write_filleted_box_model(model_path, source_radius) + model = StepModel.load(model_path) + face_id = _first_existing_fillet_face(model, source_radius, tolerance) + before = model.stats() + plan = model.existing_fillet_resize_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise SystemExit(f"existing fillet plan was blocked: {plan['message']}") + result = model.resize_existing_fillet(face_id, target_radius) + after = model.stats() + matches = _cylindrical_faces_near_radius(model, target_radius, tolerance) + old_matches = _cylindrical_faces_near_radius(model, source_radius, tolerance) + if after.solids != before.solids: + raise SystemExit(f"existing fillet resize changed solid count: before={before.solids}, after={after.solids}") + if not matches: + raise SystemExit(f"existing fillet verification failed: no cylindrical face near radius {target_radius:g}") + if old_matches and abs(source_radius - target_radius) > tolerance: + raise SystemExit(f"existing fillet still has old radius matches: {old_matches}") + print("mode=existing_fillet") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"source_radius={source_radius:.6f} target_radius={target_radius:.6f}") + print(f"matched_faces={matches}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.") + parser.add_argument( + "--mode", + default="all", + choices=["all", "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"], + help="Edge rounding/chamfering edit mode to verify.", + ) + parser.add_argument("--fillet-radius", type=float, default=1.0) + parser.add_argument("--chamfer-distance", type=float, default=1.0) + parser.add_argument("--asymmetric-distance1", type=float, default=0.8) + parser.add_argument("--asymmetric-distance2", type=float, default=1.2) + parser.add_argument("--distance-angle-distance", type=float, default=1.0) + parser.add_argument("--distance-angle-degrees", type=float, default=45.0) + parser.add_argument("--source-fillet-radius", type=float, default=1.0) + parser.add_argument("--target-fillet-radius", type=float, default=1.5) + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + modes = ( + ["fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"] + if args.mode == "all" + else [args.mode] + ) + for mode in modes: + if mode == "fillet": + _run_fillet_case(args.fillet_radius, args.tolerance) + elif mode == "chamfer": + _run_chamfer_case(args.chamfer_distance) + elif mode == "asymmetric_chamfer": + _run_asymmetric_chamfer_case(args.asymmetric_distance1, args.asymmetric_distance2) + elif mode == "distance_angle_chamfer": + _run_distance_angle_chamfer_case(args.distance_angle_distance, args.distance_angle_degrees) + elif mode == "existing_fillet": + _run_existing_fillet_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance) + else: + raise SystemExit(f"unsupported mode: {mode}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_ellipse_edge_resize.py b/scripts/verify_ellipse_edge_resize.py new file mode 100644 index 0000000..6e55600 --- /dev/null +++ b/scripts/verify_ellipse_edge_resize.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Elips, 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 + + +def _write_ellipse_face_model(path: Path) -> None: + edge = BRepBuilderAPI_MakeEdge( + gp_Elips(gp_Ax2(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)), 5.0, 2.0) + ).Edge() + wire = BRepBuilderAPI_MakeWire(edge).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + _write_step(face, path) + + +def _ellipse_edge_ids(model: StepModel) -> list[int]: + return [edge_id for edge_id in range(len(model.edges)) if model.edge_info(edge_id).get("curve") == "ellipse"] + + +def _run_case(axis_kind: str, target: float, expected_other: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix=f"geom_param_ellipse_{axis_kind}_") as temp_dir: + model_path = Path(temp_dir) / "ellipse.step" + _write_ellipse_face_model(model_path) + model = StepModel.load(model_path) + edge_ids = _ellipse_edge_ids(model) + if not edge_ids: + raise SystemExit("no ellipse Edge was recognized") + edge_id = edge_ids[0] + before = model.stats() + plan = model.ellipse_edge_axis_radius_plan(edge_id, target, axis_kind=axis_kind) + if plan["status"] == "blocked": + raise SystemExit(f"{axis_kind} radius plan was blocked: {plan['message']}") + expected_strategy = f"ellipse-edge-{axis_kind}-axis-affine" + if plan.get("resize_strategy") != expected_strategy: + raise SystemExit(f"expected {expected_strategy}, got {plan.get('resize_strategy')}") + result = model.resize_ellipse_edge_axis_radius(edge_id, target, axis_kind=axis_kind) + after = model.stats() + sampled = model._ellipse_edge_axis_radius_sampled_result(plan) + if sampled is None: + raise SystemExit("could not sample the edited ellipse-like Edge") + verified_edge, value, other, error = sampled + other_error = abs(other - expected_other) + if error > tolerance: + raise SystemExit(f"{axis_kind} radius verification failed: target={target:g}, value={value:g}, error={error:g}") + if other_error > tolerance: + raise SystemExit( + f"{axis_kind} radius changed the other radius too much: expected={expected_other:g}, " + f"value={other:g}, error={other_error:g}" + ) + + print(f"mode={axis_kind}") + print(f"source_edge={edge_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_edge={verified_edge}") + print(f"target={target:.6f} value={value:.6f} error={error:.6g}") + print(f"other_radius={other:.6f} other_error={other_error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify ellipse Edge major/minor radius resize operations.") + parser.add_argument("--mode", default="all", choices=["all", "major", "minor"]) + parser.add_argument("--major-radius", type=float, default=7.5) + parser.add_argument("--minor-radius", type=float, default=3.0) + parser.add_argument("--tolerance", type=float, default=5e-3) + args = parser.parse_args() + + if args.mode in {"all", "major"}: + _run_case("major", args.major_radius, expected_other=2.0, tolerance=args.tolerance) + if args.mode in {"all", "minor"}: + _run_case("minor", args.minor_radius, expected_other=5.0, tolerance=args.tolerance) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_face_resize_semantics.py b/scripts/verify_face_resize_semantics.py new file mode 100644 index 0000000..5eb8289 --- /dev/null +++ b/scripts/verify_face_resize_semantics.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.model import StepModel + +DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" + + +def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int: + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "plane": + continue + face_width = float(info.get("local_face_width") or 0.0) + face_height = float(info.get("local_face_height") or 0.0) + direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance + swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance + if direct or swapped: + return face_id + raise SystemExit(f"no plane Face near {width:g} x {height:g}") + + +def _plane_sizes(model: StepModel) -> list[tuple[float, float, float]]: + sizes: list[tuple[float, float, float]] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "plane": + continue + area = float(info.get("area") or 0.0) + width = float(info.get("local_face_width") or 0.0) + height = float(info.get("local_face_height") or 0.0) + sizes.append((round(area, 6), round(width, 6), round(height, 6))) + return sorted(sizes) + + +def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool: + for _area, face_width, face_height in _plane_sizes(model): + direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance + swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance + if direct or swapped: + return True + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.") + parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.") + parser.add_argument("--axis", default="width", choices=["width", "height"]) + parser.add_argument("--source-size", type=float, default=10.0) + parser.add_argument("--other-size", type=float, default=10.0) + parser.add_argument("--target-size", type=float, default=15.0) + parser.add_argument("--strategy", default="local", choices=["local", "owning"]) + parser.add_argument("--tolerance", type=float, default=1e-5) + args = parser.parse_args() + + model = StepModel.load(Path(args.model)) + face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance) + before = model.stats() + + if args.strategy == "local": + plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis) + result = model.resize_face_size_local(face_id, args.target_size, args.axis) + expected_strategy = f"local-face-{args.axis}-only-deform" + else: + plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis) + result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis) + expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}" + + resolved_strategy = str(plan.get("resize_strategy", "")) + if resolved_strategy != expected_strategy: + raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or ''}") + + after = model.stats() + if after.solids != before.solids: + raise SystemExit(f"solid count changed: before={before.solids}, after={after.solids}") + if after.faces < before.faces: + raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}") + + width = args.target_size if args.axis == "width" else args.other_size + height = args.other_size if args.axis == "width" else args.target_size + if not _has_plane_size(model, width, height, args.tolerance): + raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}") + + print(f"model={Path(args.model)}") + print(f"face_id={face_id}") + print(f"strategy={args.strategy}") + print(f"axis={args.axis}") + print(f"resolved_strategy={resolved_strategy}") + print(f"rebuild_mode={plan.get('owning_face_size_rebuild_mode', '')}") + print(f"before={before}") + print(f"after={after}") + print(f"plane_sizes={_plane_sizes(model)}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_hole_resize.py b/scripts/verify_hole_resize.py new file mode 100644 index 0000000..81ce161 --- /dev/null +++ b/scripts/verify_hole_resize.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import argparse +import math +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.geometry_utils import _finalize_boolean_result +from step_editor.model import StepModel +from step_editor.step_io import _write_step + + +def _write_through_hole_model(path: Path) -> None: + plate = BRepPrimAPI_MakeBox(30.0, 20.0, 8.0).Shape() + axis = gp_Ax2(gp_Pnt(15.0, 10.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) + cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 10.0).Shape() + cut = BRepAlgoAPI_Cut(plate, cutter) + shape = _finalize_boolean_result(cut, "verify through-hole model cut") + _write_step(shape, path) + + +def _write_blind_hole_model(path: Path) -> None: + block = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape() + axis = gp_Ax2(gp_Pnt(15.0, 10.0, 10.0), gp_Dir(0.0, 0.0, -1.0)) + cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 6.0).Shape() + cut = BRepAlgoAPI_Cut(block, cutter) + shape = _finalize_boolean_result(cut, "verify blind-hole model cut") + _write_step(shape, path) + + +def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return math.sqrt( + (left[0] - right[0]) ** 2 + + (left[1] - right[1]) ** 2 + + (left[2] - right[2]) ** 2 + ) + + +def _hole_face_ids(model: StepModel, *, blind: bool | None = None) -> list[int]: + face_ids: list[int] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + if str(info.get("feature_guess", "")) != "hole/groove candidate": + continue + span = float(info.get("angular_span") or 0.0) + if span < math.tau * 0.92: + continue + if blind is not None: + is_blind = info.get("cylinder_end_type") == "blind" + if is_blind != blind: + continue + face_ids.append(face_id) + return face_ids + + +def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int: + candidates = _hole_face_ids(model, blind=blind) + if not candidates: + kind = "blind " if blind else "" + raise SystemExit(f"no {kind}near-full cylindrical hole face was recognized") + return candidates[0] + + +def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: + info = model.face_info(face_id) + axis_point = info.get("axis_point") + axis = info.get("axis") + v_range = info.get("same_domain_v_range") or info.get("v_range") + if not isinstance(axis_point, tuple) or not isinstance(axis, tuple) or not isinstance(v_range, tuple): + raise SystemExit(f"axis center data is missing on Face {face_id}") + v_mid = (float(v_range[0]) + float(v_range[1])) * 0.5 + return ( + float(axis_point[0]) + float(axis[0]) * v_mid, + float(axis_point[1]) + float(axis[1]) * v_mid, + float(axis_point[2]) + float(axis[2]) * v_mid, + ) + + +def _diameter(model: StepModel, face_id: int) -> float: + value = model.face_info(face_id).get("diameter") + if value is None: + raise SystemExit(f"diameter is missing on Face {face_id}") + return float(value) + + +def _volume(model: StepModel) -> float: + value = model.geometry_stats().get("volume") + if not isinstance(value, (int, float)): + raise SystemExit("model volume is unavailable") + return float(value) + + +def _depth(model: StepModel, face_id: int) -> float: + info = model.face_info(face_id) + 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 + probe = float(info.get("hole_depth_estimate") or 1.0) + plan = model.cylindrical_depth_plan(face_id, probe, bottom_face_id=bottom_face_id) + value = plan.get("current_depth") + if value is None: + raise SystemExit(f"depth is missing on Face {face_id}") + return float(value) + + +def _nearest_hole_by_diameter( + model: StepModel, + target_diameter: float, + target_center: tuple[float, float, float] | None = None, + *, + blind: bool | None = None, +) -> tuple[int, float, tuple[float, float, float], float]: + best: tuple[int, float, tuple[float, float, float], float] | None = None + for face_id in _hole_face_ids(model, blind=blind): + diameter = _diameter(model, face_id) + center = _axis_center(model, face_id) + diameter_error = abs(diameter - target_diameter) + center_error = _distance(center, target_center) if target_center is not None else 0.0 + error = diameter_error + center_error + if best is None or error < best[3]: + best = (face_id, diameter, center, error) + if best is None: + raise SystemExit("no cylindrical hole face remained after edit") + return best + + +def _nearest_blind_depth(model: StepModel, target_depth: float) -> tuple[int, float, float]: + best: tuple[int, float, float] | None = None + for face_id in _hole_face_ids(model, blind=True): + try: + depth = _depth(model, face_id) + except Exception: + continue + error = abs(depth - target_depth) + if best is None or error < best[2]: + best = (face_id, depth, error) + if best is None: + raise SystemExit("no measurable blind hole remained after edit") + return best + + +def _run_diameter_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_hole_diam_") as temp_dir: + model_path = Path(temp_dir) / "through_hole.step" + _write_through_hole_model(model_path) + model = StepModel.load(model_path) + face_id = _first_hole_face(model, blind=False) + before = model.stats() + center = _axis_center(model, face_id) + plan = model.cylindrical_resize_plan(face_id, target) + if plan["status"] == "blocked": + raise SystemExit(f"diameter plan was blocked: {plan['message']}") + result = model.resize_cylindrical_hole(face_id, target) + after = model.stats() + verified_face, diameter, verified_center, error = _nearest_hole_by_diameter(model, target, center, blind=False) + if abs(diameter - target) > tolerance: + raise SystemExit(f"diameter verification failed: target={target:g}, value={diameter:g}, error={error:g}") + print("mode=diameter") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target:.6f} value={diameter:.6f} center={verified_center} error={abs(diameter - target):.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_axis_center_case(offset: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_hole_axis_") as temp_dir: + model_path = Path(temp_dir) / "through_hole.step" + _write_through_hole_model(model_path) + model = StepModel.load(model_path) + face_id = _first_hole_face(model, blind=False) + before = model.stats() + current_center = _axis_center(model, face_id) + target_center = (current_center[0] + offset, current_center[1], current_center[2]) + current_diameter = _diameter(model, face_id) + plan = model.cylindrical_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise SystemExit(f"axis_center plan was blocked: {plan['message']}") + result = model.move_cylindrical_hole_axis(face_id, target_center) + after = model.stats() + verified_face, diameter, center, _ = _nearest_hole_by_diameter(model, current_diameter, target_center, blind=False) + center_error = _distance(center, target_center) + if center_error > tolerance or abs(diameter - current_diameter) > tolerance: + raise SystemExit( + f"axis_center verification failed: target={target_center}, center={center}, " + f"center_error={center_error:g}, diameter={diameter:g}" + ) + print("mode=axis_center") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target_center} value={center} diameter={diameter:.6f} error={center_error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_suppress_case(tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir: + model_path = Path(temp_dir) / "through_hole.step" + _write_through_hole_model(model_path) + model = StepModel.load(model_path) + face_id = _first_hole_face(model, blind=False) + before = model.stats() + before_volume = _volume(model) + plan = model.cylindrical_suppress_plan(face_id) + if plan["status"] == "blocked": + raise SystemExit(f"suppress plan was blocked: {plan['message']}") + result = model.suppress_cylindrical_hole(face_id) + after = model.stats() + after_volume = _volume(model) + remaining_holes = _hole_face_ids(model) + + full_box_volume = 30.0 * 20.0 * 8.0 + removed_hole_volume = math.pi * 3.0 * 3.0 * 8.0 + volume_tolerance = max(tolerance * full_box_volume, 1e-3) + if after.solids != 1: + raise SystemExit(f"suppress verification failed: expected one solid, got {after.solids}") + if remaining_holes: + raise SystemExit(f"suppress verification failed: hole faces still detected: {remaining_holes}") + if abs(after_volume - full_box_volume) > volume_tolerance: + raise SystemExit( + f"suppress volume verification failed: target={full_box_volume:g}, " + f"value={after_volume:g}, tolerance={volume_tolerance:g}" + ) + if after_volume - before_volume < removed_hole_volume * 0.95: + raise SystemExit( + f"suppress recovered too little volume: expected_about={removed_hole_volume:g}, " + f"value={after_volume - before_volume:g}" + ) + + print("mode=suppress") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"before_volume={before_volume:.6f}") + print(f"after_volume={after_volume:.6f}") + print(f"full_box_volume={full_box_volume:.6f}") + print(f"remaining_hole_faces={remaining_holes}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_blind_depth_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_blind_depth_") as temp_dir: + model_path = Path(temp_dir) / "blind_hole.step" + _write_blind_hole_model(model_path) + model = StepModel.load(model_path) + face_id = _first_hole_face(model, blind=True) + 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 + before = model.stats() + current_depth = _depth(model, face_id) + plan = model.cylindrical_depth_plan(face_id, target, bottom_face_id=bottom_face_id) + if plan["status"] == "blocked": + raise SystemExit(f"blind_depth plan was blocked: {plan['message']}") + result = model.resize_cylindrical_depth(face_id, target, bottom_face_id=bottom_face_id) + after = model.stats() + verified_face, depth, error = _nearest_blind_depth(model, target) + if error > tolerance: + raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}") + print("mode=blind_depth") + print(f"source_face={face_id}") + print(f"bottom_face={bottom_face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_depth={current_depth:.6f}") + print(f"verified_face={verified_face}") + print(f"target={target:.6f} value={depth:.6f} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify cylindrical hole edit operations.") + parser.add_argument( + "--mode", + default="all", + choices=[ + "all", + "diameter", + "diameter_shrink", + "axis_center", + "suppress", + "blind_depth", + "blind_depth_shallow", + ], + help="Hole edit mode to verify.", + ) + parser.add_argument("--diameter", type=float, default=8.0) + parser.add_argument("--diameter-shrink", type=float, default=4.0) + parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.") + parser.add_argument("--blind-depth", type=float, default=8.0) + parser.add_argument("--blind-depth-shallow", type=float, default=4.0) + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + cases = ( + [ + ("diameter", args.diameter), + ("diameter_shrink", args.diameter_shrink), + ("axis_center", args.axis_center), + ("suppress", None), + ("blind_depth", args.blind_depth), + ("blind_depth_shallow", args.blind_depth_shallow), + ] + if args.mode == "all" + else [(args.mode, None if args.mode == "suppress" else getattr(args, args.mode.replace("-", "_")))] + ) + for mode, target in cases: + if mode in {"diameter", "diameter_shrink"}: + _run_diameter_case(float(target), args.tolerance) + elif mode == "axis_center": + _run_axis_center_case(float(target), args.tolerance) + elif mode == "suppress": + _run_suppress_case(args.tolerance) + elif mode in {"blind_depth", "blind_depth_shallow"}: + _run_blind_depth_case(float(target), args.tolerance) + else: + raise SystemExit(f"unsupported mode: {mode}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_shell_thickness_resize.py b/scripts/verify_shell_thickness_resize.py new file mode 100644 index 0000000..a88b1f1 --- /dev/null +++ b/scripts/verify_shell_thickness_resize.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + +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 + + +def _write_plate_model(path: Path) -> None: + shape = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape() + _write_step(shape, path) + + +def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int: + candidates: list[tuple[int, int]] = [] + for face_id in range(len(model.faces)): + info = model.feature_info(face_id) + if info.get("surface") != "plane": + continue + if info.get("shell_region_status") != "candidate": + continue + current = float(info.get("shell_thickness_estimate") or 0.0) + if abs(current - thickness) > tolerance: + continue + confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3) + candidates.append((confidence_rank, face_id)) + if not candidates: + raise SystemExit(f"no shell thickness candidate near {thickness:g}") + candidates.sort() + return candidates[0][1] + + +def _bounds(model: StepModel) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]: + info = model.geometry_stats() + return ( + tuple(float(value) for value in info["bbox_min"]), + tuple(float(value) for value in info["bbox_max"]), + tuple(float(value) for value in info["bbox_size"]), + ) + + +def _thickness_axis(size: tuple[float, float, float]) -> int: + return min(range(3), key=lambda index: size[index]) + + +def _verify_local_bounds( + before_min: tuple[float, float, float], + before_max: tuple[float, float, float], + after_min: tuple[float, float, float], + after_max: tuple[float, float, float], + axis: int, + target: float, + tolerance: float, +) -> None: + after_size = after_max[axis] - after_min[axis] + if abs(after_size - target) > tolerance: + raise SystemExit(f"local shell thickness failed: target={target:g}, value={after_size:g}") + min_unchanged = abs(after_min[axis] - before_min[axis]) <= tolerance + max_unchanged = abs(after_max[axis] - before_max[axis]) <= tolerance + if not (min_unchanged or max_unchanged): + raise SystemExit( + "local shell thickness did not keep either opposite side fixed: " + f"before=({before_min[axis]:g}, {before_max[axis]:g}), " + f"after=({after_min[axis]:g}, {after_max[axis]:g})" + ) + + +def _verify_owning_bounds( + before_min: tuple[float, float, float], + before_max: tuple[float, float, float], + after_min: tuple[float, float, float], + after_max: tuple[float, float, float], + axis: int, + target: float, + tolerance: float, +) -> None: + after_size = after_max[axis] - after_min[axis] + if abs(after_size - target) > tolerance: + raise SystemExit(f"owning shell thickness failed: target={target:g}, value={after_size:g}") + before_center = (before_min[axis] + before_max[axis]) * 0.5 + after_center = (after_min[axis] + after_max[axis]) * 0.5 + if abs(after_center - before_center) > tolerance: + raise SystemExit( + "owning shell thickness did not keep the thickness center fixed: " + f"before_center={before_center:g}, after_center={after_center:g}" + ) + + +def _run_case(mode: str, source_thickness: float, target_thickness: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix=f"geom_param_shell_{mode}_") as temp_dir: + model_path = Path(temp_dir) / "plate.step" + _write_plate_model(model_path) + model = StepModel.load(model_path) + face_id = _first_shell_face(model, source_thickness, tolerance) + before = model.stats() + before_min, before_max, before_size = _bounds(model) + axis = _thickness_axis(before_size) + + if mode == "local": + plan = model.shell_thickness_plan(face_id, target_thickness) + if plan["status"] == "blocked": + raise SystemExit(f"local shell plan was blocked: {plan['message']}") + result = model.resize_shell_thickness(face_id, target_thickness) + elif mode == "owning": + plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness) + if plan["status"] == "blocked": + raise SystemExit(f"owning shell plan was blocked: {plan['message']}") + result = model.resize_shell_thickness_owning_scale(face_id, target_thickness) + else: + raise SystemExit(f"unsupported mode: {mode}") + + after = model.stats() + after_min, after_max, after_size = _bounds(model) + if after.solids != before.solids: + raise SystemExit(f"{mode} shell thickness changed solid count: before={before.solids}, after={after.solids}") + if mode == "local": + _verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) + else: + _verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) + + print(f"mode={mode}") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"before_bbox_size={before_size}") + print(f"after_bbox_size={after_size}") + print(f"axis={axis}") + print(f"source_thickness={source_thickness:.6f} target_thickness={target_thickness:.6f}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.") + parser.add_argument("--mode", default="all", choices=["all", "local", "owning"]) + parser.add_argument("--source-thickness", type=float, default=2.0) + parser.add_argument("--target-thickness", type=float, default=3.0) + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + modes = ["local", "owning"] if args.mode == "all" else [args.mode] + for mode in modes: + _run_case(mode, args.source_thickness, args.target_thickness, args.tolerance) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_slot_resize.py b/scripts/verify_slot_resize.py new file mode 100644 index 0000000..01b6bec --- /dev/null +++ b/scripts/verify_slot_resize.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import argparse +import math +from pathlib import Path +import sys +import tempfile + +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.geometry_utils import _finalize_boolean_result +from step_editor.model import StepModel +from step_editor.step_io import _write_step + + +def _write_half_round_slot_model(path: Path) -> None: + box = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape() + # A cylinder centered on the top face cuts a half-round groove open to Z+. + cutter_axis = gp_Ax2(gp_Pnt(-5.0, 10.0, 10.0), gp_Dir(1.0, 0.0, 0.0)) + cutter = BRepPrimAPI_MakeCylinder(cutter_axis, 3.0, 40.0).Shape() + cut = BRepAlgoAPI_Cut(box, cutter) + shape = _finalize_boolean_result(cut, "verify slot model cut") + _write_step(shape, path) + + +def _write_obround_slot_model(path: Path) -> None: + plate = BRepPrimAPI_MakeBox(40.0, 24.0, 6.0).Shape() + axis_1 = gp_Ax2(gp_Pnt(15.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) + axis_2 = gp_Ax2(gp_Pnt(25.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) + cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape() + cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape() + connector = BRepPrimAPI_MakeBox(gp_Pnt(15.0, 9.0, -1.0), 10.0, 6.0, 8.0).Shape() + fuse_1 = BRepAlgoAPI_Fuse(cylinder_1, connector) + tool = _finalize_boolean_result(fuse_1, "verify obround slot tool fuse") + fuse_2 = BRepAlgoAPI_Fuse(tool, cylinder_2) + tool = _finalize_boolean_result(fuse_2, "verify obround slot tool second fuse") + cut = BRepAlgoAPI_Cut(plate, tool) + shape = _finalize_boolean_result(cut, "verify obround slot model cut") + _write_step(shape, path) + + +def _slot_face_ids(model: StepModel) -> list[int]: + face_ids: list[int] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + feature = model.feature_info(face_id) + if feature.get("slot_kind") != "partial-cylindrical-groove": + continue + span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0) + if 1e-6 < span < math.tau * 0.92: + face_ids.append(face_id) + return face_ids + + +def _first_slot_face(model: StepModel) -> int: + candidates = _slot_face_ids(model) + if not candidates: + raise SystemExit("no partial cylindrical slot face was recognized") + return candidates[0] + + +def _slot_metric(model: StepModel, face_id: int, key: str) -> float: + feature = model.feature_info(face_id) + info = model.face_info(face_id) + value = feature.get(key) + if value is None: + value = info.get(key) + if value is None: + raise SystemExit(f"slot metric {key} is missing on Face {face_id}") + return float(value) + + +def _slot_axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: + info = model.face_info(face_id) + axis_point = info.get("axis_point") + axis = info.get("axis") + v_range = info.get("same_domain_v_range") or info.get("v_range") + if not isinstance(axis_point, tuple) or not isinstance(axis, tuple) or not isinstance(v_range, tuple): + raise SystemExit(f"slot axis center data is missing on Face {face_id}") + v_mid = (float(v_range[0]) + float(v_range[1])) * 0.5 + return ( + float(axis_point[0]) + float(axis[0]) * v_mid, + float(axis_point[1]) + float(axis[1]) * v_mid, + float(axis_point[2]) + float(axis[2]) * v_mid, + ) + + +def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return math.sqrt( + (left[0] - right[0]) ** 2 + + (left[1] - right[1]) ** 2 + + (left[2] - right[2]) ** 2 + ) + + +def _nearest_metric(model: StepModel, key: str, target: float) -> tuple[int, float, float]: + best: tuple[int, float, float] | None = None + for face_id in _slot_face_ids(model): + value = _slot_metric(model, face_id, key) + error = abs(value - target) + if best is None or error < best[2]: + best = (face_id, value, error) + if best is None: + raise SystemExit("no slot face remained after edit") + return best + + +def _nearest_axis_center(model: StepModel, target: tuple[float, float, float]) -> tuple[int, tuple[float, float, float], float]: + best: tuple[int, tuple[float, float, float], float] | None = None + for face_id in _slot_face_ids(model): + center = _slot_axis_center(model, face_id) + error = _distance(center, target) + if best is None or error < best[2]: + best = (face_id, center, error) + if best is None: + raise SystemExit("no slot face remained after axis move") + return best + + +def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, float]]: + rows: list[tuple[int, int, float, float]] = [] + seen_pairs: set[tuple[int, int]] = set() + for face_id in _slot_face_ids(model): + info = model.face_info(face_id) + diameter = float(info.get("diameter") or 0.0) + if diameter <= 1e-9: + continue + feature = model.feature_info(face_id) + try: + cutter_plan = model._bounded_cylinder_cutter_plan(face_id, diameter, feature) + pair_plan = model._paired_obround_slot_plan(face_id, diameter, feature, cutter_plan) + except Exception: + continue + pair_face_id = pair_plan.get("slot_pair_face_id") + center_distance = pair_plan.get("slot_pair_axis_distance") + if pair_face_id in {None, ""} or center_distance in {None, ""}: + continue + pair_key = tuple(sorted((face_id, int(pair_face_id)))) + if pair_key in seen_pairs: + continue + seen_pairs.add(pair_key) + rows.append((face_id, int(pair_face_id), float(center_distance) + diameter, float(center_distance))) + return rows + + +def _nearest_obround_total_length(model: StepModel, target_total_length: float) -> tuple[int, int, float, float, float]: + best: tuple[int, int, float, float, float] | None = None + for face_id, pair_face_id, total_length, center_distance in _obround_total_lengths(model): + error = abs(total_length - target_total_length) + if best is None or error < best[4]: + best = (face_id, pair_face_id, total_length, center_distance, error) + if best is None: + raise SystemExit("no paired obround slot ends were recognized") + return best + + +def _nearest_obround_axis_pair( + model: StepModel, + target_center_1: tuple[float, float, float], + target_center_2: tuple[float, float, float], +) -> tuple[int, int, tuple[float, float, float], tuple[float, float, float], float]: + best: tuple[int, int, tuple[float, float, float], tuple[float, float, float], float] | None = None + for face_id, pair_face_id, _total_length, _center_distance in _obround_total_lengths(model): + center_1 = _slot_axis_center(model, face_id) + center_2 = _slot_axis_center(model, pair_face_id) + direct_error = max(_distance(center_1, target_center_1), _distance(center_2, target_center_2)) + swapped_error = max(_distance(center_1, target_center_2), _distance(center_2, target_center_1)) + if swapped_error < direct_error: + error = swapped_error + ordered_1, ordered_2 = center_2, center_1 + else: + error = direct_error + ordered_1, ordered_2 = center_1, center_2 + if best is None or error < best[4]: + best = (face_id, pair_face_id, ordered_1, ordered_2, error) + if best is None: + raise SystemExit("no paired obround slot ends were recognized after axis move") + return best + + +def _slot_candidate_summary(model: StepModel) -> list[tuple[int, float, float, str]]: + rows: list[tuple[int, float, float, str]] = [] + for face_id in range(len(model.faces)): + info = model.face_info(face_id) + if info.get("surface") != "cylinder": + continue + feature = model.feature_info(face_id) + span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0) + diameter = float(info.get("diameter") or feature.get("diameter") or 0.0) + rows.append((face_id, round(diameter, 6), round(span, 6), str(feature.get("slot_kind") or ""))) + return rows + + +def _run_case(mode: str, target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_slot_") as temp_dir: + model_path = Path(temp_dir) / "half_round_slot.step" + _write_half_round_slot_model(model_path) + model = StepModel.load(model_path) + face_id = _first_slot_face(model) + before = model.stats() + + if mode == "width": + metric_key = "slot_chord_width_estimate" + plan = model.cylindrical_slot_resize_plan(face_id, target, "width") + result = model.resize_cylindrical_slot_width(face_id, target) + elif mode == "depth": + metric_key = "slot_sagitta_depth_estimate" + plan = model.cylindrical_slot_resize_plan(face_id, target, "depth") + result = model.resize_cylindrical_slot_depth(face_id, target) + elif mode == "arc_length": + metric_key = "slot_arc_length_estimate" + plan = model.cylindrical_slot_resize_plan(face_id, target, "arc_length") + result = model.resize_cylindrical_slot_arc_length(face_id, target) + elif mode == "angular_span": + metric_key = "slot_angular_span" + plan = model.cylindrical_slot_angular_span_plan(face_id, target) + result = model.resize_cylindrical_slot_angular_span(face_id, target) + elif mode == "axis_center": + current_center = _slot_axis_center(model, face_id) + target_center = (current_center[0], current_center[1] + target, current_center[2]) + plan = model.cylindrical_slot_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise SystemExit(f"axis_center plan was blocked: {plan['message']}") + result = model.move_cylindrical_slot_axis(face_id, target_center) + after = model.stats() + verified_face, center, error = _nearest_axis_center(model, target_center) + if error > tolerance: + raise SystemExit( + f"axis_center verification failed: target={target_center}, nearest={center}, " + f"error={error:g}; candidates={_slot_candidate_summary(model)}" + ) + print("mode=axis_center") + print(f"source_face={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target_center} value={center} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + return + else: + raise SystemExit(f"unsupported mode: {mode}") + + if plan["status"] == "blocked": + raise SystemExit(f"{mode} plan was blocked: {plan['message']}") + + after = model.stats() + verified_face, value, error = _nearest_metric(model, metric_key, target) + if error > tolerance: + raise SystemExit( + f"{mode} verification failed: target={target:g}, nearest={value:g}, error={error:g}; " + f"candidates={_slot_candidate_summary(model)}" + ) + + print(f"mode={mode}") + print(f"source_face={face_id}") + print(f"strategy={plan.get('slot_resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"verified_face={verified_face}") + print(f"target={target:.6f} value={value:.6f} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_total_length_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_obround_") as temp_dir: + model_path = Path(temp_dir) / "obround_slot.step" + _write_obround_slot_model(model_path) + model = StepModel.load(model_path) + face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0) + before = model.stats() + plan = model.cylindrical_slot_total_length_plan(face_id, target) + if plan["status"] == "blocked": + raise SystemExit(f"total_length plan was blocked: {plan['message']}") + result = model.resize_cylindrical_slot_total_length(face_id, target) + after = model.stats() + verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length(model, target) + if error > tolerance: + raise SystemExit( + f"total_length verification failed: target={target:g}, nearest={total_length:g}, " + f"error={error:g}; pairs={_obround_total_lengths(model)}" + ) + + print("mode=total_length") + print(f"source_face={face_id}") + print(f"source_pair_face={pair_face_id}") + print(f"strategy={plan.get('slot_resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_total_length={current_total_length:.6f}") + print(f"current_center_distance={current_center_distance:.6f}") + print(f"verified_face={verified_face}") + print(f"verified_pair_face={verified_pair}") + print(f"target={target:.6f} value={total_length:.6f} center_distance={center_distance:.6f} error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_center_distance_case(target: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_obround_center_distance_") as temp_dir: + model_path = Path(temp_dir) / "obround_slot.step" + _write_obround_slot_model(model_path) + model = StepModel.load(model_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_total_length = target + slot_diameter + before = model.stats() + plan = model.cylindrical_slot_center_distance_plan(face_id, target) + if plan["status"] == "blocked": + raise SystemExit(f"center_distance plan was blocked: {plan['message']}") + result = model.resize_cylindrical_slot_center_distance(face_id, target) + after = model.stats() + verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length( + model, + target_total_length, + ) + center_error = abs(center_distance - target) + if center_error > tolerance: + raise SystemExit( + f"center_distance verification failed: target={target:g}, nearest={center_distance:g}, " + f"error={center_error:g}; pairs={_obround_total_lengths(model)}" + ) + + print("mode=center_distance") + print(f"source_face={face_id}") + print(f"source_pair_face={pair_face_id}") + print(f"strategy={plan.get('slot_resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_total_length={current_total_length:.6f}") + print(f"current_center_distance={current_center_distance:.6f}") + print(f"slot_diameter={slot_diameter:.6f}") + print(f"verified_face={verified_face}") + print(f"verified_pair_face={verified_pair}") + print( + f"target_center_distance={target:.6f} value={center_distance:.6f} " + f"total_length={total_length:.6f} total_length_error={error:.6g} error={center_error:.6g}" + ) + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_obround_axis_center_case(offset: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_obround_axis_") as temp_dir: + model_path = Path(temp_dir) / "obround_slot.step" + _write_obround_slot_model(model_path) + model = StepModel.load(model_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] + offset, current_center[2]) + target_pair_center = (pair_center[0], pair_center[1] + offset, pair_center[2]) + before = model.stats() + plan = model.cylindrical_slot_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise SystemExit(f"obround_axis_center plan was blocked: {plan['message']}") + if plan.get("resize_strategy") != "paired-obround-slot-axis-prism": + raise SystemExit(f"obround_axis_center did not choose paired strategy: {plan.get('resize_strategy')}") + result = model.move_cylindrical_slot_axis(face_id, target_center) + after = model.stats() + verified_face, verified_pair, center_1, center_2, error = _nearest_obround_axis_pair( + model, + target_center, + target_pair_center, + ) + if error > tolerance: + raise SystemExit( + f"obround_axis_center verification failed: target=({target_center}, {target_pair_center}), " + f"nearest=({center_1}, {center_2}), error={error:g}; pairs={_obround_total_lengths(model)}" + ) + + print("mode=obround_axis_center") + print(f"source_face={face_id}") + print(f"source_pair_face={pair_face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"current_total_length={current_total_length:.6f}") + print(f"current_center_distance={current_center_distance:.6f}") + print(f"verified_face={verified_face}") + print(f"verified_pair_face={verified_pair}") + print(f"target=({target_center}, {target_pair_center}) value=({center_1}, {center_2}) error={error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify partial cylindrical slot resize operations.") + parser.add_argument( + "--mode", + default="all", + choices=[ + "all", + "width", + "depth", + "arc_length", + "angular_span", + "axis_center", + "obround_axis_center", + "total_length", + "center_distance", + ], + help="Slot metric to verify.", + ) + parser.add_argument("--width", type=float, default=8.0) + parser.add_argument("--depth", type=float, default=4.0) + parser.add_argument("--arc-length", type=float, default=12.0) + parser.add_argument("--angular-span", type=float, default=2.2) + parser.add_argument("--axis-center", type=float, default=1.0, help="Radial axis-center offset to verify.") + parser.add_argument("--obround-axis-center", type=float, default=1.0, help="Obround slot axis-center offset to verify.") + parser.add_argument("--total-length", type=float, default=20.0) + parser.add_argument("--center-distance", type=float, default=14.0) + parser.add_argument("--tolerance", type=float, default=2e-4) + args = parser.parse_args() + + cases = ( + [ + ("width", args.width), + ("depth", args.depth), + ("arc_length", args.arc_length), + ("angular_span", args.angular_span), + ("axis_center", args.axis_center), + ("obround_axis_center", args.obround_axis_center), + ("total_length", args.total_length), + ("center_distance", args.center_distance), + ] + if args.mode == "all" + else [(args.mode, getattr(args, args.mode.replace("-", "_")))] + ) + for mode, target in cases: + if mode == "total_length": + _run_total_length_case(float(target), args.tolerance) + elif mode == "center_distance": + _run_center_distance_case(float(target), args.tolerance) + elif mode == "obround_axis_center": + _run_obround_axis_center_case(float(target), args.tolerance) + else: + _run_case(mode, float(target), args.tolerance) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step_editor/app.py b/step_editor/app.py index 46ea1b9..89bad38 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -108,8 +108,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.pending_hover_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None self.camera_interaction_active = False - self.edge_actor_visible_before_interaction = True - self.model_interpolation_before_interaction: int | None = None self.pointer_button_down = False self.left_button_press_position: tuple[int, int] | None = None self.left_button_dragged = False @@ -132,6 +130,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.model_part_id_array = None self.model_solid_id_array = None self.edge_id_array = None + self.model_face_cell_ids_by_face: dict[int, list[int]] = {} + self.model_face_cell_ids_by_part: dict[int, list[int]] = {} + self.model_face_cell_ids_by_solid: dict[int, list[int]] = {} + self.edge_cell_ids_by_edge: dict[int, list[int]] = {} self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {} self.edge_overlay_polydata_cache: dict[int, object] = {} self.overlay_cache_limit = 160 @@ -164,6 +166,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.scan_worker: ScanWorker | None = None self.pending_scan_kind: str | None = None self.pending_scan_context: dict[str, object] | None = None + self.scan_wait_cursor_active = False self.load_in_progress = False self.load_thread: QThread | None = None self.load_worker: LoadWorker | None = None @@ -331,11 +334,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf background: #fff8ef; border: 1px solid #edc98e; border-left: 5px solid #d97706; + padding-left: 5px; + padding-right: 3px; + padding-bottom: 7px; } QGroupBox#editSection::title { background: #fff0d8; border-color: #edc98e; color: #a45303; + left: 10px; + padding-left: 6px; + padding-right: 6px; } QGroupBox#editableSection, QGroupBox#candidateSection { @@ -441,6 +450,28 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border: 1px dashed #bcc7d4; color: #8f99a8; } + QComboBox#propertyScopeCombo { + background: #ffffff; + border: 1px solid #f59e0b; + border-radius: 5px; + color: #7c2d12; + min-height: 18px; + padding: 0px 4px; + font-weight: 600; + } + QComboBox#propertyScopeCombo:hover { + background: #fffbeb; + border-color: #ea580c; + } + QComboBox#propertyScopeCombo:focus { + background: #ffffff; + border: 1px solid #2563eb; + } + QComboBox#propertyScopeCombo:disabled { + background: #eef2f6; + border: 1px dashed #bcc7d4; + color: #8f99a8; + } QLineEdit#propertyTargetEditor { background: #fff7ed; border: 2px solid #f97316; @@ -532,6 +563,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf font-weight: 700; padding: 4px 6px; } + QTableWidget#propertyTable { + margin: 0; + padding: 0; + } + QTableWidget#propertyTable::item { + padding-left: 2px; + padding-right: 2px; + } + QTableWidget#propertyTable QHeaderView::section { + padding-left: 3px; + padding-right: 3px; + } QTabWidget::pane { border: 1px solid #d8e0eb; border-radius: 6px; @@ -779,8 +822,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf "选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。", ) object_edit_layout = QVBoxLayout(self.object_edit_box) - self.property_table = QTableWidget(0, 4) - self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "目标值", "状态"]) + object_edit_layout.setContentsMargins(2, 8, 2, 4) + object_edit_layout.setSpacing(4) + self.property_table = QTableWidget(0, 5) + self.property_table.setObjectName("propertyTable") + self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "影响范围", "目标值", "操作"]) self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.property_table.setAlternatingRowColors(True) @@ -798,14 +844,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf property_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) - property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - self.property_table.setColumnWidth(0, 106) - self.property_table.setColumnWidth(1, 92) - self.property_table.setColumnWidth(2, 92) - self.property_table.setColumnWidth(3, 62) + property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) + property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + self.property_table.setColumnWidth(0, 82) + self.property_table.setColumnWidth(1, 78) + self.property_table.setColumnWidth(2, 104) + self.property_table.setColumnWidth(3, 82) + self.property_table.setColumnWidth(4, 58) help_tip( self.property_table, - "当前选中对象的属性表。可修改的行可以在目标值列输入新值,并点击状态列里的修改按钮只应用这一行。", + "当前选中对象的属性表。可修改的行可以选择影响范围、输入目标值,并点击操作列里的按钮只应用这一行。", ) self.property_table.itemChanged.connect(self._on_property_table_item_changed) object_edit_layout.addWidget(self.property_table) @@ -834,7 +882,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_box.setVisible(False) self.hidden_edit_controls_box = edit_box edit_layout = QGridLayout(edit_box) - edit_layout.addWidget(QLabel("Face偏移"), 0, 0) + edit_layout.addWidget(QLabel("移动距离"), 0, 0) self.offset_input = QLineEdit("5.0") help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") edit_layout.addWidget(self.offset_input, 0, 1) @@ -893,6 +941,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.slot_total_length_input = QLineEdit("", edit_box) self.slot_total_length_input.setVisible(False) help_tip(self.slot_total_length_input, "长圆槽/槽孔的目标总长度。只有识别到成对半圆槽端时才会启用。") + self.slot_center_distance_input = QLineEdit("", edit_box) + self.slot_center_distance_input.setVisible(False) + help_tip(self.slot_center_distance_input, "长圆槽/槽孔两端半圆中心的目标距离。当前选中对象表会使用这个隐藏输入执行中心距修改。") self.slot_center_x_input = QLineEdit("", edit_box) self.slot_center_x_input.setVisible(False) self.slot_center_y_input = QLineEdit("", edit_box) @@ -930,7 +981,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。") self.face_area_input = QLineEdit("", edit_box) self.face_area_input.setVisible(False) - help_tip(self.face_area_input, "复杂曲面的目标Face面积。当前选中对象表会使用这个隐藏输入执行兜底面积缩放。") + 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 的平面内宽度。") + self.face_height_input = QLineEdit("", edit_box) + self.face_height_input.setVisible(False) + help_tip(self.face_height_input, "目标面高。当前选中对象表会使用这个隐藏输入只修改当前 Face 的平面内高度。") self.resize_boss_button = QPushButton("调整圆柱凸台直径") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") self.resize_boss_button.clicked.connect(self.resize_boss) @@ -1007,6 +1064,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edge_end_x_input = QLineEdit("", edit_box) self.edge_end_y_input = QLineEdit("", edit_box) self.edge_end_z_input = QLineEdit("", edit_box) + self.ellipse_edge_major_radius_input = QLineEdit("", edit_box) + self.ellipse_edge_minor_radius_input = QLineEdit("", edit_box) for endpoint_input in ( self.edge_start_x_input, self.edge_start_y_input, @@ -1017,8 +1076,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edge_end_x_input, self.edge_end_y_input, self.edge_end_z_input, + self.ellipse_edge_major_radius_input, + self.ellipse_edge_minor_radius_input, ): endpoint_input.setVisible(False) + help_tip(self.ellipse_edge_major_radius_input, "椭圆Edge目标主半径。当前选中对象表会使用这个隐藏输入执行主轴单轴缩放。") + help_tip(self.ellipse_edge_minor_radius_input, "椭圆Edge目标小半径。当前选中对象表会使用这个隐藏输入执行小轴单轴缩放。") self.edge_length_anchor_combo = NoWheelComboBox() self.edge_length_anchor_combo.addItem("自动", "auto") self.edge_length_anchor_combo.addItem("中心", "center") @@ -1030,8 +1093,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edge_length_anchor_combo, "选择修改Edge长度时尽量固定哪里:自动默认固定起点并移动终点;中心会两端各动一半;固定起点/终点会明确控制局部形变端点。", ) + self.edge_length_strategy_combo = NoWheelComboBox() + self.edge_length_strategy_combo.addItem("自动选择", "auto") + self.edge_length_strategy_combo.addItem("只改当前Edge", "local-edge-only-deform") + self.edge_length_strategy_combo.addItem("移动端面/保持垂直", "move-edge-end-plane-by-push-pull") + self.edge_length_strategy_combo.addItem("相邻圆柱直径", "resize-adjacent-cylinder-from-circular-edge-length") + self.edge_length_strategy_combo.addItem("缩放所属", "scale-owning-shape-from-edge") + self.edge_length_strategy_combo.setCurrentIndex(0) + self.edge_length_strategy_combo.setMinimumWidth(130) + help_tip( + self.edge_length_strategy_combo, + "选择Edge长度修改语义:自动会按可用性选择;只改当前Edge会让相邻面自然变斜;移动端面/保持垂直会让相关端面和同向边跟随;相邻圆柱直径会把圆Edge长度换算成孔/槽/凸台直径;缩放所属会让所属对象整体跟随变化。", + ) edge_length_layout.addWidget(self.edge_target_length_input, stretch=2) edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1) + edge_length_layout.addWidget(self.edge_length_strategy_combo, stretch=1) edit_layout.addWidget(edge_length_inputs, 18, 1) self.resize_edge_length_button = QPushButton("修改Edge长度") help_tip( diff --git a/step_editor/constants.py b/step_editor/constants.py index d31bb4f..a1b633a 100644 --- a/step_editor/constants.py +++ b/step_editor/constants.py @@ -40,6 +40,8 @@ SURFACE_TYPES = { SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__" +FACE_SELECTION_FEATURE_INFO_SURFACES = {"plane", "cylinder", "cone", "sphere", "torus"} + CURVE_TYPES = { GeomAbs_Line: "line", GeomAbs_Circle: "circle", diff --git a/step_editor/features.py b/step_editor/features.py index 6aeb716..c51112a 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -744,6 +744,11 @@ class FeatureMixin: target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {} + edit_semantics = ( + "缩小孔/槽直径:先用同轴圆柱补料封住旧孔壁,再按目标直径同轴重切;保持当前轴线和估算高度。" + if resize_mode == "shrink" + else "扩大孔/槽直径:沿当前圆柱轴线用有限长度圆柱 cutter 切到目标直径;保持当前轴线和估算高度。" + ) return { "status": readiness["resize_status"], "risk": readiness["resize_risk"], @@ -773,6 +778,9 @@ class FeatureMixin: "same_domain_range_source": axis_range["range_source"], "material_vote_summary": info.get("material_vote_summary"), "material_sample_count": info.get("material_sample_count"), + "resize_strategy": "same-axis-bounded-cylinder-recut", + "edit_strategy_label": "同轴圆柱重切", + "edit_semantics": edit_semantics, **cutter_plan, **fill_plan, } @@ -918,6 +926,8 @@ class FeatureMixin: "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), "resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder", + "edit_strategy_label": "填旧孔并切新孔", + "edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。", } def cylindrical_slot_resize_plan( @@ -1034,6 +1044,8 @@ class FeatureMixin: "slot_resize_mode": mode_key, "slot_resize_label": mode_labels.get(mode_key, "slot value"), "slot_resize_strategy": "local-sector-fixed-angular-span", + "edit_strategy_label": "局部扇形槽重建", + "edit_semantics": "保持当前槽/半孔圆弧角度,把目标宽度、深度或圆弧长度换算成圆柱直径,再填旧槽并切出新的局部扇形槽。", "slot_kind": slot_kind, "slot_status": slot_status, "slot_target_value": target_value, @@ -1084,7 +1096,11 @@ class FeatureMixin: resize_plan, pair_face_id=pair_face_id, ) + edit_strategy_label = "局部扇形槽重建" + edit_semantics = "保持当前槽/半孔圆弧角度,把目标值换算成圆柱直径,再填旧槽并切出新的局部扇形槽。" if paired_slot_plan: + edit_strategy_label = "配对长圆槽重建" + edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同一槽中心线和目标宽度重切完整长圆槽。" risk = _max_risk(risk, str(paired_slot_plan.get("slot_pair_risk", "medium"))) pair_warning = str(paired_slot_plan.get("slot_pair_warning", "")) if pair_warning: @@ -1104,6 +1120,8 @@ class FeatureMixin: **resize_plan, **slot_plan, **paired_slot_plan, + "edit_strategy_label": edit_strategy_label, + "edit_semantics": edit_semantics, "status": status, "risk": risk, "message": message, @@ -1235,6 +1253,8 @@ class FeatureMixin: "slot_resize_mode": "angular_span", "slot_resize_label": "slot angular span", "slot_resize_strategy": "local-sector-angular-span", + "edit_strategy_label": "局部扇形槽角度重建", + "edit_semantics": "保持当前圆柱半径和轴线,围绕当前角度中心填旧扇形槽并切出目标圆弧角度的新扇形槽。", "resize_mode": "widen" if (delta_span or 0.0) > 0 else "narrow", "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), @@ -1388,11 +1408,51 @@ class FeatureMixin: except Exception as exc: blockers.append(f"Could not build slot axis move tool plan: {exc}") + resize_strategy = "fill-old-slot-sector-and-cut-moved-sector" + edit_strategy_label = "填旧槽并切新槽" + edit_semantics = "先填补当前槽/半孔扇形区域,再按同宽度、同角度在目标轴心切出新槽;不整体平移零件。" if pair_plan: - risk = _max_risk(risk, "high") - warnings.append( - "A paired obround slot end was detected; this axis move edits the selected local sector, not the whole CAD slot sketch." - ) + center_1 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_1")) + center_2 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_2")) + if center_1 is None or center_2 is None: + risk = _max_risk(risk, "high") + warnings.append( + "A paired obround slot end was detected, but the full-slot centerline could not be derived." + ) + else: + pair_plan["slot_capsule_target_start_center_1"] = _tuple_add(center_1, movement) + pair_plan["slot_capsule_target_start_center_2"] = _tuple_add(center_2, movement) + pair_plan["slot_target_center_distance"] = pair_plan.get("slot_pair_axis_distance", "") + pair_plan["slot_current_center_distance"] = pair_plan.get("slot_pair_axis_distance", "") + pair_plan["slot_pair_current_axis_center_1"] = current_center + pair_plan["slot_pair_target_axis_center_1"] = target_center + try: + pair_face_id = int(pair_plan.get("slot_pair_face_id", -1)) + pair_surf = BRepAdaptor_Surface(self.faces[pair_face_id]) + pair_feature = self.feature_info(pair_face_id) + pair_axis_range = self._cylindrical_axis_range( + pair_face_id, + pair_surf, + _int_values(pair_feature.get("feature_side_face_ids")), + ) + pair_cyl = pair_surf.Cylinder() + pair_mid_parameter = (float(pair_axis_range["v_min"]) + float(pair_axis_range["v_max"])) * 0.5 + pair_center = _point_tuple( + _point_on_axis(pair_cyl.Axis().Location(), pair_cyl.Axis().Direction(), pair_mid_parameter) + ) + pair_plan["slot_pair_current_axis_center_2"] = pair_center + pair_plan["slot_pair_target_axis_center_2"] = _tuple_add(pair_center, movement) + except Exception: + pair_plan["slot_pair_current_axis_center_2"] = "" + pair_plan["slot_pair_target_axis_center_2"] = "" + if not pair_plan.get("slot_pair_target_axis_center_2"): + blockers.append("Could not derive both obround slot end centers for the axis move.") + else: + resize_strategy = "paired-obround-slot-axis-prism" + edit_strategy_label = "整条长圆槽轴心移动" + edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同槽宽、同总长度在目标轴心重切整条长圆槽;不整体平移零件。" + risk = _max_risk(risk, str(pair_plan.get("slot_pair_risk", "medium"))) + warnings.append("Detected paired partial-cylinder slot ends; axis move will relocate the full obround slot.") current_radius = current_diameter * 0.5 if current_diameter is not None else None current_width = ( @@ -1412,6 +1472,7 @@ class FeatureMixin: return { **cutter_plan, **fill_plan, + **pair_plan, "status": status, "risk": "blocked" if blockers else risk, "message": message, @@ -1455,7 +1516,9 @@ class FeatureMixin: "same_domain_face_count": axis_range.get("same_domain_face_count", 0), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), - "resize_strategy": "fill-old-slot-sector-and-cut-moved-sector", + "resize_strategy": resize_strategy, + "edit_strategy_label": edit_strategy_label, + "edit_semantics": edit_semantics, } def _paired_obround_slot_plan( @@ -1767,6 +1830,8 @@ class FeatureMixin: "slot_resize_mode": "total_length", "slot_resize_label": "slot total length", "slot_resize_strategy": "paired-obround-slot-length-prism", + "edit_strategy_label": "长圆槽总长度重建", + "edit_semantics": "保持槽宽不变,围绕长圆槽中心线对两端中心距做对称调整,然后填旧槽并重切目标总长度的长圆槽。", "resize_mode": "lengthen" if (delta_length or 0.0) > 0 else "shorten", "slot_current_total_length": current_total_length, "slot_target_total_length": target_total_length, @@ -1778,6 +1843,68 @@ class FeatureMixin: "slot_capsule_target_start_center_2": target_center_2, } + def cylindrical_slot_center_distance_plan( + self, + face_id: int, + target_center_distance: float, + pair_face_id: int | None = None, + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + current_diameter = _float_or_none(info.get("diameter")) + if current_diameter is None or current_diameter <= 1e-9: + return { + "status": "blocked", + "risk": "blocked", + "message": "Selected face is not a measurable cylindrical slot face.", + "blockers": "Selected face is not a measurable cylindrical slot face.", + "warnings": "", + } + try: + target_center_distance = float(target_center_distance) + except (TypeError, ValueError): + target_center_distance = 0.0 + + target_total_length = target_center_distance + current_diameter + plan = self.cylindrical_slot_total_length_plan( + face_id, + target_total_length, + pair_face_id=pair_face_id, + ) + current_center_distance = _float_or_none(plan.get("slot_current_center_distance")) + delta = ( + None + if current_center_distance is None + else target_center_distance - current_center_distance + ) + delta_ratio = ( + None + if current_center_distance is None or current_center_distance <= 1e-9 or delta is None + else abs(delta) / current_center_distance + ) + if target_center_distance <= 0: + blockers = str(plan.get("blockers") or "") + extra = "Target slot center distance must be greater than 0." + plan["blockers"] = "; ".join(item for item in (blockers, extra) if item) + plan["message"] = plan["blockers"] + plan["status"] = "blocked" + plan["risk"] = "blocked" + plan.update( + { + "slot_resize_mode": "center_distance", + "slot_resize_label": "slot center distance", + "slot_resize_strategy": "paired-obround-slot-center-distance-prism", + "edit_strategy_label": "长圆槽中心距重建", + "edit_semantics": "保持槽宽不变,围绕长圆槽中心线对两端半圆中心距做对称调整,然后填旧槽并重切目标中心距的长圆槽。", + "slot_target_center_distance": target_center_distance, + "slot_target_total_length": target_total_length, + "slot_center_distance_delta": delta, + "slot_center_distance_delta_ratio": delta_ratio, + } + ) + return plan + def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -1806,6 +1933,11 @@ class FeatureMixin: height_estimate = float(scoped_info.get("height_estimate", 0.0)) target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter) + edit_semantics = ( + "扩大凸台直径:按当前凸台轴线和估算高度生成目标圆柱补料体,并与所属特征做局部 Fuse。" + if resize_mode == "enlarge" + else "缩小凸台直径:先移除旧凸台包络,再补回目标直径圆柱;这是局部重建,不是缩放整个零件。" + ) return { "status": readiness["boss_resize_status"], "risk": readiness["boss_resize_risk"], @@ -1834,6 +1966,9 @@ class FeatureMixin: "material_sample_count": info.get("material_sample_count"), "feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"), "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), + "resize_strategy": "bounded-cylindrical-boss-envelope-rebuild", + "edit_strategy_label": "凸台包络重建", + "edit_semantics": edit_semantics, **tool_plan, } @@ -1980,6 +2115,8 @@ class FeatureMixin: "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), "resize_strategy": "remove-old-boss-and-fuse-moved-cylinder", + "edit_strategy_label": "移除旧凸台并补新凸台", + "edit_semantics": "先移除当前凸台包络,再按同直径在目标轴心补出新凸台;不整体平移零件。", } def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]: @@ -2004,6 +2141,8 @@ class FeatureMixin: scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) readiness = _cylinder_suppress_readiness(scoped_info) fill_plan = self._bounded_cylinder_fill_plan(face_id) + fill_plan["fill_strategy"] = "bounded-hole-suppress-fill" + fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。" return { "status": readiness["suppress_status"], "risk": readiness["suppress_risk"], @@ -2029,6 +2168,9 @@ class FeatureMixin: "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_note": feature.get("feature_bottom_note"), + "resize_strategy": "fill-cylindrical-hole-volume", + "edit_strategy_label": "圆柱补料封堵", + "edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。", **fill_plan, } @@ -2106,6 +2248,9 @@ class FeatureMixin: "feature_bottom_confidence": feature.get("feature_bottom_confidence"), "feature_bottom_detection": feature.get("feature_bottom_detection"), "feature_bottom_note": feature.get("feature_bottom_note"), + "resize_strategy": "bounded-blind-depth-cut-or-fill", + "edit_strategy_label": "盲孔/盲槽深度切削或补料", + "edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。", } plan.update(context) return plan @@ -2407,23 +2552,14 @@ class FeatureMixin: old_radius = cyl.Radius() new_radius = new_diameter / 2.0 axis_range = self._cylindrical_axis_range(face_id, surf) + end_info = self._cylinder_end_opening_info(face_id, surf, axis_range) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) span = max(v_max - v_min, 1e-6) - delta_radius = abs(new_radius - old_radius) - base_radius = max(old_radius, new_radius) resize_mode = _resize_mode(old_radius * 2.0, new_diameter) - if resize_mode == "enlarge": - axial_margin = 0.0 - start_parameter = v_min - end_parameter = v_max - else: - axial_margin = min( - max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001), - max(span * 0.01, 0.02), - ) - start_parameter = v_min - axial_margin - end_parameter = v_max + axial_margin + axial_margin = 0.0 + start_parameter = v_min + end_parameter = v_max radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() @@ -2436,7 +2572,7 @@ class FeatureMixin: "boss_tool_note": ( "扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;" "缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。" - "当前版本会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。" + "补新凸台时使用原凸台轴向范围,避免直径或轴心修改时悄悄改变高度。" ), "boss_tool_scope_face_ids": axis_range["same_domain_face_ids"], "boss_tool_scope_face_count": axis_range["same_domain_face_count"], @@ -2455,6 +2591,7 @@ class FeatureMixin: "boss_tool_start_point": _point_tuple(start), "boss_tool_exact_start_point": _point_tuple(exact_start), "boss_tool_exact_height": max(v_max - v_min, 1e-6), + **end_info, } def _cylinder_end_opening_info( diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py index 7badb1f..f30a6de 100644 --- a/step_editor/geometry_utils.py +++ b/step_editor/geometry_utils.py @@ -590,7 +590,9 @@ def _axis_aligned_edge_candidates( ) -> list[TopoDS_Shape]: candidates: list[tuple[float, TopoDS_Shape]] = [] length_reference = max(expected_length, reference_radius, 1.0) - distance_limit = max(reference_radius * 1.25, length_reference * 0.08, 0.2) + # A 90-degree edge fillet restores a sharp edge about sqrt(2) * R away + # from the fillet cylinder axis, so 1.25R misses the common box case. + distance_limit = max(reference_radius * 1.8, length_reference * 0.08, 0.2) for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): try: @@ -623,10 +625,10 @@ def _axis_aligned_edge_candidates( return [edge for _score, edge in candidates] -def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape: +def _finalize_boolean_result(op, operation_name: str, *, use_glue: bool | None = None) -> TopoDS_Shape: op.SetNonDestructive(True) - use_glue = "cut" not in operation_name.lower() - if use_glue and hasattr(op, "SetGlue"): + glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue) + if glue_enabled and hasattr(op, "SetGlue"): try: op.SetGlue(BOPAlgo_GlueFull) except Exception: diff --git a/step_editor/model.py b/step_editor/model.py index 0f7fb98..c3c9fe2 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -97,6 +97,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self.edge_part_ids: list[int] = [] self.edge_solid_ids: list[int] = [] self.solids: list[tuple[int, TopoDS_Shape]] = [] + self._quick_face_info_cache: dict[int, dict[str, object]] = {} self._face_info_cache: dict[int, dict[str, object]] = {} self._feature_info_cache: dict[int, dict[str, object]] = {} self._edge_info_cache: dict[int, dict[str, object]] = {} @@ -176,6 +177,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self.edge_part_ids.clear() self.edge_solid_ids.clear() self.solids.clear() + self._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._edge_info_cache.clear() @@ -269,10 +271,128 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY) if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces): self.face_logical_ids = [int(item) for item in logical_ids] + self._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._same_domain_face_ids_cache.clear() + def quick_face_info(self, face_id: int) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + full_info = self._face_info_cache.get(face_id) + if full_info is not None: + return dict(full_info) + cached = self._quick_face_info_cache.get(face_id) + if cached is not None: + return dict(cached) + + face = self.faces[face_id] + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + surf = BRepAdaptor_Surface(face) + surface_type = surf.GetType() + surface_label = SURFACE_TYPES.get(surface_type, f"type {surface_type}") + info: dict[str, object] = { + "kind": "face", + "face_id": face_id, + "topological_face_id": face_id, + "logical_face_id": self.face_logical_id(face_id), + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "orientation": _orientation_name(face.Orientation()), + "surface": surface_label, + "area": props.Mass(), + "area_center": _point_tuple(props.CentreOfMass()), + "u_range": (surf.FirstUParameter(), surf.LastUParameter()), + "v_range": (surf.FirstVParameter(), surf.LastVParameter()), + "boundary_edges": len(self._face_boundary_edge_ids(face_id)), + "selection_info_mode": "quick", + "selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。", + } + info.update(_shape_bounds_info(face)) + if surface_type == GeomAbs_Plane: + plane = surf.Plane() + direction = plane.Axis().Direction() + push_pull_direction = self._plane_push_pull_direction(face_id, surf) + info["plane_origin"] = _point_tuple(plane.Location()) + info["normal"] = _dir_tuple(direction) + info["oriented_normal"] = _oriented_dir_tuple(direction, face) + info["push_pull_outward_direction"] = push_pull_direction["outward_direction"] + info["push_pull_inward_direction"] = push_pull_direction["inward_direction"] + info["push_pull_plus_side"] = push_pull_direction["plus_side_state"] + info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] + info["push_pull_confidence"] = push_pull_direction["confidence"] + info["push_pull_note"] = push_pull_direction["note"] + info["push_pull_status"] = "candidate" + info["feature_type"] = "可推拉平面候选" + info["feature_source_face_id"] = face_id + info["feature_highlight_face_ids"] = (face_id,) + info["feature_edit_actions"] = "推拉平面" + info.update( + self._local_face_plane_size_info( + face_id, + face=face, + surf=surf, + center=_tuple_or_none(info.get("area_center")), + ) + ) + elif surface_type == GeomAbs_Cylinder: + cyl = surf.Cylinder() + axis = cyl.Axis() + radius = cyl.Radius() + u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) + swept_area = max(radius * max(u_span, 1e-9), 1e-9) + classification = self._classify_cylindrical_face(face_id, surf, detailed=False) + info["radius"] = radius + info["diameter"] = radius * 2.0 + info["axis_point"] = _point_tuple(axis.Location()) + info["axis"] = _dir_tuple(axis.Direction()) + info["angular_span"] = u_span + info["is_full_cylinder"] = u_span >= math.tau * 0.98 + info["height_estimate"] = props.Mass() / swept_area + info["feature_guess"] = classification["feature_guess"] + info["confidence"] = classification["confidence"] + info["material_toward_axis"] = classification["toward_axis"] + info["material_away_axis"] = classification["away_axis"] + info["material_vote_summary"] = classification["vote_summary"] + info["material_sample_count"] = classification["sample_count"] + info["note"] = classification["note"] + info["feature_source_face_id"] = face_id + info["feature_highlight_face_ids"] = (face_id,) + if u_span < math.tau * 0.92: + info["slot_kind"] = "partial-cylindrical-groove" + info["slot_angular_span"] = u_span + info["slot_open_angle"] = max(math.tau - u_span, 0.0) + info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5) + info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5)) + info["slot_arc_length_estimate"] = radius * u_span + elif surface_type == GeomAbs_Cone: + cone = surf.Cone() + info["axis_point"] = _point_tuple(cone.Location()) + info["axis"] = _dir_tuple(cone.Axis().Direction()) + info["reference_radius"] = cone.RefRadius() + info["semi_angle"] = cone.SemiAngle() + info["feature_reference_radius"] = cone.RefRadius() + info["feature_reference_diameter"] = cone.RefRadius() * 2.0 + elif surface_type == GeomAbs_Sphere: + sphere = surf.Sphere() + info["center"] = _point_tuple(sphere.Location()) + info["radius"] = sphere.Radius() + info["diameter"] = sphere.Radius() * 2.0 + info["feature_sphere_radius"] = sphere.Radius() + info["feature_sphere_diameter"] = sphere.Radius() * 2.0 + elif surface_type == GeomAbs_Torus: + torus = surf.Torus() + info["center"] = _point_tuple(torus.Location()) + info["axis"] = _dir_tuple(torus.Axis().Direction()) + info["major_radius"] = torus.MajorRadius() + info["minor_radius"] = torus.MinorRadius() + info["feature_torus_major_radius"] = torus.MajorRadius() + info["feature_torus_minor_radius"] = torus.MinorRadius() + + self._quick_face_info_cache[face_id] = dict(info) + return dict(info) + def face_info(self, face_id: int) -> dict[str, object]: if face_id in self._face_info_cache: return dict(self._face_info_cache[face_id]) @@ -313,6 +433,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] info["push_pull_confidence"] = push_pull_direction["confidence"] info["push_pull_note"] = push_pull_direction["note"] + info.update( + self._local_face_plane_size_info( + face_id, + face=face, + surf=surf, + center=_tuple_or_none(info.get("area_center")), + ) + ) elif surface_type == GeomAbs_Cylinder: cyl = surf.Cylinder() axis = cyl.Axis() @@ -389,6 +517,10 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd except Exception: return None + def cached_feature_info(self, face_id: int) -> dict[str, object] | None: + cached = self._feature_info_cache.get(face_id) + return dict(cached) if cached is not None else None + def feature_info(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -1030,6 +1162,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd return for face_id in valid_face_ids: self.face_logical_ids[face_id] = int(logical_id) + self._quick_face_info_cache.pop(face_id, None) self._face_info_cache.pop(face_id, None) self._feature_info_cache.pop(face_id, None) @@ -1492,6 +1625,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ellipse = curve.Ellipse() info["center"] = _point_tuple(ellipse.Location()) info["axis"] = _dir_tuple(ellipse.Axis().Direction()) + info["major_axis"] = _dir_tuple(ellipse.XAxis().Direction()) + info["minor_axis"] = _dir_tuple(ellipse.YAxis().Direction()) info["major_radius"] = ellipse.MajorRadius() info["minor_radius"] = ellipse.MinorRadius() adjacent_face_ids = self._edge_adjacent_face_ids(edge_id) diff --git a/step_editor/operations.py b/step_editor/operations.py index 164d8e8..b2a8c31 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -10,9 +10,11 @@ from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepA from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull from OCC.Core.BRepBuilderAPI import ( + BRepBuilderAPI_MakeEdge, BRepBuilderAPI_GTransform, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, + BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeSolid, BRepBuilderAPI_Sewing, BRepBuilderAPI_Transform, @@ -46,6 +48,7 @@ from OCC.Core.GeomAbs import ( GeomAbs_Torus, ) from OCC.Core.GProp import GProp_GProps +from OCC.Core.GC import GC_MakeArcOfCircle from OCC.Core.ShapeFix import ShapeFix_Shape from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCC.Core.TopAbs import ( @@ -204,6 +207,12 @@ class OperationMixin: "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), + "resize_strategy": "add-edge-fillet", + "edit_strategy_label": "给Edge添加新圆角", + "edit_semantics": ( + "在当前直线 Edge 及其相邻 Face 上调用 OCCT 倒圆;会替换这条边附近的局部拓扑," + "不是修改已有圆角面。" + ), } def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]: @@ -248,6 +257,11 @@ class OperationMixin: "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), + "resize_strategy": "add-edge-symmetric-chamfer", + "edit_strategy_label": "给Edge添加对称倒角", + "edit_semantics": ( + "在当前直线 Edge 及其相邻 Face 上调用 OCCT 对称倒角;会替换这条边附近的局部拓扑。" + ), } def edge_asymmetric_chamfer_plan( @@ -335,6 +349,11 @@ class OperationMixin: "distance2_to_length_ratio": ratio2, "reference_face_id": resolved_reference_face_id, "reference_face_candidates": tuple(adjacent_face_ids), + "resize_strategy": "add-edge-asymmetric-chamfer", + "edit_strategy_label": "给Edge添加不等距倒角", + "edit_semantics": ( + "按 D1/D2 两个距离在当前 Edge 两侧生成不等距倒角;参考 Face 决定 D1/D2 的方向。" + ), } ) return plan @@ -428,6 +447,11 @@ class OperationMixin: "distance_to_length_ratio": distance_ratio, "reference_face_id": resolved_reference_face_id, "reference_face_candidates": tuple(adjacent_face_ids), + "resize_strategy": "add-edge-distance-angle-chamfer", + "edit_strategy_label": "给Edge添加距离+角度倒角", + "edit_semantics": ( + "按距离 D 和角度在当前 Edge 上生成倒角;参考 Face 决定距离和角度的方向。" + ), } ) return plan @@ -437,6 +461,7 @@ class OperationMixin: edge_id: int, target_length: float, anchor_mode: str = "auto", + strategy_mode: str = "auto", ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") @@ -446,6 +471,10 @@ class OperationMixin: delta_length = target_length - current_length curve = str(info.get("curve", "")) anchor_mode = self._edge_length_anchor_mode(anchor_mode) + strategy_mode = self._edge_length_strategy_mode(strategy_mode) + force_local = strategy_mode == "local-edge-only-deform" + force_end_face = strategy_mode == "move-edge-end-plane-by-push-pull" + force_cylinder = strategy_mode == "resize-adjacent-cylinder-from-circular-edge-length" base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), @@ -453,6 +482,8 @@ class OperationMixin: "curve": curve, "edge_length_anchor_mode": anchor_mode, "edge_length_anchor_label": self._edge_length_anchor_label(anchor_mode), + "edge_length_strategy_mode": strategy_mode, + "edge_length_strategy_label": self._edge_length_strategy_label(strategy_mode), "current_length": current_length, "target_length": target_length, "delta_length": delta_length, @@ -484,7 +515,16 @@ class OperationMixin: risk = _max_risk(risk, "medium") warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。") - if not blockers and curve == "line": + if not blockers and force_local and curve != "line": + blockers.append("“只变当前Edge”策略当前只支持直线Edge。") + if not blockers and force_end_face and curve != "line": + blockers.append("“移动端面/整体尺寸”策略当前只支持直线Edge。") + if not blockers and force_cylinder and curve != "circle": + blockers.append("“相邻圆柱直径”策略当前只支持圆形/圆弧Edge。") + if not blockers and force_end_face and anchor_mode == "center": + blockers.append("“移动端面/整体尺寸”需要固定起点、固定终点或自动基准,不能使用固定中心。") + + if not blockers and curve == "line" and strategy_mode in {"auto", "local-edge-only-deform"}: local_candidate, local_skip_note = self._local_edge_length_deform_candidate( info, target_length, @@ -498,9 +538,12 @@ class OperationMixin: ) base.update(local_candidate) elif local_skip_note: - warnings.append(local_skip_note) + if force_local: + blockers.append(local_skip_note) + else: + warnings.append(local_skip_note) - if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center": + if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull"}: candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) @@ -522,15 +565,23 @@ class OperationMixin: } ) else: - warnings.append(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"}: - warnings.append( - f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径,将尝试按该基准缩放所属对象。" - ) - elif not blockers and curve == "line" and anchor_mode == "center": + 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的端面推拉路径。") + elif not blockers and curve == "line" and anchor_mode == "center" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。") - if not blockers and "resize_strategy" not in base and curve == "circle": + if not blockers and "resize_strategy" not in base and curve == "circle" and strategy_mode in {"auto", "resize-adjacent-cylinder-from-circular-edge-length"}: cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length) if cylinder_candidate is not None: risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"])) @@ -541,9 +592,12 @@ class OperationMixin: warnings.append(cylinder_warnings) base.update(cylinder_candidate) elif cylinder_notes: - warnings.extend(cylinder_notes[:3]) + if force_cylinder: + blockers.append("未找到可复用的相邻圆柱直径编辑路径:" + " ".join(cylinder_notes[:3])) + else: + warnings.extend(cylinder_notes[:3]) - if not blockers and "resize_strategy" not in base and curve != "line": + if not blockers and "resize_strategy" not in base and curve != "line" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: planar_candidate, planar_note = self._planar_edge_length_scale_candidate(info, target_length) if planar_candidate is not None: risk = _max_risk(risk, str(planar_candidate.get("affine_transform_risk", "medium"))) @@ -556,7 +610,7 @@ class OperationMixin: elif planar_note: warnings.append(planar_note) - if not blockers and "resize_strategy" not in base: + if not blockers and "resize_strategy" not in base and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode) if axis is None: blockers.append("无法为当前 Edge 推断可靠的缩放方向。") @@ -600,6 +654,8 @@ class OperationMixin: "part_solid_count": part_solid_count, } ) + elif not blockers and "resize_strategy" not in base and strategy_mode != "auto": + blockers.append(f"当前Edge不满足所选策略“{self._edge_length_strategy_label(strategy_mode)}”的执行条件。") if not blockers and base.get("resize_strategy") == "scale-owning-shape-from-edge": refine_note = self._refine_affine_edge_length_scale(base) @@ -628,6 +684,10 @@ class OperationMixin: base.update( { + "edge_length_constraint_summary": self._edge_length_constraint_summary(anchor_mode), + "edge_length_impact_summary": self._edge_length_impact_summary(base), + "edit_strategy_label": self._edge_length_strategy_label(str(base.get("resize_strategy") or strategy_mode)), + "edit_semantics": self._edge_length_impact_summary(base), "status": status, "risk": risk, "message": message, @@ -1020,6 +1080,97 @@ class OperationMixin: "keep-end": "固定终点", }.get(anchor_mode, "自动选择局部端面") + def _edge_length_strategy_mode(self, strategy_mode: str | None) -> str: + normalized = str(strategy_mode or "auto").strip().lower() + aliases = { + "自动": "auto", + "auto": "auto", + "local": "local-edge-only-deform", + "local-edge": "local-edge-only-deform", + "local-edge-only": "local-edge-only-deform", + "local-edge-only-deform": "local-edge-only-deform", + "只改当前edge": "local-edge-only-deform", + "只改这条edge": "local-edge-only-deform", + "只改当前边": "local-edge-only-deform", + "只改这条边": "local-edge-only-deform", + "只变当前edge": "local-edge-only-deform", + "只变这条edge": "local-edge-only-deform", + "只变当前边": "local-edge-only-deform", + "只变这条边": "local-edge-only-deform", + "局部边形变": "local-edge-only-deform", + "end-face": "move-edge-end-plane-by-push-pull", + "push-pull": "move-edge-end-plane-by-push-pull", + "move-edge-end-plane": "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", + "保持相邻面垂直": "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", + "adjacent-cylinder": "resize-adjacent-cylinder-from-circular-edge-length", + "resize-adjacent-cylinder-from-circular-edge-length": "resize-adjacent-cylinder-from-circular-edge-length", + "相邻圆柱": "resize-adjacent-cylinder-from-circular-edge-length", + "相邻圆柱直径": "resize-adjacent-cylinder-from-circular-edge-length", + "scale": "scale-owning-shape-from-edge", + "scale-owning": "scale-owning-shape-from-edge", + "scale-owning-shape-from-edge": "scale-owning-shape-from-edge", + "缩放所属对象": "scale-owning-shape-from-edge", + "整体缩放": "scale-owning-shape-from-edge", + } + return aliases.get(normalized, "auto") + + def _edge_length_strategy_label(self, strategy_mode: str) -> str: + return { + "auto": "自动选择", + "local-edge-only-deform": "只改当前Edge", + "move-edge-end-plane-by-push-pull": "移动端面/保持垂直", + "resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径", + "scale-owning-shape-from-edge": "缩放所属对象", + }.get(strategy_mode, "自动选择") + + def _edge_length_constraint_summary(self, anchor_mode: str) -> str: + anchor_mode = self._edge_length_anchor_mode(anchor_mode) + return { + "auto": "自动基准:默认尽量固定起点,移动终点;如果策略需要,会按可用端面调整。", + "center": "固定中心:Edge中心尽量不动,两端或所属对象围绕中心变化。", + "keep-start": "固定起点:起点尽量不动,终点或相关端面承担长度变化。", + "keep-end": "固定终点:终点尽量不动,起点或相关端面承担长度变化。", + }.get(anchor_mode, "自动基准:程序会选择更稳定的一端作为固定约束。") + + def _edge_length_impact_summary(self, plan: dict[str, object]) -> str: + strategy = str(plan.get("resize_strategy") or plan.get("edge_length_strategy_mode") or "auto") + target_kind = str(plan.get("affine_target_kind") or plan.get("local_edge_deform_target_kind") or "所属对象") + if strategy == "local-edge-only-deform": + return ( + "只改当前Edge:只移动被选Edge的端点并重建相邻平面;相邻面会自然变斜," + "必要时非共面四边面会拆成三角面,整体端面不会一起平移。" + ) + if strategy == "move-edge-end-plane-by-push-pull": + end_face = plan.get("end_face_id") + face_text = f" Face {end_face}" if end_face not in {None, ""} else "" + return ( + f"移动端面:把长度变化转换为端面{face_text}推拉;端面和同一端面区域上的相关边会跟随," + "相邻平面会尽量保持垂直,正方体这类模型会更像变成长方体。" + ) + if strategy == "resize-adjacent-cylinder-from-circular-edge-length": + return ( + "相邻圆柱直径:把圆形/圆弧Edge目标长度换算成相邻圆柱直径," + "优先重切孔/槽或重建凸台,不会把整个模型按Edge长度缩放。" + ) + if strategy == "scale-owning-shape-from-edge": + return ( + f"缩放所属对象:对所属 {target_kind} 做轴向、径向或整体缩放;" + "同一对象上的其它尺寸会跟随变化,适合作为明确选择的高风险兜底语义。" + ) + return ( + "自动选择:程序会按局部Edge形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径;" + "确认窗口会显示最终采用的实际策略。" + ) + def _circular_edge_length_cylinder_candidate( self, edge_info: dict[str, object], @@ -1341,6 +1492,245 @@ class OperationMixin: best_length = candidate_length return _tuple_normalized(best) + def ellipse_edge_axis_radius_plan( + self, + edge_id: int, + target_radius: float, + axis_kind: str = "major", + ) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + info = self.edge_info(edge_id) + axis_kind = "minor" if str(axis_kind).lower() in {"minor", "small", "y", "minor_radius"} else "major" + radius_key = "minor_radius" if axis_kind == "minor" else "major_radius" + direction_key = "minor_axis" if axis_kind == "minor" else "major_axis" + axis_label = "小半径" if axis_kind == "minor" else "主半径" + axis_direction_label = "小轴" if axis_kind == "minor" else "主轴" + current_radius = _float_or_none(info.get(radius_key)) + other_radius = _float_or_none(info.get("major_radius" if axis_kind == "minor" else "minor_radius")) + center = _tuple_or_none(info.get("center")) + direction = _tuple_normalized(_tuple_or_none(info.get(direction_key))) + other_direction = _tuple_normalized( + _tuple_or_none(info.get("major_axis" if axis_kind == "minor" else "minor_axis")) + ) + target_radius = float(target_radius) + blockers: list[str] = [] + warnings: list[str] = [ + "椭圆Edge半径修改基于当前 STEP/B-Rep 结果几何;会对所属对象做单轴仿射缩放,不是恢复 CAD 草图约束。" + ] + risk = "medium" + if info.get("curve") != "ellipse": + blockers.append("当前Edge不是椭圆Edge。") + if current_radius is None or current_radius <= 1e-9: + blockers.append(f"当前椭圆Edge缺少稳定{axis_label}。") + if other_radius is None or other_radius <= 1e-9: + blockers.append("当前椭圆Edge缺少另一个半径,不能稳定校验结果。") + if center is None: + blockers.append("当前椭圆Edge缺少稳定中心。") + if direction is None: + blockers.append(f"当前椭圆Edge缺少稳定{axis_direction_label}方向。") + if other_direction is None: + blockers.append("当前椭圆Edge缺少另一个轴方向,不能稳定校验结果。") + if target_radius <= 1e-9: + blockers.append(f"椭圆Edge目标{axis_label}必须大于 0。") + if current_radius is not None and current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-7, 1e-7): + blockers.append(f"椭圆Edge目标{axis_label}与当前值几乎相同,不需要修改。") + + scale = target_radius / max(current_radius or 1.0, 1e-9) + delta = None if current_radius is None else target_radius - current_radius + delta_ratio = None if current_radius is None or current_radius <= 0 else abs(delta or 0.0) / current_radius + if delta_ratio is not None: + if delta_ratio > 0.5: + risk = _max_risk(risk, "high") + warnings.append(f"目标{axis_label}变化超过当前值的 50%,周边几何变形或修复失败的概率较高。") + elif delta_ratio > 0.25: + risk = _max_risk(risk, "medium") + warnings.append(f"目标{axis_label}变化超过当前值的 25%,请确认影响范围。") + + 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("找不到椭圆Edge所属零件。") + part_solid_count = 0 + target_kind = "part" + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + else: + status = "caution" if risk != "low" else "ready" + message = " ".join(warnings) + + target_major = target_radius if axis_kind == "major" else info.get("major_radius") + target_minor = target_radius if axis_kind == "minor" else info.get("minor_radius") + return { + "edge_id": edge_id, + "part_id": part_id, + "solid_id": solid_id, + "curve": info.get("curve"), + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "resize_strategy": f"ellipse-edge-{axis_kind}-axis-affine", + "edit_strategy_label": f"椭圆Edge{axis_label}单轴缩放", + "edit_semantics": ( + f"沿椭圆{axis_direction_label}方向缩放所属 {target_kind},让{axis_label}接近目标值;" + "另一个半径方向尽量不动,但同一对象上的其它几何会受这个单轴缩放影响。" + ), + "ellipse_axis_kind": axis_kind, + "ellipse_axis_label": axis_label, + "ellipse_axis_direction_label": axis_direction_label, + "ellipse_current_radius": current_radius, + "ellipse_target_radius": target_radius, + "ellipse_radius_delta": delta, + "ellipse_radius_delta_ratio": delta_ratio, + "ellipse_current_major_radius": info.get("major_radius"), + "ellipse_target_major_radius": target_major, + "ellipse_current_minor_radius": info.get("minor_radius"), + "ellipse_target_minor_radius": target_minor, + "affine_scale": scale, + "affine_transform_kind": "axis-affine", + "affine_transform_label": f"沿椭圆{axis_direction_label}单轴缩放", + "affine_transform_note": "这会改变所属对象在该方向上的尺寸,可能把部分解析几何转换为 B-spline。", + "affine_axis_point": center, + "affine_axis_direction": direction, + "ellipse_other_axis_direction": other_direction, + "affine_axis_source": f"ellipse {axis_direction_label}", + "affine_anchor_source": "ellipse center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_ellipse_edge_axis_radius( + self, + edge_id: int, + target_radius: float, + axis_kind: str = "major", + ) -> str: + plan = self.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind=axis_kind) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + result_check = self._ellipse_edge_axis_radius_result_summary(plan) + return ( + f"Ellipse Edge {plan['ellipse_axis_kind']} radius resize completed: " + f"edge {edge_id}, " + f"current_radius={float(plan['ellipse_current_radius']):g}, " + f"target_radius={float(plan['ellipse_target_radius']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) + + def _ellipse_edge_axis_radius_result_summary(self, plan: dict[str, object]) -> str: + axis_kind = str(plan.get("ellipse_axis_kind") or "major") + radius_key = "minor_radius" if axis_kind == "minor" else "major_radius" + target_radius = _float_or_none(plan.get("ellipse_target_radius")) + if target_radius is None: + return "Result check: target radius was unavailable." + part_id = int(plan.get("part_id", -1)) + solid_id = int(plan.get("solid_id", -1)) + best: tuple[float, int, float, dict[str, object]] | None = None + for candidate_edge_id in range(len(self.edges)): + if part_id >= 0 and self.edge_part_ids[candidate_edge_id] != part_id: + continue + if solid_id >= 0 and self.edge_solid_ids[candidate_edge_id] != solid_id: + continue + info = self.edge_info(candidate_edge_id) + if info.get("curve") != "ellipse": + continue + value = _float_or_none(info.get(radius_key)) + if value is None: + continue + error = abs(value - target_radius) + if best is None or error < best[0]: + best = (error, candidate_edge_id, value, info) + if best is None: + sampled = self._ellipse_edge_axis_radius_sampled_result(plan) + if sampled is not None: + candidate_edge_id, value, other_value, error = sampled + return ( + f"Result check: nearest_edge={candidate_edge_id}, sampled_{axis_kind}_radius={value:.6g}, " + f"target_error={error:.6g}, sampled_other_radius={other_value:.6g}; " + "the refreshed edge is no longer an analytic ellipse." + ) + return "Result check: no ellipse-like Edge was recognized after the edit; inspect the refreshed B-Rep." + error, candidate_edge_id, value, info = best + other_key = "major_radius" if axis_kind == "minor" else "minor_radius" + other_value = _float_or_none(info.get(other_key)) + return ( + f"Result check: nearest_edge={candidate_edge_id}, " + f"nearest_{axis_kind}_radius={value:.6g}, " + f"target_error={error:.6g}, " + f"other_radius={other_value:.6g}." + if other_value is not None + else ( + f"Result check: nearest_edge={candidate_edge_id}, " + f"nearest_{axis_kind}_radius={value:.6g}, target_error={error:.6g}." + ) + ) + + def _ellipse_edge_axis_radius_sampled_result( + self, + plan: dict[str, object], + ) -> tuple[int, float, float, float] | None: + center = _tuple_or_none(plan.get("affine_axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) + other_direction = _tuple_normalized(_tuple_or_none(plan.get("ellipse_other_axis_direction"))) + target_radius = _float_or_none(plan.get("ellipse_target_radius")) + if center is None or axis_direction is None or other_direction is None or target_radius is None: + return None + part_id = int(plan.get("part_id", -1)) + solid_id = int(plan.get("solid_id", -1)) + best: tuple[int, float, float, float] | None = None + for edge_id in range(len(self.edges)): + if part_id >= 0 and self.edge_part_ids[edge_id] != part_id: + continue + if solid_id >= 0 and self.edge_solid_ids[edge_id] != solid_id: + continue + extents = self._sample_edge_axis_extents(edge_id, center, axis_direction, other_direction) + if extents is None: + continue + axis_radius, other_radius = extents + error = abs(axis_radius - target_radius) + if best is None or error < best[3]: + best = (edge_id, axis_radius, other_radius, error) + return best + + def _sample_edge_axis_extents( + self, + edge_id: int, + center: tuple[float, float, float], + axis_direction: tuple[float, float, float], + other_direction: tuple[float, float, float], + ) -> tuple[float, float] | None: + if edge_id < 0 or edge_id >= len(self.edges): + return None + curve = BRepAdaptor_Curve(self.edges[edge_id]) + first = float(curve.FirstParameter()) + last = float(curve.LastParameter()) + if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12: + return None + sample_count = 1025 + axis_extent = 0.0 + other_extent = 0.0 + for index in range(sample_count): + parameter = first + (last - first) * index / (sample_count - 1) + point = _point_tuple(curve.Value(parameter)) + relative = _tuple_sub(point, center) + axis_extent = max(axis_extent, abs(_tuple_dot(relative, axis_direction))) + other_extent = max(other_extent, abs(_tuple_dot(relative, other_direction))) + if axis_extent <= 1e-9 and other_extent <= 1e-9: + return None + return axis_extent, other_extent + def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: return self.general_edge_length_plan(edge_id, target_length, anchor_mode="auto") @@ -1528,6 +1918,8 @@ class OperationMixin: "feature_existing_fillet_support_face_ids": support_face_ids, "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", + "edit_strategy_label": "移除旧圆角并重新倒圆", + "edit_semantics": "先移除当前已有圆角面,再在恢复出的锐边上按目标半径重新倒圆;复杂 blend 可能失败并回滚。", "resize_note": ( "当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。" "执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" @@ -1738,6 +2130,674 @@ class OperationMixin: "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", } + def _local_face_plane_size_info( + self, + face_id: int, + *, + face: TopoDS_Shape | None = None, + surf: BRepAdaptor_Surface | None = None, + center: tuple[float, float, float] | None = None, + tolerance: float | None = None, + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + return {} + face_shape = face or self.faces[face_id] + surface = surf or BRepAdaptor_Surface(face_shape) + if surface.GetType() != GeomAbs_Plane: + return {} + if center is None: + try: + center = _point_tuple(_surface_center(face_shape)) + except Exception: + center = None + if center is None: + return {} + + if tolerance is None: + solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1 + source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else face_shape + tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6) + points = self._local_deform_face_vertex_points(face_shape, tolerance) + if len(points) < 3: + return {} + + normal = _tuple_normalized(_dir_tuple(surface.Plane().Axis().Direction())) + if normal is None: + return {} + if face_shape.Orientation() == TopAbs_REVERSED: + normal = _tuple_scale(normal, -1.0) + + u_dir, v_dir = _plane_basis_dirs(gp_Dir(*normal)) + width_dir = _tuple_normalized(_dir_tuple(u_dir)) + height_dir = _tuple_normalized(_dir_tuple(v_dir)) + if width_dir is None: + return {} + if height_dir is None: + return {} + + def span_for(axis: tuple[float, float, float]) -> float: + values = [_tuple_dot(_tuple_sub(point, center), axis) for point in points] + return max(values) - min(values) + + width = span_for(width_dir) + height = span_for(height_dir) + if width <= max(tolerance, 1e-9) or height <= max(tolerance, 1e-9): + return {} + return { + "local_face_width": width, + "local_face_height": height, + "local_face_width_direction": width_dir, + "local_face_height_direction": height_dir, + "local_face_size_center": center, + "local_face_size_source_point_count": len(points), + } + + def face_center_local_move_plan( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + + info = self.face_info(face_id) + target = _tuple_or_none(target_center) + current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None + source_shape = solid or (part.shape if part is not None else None) + diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 + tolerance = max(diagonal * 1e-7, 1e-6) + blockers: list[str] = [] + warnings: list[str] = [ + "只移动当前 Face 会移动这个 Face 的顶点并重建周边平面;相邻面可能变斜或被拆成三角面。" + ] + risk = "low" + status = "ready" + + if info.get("surface") != "plane": + blockers.append("当前 Face 不是平面,不能执行“只移动当前Face”。") + if current_center is None: + blockers.append("当前 Face 缺少稳定中心坐标。") + if target is None: + blockers.append("目标 Face 中心必须是有效的 X/Y/Z 坐标。") + if part is None: + blockers.append("找不到当前 Face 所属特征。") + if solid is None: + blockers.append("找不到当前 Face 所属 Solid。") + + move_vector = (0.0, 0.0, 0.0) + move_distance = 0.0 + move_ratio = 0.0 + if current_center is not None and target is not None: + move_vector = _tuple_sub(target, current_center) + move_distance = _vector_length(move_vector) + move_ratio = move_distance / max(diagonal, 1e-9) + if move_distance <= max(diagonal * 1e-7, 1e-7): + blockers.append("目标 Face 中心与当前中心几乎相同,不需要修改。") + elif move_ratio > 0.5: + risk = _max_risk(risk, "high") + warnings.append("Face 中心移动距离超过所属对象尺寸的 50%,局部形变风险很高。") + elif move_ratio > 0.2: + risk = _max_risk(risk, "medium") + warnings.append("Face 中心移动距离超过所属对象尺寸的 20%,请确认相邻面变化是否符合预期。") + + source_points: tuple[tuple[float, float, float], ...] = () + face_count = 0 + part_solid_count = 0 + if part is not None: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + if not blockers and solid is not None: + solid_faces = _explore(solid, TopAbs_FACE) + face_count = len(solid_faces) + if not solid_faces: + blockers.append("局部 Face 移动不可用:所属 Solid 没有可重建 Face。") + if face_count > 128: + blockers.append("局部 Face 移动暂只对较简单的平面实体开放,复杂模型请使用推拉或整体平移。") + for face in solid_faces: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + blockers.append("局部 Face 移动暂只支持全平面实体;含曲面的模型请使用其它编辑方式。") + break + if len(_explore(face, TopAbs_WIRE)) != 1: + blockers.append("局部 Face 移动暂不处理带内孔的 Face;请使用孔/槽专门入口。") + break + if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: + blockers.append("局部 Face 移动不可用:部分 Face 顶点环无法稳定读取。") + break + + if not blockers: + points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) + if len(points) < 3: + blockers.append("局部 Face 移动不可用:当前 Face 顶点环无法稳定读取。") + else: + source_points = tuple(points) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "可以只移动当前平面 Face,并让相邻平面按新的顶点位置重建。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "current_face_center": current_center, + "target_face_center": target, + "face_center_move_vector": move_vector, + "face_center_move_distance": move_distance, + "face_center_move_ratio": move_ratio, + "bbox_diagonal": diagonal, + "local_face_deform_target_kind": target_kind, + "local_face_deform_face_count": face_count, + "local_face_deform_source_points": source_points, + "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_semantics": ( + "只移动当前 Face 的顶点,周边相邻面按新顶点重建;这不是平移所属对象,也不是面面积缩放。" + ), + } + + def face_area_local_resize_plan(self, face_id: int, target_area: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + + info = self.face_info(face_id) + current_area = _float_or_none(info.get("area")) + center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + 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 + solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None + source_shape = solid or (part.shape if part is not None else None) + diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 + tolerance = max(diagonal * 1e-7, 1e-6) + blockers: list[str] = [] + warnings: list[str] = [ + "只缩放当前 Face 面积会在该平面内移动当前 Face 的顶点,并重建相邻平面。" + ] + risk = "low" + status = "ready" + + try: + target_area = float(target_area) + except (TypeError, ValueError): + target_area = 0.0 + blockers.append("目标面面积必须是数字。") + if info.get("surface") != "plane": + blockers.append("当前 Face 不是平面,不能执行“只缩放当前Face面积”。") + if current_area is None or current_area <= 1e-9: + blockers.append("当前 Face 缺少稳定面积。") + if center is None: + blockers.append("当前 Face 缺少稳定中心坐标。") + if target_area <= 1e-9: + blockers.append("目标面面积必须大于 0。") + if part is None: + blockers.append("找不到当前 Face 所属特征。") + if solid is None: + blockers.append("找不到当前 Face 所属 Solid。") + + scale = ( + math.sqrt(target_area / max(float(current_area), 1e-9)) + if target_area > 1e-9 and current_area is not None and current_area > 1e-9 + else 1.0 + ) + area_delta = target_area - float(current_area or 0.0) + area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9) + if current_area is not None and abs(area_delta) <= max(current_area * 1e-6, 1e-6): + blockers.append("目标面面积与当前面积几乎相同,不需要修改。") + if area_delta_ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append("目标面面积变化超过当前面积的 100%,相邻面形变风险很高。") + elif area_delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("目标面面积变化超过当前面积的 35%,请确认相邻面变化是否符合预期。") + if scale < 0.2: + risk = _max_risk(risk, "high") + warnings.append("当前 Face 会被缩得很小,可能生成薄小面或退化边。") + elif scale > 2.5: + risk = _max_risk(risk, "high") + warnings.append("当前 Face 会被放大很多,可能穿过相邻几何。") + + point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () + face_count = 0 + part_solid_count = 0 + if part is not None: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + if not blockers and solid is not None and center is not None: + solid_faces = _explore(solid, TopAbs_FACE) + face_count = len(solid_faces) + if not solid_faces: + blockers.append("局部 Face 面积缩放不可用:所属 Solid 没有可重建 Face。") + if face_count > 128: + blockers.append("局部 Face 面积缩放暂只对较简单的平面实体开放。") + for face in solid_faces: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + blockers.append("局部 Face 面积缩放暂只支持全平面实体;含曲面的模型请使用所属对象缩放。") + break + if len(_explore(face, TopAbs_WIRE)) != 1: + blockers.append("局部 Face 面积缩放暂不处理带内孔的 Face。") + break + if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: + blockers.append("局部 Face 面积缩放不可用:部分 Face 顶点环无法稳定读取。") + break + + if not blockers: + points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) + if len(points) < 3: + blockers.append("局部 Face 面积缩放不可用:当前 Face 顶点环无法稳定读取。") + else: + target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for point in points: + relative = _tuple_sub(point, center) + target_point = ( + center[0] + relative[0] * scale, + center[1] + relative[1] * scale, + center[2] + relative[2] * scale, + ) + target_items.append((point, target_point)) + point_targets = tuple(target_items) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "可以只缩放当前平面 Face 的面积,并让相邻平面按新的顶点位置重建。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "current_area": current_area, + "target_area": target_area, + "area_delta": area_delta, + "area_delta_ratio": area_delta_ratio, + "area_center": center, + "local_face_area_scale": scale, + "bbox_diagonal": diagonal, + "local_face_deform_target_kind": target_kind, + "local_face_deform_face_count": face_count, + "local_face_deform_source_point_targets": point_targets, + "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_semantics": ( + "围绕当前 Face 中心在该平面内缩放这个 Face 的顶点,周边相邻面按新顶点重建;" + "这不是缩放所属对象。" + ), + } + + def face_size_local_resize_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + 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 "面宽" + 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)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None + source_shape = solid or (part.shape if part is not None else None) + diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 + tolerance = max(diagonal * 1e-7, 1e-6) + blockers: list[str] = [] + warnings: list[str] = [ + f"{axis_label}(当前面)会只沿选中 Face 自身平面内的一个方向缩放当前 Face 顶点,并重建相邻平面。" + ] + risk = "low" + status = "ready" + + try: + target_size = float(target_size) + except (TypeError, ValueError): + target_size = 0.0 + blockers.append(f"目标{axis_label}必须是数字。") + if info.get("surface") != "plane": + blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(当前面)”。") + if center is None: + blockers.append("当前 Face 缺少稳定中心坐标。") + if target_size <= 1e-9: + blockers.append(f"目标{axis_label}必须大于 0。") + if part is None: + blockers.append("找不到当前 Face 所属特征。") + if solid is None: + blockers.append("找不到当前 Face 所属 Solid。") + + size_info = self._local_face_plane_size_info( + face_id, + center=center, + tolerance=tolerance, + ) + current_width = _float_or_none(size_info.get("local_face_width")) + current_height = _float_or_none(size_info.get("local_face_height")) + width_dir = _tuple_or_none(size_info.get("local_face_width_direction")) + height_dir = _tuple_or_none(size_info.get("local_face_height_direction")) + current_size = current_height if axis_key == "height" else current_width + axis_dir = height_dir if axis_key == "height" else width_dir + if current_size is None or current_size <= 1e-9 or axis_dir is None: + blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。") + + scale = target_size / max(float(current_size or 1.0), 1e-9) + delta_size = target_size - float(current_size or 0.0) + delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9) + if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6): + blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。") + if delta_ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append(f"目标{axis_label}变化超过当前值的 100%,相邻面形变风险很高。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append(f"目标{axis_label}变化超过当前值的 35%,请确认相邻面变化是否符合预期。") + if scale < 0.2: + risk = _max_risk(risk, "high") + warnings.append("当前 Face 会沿一个方向被缩得很窄,可能生成薄小面或退化边。") + elif scale > 2.5: + risk = _max_risk(risk, "high") + warnings.append("当前 Face 会沿一个方向被拉得很长,可能穿过相邻几何。") + + point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () + face_count = 0 + part_solid_count = 0 + if part is not None: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + if not blockers and solid is not None and center is not None and axis_dir is not None: + solid_faces = _explore(solid, TopAbs_FACE) + face_count = len(solid_faces) + if not solid_faces: + blockers.append(f"局部 Face {axis_label}修改不可用:所属 Solid 没有可重建 Face。") + if face_count > 128: + blockers.append(f"局部 Face {axis_label}修改暂只对较简单的平面实体开放。") + for face in solid_faces: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + blockers.append(f"局部 Face {axis_label}修改暂只支持全平面实体;含曲面的模型请使用所属对象缩放。") + break + if len(_explore(face, TopAbs_WIRE)) != 1: + blockers.append(f"局部 Face {axis_label}修改暂不处理带内孔的 Face。") + break + if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: + blockers.append(f"局部 Face {axis_label}修改不可用:部分 Face 顶点环无法稳定读取。") + break + + if not blockers: + points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) + if len(points) < 3: + blockers.append(f"局部 Face {axis_label}修改不可用:当前 Face 顶点环无法稳定读取。") + else: + target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for point in points: + relative = _tuple_sub(point, center) + along = _tuple_dot(relative, axis_dir) + axial = _tuple_scale(axis_dir, along) + rest = _tuple_sub(relative, axial) + scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale)) + target_point = _tuple_add(center, scaled) + target_items.append((point, target_point)) + point_targets = tuple(target_items) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = f"可以只修改当前平面 Face 的{axis_label},并让相邻平面按新的顶点位置重建。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "face_size_axis": axis_key, + "face_size_label": axis_label, + "current_face_width": current_width, + "target_face_width": target_size if axis_key == "width" else current_width, + "current_face_height": current_height, + "target_face_height": target_size if axis_key == "height" else current_height, + "current_face_size": current_size, + "target_face_size": target_size, + "face_size_delta": delta_size, + "face_size_delta_ratio": delta_ratio, + "face_size_scale": scale, + "face_size_center": center, + "face_size_axis_direction": axis_dir, + "face_width_direction": width_dir, + "face_height_direction": height_dir, + "bbox_diagonal": diagonal, + "local_face_deform_target_kind": target_kind, + "local_face_deform_face_count": face_count, + "local_face_deform_source_point_targets": point_targets, + "local_face_deform_moved_point_count": len(point_targets), + "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_semantics": ( + f"围绕当前 Face 中心,只沿 Face 平面内的{axis_label}方向缩放该 Face 顶点;" + "另一方向尺寸保持不主动缩放,周边相邻面按新顶点重建;这不是缩放所属对象。" + ), + } + + def face_size_owning_scale_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + 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 "面宽" + 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)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None + source_shape = solid or (part.shape if part is not None else None) + diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 + tolerance = max(diagonal * 1e-7, 1e-6) + blockers: list[str] = [] + warnings: list[str] = [ + f"{axis_label}(整体)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。" + ] + risk = "medium" + + try: + target_size = float(target_size) + except (TypeError, ValueError): + target_size = 0.0 + blockers.append(f"目标{axis_label}必须是数字。") + if info.get("surface") != "plane": + blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(整体)”。") + if center is None: + blockers.append("当前 Face 缺少稳定中心坐标,不能确定缩放基准。") + if target_size <= 1e-9: + blockers.append(f"目标{axis_label}必须大于 0。") + if part is None: + blockers.append("找不到当前 Face 所属特征。") + if source_shape is None: + blockers.append("找不到当前 Face 可缩放的所属对象。") + + size_info = self._local_face_plane_size_info( + face_id, + center=center, + tolerance=tolerance, + ) + current_width = _float_or_none(size_info.get("local_face_width")) + current_height = _float_or_none(size_info.get("local_face_height")) + width_dir = _tuple_or_none(size_info.get("local_face_width_direction")) + height_dir = _tuple_or_none(size_info.get("local_face_height_direction")) + current_size = current_height if axis_key == "height" else current_width + axis_dir = height_dir if axis_key == "height" else width_dir + if current_size is None or current_size <= 1e-9 or axis_dir is None: + blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。") + + scale = target_size / max(float(current_size or 1.0), 1e-9) + delta_size = target_size - float(current_size or 0.0) + delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9) + if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6): + blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。") + if delta_ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append(f"目标{axis_label}变化超过当前值的 100%,所属对象会发生很大的单向缩放。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "high") + warnings.append(f"目标{axis_label}变化超过当前值的 35%,请重点检查同一对象上的孔、槽、凸台和厚度。") + elif delta_ratio > 0.15: + risk = _max_risk(risk, "medium") + warnings.append(f"目标{axis_label}变化超过当前值的 15%,其它特征会跟随缩放。") + if scale < 0.2: + risk = _max_risk(risk, "high") + warnings.append("所属对象会沿一个方向被缩得很窄,可能生成退化边或薄小面。") + elif scale > 2.5: + risk = _max_risk(risk, "high") + warnings.append("所属对象会沿一个方向被拉得很长,可能明显扭曲其它特征间距。") + + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + 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 顶点。") + + owning_rebuild_mode = "affine-transform" + point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () + face_count = 0 + if not blockers and solid is not None and center is not None and axis_dir is not None: + solid_faces = _explore(solid, TopAbs_FACE) + face_count = len(solid_faces) + can_rebuild_planar = bool(solid_faces) and face_count <= 128 + if not solid_faces: + warnings.append("所属对象没有可重建 Face,将使用通用仿射缩放。") + elif face_count > 128: + warnings.append("所属对象 Face 数较多,将使用通用仿射缩放。") + if can_rebuild_planar: + for face in solid_faces: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + can_rebuild_planar = False + warnings.append("所属对象含曲面,将使用通用仿射缩放;部分解析几何可能变为 B-spline。") + break + if len(_explore(face, TopAbs_WIRE)) != 1: + can_rebuild_planar = False + warnings.append("所属对象包含带内孔的 Face,将使用通用仿射缩放。") + break + if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: + can_rebuild_planar = False + warnings.append("所属对象部分 Face 顶点环无法稳定读取,将使用通用仿射缩放。") + break + if can_rebuild_planar: + target_by_key: dict[tuple[int, int, int], tuple[tuple[float, float, float], tuple[float, float, float]]] = {} + for face in solid_faces: + for point in self._local_deform_face_vertex_points(face, tolerance): + key = self._local_point_key(point, tolerance) + if key in target_by_key: + continue + relative = _tuple_sub(point, center) + along = _tuple_dot(relative, axis_dir) + axial = _tuple_scale(axis_dir, along) + rest = _tuple_sub(relative, axial) + scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale)) + target_by_key[key] = (point, _tuple_add(center, scaled)) + point_targets = tuple(target_by_key.values()) + if point_targets: + owning_rebuild_mode = "planar-rebuild" + warnings.append("当前所属对象是简单全平面实体,会优先重建平面 Face,减少仿射后变成样条面的风险。") + + status = "blocked" if blockers else ("caution" if risk != "low" else "ready") + if blockers: + risk = "blocked" + message = " ".join(blockers) + elif warnings: + message = " ".join(warnings) + else: + message = f"可以沿当前 Face 的{axis_label}方向缩放所属{target_label}。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "face_size_axis": axis_key, + "face_size_label": axis_label, + "current_face_width": current_width, + "target_face_width": target_size if axis_key == "width" else current_width, + "current_face_height": current_height, + "target_face_height": target_size if axis_key == "height" else current_height, + "current_face_size": current_size, + "target_face_size": target_size, + "face_size_delta": delta_size, + "face_size_delta_ratio": delta_ratio, + "face_size_scale": scale, + "face_size_center": center, + "face_size_axis_direction": axis_dir, + "face_width_direction": width_dir, + "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_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_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它特征间距。", + "affine_axis_point": center, + "affine_axis_direction": axis_dir, + "affine_axis_source": f"selected face {axis_key} direction", + "affine_anchor_source": "selected face center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + "owning_face_size_rebuild_mode": owning_rebuild_mode, + "local_face_deform_target_kind": target_kind, + "local_face_deform_face_count": face_count, + "local_face_deform_source_point_targets": point_targets, + "local_face_deform_moved_point_count": len(point_targets), + "local_face_deform_distance_hint": abs(delta_size), + } + def cylindrical_boss_height_plan( self, face_id: int, @@ -1912,6 +2972,10 @@ class OperationMixin: if require_boss else "push-pull-cylindrical-end-cap-to-target-height" ), + "edit_strategy_label": "端盖推拉调整高度" if require_boss else "圆柱端盖推拉调整高度", + "edit_semantics": ( + f"通过推拉识别到的{edit_label}端盖 Face 改变高度;这是局部端面移动,不是整体缩放。" + ), **selected_cap, } @@ -1977,6 +3041,146 @@ class OperationMixin: f"risk={plan['risk']}. {push_result}" ) + def shell_thickness_owning_scale_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.feature_info(face_id) + blockers: list[str] = [] + warnings: list[str] = [ + "该方式会沿薄壁/壳体厚度方向缩放所属特征或 Solid;它不是推拉当前平面,也不是局部壳命令参数。" + ] + risk = "medium" + + try: + target_thickness = float(target_thickness) + except (TypeError, ValueError): + target_thickness = 0.0 + 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 不是平面,不能按薄壁厚度整体缩放。") + if info.get("shell_region_status") != "candidate": + blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。"))) + if current_thickness is None or current_thickness <= 1e-9: + blockers.append("当前薄壁厚度估算无效。") + if signed_thickness is None or abs(signed_thickness) <= 1e-9: + blockers.append("当前薄壁厚度方向无效。") + if normal is None: + blockers.append("当前平面缺少稳定法向,不能按厚度方向整体缩放。") + if target_thickness <= 1e-9: + 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("目标薄壁厚度与当前值几乎相同,不需要修改。") + if delta_ratio > 0.75: + risk = _max_risk(risk, "high") + warnings.append("薄壁厚度变化超过 75%,会明显影响所属对象上的其它厚度方向尺寸。") + elif delta_ratio > 0.3: + 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("薄壁/壳体相对面识别置信度较低。") + elif confidence == "medium": + risk = _max_risk(risk, "medium") + warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。") + if overlap_ratio is not None: + if overlap_ratio < 0.25: + risk = _max_risk(risk, "high") + warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。") + elif overlap_ratio < 0.55: + risk = _max_risk(risk, "medium") + warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") + + scale_center = None + if plane_origin is not None and normal is not None and signed_thickness is not None: + scale_center = ( + plane_origin[0] + normal[0] * signed_thickness * 0.5, + plane_origin[1] + normal[1] * signed_thickness * 0.5, + plane_origin[2] + normal[2] * signed_thickness * 0.5, + ) + 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("当前薄壁区域缺少稳定缩放中心,不能整体缩放所属对象。") + + 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("找不到当前薄壁区域所属特征。") + 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};同一对象上的其它尺寸会跟随变化。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "shell_region_kind": info.get("shell_region_kind"), + "shell_confidence": confidence, + "shell_source_face_ids": tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]), + "shell_opposite_face_id": info.get("shell_opposite_face_id"), + "shell_current_thickness": current_thickness, + "shell_target_thickness": target_thickness, + "shell_delta_thickness": delta_thickness, + "shell_delta_ratio": delta_ratio, + "shell_signed_thickness": signed_thickness, + "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_semantics": ( + "按目标薄壁厚度和当前厚度的比例,沿厚度方向缩放所属特征或 Solid;" + "这会改变同一对象上的其它尺寸,不是局部推拉当前平面。" + ), + "affine_scale": scale, + "affine_transform_kind": "axis-affine", + "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), + "affine_axis_source": "shell thickness normal", + "affine_anchor_source": "midpoint between source and opposite plane", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_shell_thickness_owning_scale(self, face_id: int, target_thickness: float) -> str: + plan = self.shell_thickness_owning_scale_plan(face_id, target_thickness) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + return ( + "Shell thickness resize completed by axis owning-shape scaling: " + f"face {face_id}, " + f"thickness={float(plan['shell_current_thickness']):g}->{float(plan['shell_target_thickness']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + def resize_cylindrical_height(self, face_id: int, target_height: float) -> str: plan = self.cylindrical_height_plan(face_id, target_height) if plan["status"] == "blocked": @@ -1995,6 +3199,462 @@ class OperationMixin: f"risk={plan['risk']}. {push_result}" ) + def cylindrical_height_owning_scale_plan(self, face_id: int, target_height: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + blockers: list[str] = [] + warnings: list[str] = [ + "该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是推拉某个端盖,也不是只修改单个圆柱面。" + ] + risk = "medium" + + try: + target_height = float(target_height) + except (TypeError, ValueError): + target_height = 0.0 + blockers.append("目标圆柱高度必须是数字。") + + axis_point = _tuple_or_none(info.get("axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + current_height = _float_or_none(info.get("same_domain_height_estimate")) + if current_height is None: + current_height = _float_or_none(info.get("height_estimate")) + axis_range_value = info.get("same_domain_v_range") or info.get("v_range") + if ( + current_height is None + and info.get("surface") == "cylinder" + and 0 <= face_id < len(self.faces) + ): + try: + axis_range = self._cylindrical_axis_range( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + _int_values(feature.get("feature_side_face_ids")), + ) + current_height = _float_or_none(axis_range.get("span")) + axis_range_value = (axis_range.get("v_min"), axis_range.get("v_max")) + except Exception: + pass + + 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: + risk = _max_risk(risk, "high") + warnings.append("当前圆柱面不是完整圆柱;轴向整体缩放会影响所属对象,但不等于稳定的局部槽/半孔高度编辑。") + if current_height is None or current_height <= 1e-9: + blockers.append("当前圆柱缺少稳定高度估算,不能按高度整体缩放。") + if axis_point is None or axis_direction is None: + blockers.append("当前圆柱缺少稳定轴线,不能沿轴向整体缩放。") + if target_height <= 1e-9: + blockers.append("目标圆柱高度必须大于 0。") + + scale_center = None + if ( + axis_point is not None + and axis_direction is not None + and isinstance(axis_range_value, (list, tuple)) + and len(axis_range_value) >= 2 + ): + v_min = _float_or_none(axis_range_value[0]) + v_max = _float_or_none(axis_range_value[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + scale_center = ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + 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("当前圆柱缺少稳定缩放中心,不能整体缩放所属对象。") + + current_height_value = float(current_height or 0.0) + scale = target_height / max(current_height_value, 1e-9) + delta_height = target_height - current_height_value + delta_ratio = abs(delta_height) / max(current_height_value, 1e-9) + if current_height is not None and abs(delta_height) <= max(current_height * 1e-6, 1e-6): + blockers.append("目标圆柱高度与当前值几乎相同,不需要修改。") + if delta_ratio > 0.6: + risk = _max_risk(risk, "high") + warnings.append("圆柱高度变化超过 60%,会明显影响同一对象上的其它几何位置。") + elif delta_ratio > 0.25: + warnings.append("圆柱高度变化超过 25%,修改后请重点检查相邻特征。") + + 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("找不到当前圆柱面所属特征。") + 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};同一对象上的孔距、台阶位置和其它轴向尺寸会跟随变化。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "current_height": current_height, + "target_height": target_height, + "delta_height": delta_height, + "height_delta_ratio": delta_ratio, + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "axis": axis_direction, + "scale_center": scale_center, + "resize_strategy": "axis-scale-owning-shape-from-cylinder-height", + "edit_strategy_label": "高度(整体)", + "edit_semantics": ( + "按目标圆柱高度和当前高度的比例,沿圆柱轴向缩放所属特征或 Solid;" + "这会改变同一对象上的其它轴向尺寸,不是端盖推拉。" + ), + "affine_scale": scale, + "affine_transform_kind": "axis-affine", + "affine_transform_label": f"按圆柱高度轴向缩放所属{target_label}", + "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。", + "affine_axis_point": scale_center, + "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), + "affine_axis_source": "cylinder axis", + "affine_anchor_source": "cylinder axis center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_cylindrical_height_owning_scale(self, face_id: int, target_height: float) -> str: + plan = self.cylindrical_height_owning_scale_plan(face_id, target_height) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + return ( + "Cylindrical height resize completed by axis owning-shape scaling: " + f"face {face_id}, " + f"height={float(plan['current_height']):g}->{float(plan['target_height']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + + def cylindrical_depth_owning_scale_plan( + self, + face_id: int, + target_depth: float, + bottom_face_id: int | None = None, + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + blockers: list[str] = [] + warnings: list[str] = [ + "该方式会沿盲孔/盲槽轴向缩放所属特征或 Solid;它不是加深切削,也不是变浅补料。" + ] + risk = "medium" + + try: + target_depth = float(target_depth) + except (TypeError, ValueError): + target_depth = 0.0 + blockers.append("目标盲孔/盲槽深度必须是数字。") + + if info.get("surface") != "cylinder": + blockers.append("当前选中 Face 不是圆柱面。") + if str(info.get("feature_guess", "")) != "hole/groove candidate": + blockers.append("深度(整体)当前只对孔/槽候选开放。") + if target_depth <= 1e-9: + blockers.append("目标盲孔/盲槽深度必须大于 0。") + + context: dict[str, object] = {} + if info.get("surface") == "cylinder": + try: + context = self._blind_cylindrical_depth_context( + face_id, + info, + feature, + max(target_depth, 1e-6), + bottom_face_id=bottom_face_id, + ) + except Exception as exc: + context = {"context_status": "blocked", "context_message": str(exc)} + if context.get("context_status") == "blocked": + blockers.append(str(context.get("context_message") or "当前盲孔/盲槽深度方向不稳定。")) + + current_depth = _float_or_none(context.get("depth_current_depth")) + if current_depth is None: + current_depth = _float_or_none(info.get("hole_depth_estimate")) + if current_depth is None: + current_depth = _float_or_none(info.get("same_domain_height_estimate")) + if current_depth is None or current_depth <= 1e-9: + blockers.append("当前对象缺少稳定深度估算,不能按深度整体缩放。") + + axis_direction = _tuple_normalized(_tuple_or_none(context.get("depth_axis_direction"))) + if axis_direction is None: + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + if axis_direction is None: + blockers.append("当前对象缺少稳定轴线方向,不能按深度整体缩放。") + + scale_center = None + open_point = _tuple_or_none(context.get("depth_open_point")) + bottom_point = _tuple_or_none(context.get("depth_current_bottom_point")) + if open_point is not None and bottom_point is not None: + scale_center = ( + (open_point[0] + bottom_point[0]) * 0.5, + (open_point[1] + bottom_point[1]) * 0.5, + (open_point[2] + bottom_point[2]) * 0.5, + ) + 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("当前对象缺少稳定缩放中心,不能整体缩放所属对象。") + + current_depth_value = float(current_depth or 0.0) + scale = target_depth / max(current_depth_value, 1e-9) + delta_depth = target_depth - current_depth_value + delta_ratio = abs(delta_depth) / max(current_depth_value, 1e-9) + if current_depth is not None and abs(delta_depth) <= max(current_depth * 1e-6, 1e-6): + blockers.append("目标盲孔/盲槽深度与当前值几乎相同,不需要修改。") + if delta_ratio > 0.75: + risk = _max_risk(risk, "high") + warnings.append("盲孔/盲槽深度变化超过 75%,会明显影响所属对象上的其它轴向尺寸。") + 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: + risk = _max_risk(risk, "high") + warnings.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("找不到当前对象所属特征。") + 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};孔距、壁厚和其它同向尺寸会跟随变化。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "current_depth": current_depth, + "target_depth": target_depth, + "delta_depth": delta_depth, + "depth_delta_ratio": delta_ratio, + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "angular_span": info.get("angular_span"), + "cylinder_end_type": info.get("cylinder_end_type"), + "feature_bottom_face_ids": context.get("feature_bottom_face_ids", feature.get("feature_bottom_face_ids")), + "manual_bottom_face_id": context.get("manual_bottom_face_id", "" if bottom_face_id is None else bottom_face_id), + "manual_bottom_face_used": bool(context.get("manual_bottom_face_used", bottom_face_id is not None)), + "feature_opening_face_ids": feature.get("feature_opening_face_ids"), + "depth_open_point": context.get("depth_open_point"), + "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_semantics": ( + "按目标盲孔/盲槽深度和当前深度的比例,沿孔/槽轴向缩放所属特征或 Solid;" + "这会改变同一对象上的其它轴向尺寸,不是局部切削或补料。" + ), + "affine_scale": scale, + "affine_transform_kind": "axis-affine", + "affine_transform_label": f"按盲孔/盲槽深度轴向缩放所属{target_label}", + "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。", + "affine_axis_point": scale_center, + "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), + "affine_axis_source": "blind depth direction", + "affine_anchor_source": "blind depth midpoint", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_cylindrical_depth_owning_scale( + self, + face_id: int, + target_depth: float, + bottom_face_id: int | None = None, + ) -> str: + plan = self.cylindrical_depth_owning_scale_plan( + face_id, + target_depth, + bottom_face_id=bottom_face_id, + ) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + 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']}." + ) + + def cylindrical_owning_scale_plan(self, face_id: int, target_diameter: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + blockers: list[str] = [] + warnings: list[str] = [ + "该方式会按目标直径比例均匀缩放所属特征或 Solid;它不是重切孔壁,也不是只修改单个圆柱面。" + ] + risk = "medium" + + try: + target_diameter = float(target_diameter) + except (TypeError, ValueError): + target_diameter = 0.0 + blockers.append("目标圆柱直径必须是数字。") + + current_diameter = _float_or_none(info.get("diameter")) + 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"))) + if info.get("surface") != "cylinder": + blockers.append("当前选中 Face 不是圆柱面。") + if current_diameter is None or current_diameter <= 1e-9: + blockers.append("当前圆柱面缺少有效直径。") + axis_range_value = info.get("same_domain_v_range") or info.get("v_range") + scale_center = None + if ( + axis_point is not None + and axis_direction is not None + and isinstance(axis_range_value, (list, tuple)) + and len(axis_range_value) >= 2 + ): + v_min = _float_or_none(axis_range_value[0]) + v_max = _float_or_none(axis_range_value[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + scale_center = ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + 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("当前圆柱面缺少稳定缩放中心,不能缩放所属对象。") + if target_diameter <= 1e-9: + blockers.append("目标圆柱直径必须大于 0。") + + scale = target_diameter / max(current_diameter or 1.0, 1e-9) + delta_diameter = target_diameter - float(current_diameter or 0.0) + delta_ratio = abs(delta_diameter) / max(float(current_diameter or 0.0), 1e-9) + if current_diameter is not None and abs(delta_diameter) <= max(current_diameter * 1e-6, 1e-6): + blockers.append("目标圆柱直径与当前值几乎相同,不需要修改。") + if delta_ratio > 0.5: + risk = _max_risk(risk, "high") + warnings.append("圆柱直径变化超过 50%,会明显影响同一对象上的高度、厚度和其它尺寸。") + elif delta_ratio > 0.2: + warnings.append("圆柱直径变化超过 20%,修改后请重点检查相邻特征。") + + 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("找不到当前圆柱面所属特征。") + 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},同一对象上的高度、厚度和其它尺寸会同比例变化。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "feature_type": info.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "current_diameter": current_diameter, + "target_diameter": target_diameter, + "current_radius": current_radius, + "target_radius": target_diameter * 0.5, + "delta_diameter": delta_diameter, + "diameter_delta_ratio": delta_ratio, + "scale_center": scale_center, + "axis_point": axis_point, + "axis": axis_direction, + "resize_strategy": "uniform-scale-owning-shape-from-cylinder-diameter", + "edit_strategy_label": "按圆柱直径缩放所属对象", + "edit_semantics": ( + "按目标圆柱直径和当前直径的比例,围绕当前圆柱面的轴向中心均匀缩放所属特征或 Solid;" + "高度、厚度和同一对象上的其它尺寸会同比例变化。" + ), + "affine_scale": scale, + "affine_transform_kind": "uniform", + "affine_transform_label": "按圆柱直径均匀缩放所属对象", + "affine_axis_point": scale_center, + "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), + "affine_axis_source": "cylinder axis center", + "affine_anchor_source": "cylinder axis center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + 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) + 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']}." + ) + def conical_reference_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -2072,6 +3732,8 @@ class OperationMixin: "axis_point": axis_point, "axis": axis_direction, "resize_strategy": "radial-affine-scale-cone-reference-radius", + "edit_strategy_label": "围绕圆锥轴径向缩放", + "edit_semantics": "按目标参考半径围绕圆锥轴径向缩放所属对象;会影响同一对象上的其它径向尺寸。", "affine_scale": scale, "affine_transform_kind": "radial-affine", "affine_transform_label": "围绕圆锥轴径向缩放", @@ -2168,6 +3830,8 @@ class OperationMixin: "radius_delta_ratio": delta_ratio, "center": center, "resize_strategy": "uniform-scale-sphere-radius", + "edit_strategy_label": "围绕球心均匀缩放", + "edit_semantics": "按目标球面半径围绕球心均匀缩放所属对象;不是只替换单个球面历史参数。", "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "围绕球心均匀缩放", @@ -2273,6 +3937,8 @@ class OperationMixin: "center": center, "axis": axis, "resize_strategy": "uniform-scale-torus-radius", + "edit_strategy_label": "围绕环面中心均匀缩放", + "edit_semantics": "按目标环面半径围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "围绕环面中心均匀缩放", @@ -2306,7 +3972,7 @@ class OperationMixin: info = self.face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ - "Face面积修改是复杂曲面的兜底几何缩放;会围绕Face面积中心均匀缩放所属零件/Solid。" + "目标面面积不是 STEP 原始 CAD 历史参数;当前采用围绕当前面的面积中心均匀缩放所属特征或 Solid 的语义。" ] risk = "high" @@ -2314,20 +3980,24 @@ class OperationMixin: target_area = float(target_area) except (TypeError, ValueError): target_area = 0.0 - blockers.append("目标Face面积必须是数字。") + blockers.append("目标面面积必须是数字。") current_area = _float_or_none(info.get("area")) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if current_area is None or current_area <= 1e-9: - blockers.append("当前Face缺少有效面积。") + blockers.append("当前面缺少有效面积。") if center is None: - blockers.append("当前Face缺少稳定面积中心,不能缩放。") + blockers.append("当前面缺少稳定面积中心,不能缩放。") if target_area <= 1e-9: - blockers.append("目标Face面积必须大于 0。") + blockers.append("目标面面积必须大于 0。") if current_area is not None and abs(target_area - current_area) <= max(current_area * 1e-6, 1e-6): - blockers.append("目标Face面积与当前值几乎相同,不需要修改。") + blockers.append("目标面面积与当前值几乎相同,不需要修改。") - scale = math.sqrt(target_area / max(current_area or 1.0, 1e-9)) + scale = ( + math.sqrt(target_area / max(float(current_area), 1e-9)) + if target_area > 1e-9 and current_area is not None and current_area > 1e-9 + else 1.0 + ) area_delta = target_area - float(current_area or 0.0) area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9) if area_delta_ratio <= 0.15: @@ -2341,12 +4011,13 @@ class OperationMixin: 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("找不到当前Face所属零件。") + 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" - warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") + target_label = "Solid" if target_kind == "solid" else "特征" + warnings.append(f"当前会缩放所属{target_label},该对象上的其它尺寸会一起变化。") status = "blocked" if blockers else "caution" if blockers: @@ -2367,9 +4038,12 @@ class OperationMixin: "area_delta_ratio": area_delta_ratio, "area_center": center, "resize_strategy": "uniform-scale-face-area-fallback", + "edit_strategy_label": "按目标面面积缩放所属对象", + "edit_semantics": "围绕当前面的面积中心均匀缩放所属特征或 Solid;目标 Face 和同一对象上的其它尺寸会一起变化。", "affine_scale": scale, "affine_transform_kind": "uniform", - "affine_transform_label": "围绕Face面积中心均匀缩放", + "affine_transform_label": "按目标面面积缩放所属对象", + "affine_transform_note": "围绕当前面面积中心做均匀缩放;目标 Face 和同一所属对象上的其它尺寸会一起变化。", "affine_axis_point": center, "affine_axis_direction": (0.0, 0.0, 1.0), "affine_axis_source": "face area center", @@ -2594,8 +4268,14 @@ class OperationMixin: target_length: float, deflection: float = 0.8, anchor_mode: str = "auto", + strategy_mode: str = "auto", ): - plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + plan = self.general_edge_length_plan( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") == "local-edge-only-deform": @@ -2614,11 +4294,33 @@ class OperationMixin: BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) - def resize_straight_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: - return self.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + def resize_straight_edge_length( + self, + edge_id: int, + target_length: float, + anchor_mode: str = "auto", + strategy_mode: str = "auto", + ) -> str: + return self.resize_general_edge_length( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) - def resize_general_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: - plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + def resize_general_edge_length( + self, + edge_id: int, + target_length: float, + anchor_mode: str = "auto", + strategy_mode: str = "auto", + ) -> str: + plan = self.general_edge_length_plan( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") == "local-edge-only-deform": @@ -2737,6 +4439,146 @@ class OperationMixin: f"risk={plan['risk']}. {result_check}" ) + def face_center_local_move_preview_polydata( + self, + face_id: int, + target_center: tuple[float, float, float], + deflection: float = 0.8, + ): + plan = self.face_center_local_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + preview_shape = self._local_face_deform_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) + + def move_face_center_local( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> str: + plan = self.face_center_local_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + self._apply_local_face_deform(plan) + return ( + "Face center move completed by local face-only deformation: " + f"face {face_id}, " + f"current_center={plan.get('current_face_center')}, " + f"target_center={plan.get('target_face_center')}, " + f"move={plan.get('face_center_move_vector')}, " + f"moved_points={plan.get('local_face_deform_moved_point_count')}, " + f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " + f"target={plan.get('local_face_deform_target_kind')}, " + f"risk={plan['risk']}." + ) + + def face_area_local_resize_preview_polydata( + self, + face_id: int, + target_area: float, + deflection: float = 0.8, + ): + plan = self.face_area_local_resize_plan(face_id, target_area) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + preview_shape = self._local_face_deform_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) + + def resize_face_area_local(self, face_id: int, target_area: float) -> str: + plan = self.face_area_local_resize_plan(face_id, target_area) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + self._apply_local_face_deform(plan) + return ( + "Face area resize completed by local face-only deformation: " + f"face {face_id}, " + f"current_area={float(plan['current_area']):g}, " + f"target_area={float(plan['target_area']):g}, " + f"delta={float(plan['area_delta']):g}, " + f"area_scale={float(plan['local_face_area_scale']):g}, " + f"moved_points={plan.get('local_face_deform_moved_point_count')}, " + f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " + f"target={plan.get('local_face_deform_target_kind')}, " + f"risk={plan['risk']}." + ) + + def face_size_local_resize_preview_polydata( + self, + face_id: int, + target_size: float, + axis: str = "width", + deflection: float = 0.8, + ): + plan = self.face_size_local_resize_plan(face_id, target_size, axis) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + preview_shape = self._local_face_deform_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) + + def resize_face_size_local(self, face_id: int, target_size: float, axis: str = "width") -> str: + plan = self.face_size_local_resize_plan(face_id, target_size, axis) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + self._apply_local_face_deform(plan) + return ( + "Face local size resize completed by local face-only deformation: " + f"face {face_id}, " + f"axis={plan.get('face_size_axis')}, " + f"current_size={float(plan['current_face_size']):g}, " + f"target_size={float(plan['target_face_size']):g}, " + f"delta={float(plan['face_size_delta']):g}, " + f"scale={float(plan['face_size_scale']):g}, " + f"moved_points={plan.get('local_face_deform_moved_point_count')}, " + f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " + f"target={plan.get('local_face_deform_target_kind')}, " + f"risk={plan['risk']}." + ) + + def face_size_owning_scale_preview_polydata( + self, + face_id: int, + target_size: float, + axis: str = "width", + deflection: float = 0.8, + ): + plan = self.face_size_owning_scale_plan(face_id, target_size, axis) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild": + preview_shape = self._local_face_deform_shape(plan) + else: + preview_shape = self._edge_length_affine_preview_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) + + def resize_face_size_owning_scale(self, face_id: int, target_size: float, axis: str = "width") -> str: + plan = self.face_size_owning_scale_plan(face_id, target_size, axis) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild": + self._apply_local_face_deform(plan) + else: + self._apply_edge_length_affine_transform(plan) + return ( + "Face owning size resize completed by axis-affine scaling: " + f"face {face_id}, " + f"axis={plan.get('face_size_axis')}, " + f"current_size={float(plan['current_face_size']):g}, " + f"target_size={float(plan['target_face_size']):g}, " + f"delta={float(plan['face_size_delta']):g}, " + f"scale={float(plan['face_size_scale']):g}, " + f"rebuild_mode={plan.get('owning_face_size_rebuild_mode')}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + def _edge_length_result_summary(self, plan: dict[str, object]) -> str: check = self._edge_length_result_check(plan) if check is None: @@ -3008,6 +4850,109 @@ class OperationMixin: _ensure_valid_shape(part.shape) self.refresh_topology() + def _local_face_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape: + _target_kind, solid, _part, _source_solid = self._local_face_deform_target(plan) + move_distance = _float_or_none(plan.get("local_face_deform_distance_hint")) + if move_distance is None: + move_distance = _float_or_none(plan.get("face_center_move_distance")) + if move_distance is None: + move_distance = 0.0 + tolerance = max(_shape_diagonal(solid) * 1e-7, abs(move_distance) * 1e-7, 1e-6) + faces = _explore(solid, TopAbs_FACE) + moved_faces: list[TopoDS_Shape] = [] + moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {} + moved_count = 0 + for face in faces: + points = self._local_deform_face_vertex_points(face, tolerance) + if len(points) < 3: + raise RuntimeError("Local face deformation could not read a stable face vertex loop.") + for point in points: + moved = self._local_face_deform_moved_point(point, plan, tolerance) + if _vector_length(_tuple_sub(moved, point)) > tolerance: + moved_count += 1 + moved_points[self._local_point_key(moved, tolerance)] = moved + + if not moved_points or moved_count == 0: + raise RuntimeError("Local face deformation produced no moved vertices.") + center = ( + sum(point[0] for point in moved_points.values()) / len(moved_points), + sum(point[1] for point in moved_points.values()) / len(moved_points), + sum(point[2] for point in moved_points.values()) / len(moved_points), + ) + + for face in faces: + points = [ + self._local_face_deform_moved_point(point, plan, tolerance) + for point in self._local_deform_face_vertex_points(face, tolerance) + ] + points = self._dedupe_local_points(points, tolerance) + if len(points) < 3: + raise RuntimeError("Local face deformation collapsed a face.") + points = self._orient_local_polygon_outward(points, center) + if self._local_points_are_planar(points, tolerance): + moved_faces.append(self._make_local_polygon_face(points)) + else: + for index in range(1, len(points) - 1): + triangle = [points[0], points[index], points[index + 1]] + triangle = self._orient_local_polygon_outward(triangle, center) + moved_faces.append(self._make_local_polygon_face(triangle)) + + sewing = BRepBuilderAPI_Sewing(tolerance) + for face in moved_faces: + sewing.Add(face) + sewing.Perform() + sewed = sewing.SewedShape() + if sewed.IsNull(): + raise RuntimeError("Local face deformation sewing produced an empty shape.") + if sewed.ShapeType() == TopAbs_SHELL: + shell = topods.Shell(sewed) + else: + shells = _explore(sewed, TopAbs_SHELL) + if not shells: + raise RuntimeError("Local face deformation did not produce a sewable shell.") + shell = topods.Shell(shells[0]) + solid_builder = BRepBuilderAPI_MakeSolid(shell) + solid_shape = solid_builder.Solid() + if solid_shape.IsNull(): + raise RuntimeError("Local face deformation could not create a solid from the rebuilt shell.") + return _ensure_valid_or_repaired_shape(solid_shape, "local face deformation") + + def _apply_local_face_deform(self, plan: dict[str, object]) -> None: + target_kind, _source_shape, part, source_solid = self._local_face_deform_target(plan) + transformed = self._local_face_deform_shape(plan) + if target_kind == "part": + part.shape = transformed + else: + part_solids = _explore(part.shape, TopAbs_SOLID) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and source_solid is not None and _same_shape(item, source_solid): + shapes.append(transformed) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + self.refresh_topology() + + def _local_face_deform_target( + self, + plan: dict[str, object], + ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: + part_id = int(plan.get("part_id", -1)) + solid_id = int(plan.get("solid_id", -1)) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + target_kind = str(plan.get("local_face_deform_target_kind", "part")) + solid = self.solids[solid_id][1] + return ("solid" if target_kind == "solid" else "part"), solid, part, solid + def _local_edge_deform_target( self, plan: dict[str, object], @@ -3091,6 +5036,28 @@ class OperationMixin: return (point[0] + end_move[0], point[1] + end_move[1], point[2] + end_move[2]) return point + def _local_face_deform_moved_point( + self, + point: tuple[float, float, float], + plan: dict[str, object], + tolerance: float, + ) -> tuple[float, float, float]: + point_targets = plan.get("local_face_deform_source_point_targets") + if isinstance(point_targets, (list, tuple)): + for item in point_targets: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + source = _tuple_or_none(item[0]) + target = _tuple_or_none(item[1]) + if source is not None and target is not None and _vector_length(_tuple_sub(point, source)) <= tolerance: + return target + source_points = tuple(_tuple_or_none(item) for item in plan.get("local_face_deform_source_points", ())) + move = _tuple_or_none(plan.get("face_center_move_vector")) or (0.0, 0.0, 0.0) + for source in source_points: + if source is not None and _vector_length(_tuple_sub(point, source)) <= tolerance: + return (point[0] + move[0], point[1] + move[1], point[2] + move[2]) + return point + def _make_local_polygon_face(self, points: list[tuple[float, float, float]]) -> TopoDS_Shape: polygon = BRepBuilderAPI_MakePolygon() for point in points: @@ -3698,6 +5665,21 @@ class OperationMixin: raise ValueError(str(plan["message"])) return self._resize_cylindrical_slot_length_with_capsule_tool(plan) + def resize_cylindrical_slot_center_distance( + self, + face_id: int, + target_center_distance: float, + pair_face_id: int | None = None, + ) -> str: + plan = self.cylindrical_slot_center_distance_plan( + face_id, + target_center_distance, + pair_face_id=pair_face_id, + ) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self._resize_cylindrical_slot_length_with_capsule_tool(plan) + def _resize_cylindrical_slot_parameter( self, face_id: int, @@ -3746,6 +5728,58 @@ class OperationMixin: f"risk={plan['risk']}. {resize_result}" ) + def _slot_fill_radius(self, plan: dict[str, object]) -> float: + fill_radius = _float_or_none(plan.get("fill_radius")) + nominal_diameter = _float_or_none(plan.get("current_diameter")) + if nominal_diameter is None: + nominal_diameter = _float_or_none(plan.get("slot_target_diameter")) + nominal_radius = nominal_diameter * 0.5 if nominal_diameter is not None else None + if fill_radius is None or fill_radius <= 1e-9: + fill_radius = nominal_radius if nominal_radius is not None else 0.0 + if nominal_radius is not None and nominal_radius > 0: + overlap = min(max(nominal_radius * 0.02, 0.02), 0.2) + fill_radius = max(fill_radius, nominal_radius + overlap) + return float(fill_radius) + + def _drop_tiny_artifact_solids( + self, + result: TopoDS_Shape, + reference_shape: TopoDS_Shape, + ) -> tuple[TopoDS_Shape, dict[str, object]]: + solids = _explore(result, TopAbs_SOLID) + if len(solids) <= 1: + return result, {"discarded_count": 0, "discarded_volume": 0.0} + + def solid_volume(shape: TopoDS_Shape) -> float: + value = _shape_volume_info(shape).get("volume") + try: + return abs(float(value)) + except (TypeError, ValueError): + return 0.0 + + volumes = [solid_volume(solid) for solid in solids] + largest_index = max(range(len(solids)), key=lambda index: volumes[index]) + largest_volume = volumes[largest_index] + if largest_volume <= 1e-9: + return result, {"discarded_count": 0, "discarded_volume": 0.0} + + reference_volume = solid_volume(reference_shape) + volume_limit = max(largest_volume, reference_volume, 1.0) * 1e-4 + tiny_indices = [index for index, volume in enumerate(volumes) if index != largest_index and volume <= volume_limit] + discarded_volume = sum(volumes[index] for index in tiny_indices) + if len(tiny_indices) != len(solids) - 1 or discarded_volume > volume_limit: + return result, {"discarded_count": 0, "discarded_volume": 0.0} + + return ( + solids[largest_index], + { + "discarded_count": len(tiny_indices), + "discarded_volume": discarded_volume, + "largest_volume": largest_volume, + "volume_limit": volume_limit, + }, + ) + def _resize_cylindrical_slot_length_with_capsule_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) part_id = self.face_part_ids[face_id] @@ -3759,9 +5793,9 @@ class OperationMixin: target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1") target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2") try: - filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"])) + filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) fuse = BRepAlgoAPI_Fuse(part.shape, filler) - source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse") + source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse", use_glue=False) cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) / 2.0) op = BRepAlgoAPI_Cut(source_shape, cutter) @@ -3776,8 +5810,9 @@ class OperationMixin: self.refresh_topology() raise + mode_label = "center distance" if plan.get("slot_resize_mode") == "center_distance" else "total length" return ( - "Obround cylindrical slot total length resize completed: " + f"Obround cylindrical slot {mode_label} resize completed: " f"length {float(plan.get('slot_current_total_length', 0.0)):g} -> {float(plan['slot_target_total_length']):g}, " f"center_distance={float(plan['slot_current_center_distance']):g}->{float(plan['slot_target_center_distance']):g}, " f"diameter={float(plan['slot_target_diameter']):g}, " @@ -3798,12 +5833,12 @@ class OperationMixin: try: filler = self._slot_sector_prism_tool( face_id, - float(plan["fill_radius"]), + self._slot_fill_radius(plan), float(plan["fill_start_parameter"]), float(plan["fill_end_parameter"]), ) fuse = BRepAlgoAPI_Fuse(part.shape, filler) - source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse") + source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse", use_glue=False) cutter = self._slot_sector_prism_tool( face_id, @@ -3812,6 +5847,7 @@ class OperationMixin: float(plan["cutter_end_parameter"]), u_first_override=float(plan["slot_target_u_first"]), u_last_override=float(plan["slot_target_u_last"]), + u_padding=0.0, ) op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "slot angular-span cut") @@ -3989,6 +6025,109 @@ class OperationMixin: **best, } + def _verify_obround_slot_axis_move_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_center_1 = _tuple_or_none(plan.get("slot_pair_target_axis_center_1")) + target_center_2 = _tuple_or_none(plan.get("slot_pair_target_axis_center_2")) + target_distance = _float_or_none(plan.get("slot_target_center_distance")) + if target_distance is None: + target_distance = _float_or_none(plan.get("slot_pair_axis_distance")) + target_diameter = _float_or_none(plan.get("slot_target_diameter")) + axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) + length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) + if ( + target_center_1 is None + or target_center_2 is None + or target_distance is None + or target_diameter is None + or axis_dir is None + or length_dir is None + ): + return {"matched": False, "detail": " Missing obround slot axis verification data."} + target_radius = target_diameter * 0.5 + if target_radius <= 1e-9 or target_distance <= 1e-9: + return {"matched": False, "detail": " Invalid target obround slot axis data."} + + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0) + radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) + distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4) + center_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + candidates: list[dict[str, object]] = [] + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + radius = float(cyl.Radius()) + if abs(radius - target_radius) > radius_tolerance: + continue + candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) + if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4: + continue + axis_range = self._cylindrical_axis_range(face_id, surf) + mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)) + candidates.append({"face_id": face_id, "mid": mid, "radius": radius}) + except Exception: + continue + + best: dict[str, object] | None = None + best_score = math.inf + for index, first in enumerate(candidates): + for second in candidates[index + 1 :]: + raw_offset = _tuple_sub(second["mid"], first["mid"]) + axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) + section_offset = _tuple_sub(raw_offset, axis_offset) + distance = _vector_length(section_offset) + if distance <= 1e-9: + continue + direction = _tuple_normalized(section_offset) + if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96: + continue + distance_error = abs(distance - target_distance) + direct_center_error = max( + _vector_length(_tuple_sub(first["mid"], target_center_1)), + _vector_length(_tuple_sub(second["mid"], target_center_2)), + ) + swapped_center_error = max( + _vector_length(_tuple_sub(first["mid"], target_center_2)), + _vector_length(_tuple_sub(second["mid"], target_center_1)), + ) + center_error = min(direct_center_error, swapped_center_error) + score = ( + distance_error / max(distance_tolerance, 1e-9) + + center_error / max(center_tolerance, 1e-9) + ) + candidate = { + "matched": distance_error <= distance_tolerance and center_error <= center_tolerance, + "face_id": first["face_id"], + "paired_face_id": second["face_id"], + "center_distance": distance, + "target_center_distance": target_distance, + "center_distance_error": distance_error, + "center_error": center_error, + "center_tolerance": center_tolerance, + } + if candidate["matched"]: + return candidate + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " No paired obround slot ends were found at the target axis."} + return { + "matched": False, + "detail": ( + f" Closest obround slot center error {float(best['center_error']):.6g}, " + f"distance {float(best['center_distance']):.6g}, target distance {target_distance:.6g}." + ), + **best, + } + def _resize_cylindrical_slot_with_capsule_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) face = self.faces[face_id] @@ -4011,7 +6150,7 @@ class OperationMixin: source_shape = part.shape try: if plan["resize_mode"] == "shrink": - filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"])) + filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "obround slot fill/fuse") @@ -4057,6 +6196,30 @@ class OperationMixin: if abs(_tuple_dot(length_dir, side_dir)) > 1e-4: raise ValueError("Obround slot capsule frame length/side axes are not perpendicular.") + height = max(float(plan.get("cutter_height", 0.0)), 1e-6) + height_vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height) + try: + top_1 = _tuple_add(center_1, _tuple_scale(side_dir, radius)) + top_2 = _tuple_add(center_2, _tuple_scale(side_dir, radius)) + bottom_2 = _tuple_add(center_2, _tuple_scale(side_dir, -radius)) + bottom_1 = _tuple_add(center_1, _tuple_scale(side_dir, -radius)) + end_mid_2 = _tuple_add(center_2, _tuple_scale(length_dir, radius)) + end_mid_1 = _tuple_add(center_1, _tuple_scale(length_dir, -radius)) + + top_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*top_1), gp_Pnt(*top_2)).Edge() + end_arc_2 = GC_MakeArcOfCircle(gp_Pnt(*top_2), gp_Pnt(*end_mid_2), gp_Pnt(*bottom_2)).Value() + end_edge_2 = BRepBuilderAPI_MakeEdge(end_arc_2).Edge() + bottom_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*bottom_2), gp_Pnt(*bottom_1)).Edge() + end_arc_1 = GC_MakeArcOfCircle(gp_Pnt(*bottom_1), gp_Pnt(*end_mid_1), gp_Pnt(*top_1)).Value() + end_edge_1 = BRepBuilderAPI_MakeEdge(end_arc_1).Edge() + wire = BRepBuilderAPI_MakeWire(top_edge, end_edge_2, bottom_edge, end_edge_1).Wire() + tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "obround slot circular profile") + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism") + except Exception: + # Keep the older sampled profile as a fallback for unusual frames. The + # circular profile is preferred because it preserves cylindrical slot ends. + pass + sample_count = max(12, min(96, int(math.pi * max(radius, 1.0) / max(radius * 0.12, 0.02)))) points: list[tuple[float, float, float]] = [ _tuple_add(center_1, _tuple_scale(side_dir, radius)), @@ -4073,9 +6236,7 @@ class OperationMixin: points.append(_tuple_add(center_1, radial)) tool_face = self._make_local_polygon_face(self._dedupe_local_points(points, max(radius * 1e-7, 1e-7))) - height = max(float(plan.get("cutter_height", 0.0)), 1e-6) - vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height) - return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "obround slot capsule prism") + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism") def _resize_cylindrical_slot_with_sector_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) @@ -4101,7 +6262,7 @@ class OperationMixin: if plan["resize_mode"] == "shrink": filler = self._slot_sector_prism_tool( face_id, - float(plan["fill_radius"]), + self._slot_fill_radius(plan), float(plan["fill_start_parameter"]), float(plan["fill_end_parameter"]), ) @@ -4144,6 +6305,8 @@ class OperationMixin: *, u_first_override: float | None = None, u_last_override: float | None = None, + axis_point_offset: tuple[float, float, float] | None = None, + u_padding: float | None = None, ) -> TopoDS_Shape: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) @@ -4156,6 +6319,13 @@ class OperationMixin: axis = cyl.Axis() axis_point = axis.Location() direction = axis.Direction() + tool_axis_point = axis_point + if axis_point_offset is not None: + tool_axis_point = gp_Pnt( + axis_point.X() + float(axis_point_offset[0]), + axis_point.Y() + float(axis_point_offset[1]), + axis_point.Z() + float(axis_point_offset[2]), + ) u_first = float(surf.FirstUParameter()) if u_first_override is None else float(u_first_override) u_last = float(surf.LastUParameter()) if u_last_override is None else float(u_last_override) span = abs(u_last - u_first) @@ -4165,38 +6335,163 @@ class OperationMixin: start_parameter, end_parameter = end_parameter, start_parameter height = max(float(end_parameter) - float(start_parameter), 1e-6) - pad = min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4)) + pad = ( + min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4)) + if u_padding is None + else max(float(u_padding), 0.0) + ) if u_last >= u_first: u_start = u_first - pad u_end = u_last + pad else: u_start = u_first + pad u_end = u_last - pad - sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2)) v_mid = (float(surf.FirstVParameter()) + float(surf.LastVParameter())) / 2.0 - axis_start = _point_on_axis(axis_point, direction, float(start_parameter)) + axis_start = _point_on_axis(tool_axis_point, direction, float(start_parameter)) axis_start_tuple = _point_tuple(axis_start) - points: list[tuple[float, float, float]] = [axis_start_tuple] - for index in range(sample_count): - u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1) - source = surf.Value(u, v_mid) + + def radial_point(u: float) -> tuple[float, float, float]: + source = surf.Value(float(u), v_mid) source_parameter = _axis_parameter(axis_point, direction, source) source_axis = _point_on_axis(axis_point, direction, source_parameter) radial = _tuple_sub(_point_tuple(source), _point_tuple(source_axis)) unit = _tuple_normalized(radial) if unit is None: raise ValueError("Could not derive slot sector radial direction.") - points.append( - ( - axis_start_tuple[0] + unit[0] * float(radius), - axis_start_tuple[1] + unit[1] * float(radius), - axis_start_tuple[2] + unit[2] * float(radius), - ) + return ( + axis_start_tuple[0] + unit[0] * float(radius), + axis_start_tuple[1] + unit[1] * float(radius), + axis_start_tuple[2] + unit[2] * float(radius), ) + height_vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height) + try: + start_tuple = radial_point(u_start) + mid_tuple = radial_point((u_start + u_end) * 0.5) + end_tuple = radial_point(u_end) + center_point = gp_Pnt(*axis_start_tuple) + start_point = gp_Pnt(*start_tuple) + mid_point = gp_Pnt(*mid_tuple) + end_point = gp_Pnt(*end_tuple) + center_to_start = BRepBuilderAPI_MakeEdge(center_point, start_point).Edge() + arc = GC_MakeArcOfCircle(start_point, mid_point, end_point).Value() + arc_edge = BRepBuilderAPI_MakeEdge(arc).Edge() + end_to_center = BRepBuilderAPI_MakeEdge(end_point, center_point).Edge() + wire = BRepBuilderAPI_MakeWire(center_to_start, arc_edge, end_to_center).Wire() + tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "slot sector circular profile") + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism") + except Exception: + # Fall back to the older sampled polygon path for unusual parameterizations. + # The circular path is preferred because it preserves a real cylindrical + # wall after Cut/Fuse; the fallback keeps the edit attempt available. + pass + + sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2)) + points: list[tuple[float, float, float]] = [axis_start_tuple] + for index in range(sample_count): + u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1) + points.append(radial_point(u)) + tool_face = self._make_local_polygon_face(points) - vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height) - return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "slot sector prism") + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism") + + def move_cylindrical_slot_axis( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> str: + plan = self.cylindrical_slot_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + 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] = {} + artifact_cleanup: dict[str, object] = {"discarded_count": 0, "discarded_volume": 0.0} + try: + if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism": + filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + filled_shape = _finalize_boolean_result(fuse, "obround slot axis move fill/fuse", use_glue=False) + + target_plan = dict(plan) + target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1") + target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2") + cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) * 0.5) + op = BRepAlgoAPI_Cut(filled_shape, cutter) + result = _finalize_boolean_result(op, "obround slot axis move cut") + else: + filler = self._slot_sector_prism_tool( + face_id, + self._slot_fill_radius(plan), + float(plan["fill_start_parameter"]), + float(plan["fill_end_parameter"]), + ) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + filled_shape = _finalize_boolean_result(fuse, "slot axis move old-sector fill/fuse", use_glue=False) + + movement = _tuple_or_none(plan.get("axis_move_vector")) + if movement is None: + raise ValueError("Could not derive slot axis movement vector.") + cutter = self._slot_sector_prism_tool( + face_id, + float(plan["slot_target_diameter"]) * 0.5, + float(plan["cutter_start_parameter"]), + float(plan["cutter_end_parameter"]), + axis_point_offset=movement, + ) + op = BRepAlgoAPI_Cut(filled_shape, cutter) + result = _finalize_boolean_result(op, "slot axis move target-sector cut") + result = _prepare_shape_for_step_export(result) + before_solids = _topology_shape_count(old_part_shape, TopAbs_SOLID) + after_solids = _topology_shape_count(result, TopAbs_SOLID) + if before_solids == 1 and after_solids > 1: + result, artifact_cleanup = self._drop_tiny_artifact_solids(result, old_part_shape) + after_solids = _topology_shape_count(result, TopAbs_SOLID) + if before_solids and after_solids != before_solids: + raise RuntimeError( + "槽/半孔轴心移动结果改变了 Solid 数量,当前版本已回滚," + "避免把一个实体拆成多个独立实体。请改用“轴心(整体)”或槽孔总长度/槽宽等更稳定参数。" + ) + part.shape = result + self.refresh_topology() + + if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism": + verification = self._verify_obround_slot_axis_move_result(plan, part_id) + else: + verification_plan = dict(plan) + verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"] + verification_plan["cutter_start_point"] = plan["target_cutter_start_point"] + verification = self._verify_slot_angular_span_result(verification_plan, part_id) + if not verification["matched"]: + detail = str(verification.get("detail", "slot axis move result verification failed")) + raise RuntimeError( + "槽/半孔轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的槽面," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Cylindrical slot axis move completed: " + f"face {face_id}, " + f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " + f"move={plan.get('axis_move_vector')}, " + f"diameter={float(plan['slot_target_diameter']):g}, " + f"span={float(plan['slot_target_angular_span']):g}, " + f"height={float(plan['cutter_height']):g}, " + f"discarded_tiny_solids={int(artifact_cleanup.get('discarded_count', 0))}, " + f"discarded_tiny_volume={float(artifact_cleanup.get('discarded_volume', 0.0)):g}, " + f"risk={plan['risk']}, " + f"verified_face={verification.get('face_id', '')}." + ) def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) @@ -4231,7 +6526,7 @@ class OperationMixin: float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) - source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse") + source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse", use_glue=False) cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) @@ -4285,7 +6580,7 @@ class OperationMixin: float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) - filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse") + filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse", use_glue=False) cutter_start = gp_Pnt(*plan["target_cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, direction) @@ -4402,13 +6697,13 @@ class OperationMixin: if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool) - result = _finalize_boolean_result(op, "cylindrical boss fuse") + result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False) action = "enlarged by bounded fuse" else: removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() remove_op = BRepAlgoAPI_Cut(part.shape, removal) - removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope") + removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False) if _topology_shape_count(removed, TopAbs_FACE) == 0: exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) exact_axis = gp_Ax2(exact_start, direction) @@ -4420,7 +6715,7 @@ class OperationMixin: result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") else: fuse_op = BRepAlgoAPI_Fuse(removed, replacement) - result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild") + result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False) action = "shrunk by removing old envelope and fusing target cylinder" part.shape = result @@ -4460,7 +6755,7 @@ class OperationMixin: ) removal = BRepPrimAPI_MakeCylinder(removal_axis, removal_radius, height).Shape() remove_op = BRepAlgoAPI_Cut(part.shape, removal) - removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope") + removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope", use_glue=False) target_start_values = _tuple_or_none(plan.get("target_boss_tool_start_point")) target_height = height @@ -4484,7 +6779,7 @@ class OperationMixin: action = "rebuilt moved cylinder" else: fuse_op = BRepAlgoAPI_Fuse(removed, replacement) - result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder") + result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder", use_glue=False) action = "removed old envelope and fused moved cylinder" part.shape = result @@ -4541,7 +6836,7 @@ class OperationMixin: float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) - result = _finalize_boolean_result(fuse, "cylinder suppress/fill") + result = _finalize_boolean_result(fuse, "cylinder suppress/fill", use_glue=False) part.shape = result self.refresh_topology() return ( @@ -4584,7 +6879,7 @@ class OperationMixin: action = "deepened by bounded cut" else: op = BRepAlgoAPI_Fuse(part.shape, tool) - result = _finalize_boolean_result(op, "blind depth fill/fuse") + result = _finalize_boolean_result(op, "blind depth fill/fuse", use_glue=False) action = "made shallower by bounded fill" part.shape = result diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 1f528a7..04f637a 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -481,6 +481,25 @@ 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尺寸方向", + "face_size_label": "Face尺寸名称", + "current_face_width": "当前面宽", + "target_face_width": "目标面宽", + "current_face_height": "当前面高", + "target_face_height": "目标面高", + "current_face_size": "当前Face尺寸", + "target_face_size": "目标Face尺寸", + "face_size_delta": "Face尺寸变化量", + "face_size_delta_ratio": "Face尺寸变化比例", + "face_size_scale": "Face尺寸缩放比例", + "face_size_center": "Face尺寸缩放中心", + "face_size_axis_direction": "Face尺寸缩放方向", + "owning_face_size_rebuild_mode": "所属对象尺寸重建模式", "length_center": "长度中心", "bbox_min": "包围盒最小点", "bbox_max": "包围盒最大点", @@ -613,6 +632,8 @@ INFO_LABELS = { "feature_edit_actions": "当前可用操作", "resize_status": "切削状态", "resize_strategy": "编辑策略", + "edit_strategy_label": "编辑策略说明", + "edit_semantics": "编辑方式", "resize_mode": "调整模式", "resize_risk": "切削风险", "resize_warnings": "切削警告", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index f313b4f..5977502 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -5,7 +5,7 @@ import math from pathlib import Path import vtk -from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtCore import Qt, QThread, Slot from PySide6.QtWidgets import ( QApplication, QFileDialog, @@ -33,6 +33,32 @@ def _message_field(message: str, key: str) -> str | None: return value or None +def _triple_or_none(value: object) -> tuple[float, float, float] | None: + if isinstance(value, str): + text = value.strip().strip("()[]") + chunks = [chunk.strip() for chunk in text.replace(";", ",").split(",") if chunk.strip()] + elif isinstance(value, (list, tuple)): + chunks = list(value) + else: + return None + if len(chunks) != 3: + return None + try: + return (float(chunks[0]), float(chunks[1]), float(chunks[2])) + except (TypeError, ValueError): + return None + + +def _unit_triple_or_none(value: object) -> tuple[float, float, float] | None: + triple = _triple_or_none(value) + if triple is None: + return None + length = math.sqrt(triple[0] * triple[0] + triple[1] * triple[1] + triple[2] * triple[2]) + if length <= 1e-12: + return None + return (triple[0] / length, triple[1] / length, triple[2] / length) + + class WindowActionMixin: def export_all(self) -> None: if self.model is None: @@ -306,7 +332,7 @@ class WindowActionMixin: try: distance = float(self.offset_input.text()) except ValueError: - QMessageBox.critical(self, "距离无效", "请输入数字形式的Face偏移距离。") + QMessageBox.critical(self, "距离无效", "请输入数字形式的面移动距离。") return face_id = self.selected_face_id plan = self._quick_push_pull_plan(face_id, distance) @@ -314,6 +340,30 @@ class WindowActionMixin: QMessageBox.information(self, "不能推拉平面", str(plan["message"])) self.statusBar().showMessage("推拉平面已阻止") return + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认推拉平面", + ( + f"Face: {face_id}\n" + f"本次移动距离: {_format_value(distance)}\n" + f"当前法向位置: {_format_value(plan.get('current_plane_position'))}\n" + f"目标法向位置: {_format_value(plan.get('target_plane_position'))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"影响范围: {_format_value(plan.get('edit_semantics'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消推拉平面") + return self._show_push_pull_preview(face_id, distance, plan=plan) def action(): @@ -334,6 +384,9 @@ class WindowActionMixin: "target_plane_position": plan.get("target_plane_position"), "direction_confidence": plan.get("direction_confidence"), "direction_note": plan.get("direction_note"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "push_pull_risk": plan.get("risk"), "push_pull_status": plan.get("status"), "push_pull_message": plan.get("message"), @@ -355,7 +408,7 @@ class WindowActionMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 Face 不是平面,不能执行 Face 偏移。", + "message": "当前选中的面不是平面,不能移动这个面。", "face_id": face_id, "distance": distance, "surface": surface, @@ -368,7 +421,7 @@ class WindowActionMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前平面缺少稳定推拉方向,请重新选择该 Face 后再试。", + "message": "当前平面缺少稳定移动方向,请重新选择这个面后再试。", "face_id": face_id, "distance": distance, "surface": surface, @@ -383,7 +436,7 @@ class WindowActionMixin: return { "status": "blocked", "risk": "blocked", - "message": "Face 偏移距离为 0,不需要修改。", + "message": "面移动距离为 0,不需要修改。", "face_id": face_id, "distance": distance, "surface": surface, @@ -392,11 +445,11 @@ class WindowActionMixin: if distance_abs > bbox_diagonal * 0.2: risk = "high" status = "caution" - warnings.append("Face 偏移距离相对当前 Face 尺寸较大,布尔失败或大范围变形风险较高。") + warnings.append("面移动距离相对当前面尺寸较大,布尔失败或大范围变形风险较高。") elif distance_abs > bbox_diagonal * 0.08: risk = "medium" status = "caution" - warnings.append("Face 偏移距离相对当前 Face 尺寸偏大,请确认预览范围。") + warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。") scope_face_ids = ( _int_values(info.get("push_pull_scope_face_ids")) @@ -439,6 +492,12 @@ class WindowActionMixin: "outward_direction": tuple(float(item) for item in outward), "current_plane_position": current_plane_position, "target_plane_position": target_plane_position, + "resize_strategy": "push-pull-planar-face-region", + "edit_strategy_label": "沿面法向推拉当前平面区域", + "edit_semantics": ( + "沿当前 Face 的法向移动平面区域;正向通常表示向外加料,负向表示向内切削。" + "相邻侧面会跟随重建,但这不是平移整个所属对象,也不是只改面积。" + ), "direction_confidence": info.get("push_pull_confidence", "cached"), "direction_note": info.get("push_pull_note", "使用当前选中 Face 的缓存方向,完整检查在后台执行。"), "push_pull_scope_face_ids": tuple(scope_face_ids), @@ -538,6 +597,92 @@ class WindowActionMixin: target_id=face_id, ) + def resize_shell_thickness_owning_scale(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再执行薄壁厚度(整体)。"): + return + if self.selected_face_id is None: + 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, "目标厚度无效", "请输入数字形式的目标薄壁厚度。") + return + if target_thickness <= 0: + QMessageBox.critical(self, "目标厚度无效", "目标薄壁厚度必须大于 0。") + return + + face_id = self.selected_face_id + plan = self.model.shell_thickness_owning_scale_plan(face_id, target_thickness) + lines = [ + f"Face: {face_id}", + f"当前厚度: {_format_value(plan.get('shell_current_thickness'))}", + f"目标厚度: {_format_value(plan.get('shell_target_thickness'))}", + f"厚度变化量: {_format_value(plan.get('shell_delta_thickness'))}", + f"厚度变化比例: {_format_percent(plan.get('shell_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('affine_scale'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + f"厚度方向: {_format_value(plan.get('affine_axis_direction'))}", + f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}", + "当前语义: 沿薄壁/壳体厚度方向整体缩放所属特征或 Solid;不是推拉当前平面区域。", + ] + if not self._confirm_quick_edit_plan( + "薄壁厚度(整体)缩放所属对象", + plan, + lines, + "薄壁厚度(整体)已阻止", + "已取消薄壁厚度(整体)", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_shell_thickness_owning_scale(face_id, target_thickness) + + self._run_edit_action( + action, + operation_name="薄壁厚度(整体)缩放所属对象", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "plane"), + "shell_region_kind": plan.get("shell_region_kind"), + "shell_confidence": plan.get("shell_confidence"), + "shell_source_face_ids": plan.get("shell_source_face_ids"), + "shell_opposite_face_id": plan.get("shell_opposite_face_id"), + "shell_current_thickness": plan.get("shell_current_thickness"), + "shell_target_thickness": plan.get("shell_target_thickness"), + "shell_delta_thickness": plan.get("shell_delta_thickness"), + "shell_delta_ratio": plan.get("shell_delta_ratio"), + "shell_signed_thickness": plan.get("shell_signed_thickness"), + "shell_overlap_ratio_estimate": plan.get("shell_overlap_ratio_estimate"), + "shell_opposite_normal_dot": plan.get("shell_opposite_normal_dot"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "part_solid_count": plan.get("part_solid_count"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + @staticmethod def _max_quick_risk(current: str, candidate: str) -> str: order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} @@ -693,6 +838,13 @@ class WindowActionMixin: "hole_depth_estimate": info.get("hole_depth_estimate"), "feature_bottom_face_ids": info.get("feature_bottom_face_ids"), "feature_opening_face_ids": info.get("feature_opening_face_ids"), + "resize_strategy": "same-axis-bounded-cylinder-recut", + "edit_strategy_label": "同轴圆柱重切", + "edit_semantics": ( + "缩小孔/槽直径:先补料再同轴重切;保持当前轴线和估算高度。" + if resize_mode == "shrink" + else "扩大孔/槽直径:沿当前圆柱轴线用有限长度 cutter 切到目标直径;保持当前轴线和估算高度。" + ), "quick_preflight": True, } @@ -715,11 +867,19 @@ class WindowActionMixin: warnings = str(plan.get("warnings", "")) warnings_line = f"\n警告: {warnings}\n" if warnings else "" + semantic_lines = list(lines) + strategy_label = str(plan.get("edit_strategy_label") or "").strip() + edit_semantics = str(plan.get("edit_semantics") or "").strip() + if strategy_label: + semantic_lines.append(f"编辑策略: {strategy_label}") + if edit_semantics: + semantic_lines.append(f"编辑方式: {edit_semantics}") + result = QMessageBox.question( self, f"确认{title}", ( - "\n".join(lines) + "\n".join(semantic_lines) + f"\n风险: {plan.get('risk')}\n" + warnings_line + f"\n{plan.get('message', '')}\n\n" @@ -798,6 +958,9 @@ class WindowActionMixin: "resize_message": plan.get("message"), "resize_warnings": plan.get("warnings"), "resize_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "material_vote_summary": plan.get("material_vote_summary"), "cylinder_end_type": plan.get("cylinder_end_type"), "hole_depth_estimate": plan.get("hole_depth_estimate"), @@ -808,6 +971,81 @@ class WindowActionMixin: target_id=face_id, ) + def resize_cylindrical_owning_scale(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再缩放圆柱尺寸。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱 Face。") + return + try: + diameter = float(self.hole_diameter_input.text()) + except ValueError: + QMessageBox.critical(self, "圆柱尺寸无效", "请输入数字形式的目标圆柱直径或半径。") + return + face_id = self.selected_face_id + plan = self.model.cylindrical_owning_scale_plan(face_id, diameter) + lines = [ + f"Face: {face_id}", + f"当前直径: {_format_value(plan.get('current_diameter'))}", + f"目标直径: {_format_value(plan.get('target_diameter'))}", + f"当前半径: {_format_value(plan.get('current_radius'))}", + f"目标半径: {_format_value(plan.get('target_radius'))}", + f"直径变化量: {_format_value(plan.get('delta_diameter'))}", + f"直径变化比例: {_format_percent(plan.get('diameter_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('affine_scale'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + ] + if not self._confirm_quick_edit_plan( + "圆柱尺寸缩放所属对象", + plan, + lines, + "圆柱尺寸缩放所属对象已阻止", + "已取消圆柱尺寸缩放所属对象", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_owning_scale(face_id, diameter) + + self._run_edit_action( + action, + operation_name="圆柱尺寸缩放所属对象", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "new_diameter": diameter, + "old_diameter": plan.get("current_diameter"), + "delta_diameter": plan.get("delta_diameter"), + "diameter_delta_ratio": plan.get("diameter_delta_ratio"), + "target_radius": plan.get("target_radius"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + def move_cylindrical_hole_axis(self) -> None: if self.model is None: return @@ -871,6 +1109,8 @@ class WindowActionMixin: "axis_move_radial_distance": plan.get("axis_move_radial_distance"), "axis_move_axial_delta": plan.get("axis_move_axial_delta"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "move_axis_status": plan.get("status"), "move_axis_risk": plan.get("risk"), "move_axis_message": plan.get("message"), @@ -890,6 +1130,240 @@ class WindowActionMixin: target_id=face_id, ) + def move_cylindrical_slot_axis(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再移动槽/半孔轴心。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择槽/半孔", "请先选择一个槽/半孔候选 Face。") + return + try: + target_center = ( + float(self.slot_center_x_input.text()), + float(self.slot_center_y_input.text()), + float(self.slot_center_z_input.text()), + ) + except (AttributeError, ValueError): + QMessageBox.critical(self, "槽/半孔轴心坐标无效", "请输入 X, Y, Z 三个数字形式的目标坐标。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_slot_axis_move_plan(face_id, target_center) + lines = [ + f"Face: {face_id}", + f"当前轴心: {_format_value(plan.get('current_axis_center'))}", + f"目标轴心: {_format_value(plan.get('target_axis_center'))}", + f"移动向量: {_format_value(plan.get('axis_move_vector'))}", + f"移动距离: {_format_value(plan.get('axis_move_distance'))}", + f"槽宽/直径: {_format_value(plan.get('slot_target_diameter'))}", + f"槽圆弧角度: {_format_value(plan.get('slot_target_angular_span'))}", + f"候选判断: {plan.get('feature_guess')}", + f"置信度: {plan.get('confidence')}", + "当前版本会先填旧扇形槽,再在目标轴心切出同宽度、同角度的新扇形槽。", + ] + if not self._confirm_quick_edit_plan( + "槽/半孔轴心坐标修改", + plan, + lines, + "槽/半孔轴心坐标修改已阻止", + "已取消槽/半孔轴心坐标修改", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.move_cylindrical_slot_axis(face_id, target_center) + + self._run_edit_action( + action, + operation_name="修改槽/半孔轴心坐标", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "cylinder"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "slot_kind": plan.get("slot_kind"), + "slot_status": plan.get("slot_status"), + "current_diameter": plan.get("current_diameter"), + "target_diameter": plan.get("target_diameter"), + "slot_current_angular_span": plan.get("slot_current_angular_span"), + "slot_target_angular_span": plan.get("slot_target_angular_span"), + "current_axis_center": plan.get("current_axis_center"), + "target_axis_center": plan.get("target_axis_center"), + "axis_move_vector": plan.get("axis_move_vector"), + "axis_move_distance": plan.get("axis_move_distance"), + "axis_move_radial_distance": plan.get("axis_move_radial_distance"), + "axis_move_axial_delta": plan.get("axis_move_axial_delta"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "move_axis_status": plan.get("status"), + "move_axis_risk": plan.get("risk"), + "move_axis_message": plan.get("message"), + "move_axis_warnings": plan.get("warnings"), + "move_axis_blockers": plan.get("blockers"), + "fill_strategy": plan.get("fill_strategy"), + "fill_height": plan.get("fill_height"), + "fill_radius": plan.get("fill_radius"), + "cutter_strategy": plan.get("cutter_strategy"), + "cutter_height": plan.get("cutter_height"), + "target_cutter_axis_point": plan.get("target_cutter_axis_point"), + "target_cutter_start_point": plan.get("target_cutter_start_point"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_width_owning_scale(self) -> None: + self._resize_slot_metric_owning_scale("width") + + def resize_slot_depth_owning_scale(self) -> None: + self._resize_slot_metric_owning_scale("depth") + + def resize_slot_arc_length_owning_scale(self) -> None: + self._resize_slot_metric_owning_scale("arc_length") + + def _resize_slot_metric_owning_scale(self, mode: str) -> None: + if self.model is None: + return + labels = { + "width": ("槽宽", "slot_width_input", "slot_chord_width_estimate"), + "depth": ("槽深", "slot_depth_input", "slot_sagitta_depth_estimate"), + "arc_length": ("弧长", "slot_arc_length_input", "slot_arc_length_estimate"), + } + label, input_attr, current_key = labels.get(mode, labels["width"]) + operation_name = f"{label}(整体)" + if self._edit_busy(f"请等待当前编辑完成后再执行{operation_name}。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择槽/半孔", "请先选择一个槽/半孔候选 Face。") + return + if not hasattr(self, input_attr): + return + try: + target_value = float(getattr(self, input_attr).text()) + except ValueError: + QMessageBox.critical(self, f"目标{label}无效", f"请输入数字形式的目标{label}。") + return + if target_value <= 0: + QMessageBox.critical(self, f"目标{label}无效", f"目标{label}必须大于 0。") + return + + face_id = self.selected_face_id + info = self._selected_face_info_snapshot() + if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": + QMessageBox.information(self, "不是槽/半孔候选", "当前选中 Face 没有被识别为槽/半孔候选。") + return + angular_span = _float_or_none(info.get("slot_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) + if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: + QMessageBox.information(self, f"不能整体修改{label}", "当前候选没有稳定的部分圆柱角度,不能换算为目标圆柱直径。") + return + + span = min(max(float(angular_span), 1e-6), math.tau - 1e-6) + sin_half_span = math.sin(span * 0.5) + sagitta_factor = 1.0 - math.cos(min(span, math.pi) * 0.5) + if mode == "width": + if abs(sin_half_span) <= 1e-6: + QMessageBox.information(self, "不能整体修改槽宽", "当前槽圆弧角度过小,不能可靠换算槽宽。") + return + target_diameter = target_value / sin_half_span + elif mode == "depth": + if sagitta_factor <= 1e-6: + QMessageBox.information(self, "不能整体修改槽深", "当前槽圆弧角度过小,不能可靠换算槽深。") + return + target_diameter = 2.0 * target_value / sagitta_factor + else: + target_diameter = 2.0 * target_value / span + + current_value = _float_or_none(info.get(current_key)) + current_diameter = _float_or_none(info.get("diameter")) + plan = self.model.cylindrical_owning_scale_plan(face_id, target_diameter) + lines = [ + f"Face: {face_id}", + f"当前{label}: {_format_value(current_value)}", + f"目标{label}: {_format_value(target_value)}", + f"圆弧角度: {_format_value(angular_span)} rad / {_format_value(math.degrees(float(angular_span)))} 度", + f"当前圆柱直径: {_format_value(current_diameter)}", + f"换算目标圆柱直径: {_format_value(target_diameter)}", + f"缩放比例: {_format_value(plan.get('affine_scale'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + "当前语义: 整体缩放所属特征或 Solid;不是局部重切槽,槽上其它尺寸也会跟随变化。", + ] + if not self._confirm_quick_edit_plan( + f"{operation_name}缩放所属对象", + plan, + lines, + f"{operation_name}已阻止", + f"已取消{operation_name}", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_owning_scale(face_id, target_diameter) + + slot_parameters = { + "slot_metric_mode": mode, + "slot_metric_label": label, + "slot_angular_span": angular_span, + "slot_current_value": current_value, + "slot_target_value": target_value, + "current_diameter": current_diameter, + "target_diameter": target_diameter, + "derived_new_diameter": target_diameter, + "old_diameter": plan.get("current_diameter"), + "new_diameter": target_diameter, + "delta_diameter": plan.get("delta_diameter"), + "diameter_delta_ratio": plan.get("diameter_delta_ratio"), + "target_radius": plan.get("target_radius"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": "按槽参数换算为目标圆柱直径后,整体缩放所属特征或 Solid;不是局部重切槽。", + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + } + if mode == "width": + slot_parameters.update({"old_slot_width": current_value, "new_slot_width": target_value}) + elif mode == "depth": + slot_parameters.update({"old_slot_depth": current_value, "new_slot_depth": target_value}) + else: + slot_parameters.update({"old_slot_arc_length": current_value, "new_slot_arc_length": target_value}) + + self._run_edit_action( + action, + operation_name=f"{operation_name}缩放所属对象", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "cylinder"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + **slot_parameters, + }, + target_kind="face", + target_id=face_id, + ) + def resize_slot_width(self) -> None: if self.model is None: return @@ -969,6 +1443,8 @@ class WindowActionMixin: "slot_resize_mode": "width", "slot_resize_strategy": plan.get("slot_resize_strategy"), "resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "surface": plan.get("surface", "cylinder"), "slot_pair_face_id": plan.get("slot_pair_face_id"), "slot_pair_manual_face_id": pair_face_id, @@ -1088,6 +1564,8 @@ class WindowActionMixin: "slot_resize_mode": "depth", "slot_resize_strategy": plan.get("slot_resize_strategy"), "resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "surface": plan.get("surface", "cylinder"), "slot_pair_face_id": plan.get("slot_pair_face_id"), "slot_pair_manual_face_id": pair_face_id, @@ -1215,6 +1693,8 @@ class WindowActionMixin: "slot_resize_mode": "arc_length", "slot_resize_strategy": plan.get("slot_resize_strategy"), "resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "surface": plan.get("surface", "cylinder"), "slot_pair_face_id": plan.get("slot_pair_face_id"), "slot_pair_manual_face_id": pair_face_id, @@ -1340,6 +1820,8 @@ class WindowActionMixin: "resize_mode": plan.get("resize_mode"), "resize_strategy": plan.get("slot_resize_strategy"), "slot_resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "slot_resize_mode": plan.get("slot_resize_mode"), "slot_pair_face_id": plan.get("slot_pair_face_id"), "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), @@ -1437,6 +1919,103 @@ class WindowActionMixin: "derived_new_diameter": plan.get("slot_target_diameter"), "resize_strategy": plan.get("slot_resize_strategy"), "slot_resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "slot_resize_mode": plan.get("slot_resize_mode"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_manual_face_id": pair_face_id, + "slot_pair_manual": plan.get("slot_pair_manual"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_center_distance(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再调整槽孔中心距。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择槽孔", "请先选择一个已识别为长圆槽端部的 Face。") + return + if not hasattr(self, "slot_center_distance_input"): + return + try: + target_center_distance = float(self.slot_center_distance_input.text()) + except ValueError: + QMessageBox.critical(self, "目标槽孔中心距无效", "请输入数字形式的目标槽孔中心距。") + return + + face_id = self.selected_face_id + try: + pair_face_id = self._slot_pair_face_override(face_id) + except ValueError as exc: + QMessageBox.critical(self, "槽孔配对端无效", str(exc)) + return + plan = self.model.cylindrical_slot_center_distance_plan( + face_id, + target_center_distance, + pair_face_id=pair_face_id, + ) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整槽孔中心距", str(plan["message"])) + self.statusBar().showMessage("槽孔中心距调整已阻止") + return + lines = [ + f"Face: {face_id}", + f"当前两端中心距: {_format_value(plan.get('slot_current_center_distance'))}", + f"目标两端中心距: {_format_value(plan.get('slot_target_center_distance'))}", + f"当前槽孔总长度: {_format_value(plan.get('slot_current_total_length'))}", + f"目标槽孔总长度: {_format_value(plan.get('slot_target_total_length'))}", + f"槽宽/直径: {_format_value(plan.get('slot_target_diameter'))}", + f"配对端 Face: {_format_value(plan.get('slot_pair_face_id'))}", + ] + if not self._confirm_quick_edit_plan( + "槽孔中心距调整", + plan, + lines, + "槽孔中心距调整已阻止", + "已取消槽孔中心距调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_slot_center_distance( + face_id, + target_center_distance, + pair_face_id=pair_face_id, + ) + + self._run_edit_action( + action, + operation_name="调整槽孔中心距", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "cylinder"), + "new_slot_center_distance": target_center_distance, + "old_slot_center_distance": plan.get("slot_current_center_distance"), + "slot_center_distance_delta": plan.get("slot_center_distance_delta"), + "slot_center_distance_delta_ratio": plan.get("slot_center_distance_delta_ratio"), + "slot_current_total_length": plan.get("slot_current_total_length"), + "slot_target_total_length": plan.get("slot_target_total_length"), + "slot_current_center_distance": plan.get("slot_current_center_distance"), + "slot_target_center_distance": plan.get("slot_target_center_distance"), + "slot_target_diameter": plan.get("slot_target_diameter"), + "derived_new_diameter": plan.get("slot_target_diameter"), + "resize_strategy": plan.get("slot_resize_strategy"), + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "slot_resize_mode": plan.get("slot_resize_mode"), "slot_pair_face_id": plan.get("slot_pair_face_id"), "slot_pair_manual_face_id": pair_face_id, @@ -1499,6 +2078,8 @@ class WindowActionMixin: f"置信度: {plan['confidence']}\n" f"材料投票: {plan['material_vote_summary']}\n" f"工具策略: {plan['boss_tool_strategy']}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"工具高度: {_format_value(plan['boss_tool_height'])}\n" f"轴向重叠余量: {_format_value(plan['boss_tool_axial_margin'])}\n" f"风险: {plan['risk']}\n\n" @@ -1541,6 +2122,9 @@ class WindowActionMixin: "boss_resize_message": plan.get("message"), "boss_resize_warnings": plan.get("warnings"), "boss_resize_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "material_vote_summary": plan.get("material_vote_summary"), "boss_tool_strategy": plan.get("boss_tool_strategy"), "boss_tool_height": plan.get("boss_tool_height"), @@ -1622,6 +2206,8 @@ class WindowActionMixin: "axis_move_radial_distance": plan.get("axis_move_radial_distance"), "axis_move_axial_delta": plan.get("axis_move_axial_delta"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "move_axis_status": plan.get("status"), "move_axis_risk": plan.get("risk"), "move_axis_message": plan.get("message"), @@ -1681,6 +2267,8 @@ class WindowActionMixin: 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" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -1724,6 +2312,8 @@ class WindowActionMixin: "boss_height_warnings": plan.get("warnings"), "boss_height_blockers": plan.get("blockers"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "same_domain_face_ids": plan.get("same_domain_face_ids"), "feature_start_end_face_ids": plan.get("feature_start_end_face_ids"), "feature_end_end_face_ids": plan.get("feature_end_end_face_ids"), @@ -1808,6 +2398,88 @@ class WindowActionMixin: target_id=face_id, ) + def resize_cylindrical_height_owning_scale(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再执行高度(整体)。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱面", "请先选择一个可测高度的圆柱 Face。") + return + if not hasattr(self, "boss_height_input"): + return + try: + target_height = float(self.boss_height_input.text()) + except ValueError: + QMessageBox.critical(self, "高度无效", "请输入数字形式的目标圆柱高度。") + return + if target_height <= 0: + QMessageBox.critical(self, "高度无效", "目标圆柱高度必须大于 0。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_height_owning_scale_plan(face_id, target_height) + lines = [ + f"Face: {face_id}", + f"当前高度: {_format_value(plan.get('current_height'))}", + f"目标高度: {_format_value(plan.get('target_height'))}", + f"高度变化量: {_format_value(plan.get('delta_height'))}", + f"高度变化比例: {_format_percent(plan.get('height_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('affine_scale'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + f"轴向: {_format_value(plan.get('affine_axis_direction'))}", + "当前语义: 沿圆柱轴向整体缩放所属特征或 Solid;不是推拉单个端盖。", + ] + if not self._confirm_quick_edit_plan( + "高度(整体)缩放所属对象", + plan, + lines, + "高度(整体)已阻止", + "已取消高度(整体)", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_height_owning_scale(face_id, target_height) + + self._run_edit_action( + action, + operation_name="高度(整体)缩放所属对象", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "cylinder"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "new_height": target_height, + "old_height": plan.get("current_height"), + "delta_height": plan.get("delta_height"), + "height_delta_ratio": plan.get("height_delta_ratio"), + "diameter": plan.get("diameter"), + "radius": plan.get("radius"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + def suppress_hole(self) -> None: if self.model is None: return @@ -2027,6 +2699,11 @@ class WindowActionMixin: "feature_bottom_confidence": info.get("feature_bottom_confidence"), "feature_bottom_detection": info.get("feature_bottom_detection"), "feature_bottom_note": info.get("feature_bottom_note"), + "resize_strategy": "bounded-blind-depth-cut-or-fill", + "edit_strategy_label": "盲孔/盲槽深度切削或补料", + "edit_semantics": ( + "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。" + ), "quick_preflight": True, } @@ -2101,6 +2778,9 @@ class WindowActionMixin: "depth_message": plan.get("message"), "depth_warnings": plan.get("warnings"), "depth_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "cylinder_end_type": plan.get("cylinder_end_type"), "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), "manual_bottom_face_id": plan.get("manual_bottom_face_id"), @@ -2117,6 +2797,110 @@ class WindowActionMixin: target_id=face_id, ) + def resize_hole_depth_owning_scale(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再执行盲孔/盲槽深度(整体)。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱面", "请先选择一个盲孔或盲槽的圆柱面。") + return + try: + target_depth = float(self.hole_depth_input.text()) + except ValueError: + QMessageBox.critical(self, "深度无效", "请输入数字形式的目标盲孔/盲槽深度。") + return + if target_depth <= 0: + QMessageBox.critical(self, "深度无效", "目标盲孔/盲槽深度必须大于 0。") + return + try: + bottom_face_id = self._hole_bottom_face_override() + except ValueError as exc: + QMessageBox.critical(self, "底面 Face ID 无效", str(exc)) + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_depth_owning_scale_plan( + face_id, + target_depth, + bottom_face_id=bottom_face_id, + ) + lines = [ + f"Face: {face_id}", + f"当前深度: {_format_value(plan.get('current_depth'))}", + f"目标深度: {_format_value(plan.get('target_depth'))}", + f"深度变化量: {_format_value(plan.get('delta_depth'))}", + f"深度变化比例: {_format_percent(plan.get('depth_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('affine_scale'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + f"缩放轴向: {_format_value(plan.get('affine_axis_direction'))}", + f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids'))}", + "当前语义: 沿盲孔/盲槽方向整体缩放所属特征或 Solid;不是加深切削,也不是变浅补料。", + ] + if bottom_face_id is not None: + lines.append(f"手动底面 Face: {bottom_face_id}") + if not self._confirm_quick_edit_plan( + "盲孔/盲槽深度(整体)缩放所属对象", + plan, + lines, + "盲孔/盲槽深度(整体)已阻止", + "已取消盲孔/盲槽深度(整体)", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_depth_owning_scale( + face_id, + target_depth, + bottom_face_id=bottom_face_id, + ) + + self._run_edit_action( + action, + operation_name="盲孔/盲槽深度(整体)缩放所属对象", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface", "cylinder"), + "old_depth": plan.get("current_depth"), + "new_depth": target_depth, + "delta_depth": plan.get("delta_depth"), + "depth_delta_ratio": plan.get("depth_delta_ratio"), + "diameter": plan.get("diameter"), + "radius": plan.get("radius"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "cylinder_end_type": plan.get("cylinder_end_type"), + "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), + "manual_bottom_face_id": plan.get("manual_bottom_face_id"), + "manual_bottom_face_used": plan.get("manual_bottom_face_used"), + "feature_opening_face_ids": plan.get("feature_opening_face_ids"), + "depth_open_point": plan.get("depth_open_point"), + "depth_current_bottom_point": plan.get("depth_current_bottom_point"), + "depth_current_depth_source": plan.get("depth_current_depth_source"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + def resize_existing_fillet(self) -> None: if self.model is None: return @@ -2156,6 +2940,8 @@ class WindowActionMixin: f"支撑 Face: {_format_value(plan.get('feature_existing_fillet_support_face_ids', ''))}\n" f"边界 Edge: {_format_value(plan.get('feature_boundary_edge_ids', ''))}\n" f"策略: {plan['resize_strategy']}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -2197,6 +2983,8 @@ class WindowActionMixin: "feature_existing_fillet_support_face_ids": plan.get("feature_existing_fillet_support_face_ids"), "feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "resize_note": plan.get("resize_note"), "axis_point": plan.get("axis_point"), "axis": plan.get("axis"), @@ -2243,6 +3031,8 @@ class WindowActionMixin: f"半径/Edge长度比例: {_format_percent(plan['radius_to_length_ratio'])}\n" f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -2279,6 +3069,9 @@ class WindowActionMixin: "fillet_message": plan.get("message"), "fillet_warnings": plan.get("warnings"), "fillet_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), }, target_kind="edge", target_id=edge_id, @@ -2320,6 +3113,8 @@ class WindowActionMixin: f"倒角距离/Edge长度比例: {_format_percent(plan['distance_to_length_ratio'])}\n" f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -2356,6 +3151,9 @@ class WindowActionMixin: "chamfer_message": plan.get("message"), "chamfer_warnings": plan.get("warnings"), "chamfer_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), }, target_kind="edge", target_id=edge_id, @@ -2436,6 +3234,9 @@ class WindowActionMixin: "chamfer_message": plan.get("message"), "chamfer_warnings": plan.get("warnings"), "chamfer_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), }, target_kind="edge", target_id=edge_id, @@ -2515,6 +3316,9 @@ class WindowActionMixin: "chamfer_message": plan.get("message"), "chamfer_warnings": plan.get("warnings"), "chamfer_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), }, target_kind="edge", target_id=edge_id, @@ -2536,7 +3340,13 @@ class WindowActionMixin: edge_id = self.selected_edge_id anchor_mode = self._edge_length_anchor_mode_from_ui() - plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + strategy_mode = self._edge_length_strategy_mode_from_ui() + plan = self.model.general_edge_length_plan( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) if plan["status"] == "blocked": QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) self.statusBar().showMessage("Edge长度修改已阻止") @@ -2554,6 +3364,9 @@ class WindowActionMixin: f"目标Edge长度: {_format_value(plan['target_length'])}\n" f"变化量: {_format_value(plan['delta_length'])}\n" f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"Edge修改策略: {_format_value(plan.get('edge_length_strategy_label', ''))}\n" + f"固定约束: {_format_value(plan.get('edge_length_constraint_summary', ''))}\n" + f"影响范围: {_format_value(plan.get('edge_length_impact_summary', ''))}\n" f"策略: {_format_value(plan.get('resize_strategy', ''))}\n" f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n" f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n" @@ -2583,10 +3396,15 @@ class WindowActionMixin: self.statusBar().showMessage("已取消Edge长度修改") return - self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode) + self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode, strategy_mode=strategy_mode) def action(): - return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + return self.model.resize_general_edge_length( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) self._run_edit_action( action, @@ -2601,6 +3419,12 @@ class WindowActionMixin: "length_change_ratio": plan.get("length_change_ratio"), "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "edge_length_strategy_mode": plan.get("edge_length_strategy_mode"), + "edge_length_strategy_label": plan.get("edge_length_strategy_label"), + "edge_length_constraint_summary": plan.get("edge_length_constraint_summary"), + "edge_length_impact_summary": plan.get("edge_length_impact_summary"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), "local_edge_deform_anchor": plan.get("local_edge_deform_anchor"), "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), @@ -2676,7 +3500,13 @@ class WindowActionMixin: edge_id = self.selected_edge_id anchor_mode = self._edge_length_anchor_mode_from_ui() - plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + strategy_mode = self._edge_length_strategy_mode_from_ui() + plan = self.model.general_edge_length_plan( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) if plan["status"] == "blocked": QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) self.statusBar().showMessage("Edge长度修改已阻止") @@ -2696,6 +3526,9 @@ class WindowActionMixin: f"目标Edge长度: {_format_value(plan['target_length'])}\n" f"变化量: {_format_value(plan['delta_length'])}\n" f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"Edge修改策略: {_format_value(plan.get('edge_length_strategy_label', ''))}\n" + f"固定约束: {_format_value(plan.get('edge_length_constraint_summary', ''))}\n" + f"影响范围: {_format_value(plan.get('edge_length_impact_summary', ''))}\n" f"策略: {_format_value(strategy)}\n" f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n" f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n" @@ -2725,10 +3558,15 @@ class WindowActionMixin: self.statusBar().showMessage("已取消Edge长度修改") return - self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode) + self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode, strategy_mode=strategy_mode) def action(): - return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + return self.model.resize_general_edge_length( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) self._run_edit_action( action, @@ -2744,6 +3582,12 @@ class WindowActionMixin: "length_change_ratio": plan.get("length_change_ratio"), "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "edge_length_strategy_mode": plan.get("edge_length_strategy_mode"), + "edge_length_strategy_label": plan.get("edge_length_strategy_label"), + "edge_length_constraint_summary": plan.get("edge_length_constraint_summary"), + "edge_length_impact_summary": plan.get("edge_length_impact_summary"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), "local_edge_deform_anchor": plan.get("local_edge_deform_anchor"), "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), @@ -2803,6 +3647,103 @@ class WindowActionMixin: target_id=edge_id, ) + def resize_ellipse_edge_major_radius(self) -> None: + self._resize_ellipse_edge_axis_radius("major") + + def resize_ellipse_edge_minor_radius(self) -> None: + self._resize_ellipse_edge_axis_radius("minor") + + def _resize_ellipse_edge_axis_radius(self, axis_kind: str) -> None: + if self.model is None: + return + axis_kind = "minor" if str(axis_kind).lower() == "minor" else "major" + axis_label = "小半径" if axis_kind == "minor" else "主半径" + target_attr = "ellipse_edge_minor_radius_input" if axis_kind == "minor" else "ellipse_edge_major_radius_input" + if self._edit_busy(f"请等待当前编辑完成后再修改椭圆Edge{axis_label}。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择椭圆Edge", "请先选择一条椭圆Edge。") + return + if not hasattr(self, target_attr): + return + try: + target_radius = float(getattr(self, target_attr).text()) + except ValueError: + QMessageBox.critical(self, f"目标{axis_label}无效", f"请输入数字形式的椭圆Edge目标{axis_label}。") + return + + edge_id = self.selected_edge_id + plan = self.model.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind=axis_kind) + if plan["status"] == "blocked": + QMessageBox.information(self, f"不能修改椭圆Edge{axis_label}", str(plan["message"])) + self.statusBar().showMessage(f"椭圆Edge{axis_label}修改已阻止") + return + + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + if plan["risk"] != "low": + result = QMessageBox.question( + self, + f"确认修改椭圆Edge{axis_label}", + ( + f"Edge: {plan['edge_id']}\n" + f"当前主半径: {_format_value(plan.get('ellipse_current_major_radius'))}\n" + f"目标主半径: {_format_value(plan.get('ellipse_target_major_radius'))}\n" + f"当前小半径: {_format_value(plan.get('ellipse_current_minor_radius'))}\n" + f"目标小半径: {_format_value(plan.get('ellipse_target_minor_radius'))}\n" + f"缩放方向: {_format_value(plan.get('ellipse_axis_direction_label'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"影响范围: {_format_value(plan.get('edit_semantics'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + "继续操作会对当前 B-Rep 结果几何做单轴仿射缩放,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage(f"已取消椭圆Edge{axis_label}修改") + return + + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_ellipse_edge_axis_radius(edge_id, target_radius, axis_kind=axis_kind) + + self._run_edit_action( + action, + operation_name=f"修改椭圆Edge{axis_label}", + target=f"Edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": "ellipse-edge", + "ellipse_axis_kind": plan.get("ellipse_axis_kind"), + "ellipse_axis_label": plan.get("ellipse_axis_label"), + "ellipse_current_radius": plan.get("ellipse_current_radius"), + "ellipse_target_radius": target_radius, + "ellipse_radius_delta": plan.get("ellipse_radius_delta"), + "ellipse_radius_delta_ratio": plan.get("ellipse_radius_delta_ratio"), + "ellipse_current_major_radius": plan.get("ellipse_current_major_radius"), + "ellipse_target_major_radius": plan.get("ellipse_target_major_radius"), + "ellipse_current_minor_radius": plan.get("ellipse_current_minor_radius"), + "ellipse_target_minor_radius": plan.get("ellipse_target_minor_radius"), + "affine_scale": plan.get("affine_scale"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + def _edge_length_anchor_mode_from_ui(self) -> str: combo = getattr(self, "edge_length_anchor_combo", None) if combo is None: @@ -2810,6 +3751,13 @@ class WindowActionMixin: data = combo.currentData() return str(data or "auto") + def _edge_length_strategy_mode_from_ui(self) -> str: + combo = getattr(self, "edge_length_strategy_combo", None) + if combo is None: + return "auto" + data = combo.currentData() + return str(data or "auto") + def _edge_endpoint_target_from_inputs(self, endpoint_role: str) -> tuple[float, float, float] | None: prefix = "edge_start" if endpoint_role == "start" else "edge_end" try: @@ -2982,7 +3930,7 @@ class WindowActionMixin: def resize_cone_reference_radius(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再修改圆锥面。"): + if self._edit_busy("请等待当前编辑完成后再修改圆锥面(整体)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。") @@ -2998,15 +3946,15 @@ class WindowActionMixin: face_id = self.selected_face_id plan = self.model.conical_reference_radius_plan(face_id, target_radius) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改圆锥面", str(plan["message"])) - self.statusBar().showMessage("圆锥面修改已阻止") + QMessageBox.information(self, "不能修改圆锥面(整体)", str(plan["message"])) + self.statusBar().showMessage("圆锥面(整体)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认修改圆锥面", + "确认修改圆锥面(整体)", ( f"Face: {face_id}\n" f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n" @@ -3015,6 +3963,8 @@ class WindowActionMixin: f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -3024,7 +3974,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消圆锥面修改") + self.statusBar().showMessage("已取消圆锥面(整体)修改") return self.clear_edit_preview(render=False) @@ -3033,7 +3983,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修改圆锥面参考半径", + operation_name="修改圆锥面参数(整体)", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -3053,6 +4003,8 @@ class WindowActionMixin: "affine_axis_direction": plan.get("affine_axis_direction"), "affine_target_kind": plan.get("affine_target_kind"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), "resize_message": plan.get("message"), @@ -3066,7 +4018,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 或球面特征。") @@ -3082,15 +4034,15 @@ class WindowActionMixin: face_id = self.selected_face_id plan = self.model.spherical_radius_plan(face_id, target_radius) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改球面", str(plan["message"])) - self.statusBar().showMessage("球面修改已阻止") + QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"])) + self.statusBar().showMessage("球面(整体)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认修改球面", + "确认修改球面(整体)", ( f"Face: {face_id}\n" f"当前半径: {_format_value(plan.get('current_radius'))}\n" @@ -3099,6 +4051,8 @@ class WindowActionMixin: f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -3108,7 +4062,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消球面修改") + self.statusBar().showMessage("已取消球面(整体)修改") return self.clear_edit_preview(render=False) @@ -3117,7 +4071,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修改球面半径", + operation_name="修改球面半径(整体)", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -3136,6 +4090,8 @@ class WindowActionMixin: "affine_axis_point": plan.get("affine_axis_point"), "affine_target_kind": plan.get("affine_target_kind"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), "resize_message": plan.get("message"), @@ -3155,7 +4111,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 或环面特征。") @@ -3169,12 +4125,12 @@ 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) 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", "")) @@ -3192,6 +4148,8 @@ class WindowActionMixin: f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -3201,7 +4159,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消环面修改") + self.statusBar().showMessage("已取消环面(整体)修改") return self.clear_edit_preview(render=False) @@ -3233,6 +4191,8 @@ class WindowActionMixin: "affine_axis_direction": plan.get("affine_axis_direction"), "affine_target_kind": plan.get("affine_target_kind"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), "resize_message": plan.get("message"), @@ -3246,7 +4206,7 @@ class WindowActionMixin: def resize_face_area(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。") @@ -3256,39 +4216,42 @@ class WindowActionMixin: try: target_area = float(self.face_area_input.text()) except ValueError: - QMessageBox.critical(self, "Face面积无效", "请输入数字形式的目标Face面积。") + QMessageBox.critical(self, "面面积无效", "请输入数字形式的目标面面积。") return face_id = self.selected_face_id plan = self.model.face_area_scale_plan(face_id, target_area) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改Face面积", str(plan["message"])) - self.statusBar().showMessage("Face面积修改已阻止") + QMessageBox.information(self, "不能修改面面积", str(plan["message"])) + self.statusBar().showMessage("面面积修改已阻止") return warnings = str(plan.get("warnings", "")) warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认修改Face面积", + "确认修改面面积", ( f"Face: {face_id}\n" f"曲面类型: {_format_value(plan.get('surface'))}\n" f"当前面积: {_format_value(plan.get('current_area'))}\n" f"目标面积: {_format_value(plan.get('target_area'))}\n" - f"变化比例: {_format_percent(plan.get('area_delta_ratio'))}\n" - f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" - f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" - f"策略: {_format_value(plan.get('resize_strategy'))}\n" - f"风险: {plan['risk']}\n\n" - f"{warnings_line}" - f"{plan['message']}\n\n" - "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" - ), + f"变化比例: {_format_percent(plan.get('area_delta_ratio'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n" + f"编辑方式: {_format_value(plan.get('edit_semantics'))}\n" + f"影响说明: {_format_value(plan.get('affine_transform_note'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "这不是只改变一个面的 CAD 历史面积参数;继续操作会缩放所属对象并刷新 B-Rep 结果几何,支持失败回滚/撤销。确定继续吗?" + ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消Face面积修改") + self.statusBar().showMessage("已取消面面积修改") return self.clear_edit_preview(render=False) @@ -3297,7 +4260,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修改Face面积", + operation_name="修改面面积", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -3311,9 +4274,12 @@ class WindowActionMixin: "affine_scale": plan.get("affine_scale"), "affine_transform_kind": plan.get("affine_transform_kind"), "affine_transform_label": plan.get("affine_transform_label"), + "affine_transform_note": plan.get("affine_transform_note"), "affine_axis_point": plan.get("affine_axis_point"), "affine_target_kind": plan.get("affine_target_kind"), "resize_strategy": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), "resize_message": plan.get("message"), @@ -3324,6 +4290,908 @@ class WindowActionMixin: target_id=face_id, ) + def resize_face_area_local(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再只缩放当前Face面积。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + if not hasattr(self, "face_area_input"): + return + try: + target_area = float(self.face_area_input.text()) + except ValueError: + QMessageBox.critical(self, "面面积无效", "请输入数字形式的目标面面积。") + return + + face_id = self.selected_face_id + plan = self.model.face_area_local_resize_plan(face_id, target_area) + lines = [ + f"Face: {face_id}", + f"当前面积: {_format_value(plan.get('current_area'))}", + f"目标面积: {_format_value(plan.get('target_area'))}", + f"面积变化量: {_format_value(plan.get('area_delta'))}", + f"面积变化比例: {_format_percent(plan.get('area_delta_ratio'))}", + f"面内缩放比例: {_format_value(plan.get('local_face_area_scale'))}", + f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", + f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", + 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) + + def action(): + return self.model.resize_face_area_local(face_id, target_area) + + self._run_edit_action( + action, + operation_name="只缩放当前Face面积", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "current_area": plan.get("current_area"), + "target_area": plan.get("target_area"), + "area_delta": plan.get("area_delta"), + "area_delta_ratio": plan.get("area_delta_ratio"), + "area_center": plan.get("area_center"), + "local_face_area_scale": plan.get("local_face_area_scale"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "local_face_deform_target_kind": plan.get("local_face_deform_target_kind"), + "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": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_face_width_local(self) -> None: + self._resize_face_size_local("width") + + def resize_face_height_local(self) -> None: + self._resize_face_size_local("height") + + def resize_face_width_owning_scale(self) -> None: + self._resize_face_size_owning_scale("width") + + def resize_face_height_owning_scale(self) -> None: + self._resize_face_size_owning_scale("height") + + def _resize_face_size_local(self, axis: str) -> None: + 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}(当前面)。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + input_attr = "face_height_input" if axis_key == "height" else "face_width_input" + if not hasattr(self, input_attr): + return + try: + target_size = float(getattr(self, input_attr).text()) + except ValueError: + QMessageBox.critical(self, f"{axis_label}无效", f"请输入数字形式的目标{axis_label}。") + return + + face_id = self.selected_face_id + 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"变化量: {_format_value(plan.get('face_size_delta'))}", + f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('face_size_scale'))}", + f"缩放方向: {_format_value(plan.get('face_size_axis_direction'))}", + f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", + f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", + f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", + ] + if not self._confirm_quick_edit_plan( + f"{axis_label}(当前面)", + plan, + lines, + f"{axis_label}(当前面)已阻止", + f"已取消{axis_label}(当前面)", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_face_size_local(face_id, target_size, axis_key) + + self._run_edit_action( + action, + operation_name=f"修改{axis_label}(当前面)", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "face_size_axis": plan.get("face_size_axis"), + "face_size_label": plan.get("face_size_label"), + "current_face_width": plan.get("current_face_width"), + "target_face_width": plan.get("target_face_width"), + "current_face_height": plan.get("current_face_height"), + "target_face_height": plan.get("target_face_height"), + "current_face_size": plan.get("current_face_size"), + "target_face_size": plan.get("target_face_size"), + "face_size_delta": plan.get("face_size_delta"), + "face_size_delta_ratio": plan.get("face_size_delta_ratio"), + "face_size_scale": plan.get("face_size_scale"), + "face_size_center": plan.get("face_size_center"), + "face_size_axis_direction": plan.get("face_size_axis_direction"), + "face_width_direction": plan.get("face_width_direction"), + "face_height_direction": plan.get("face_height_direction"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "local_face_deform_target_kind": plan.get("local_face_deform_target_kind"), + "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": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def _resize_face_size_owning_scale(self, axis: str) -> None: + 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}(整体)。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + input_attr = "face_height_input" if axis_key == "height" else "face_width_input" + if not hasattr(self, input_attr): + return + try: + target_size = float(getattr(self, input_attr).text()) + except ValueError: + QMessageBox.critical(self, f"{axis_label}无效", f"请输入数字形式的目标{axis_label}。") + return + + face_id = self.selected_face_id + 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"变化量: {_format_value(plan.get('face_size_delta'))}", + f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", + f"缩放比例: {_format_value(plan.get('face_size_scale'))}", + f"缩放方向: {_format_value(plan.get('face_size_axis_direction'))}", + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}", + f"缩放类型: {_format_value(plan.get('affine_transform_label'))}", + f"重建模式: {_format_value(plan.get('owning_face_size_rebuild_mode'))}", + f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", + f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", + f"影响说明: {_format_value(plan.get('affine_transform_note'))}", + ] + if not self._confirm_quick_edit_plan( + f"{axis_label}(整体)", + plan, + lines, + f"{axis_label}(整体)已阻止", + f"已取消{axis_label}(整体)", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_face_size_owning_scale(face_id, target_size, axis_key) + + self._run_edit_action( + action, + operation_name=f"修改{axis_label}(整体)", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "face_size_axis": plan.get("face_size_axis"), + "face_size_label": plan.get("face_size_label"), + "current_face_width": plan.get("current_face_width"), + "target_face_width": plan.get("target_face_width"), + "current_face_height": plan.get("current_face_height"), + "target_face_height": plan.get("target_face_height"), + "current_face_size": plan.get("current_face_size"), + "target_face_size": plan.get("target_face_size"), + "face_size_delta": plan.get("face_size_delta"), + "face_size_delta_ratio": plan.get("face_size_delta_ratio"), + "face_size_scale": plan.get("face_size_scale"), + "face_size_center": plan.get("face_size_center"), + "face_size_axis_direction": plan.get("face_size_axis_direction"), + "face_width_direction": plan.get("face_width_direction"), + "face_height_direction": plan.get("face_height_direction"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_transform_note": plan.get("affine_transform_note"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_axis_source": plan.get("affine_axis_source"), + "affine_anchor_source": plan.get("affine_anchor_source"), + "affine_target_kind": plan.get("affine_target_kind"), + "owning_face_size_rebuild_mode": plan.get("owning_face_size_rebuild_mode"), + "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": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def move_selected_face_plane_position_by_translation(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再按面偏移平移所属对象。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + try: + distance = float(self.offset_input.text()) + except (AttributeError, ValueError): + QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。") + return + face_id = self.selected_face_id + frame = self._selected_plane_offset_frame(face_id) + if frame is None: + QMessageBox.information(self, "不能按面偏移平移", "当前 Face 缺少稳定平面方向或基准点。") + return + plane_origin, plane_direction, current_position = frame + target_position = current_position + distance + vector = ( + plane_direction[0] * distance, + plane_direction[1] * distance, + plane_direction[2] * distance, + ) + + if self.selected_solid_id is not None: + moved_kind = "solid" + moved_id = self.selected_solid_id + plan = self.model.translate_solid_plan(moved_id, vector) + + def action(): + return self.model.translate_solid(moved_id, vector) + + elif self.selected_part_id is not None: + moved_kind = "part" + moved_id = self.selected_part_id + plan = self.model.translate_part_plan(moved_id, vector) + + def action(): + return self.model.translate_part(moved_id, vector) + + else: + QMessageBox.information(self, "不能按面偏移平移", "当前 Face 没有关联到稳定的所属对象。") + return + + if plan["status"] == "blocked": + QMessageBox.information(self, "不能按面偏移平移", str(plan["message"])) + self.statusBar().showMessage("面偏移整体平移已阻止") + return + if not self._confirm_face_plane_position_translation_plan( + plan, + face_id, + current_position, + target_position, + plane_direction, + ): + self.statusBar().showMessage("已取消面偏移整体平移") + return + self.clear_edit_preview(render=False) + self._run_edit_action( + action, + operation_name="面偏移(整体)", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "moved_target_kind": moved_kind, + "moved_target_id": moved_id, + "current_plane_position": current_position, + "target_plane_position": target_position, + "plane_origin": plane_origin, + "plane_direction": plane_direction, + "translation_vector": vector, + "translation_distance": plan.get("translation_distance"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "translate_status": plan.get("status"), + "translate_risk": plan.get("risk"), + "translate_message": plan.get("message"), + "translate_warnings": plan.get("warnings"), + "translate_blockers": plan.get("blockers"), + "resize_strategy": "translate-owning-shape-from-plane-offset", + "edit_strategy_label": "按面偏移平移所属对象", + "edit_semantics": "把目标面偏移换算成沿当前平面方向的平移量,并平移所属特征或 Solid;不推拉当前面,不切削,也不补料。", + }, + target_kind="face", + target_id=face_id, + ) + + def move_selected_face_plane_position_local(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再按面偏移只移动当前Face。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + try: + distance = float(self.offset_input.text()) + except (AttributeError, ValueError): + QMessageBox.critical(self, "面偏移无效", "请输入数字形式的目标面偏移。") + return + + face_id = self.selected_face_id + frame = self._selected_plane_offset_frame(face_id) + if frame is None: + QMessageBox.information(self, "不能只移动当前Face", "当前 Face 缺少稳定平面方向或基准点。") + return + plane_origin, plane_direction, current_position = frame + current_center = self._selected_face_center_for_translation(face_id) + if current_center is None: + QMessageBox.information(self, "不能只移动当前Face", "当前 Face 缺少稳定中心坐标。") + return + + target_position = current_position + distance + vector = ( + plane_direction[0] * distance, + plane_direction[1] * distance, + plane_direction[2] * distance, + ) + target_center = ( + current_center[0] + vector[0], + current_center[1] + vector[1], + current_center[2] + vector[2], + ) + plan = self.model.face_center_local_move_plan(face_id, target_center) + lines = [ + f"Face: {face_id}", + f"当前面偏移: {_format_value(current_position)}", + f"目标面偏移: {_format_value(target_position)}", + f"平面方向: {_format_value(plane_direction)}", + f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", + f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", + f"移动/所属对象尺寸: {_format_percent(plan.get('face_center_move_ratio'))}", + f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", + f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", + 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) + + def action(): + return self.model.move_face_center_local(face_id, target_center) + + self._run_edit_action( + action, + operation_name="面偏移(当前面)", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "current_plane_position": current_position, + "target_plane_position": target_position, + "plane_origin": plane_origin, + "plane_direction": plane_direction, + "plane_offset_distance": distance, + "current_face_center": plan.get("current_face_center"), + "target_face_center": plan.get("target_face_center"), + "face_center_move_vector": plan.get("face_center_move_vector"), + "face_center_move_distance": plan.get("face_center_move_distance"), + "face_center_move_ratio": plan.get("face_center_move_ratio"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "local_face_deform_target_kind": plan.get("local_face_deform_target_kind"), + "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_semantics": ( + "把目标面偏移换算成沿当前平面方向的移动量,只移动当前 Face 的顶点并重建相邻平面;" + "不推拉加料/切削,也不平移所属对象。" + ), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def move_selected_face_center(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再移动Face中心。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个 Face。") + return + try: + vector = self._translation_vector_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "Face中心坐标无效", str(exc)) + return + + face_id = self.selected_face_id + current_center = self._selected_face_center_for_translation(face_id) + target_center = ( + (current_center[0] + vector[0], current_center[1] + vector[1], current_center[2] + vector[2]) + if current_center is not None + else None + ) + if self.selected_solid_id is not None: + moved_kind = "solid" + moved_id = self.selected_solid_id + plan = self.model.translate_solid_plan(moved_id, vector) + + def action(): + return self.model.translate_solid(moved_id, vector) + + elif self.selected_part_id is not None: + moved_kind = "part" + moved_id = self.selected_part_id + plan = self.model.translate_part_plan(moved_id, vector) + + def action(): + return self.model.translate_part(moved_id, vector) + + else: + QMessageBox.information(self, "不能移动Face中心", "当前 Face 没有关联到稳定的所属对象。") + return + + if plan["status"] == "blocked": + QMessageBox.information(self, "不能移动Face中心", str(plan["message"])) + self.statusBar().showMessage("Face中心移动已阻止") + return + if not self._confirm_face_center_move_plan(plan, face_id, current_center, target_center): + self.statusBar().showMessage("已取消Face中心移动") + return + + self._run_edit_action( + action, + operation_name="移动Face中心", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "moved_target_kind": moved_kind, + "moved_target_id": moved_id, + "current_face_center": current_center, + "target_face_center": target_center, + "translation_vector": vector, + "translation_distance": plan.get("translation_distance"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "translate_status": plan.get("status"), + "translate_risk": plan.get("risk"), + "translate_message": plan.get("message"), + "translate_warnings": plan.get("warnings"), + "translate_blockers": plan.get("blockers"), + "edit_strategy_label": "平移Face所属对象", + "edit_semantics": "把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。", + }, + target_kind="face", + target_id=face_id, + ) + + def move_selected_face_center_local(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再只移动当前Face。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") + return + try: + vector = self._translation_vector_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "Face中心坐标无效", str(exc)) + return + + 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 缺少稳定中心坐标。") + return + target_center = ( + current_center[0] + vector[0], + current_center[1] + vector[1], + current_center[2] + vector[2], + ) + 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('target_face_center'))}", + f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", + f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", + f"移动/所属对象尺寸: {_format_percent(plan.get('face_center_move_ratio'))}", + f"重建Face数: {_format_value(plan.get('local_face_deform_face_count'))}", + f"移动顶点数: {_format_value(plan.get('local_face_deform_moved_point_count'))}", + 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) + + def action(): + return self.model.move_face_center_local(face_id, target_center) + + self._run_edit_action( + action, + operation_name="只移动当前Face", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "current_face_center": plan.get("current_face_center"), + "target_face_center": plan.get("target_face_center"), + "face_center_move_vector": plan.get("face_center_move_vector"), + "face_center_move_distance": plan.get("face_center_move_distance"), + "face_center_move_ratio": plan.get("face_center_move_ratio"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "local_face_deform_target_kind": plan.get("local_face_deform_target_kind"), + "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": plan.get("resize_strategy"), + "edit_strategy_label": plan.get("edit_strategy_label"), + "edit_semantics": plan.get("edit_semantics"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def move_selected_axis_center_by_translation(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再按轴心平移所属对象。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱特征", "请先选择一个带稳定轴心的孔、槽或凸台 Face。") + return + try: + vector = self._translation_vector_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "轴心坐标无效", str(exc)) + return + + face_id = self.selected_face_id + info = self._selected_face_info_snapshot() + current_center = self._selected_axis_center_for_translation(info) + if current_center is None: + QMessageBox.information(self, "不能按轴心平移", "当前对象缺少稳定轴心坐标。") + return + target_center = ( + current_center[0] + vector[0], + current_center[1] + vector[1], + current_center[2] + vector[2], + ) + if self.selected_solid_id is not None: + moved_kind = "solid" + moved_id = self.selected_solid_id + plan = self.model.translate_solid_plan(moved_id, vector) + + def action(): + return self.model.translate_solid(moved_id, vector) + + elif self.selected_part_id is not None: + moved_kind = "part" + moved_id = self.selected_part_id + plan = self.model.translate_part_plan(moved_id, vector) + + def action(): + return self.model.translate_part(moved_id, vector) + + else: + QMessageBox.information(self, "不能按轴心平移", "当前对象没有关联到稳定的所属特征或 Solid。") + return + + if plan["status"] == "blocked": + QMessageBox.information(self, "不能按轴心平移", str(plan["message"])) + self.statusBar().showMessage("按轴心平移所属对象已阻止") + return + if not self._confirm_axis_center_translation_plan(plan, face_id, current_center, target_center): + self.statusBar().showMessage("已取消按轴心平移所属对象") + return + + self.clear_edit_preview(render=False) + self._run_edit_action( + action, + operation_name="按轴心平移所属对象", + target=f"Face {face_id}", + parameters={ + "face_id": face_id, + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": info.get("surface"), + "feature_type": info.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "moved_target_kind": moved_kind, + "moved_target_id": moved_id, + "current_axis_center": current_center, + "target_axis_center": target_center, + "translation_vector": vector, + "translation_distance": plan.get("translation_distance"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "translate_status": plan.get("status"), + "translate_risk": plan.get("risk"), + "translate_message": plan.get("message"), + "translate_warnings": plan.get("warnings"), + "translate_blockers": plan.get("blockers"), + "resize_strategy": "translate-owning-shape-from-axis-center", + "edit_strategy_label": "按轴心平移所属对象", + "edit_semantics": "把目标轴心坐标换算成平移量,并平移所属特征或 Solid;不填旧孔、不切新孔、不重建局部凸台。", + }, + target_kind="face", + target_id=face_id, + ) + + def _selected_axis_center_for_translation(self, info: dict[str, object]) -> tuple[float, float, float] | None: + center = _triple_or_none(info.get("axis_center")) + if center is not None: + return center + axis_point = _triple_or_none(info.get("axis_point")) + axis_direction = _triple_or_none(info.get("axis")) + axis_range = info.get("same_domain_v_range") or info.get("v_range") + if ( + axis_point is not None + and axis_direction is not None + and isinstance(axis_range, (list, tuple)) + and len(axis_range) >= 2 + ): + v_min = _float_or_none(axis_range[0]) + v_max = _float_or_none(axis_range[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + return ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + return _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")) + + def _confirm_axis_center_translation_plan( + self, + plan: dict[str, object], + face_id: int, + current_center: tuple[float, float, float] | None, + target_center: tuple[float, float, float] | None, + ) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + 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_center)}\n" + f"目标轴心: {_format_value(target_center)}\n" + f"平移向量: {_format_value(plan['translation_vector'])}\n" + f"平移距离: {_format_value(plan['translation_distance'])}\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, + ) + return result == QMessageBox.StandardButton.Yes + + def _selected_face_center_for_translation(self, face_id: int) -> tuple[float, float, float] | None: + info = dict(getattr(self, "current_info_values", {}) or {}) + try: + current_face_id = int(info.get("face_id", -1)) + except (TypeError, ValueError): + current_face_id = -1 + if current_face_id != face_id and self.model is not None: + try: + info = self.model.quick_face_info(face_id) + except Exception: + info = {} + center = _triple_or_none(info.get("area_center")) + if center is not None: + return center + bbox_min = _triple_or_none(info.get("bbox_min")) + bbox_max = _triple_or_none(info.get("bbox_max")) + if bbox_min is not None and bbox_max is not None: + return ( + (bbox_min[0] + bbox_max[0]) * 0.5, + (bbox_min[1] + bbox_max[1]) * 0.5, + (bbox_min[2] + bbox_max[2]) * 0.5, + ) + return None + + def _selected_plane_offset_frame( + self, + face_id: int, + ) -> tuple[tuple[float, float, float], tuple[float, float, float], float] | None: + info = dict(getattr(self, "current_info_values", {}) or {}) + try: + current_face_id = int(info.get("face_id", -1)) + except (TypeError, ValueError): + current_face_id = -1 + if current_face_id != face_id and self.model is not None: + try: + info = self.model.quick_face_info(face_id) + except Exception: + info = {} + if str(info.get("surface", "")) != "plane": + return None + origin = _triple_or_none(info.get("plane_origin")) + direction = ( + _unit_triple_or_none(info.get("push_pull_outward_direction")) + or _unit_triple_or_none(info.get("oriented_normal")) + or _unit_triple_or_none(info.get("normal")) + ) + if origin is None or direction is None: + return None + current_position = ( + origin[0] * direction[0] + + origin[1] * direction[1] + + origin[2] * direction[2] + ) + return origin, direction, current_position + + def _confirm_face_center_move_plan( + self, + plan: dict[str, object], + face_id: int, + current_center: tuple[float, float, float] | None, + target_center: tuple[float, float, float] | None, + ) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认移动Face中心", + ( + 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"当前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" + f"编辑策略: 平移Face所属对象\n" + f"编辑方式: 把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "当前语义是平移这个 Face 所属的特征或 Solid,让 Face 中心到达目标坐标;" + "不会只扭曲单个 Face。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + + def _confirm_face_plane_position_translation_plan( + self, + plan: dict[str, object], + face_id: int, + current_position: float, + target_position: float, + plane_direction: tuple[float, float, float], + ) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + 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(plane_direction)}\n" + f"平移向量: {_format_value(plan['translation_vector'])}\n" + f"平移距离: {_format_value(plan['translation_distance'])}\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, + ) + return result == QMessageBox.StandardButton.Yes + def translate_selected_part(self) -> None: if self.model is None: return @@ -3844,9 +5712,20 @@ class WindowActionMixin: self.pending_scan_context = dict(context or {}) self._clear_hover(render=True) self._update_action_states() - self.scan_thread = None - self.scan_worker = None - QTimer.singleShot(0, lambda: self._run_scan_task_sync(action)) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + self.scan_wait_cursor_active = True + thread = QThread(self) + worker = ScanWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_scan_task_result, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_scan_task_result, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_scan_thread) + self.scan_thread = thread + self.scan_worker = worker + thread.start() def _run_scan_task_sync(self, action) -> None: if not self.scan_in_progress: @@ -3894,6 +5773,12 @@ class WindowActionMixin: self.statusBar().showMessage("扫描失败") def _end_scan_task(self) -> None: + if getattr(self, "scan_wait_cursor_active", False): + try: + QApplication.restoreOverrideCursor() + except Exception: + pass + self.scan_wait_cursor_active = False self.scan_in_progress = False self.pending_scan_kind = None self.pending_scan_context = None @@ -4301,6 +6186,8 @@ class WindowActionMixin: progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。" elif "孔径" in operation_name: progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步红/绿预览。" + elif "圆柱尺寸缩放所属对象" in operation_name: + progress_text = f"{operation_name} 正在后台计算;当前会均匀缩放所属特征或 Solid,其它尺寸也会跟随变化。" elif "槽/半孔宽度" in operation_name: progress_text = f"{operation_name} 正在后台计算;槽宽会在后台换算并重建孔/槽几何。" elif "槽/半孔深度" in operation_name: @@ -4324,6 +6211,22 @@ class WindowActionMixin: 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} 正在后台计算;当前会在面内移动所选 Face 顶点并重建相邻平面。" + elif "面宽(整体)" in operation_name or "面高(整体)" in operation_name: + progress_text = f"{operation_name} 正在后台计算;当前会沿选中 Face 的平面内方向缩放所属特征或 Solid,其它几何会跟随变化。" + elif "面宽(当前面)" in operation_name or "面高(当前面)" in operation_name: + progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。" + elif "面偏移(当前面)" in operation_name: + progress_text = f"{operation_name} 正在后台计算;当前会沿平面方向移动所选 Face 顶点并重建相邻平面。" + 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: + 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} 正在后台计算,完成后会刷新模型位置和拓扑索引。" elif "旋转" in operation_name: @@ -4527,12 +6430,25 @@ class WindowActionMixin: summary_parts.append(f"edge_center={_format_value(parameters['target_edge_center'])}") if parameters.get("target_endpoint_point") is not None: summary_parts.append(f"edge_endpoint={_format_value(parameters['target_endpoint_point'])}") + if parameters.get("target_face_center") is not None: + summary_parts.append(f"face_center={_format_value(parameters['target_face_center'])}") + if parameters.get("target_axis_center") is not None: + summary_parts.append(f"axis_center={_format_value(parameters['target_axis_center'])}") + if parameters.get("target_plane_position") is not None: + summary_parts.append(f"plane_offset={_format_value(parameters['target_plane_position'])}") + if parameters.get("target_area") is not None: + summary_parts.append(f"area={_format_value(parameters['target_area'])}") if "new_diameter" in parameters: summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}") if parameters.get("target_length") is not None: summary_parts.append(f"target_length={_format_value(parameters['target_length'])}") if parameters.get("new_slot_total_length") is not None: summary_parts.append(f"slot_total_length={_format_value(parameters['new_slot_total_length'])}") + if parameters.get("new_slot_center_distance") is not None: + summary_parts.append(f"slot_center_distance={_format_value(parameters['new_slot_center_distance'])}") + if parameters.get("ellipse_target_radius") is not None: + label = parameters.get("ellipse_axis_kind") or "axis" + summary_parts.append(f"ellipse_{label}_radius={_format_value(parameters['ellipse_target_radius'])}") if parameters.get("new_slot_angular_span_degrees") is not None: summary_parts.append(f"slot_angle={_format_value(parameters['new_slot_angular_span_degrees'])}deg") if parameters.get("target_bbox_diagonal") is not None: diff --git a/step_editor/window_core.py b/step_editor/window_core.py index baefb54..b68b852 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -24,6 +24,23 @@ from .workers import EditWorker, LoadWorker, ScanWorker VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0) +def _prepare_static_mapper(mapper) -> None: + """Hint that displayed STEP geometry is static while the camera moves.""" + if hasattr(mapper, "ScalarVisibilityOff"): + mapper.ScalarVisibilityOff() + if hasattr(mapper, "StaticOn"): + mapper.StaticOn() + elif hasattr(mapper, "SetStatic"): + mapper.SetStatic(True) + + +def _safe_int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + class WindowCoreMixin: @Slot(object) def _run_ui_task(self, callback) -> None: @@ -121,37 +138,11 @@ class WindowCoreMixin: return self.camera_interaction_active = True self._cancel_hover_tracking(render=False) - if self.edge_actor is not None: - try: - self.edge_actor_visible_before_interaction = bool(self.edge_actor.GetVisibility()) - self.edge_actor.SetVisibility(False) - except Exception: - self.edge_actor_visible_before_interaction = True - if self.model_actor is not None: - try: - prop = self.model_actor.GetProperty() - self.model_interpolation_before_interaction = int(prop.GetInterpolation()) - prop.SetInterpolationToGouraud() - except Exception: - self.model_interpolation_before_interaction = None def _end_camera_interaction(self) -> None: if not getattr(self, "camera_interaction_active", False): return self.camera_interaction_active = False - if self.edge_actor is not None: - try: - self.edge_actor.SetVisibility(bool(getattr(self, "edge_actor_visible_before_interaction", True))) - except Exception: - pass - if self.model_actor is not None and self.model_interpolation_before_interaction is not None: - try: - self.model_actor.GetProperty().SetInterpolation(int(self.model_interpolation_before_interaction)) - except Exception: - pass - self.model_interpolation_before_interaction = None - if hasattr(self, "render_window"): - self.render_window.Render() def closeEvent(self, event) -> None: load_thread_running = bool( @@ -669,13 +660,15 @@ class WindowCoreMixin: edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id) label = f"Solid {self.selected_solid_id}" elif self.selected_kind == "feature" and self.selected_face_id is not None: - info = self.model.feature_info(self.selected_face_id) + info = dict(getattr(self, "current_info_values", {}) or {}) + if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id: + info = {} face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] edge_ids = _int_values(info.get("feature_boundary_edge_ids")) label = f"特征 Face {self.selected_face_id}" elif self.selected_kind == "face" and self.selected_face_id is not None: - face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] - edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id) + face_ids = [self.selected_face_id] + edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id) label = f"Face {self.selected_face_id}" elif self.selected_kind == "edge" and self.selected_edge_id is not None: info = self.model.edge_info(self.selected_edge_id) @@ -737,16 +730,18 @@ class WindowCoreMixin: face_polydata = self.model.build_face_polydata(face_ids=face_ids) edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) elif self.selected_kind == "feature" and self.selected_face_id is not None: - info = self.model.feature_info(self.selected_face_id) + info = dict(getattr(self, "current_info_values", {}) or {}) + if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id: + info = {} face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] edge_ids = _int_values(info.get("feature_boundary_edge_ids")) - face_polydata = self.model.build_face_polydata(face_ids=face_ids) - edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) + face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False) + if edge_ids: + edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) elif self.selected_kind == "face" and self.selected_face_id is not None: - face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] - face_polydata = self.model.build_face_polydata(face_ids=face_ids) + face_polydata = self._cached_face_overlay_polydata(face_ids=[self.selected_face_id], smooth=False) elif self.selected_kind == "edge" and self.selected_edge_id is not None: - edge_polydata = self.model.build_edge_polydata(edge_ids=[self.selected_edge_id]) + edge_polydata = self._cached_edge_overlay_polydata(self.selected_edge_id) return _merge_polydata_bounds(face_polydata, edge_polydata) @@ -773,12 +768,13 @@ class WindowCoreMixin: face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id] self._highlight_faces(face_ids=face_ids) elif self.selected_kind == "feature" and self.selected_face_id is not None: - info = self.model.feature_info(self.selected_face_id) + info = dict(getattr(self, "current_info_values", {}) or {}) + if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id: + info = {} face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] self._highlight_faces(face_ids=face_ids) elif self.selected_kind == "face" and self.selected_face_id is not None: - face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] - self._highlight_faces(face_ids=face_ids) + self._highlight_faces(face_ids=[self.selected_face_id]) elif self.selected_kind == "edge" and self.selected_edge_id is not None: self._highlight_edge(self.selected_edge_id) @@ -786,6 +782,33 @@ class WindowCoreMixin: self.face_overlay_polydata_cache.clear() self.edge_overlay_polydata_cache.clear() + def _rebuild_polydata_cell_indexes(self) -> None: + self.model_face_cell_ids_by_face = {} + self.model_face_cell_ids_by_part = {} + self.model_face_cell_ids_by_solid = {} + if self.model_polydata is None: + return + face_arr = self.model_face_id_array + part_arr = self.model_part_id_array + solid_arr = self.model_solid_id_array + if face_arr is None or part_arr is None or solid_arr is None: + return + for cell_id in range(self.model_polydata.GetNumberOfCells()): + face_id = int(face_arr.GetValue(cell_id)) + part_id = int(part_arr.GetValue(cell_id)) + solid_id = int(solid_arr.GetValue(cell_id)) + self.model_face_cell_ids_by_face.setdefault(face_id, []).append(cell_id) + self.model_face_cell_ids_by_part.setdefault(part_id, []).append(cell_id) + self.model_face_cell_ids_by_solid.setdefault(solid_id, []).append(cell_id) + + def _rebuild_edge_polydata_cell_index(self) -> None: + self.edge_cell_ids_by_edge = {} + if self.edge_polydata is None or self.edge_id_array is None: + return + for cell_id in range(self.edge_polydata.GetNumberOfCells()): + edge_id = int(self.edge_id_array.GetValue(cell_id)) + self.edge_cell_ids_by_edge.setdefault(edge_id, []).append(cell_id) + def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object: if len(cache) >= self.overlay_cache_limit: try: @@ -826,8 +849,22 @@ class WindowCoreMixin: part_arr = self.model_part_id_array if face_arr is None or part_arr is None: return None + candidate_cell_ids = None + if face_set is not None and self.model_face_cell_ids_by_face: + candidate_cell_ids = [] + for face_id in face_set: + candidate_cell_ids.extend(self.model_face_cell_ids_by_face.get(int(face_id), ())) + elif part_set is not None and self.model_face_cell_ids_by_part: + candidate_cell_ids = [] + for part_id in part_set: + candidate_cell_ids.extend(self.model_face_cell_ids_by_part.get(int(part_id), ())) + cell_ids = ( + range(self.model_polydata.GetNumberOfCells()) + if candidate_cell_ids is None + else sorted(set(candidate_cell_ids)) + ) ids = vtk.vtkIdList() - for cell_id in range(self.model_polydata.GetNumberOfCells()): + for cell_id in cell_ids: if face_set is not None and int(face_arr.GetValue(cell_id)) not in face_set: continue if part_set is not None and int(part_arr.GetValue(cell_id)) not in part_set: @@ -838,8 +875,11 @@ class WindowCoreMixin: def _extract_visible_edge_polydata(self, edge_id: int): if self.edge_polydata is None or self.edge_id_array is None: return None + cell_ids = self.edge_cell_ids_by_edge.get(int(edge_id)) + if cell_ids is None: + cell_ids = range(self.edge_polydata.GetNumberOfCells()) ids = vtk.vtkIdList() - for cell_id in range(self.edge_polydata.GetNumberOfCells()): + for cell_id in cell_ids: if int(self.edge_id_array.GetValue(cell_id)) == edge_id: ids.InsertNextId(cell_id) return self._extract_cells_as_polydata(self.edge_polydata, ids) @@ -868,8 +908,10 @@ class WindowCoreMixin: 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") + self._rebuild_polydata_cell_indexes() mapper = vtk.vtkPolyDataMapper() mapper.SetInputData(self.model_polydata) + _prepare_static_mapper(mapper) self.model_actor = vtk.vtkActor() self.model_actor.SetMapper(mapper) self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73) @@ -886,8 +928,10 @@ class WindowCoreMixin: self.edge_polydata = edge_polydata self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id") + self._rebuild_edge_polydata_cell_index() edge_mapper = vtk.vtkPolyDataMapper() edge_mapper.SetInputData(self.edge_polydata) + _prepare_static_mapper(edge_mapper) self.edge_actor = vtk.vtkActor() self.edge_actor.SetMapper(edge_mapper) self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1) @@ -941,7 +985,7 @@ class WindowCoreMixin: self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() - self._clear_hover(render=True) + self._clear_hover(render=False) def on_left_button_release(self, _obj, _event) -> None: if getattr(self, "skip_next_vtk_left_release", False): @@ -999,10 +1043,12 @@ class WindowCoreMixin: target = self._pick_selection_target(mode, x, y) if target is None: self._cancel_hover_tracking(render=False) - self._reset_selection(clear_highlight=True) if hasattr(self, "render_window"): self.render_window.Render() - self.statusBar().showMessage("已取消选择") + if self.selected_kind is not None: + self.statusBar().showMessage("未命中对象,已保持当前选择") + else: + self.statusBar().showMessage("未命中对象") return self._clear_hover(render=False) @@ -1019,7 +1065,7 @@ class WindowCoreMixin: self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() - self._clear_hover(render=True) + self._clear_hover(render=False) def on_pointer_button_release(self, _obj, _event) -> None: if not self._is_ui_thread(): @@ -1083,6 +1129,8 @@ class WindowCoreMixin: if not self._is_ui_thread(): self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y)) return + if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False): + return position = (int(x), int(y)) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) if threshold > 0: @@ -1093,10 +1141,14 @@ class WindowCoreMixin: if max(dx, dy) < threshold: return self.pending_hover_position = position - if not self.hover_timer.isActive(): - self.hover_timer.start(self.hover_interval_ms) + if self.hover_timer.isActive(): + self.hover_timer.stop() + self.hover_timer.start(self.hover_interval_ms) def _update_hover_target(self) -> None: + if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False): + self._clear_hover(render=False) + return if ( self.operation_in_progress or self.scan_in_progress @@ -1109,9 +1161,21 @@ class WindowCoreMixin: return x, y = self.pending_hover_position self.last_hover_pick_position = (x, y) - target = self._pick_selection_target(self._current_selection_mode(), x, y) + target = self._pick_hover_target(self._current_selection_mode(), x, y) self._show_hover_target(target) + def _pick_hover_target(self, mode: str, x: int, y: int) -> dict[str, object] | None: + if self.model is None: + return None + if mode == "Edge": + edge_hit = self._pick_edge_cell(x, y) + return self._edge_target_from_cell_hit(edge_hit) if edge_hit is not None else None + + face_hit = self._pick_face_cell(x, y) + if face_hit is None: + return None + return self._target_from_face_hit(face_hit, mode) + def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None: if self.model is None: return None @@ -1510,29 +1574,26 @@ class WindowCoreMixin: self.selected_kind = "feature" if feature_mode else "face" self.selected_face_id = face_id self.selected_pick_position = pick_position - info = self.model.face_info(face_id) + info = self.model.quick_face_info(face_id) if feature_mode: - info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征" - highlight_face_ids = [face_id] + info = self._feature_info_for_selected_face(face_id, info) + info["kind"] = "feature" + info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征") + highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id] else: - highlight_face_ids = self.model.connected_same_domain_face_ids(face_id) - if len(highlight_face_ids) > 1: - info["same_domain_face_ids"] = tuple(highlight_face_ids) - info["same_domain_face_count"] = len(highlight_face_ids) - info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续Face区域。" - logical_id = self.model.face_region_logical_id(face_id) - input_info = self.model.feature_info(face_id) if info.get("surface") == "plane" else info + highlight_face_ids = [face_id] + logical_id = int(info.get("logical_face_id", face_id)) + input_info = dict(info) 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.set_info(self._with_pick_info(info, pick_position)) + self.set_info(self._with_pick_info(input_info, pick_position)) self._update_action_states() - suffix = f",同域区域 {len(highlight_face_ids)} 个Face" if not feature_mode and len(highlight_face_ids) > 1 else "" raw_note = f"(拓扑Face {face_id})" if not feature_mode and logical_id != face_id else "" - message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择Face {face_id}" + message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源Face {face_id}" 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: @@ -1540,7 +1601,8 @@ class WindowCoreMixin: return if self._edit_busy("编辑计算中,暂时不能选择特征。"): return - info = self.model.feature_info(face_id) + info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id)) + info["kind"] = "feature" self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "feature" self.selected_face_id = face_id @@ -1559,6 +1621,23 @@ class WindowCoreMixin: def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: fillet_radius_suggestion: str | None = None + for target_attr in ( + "hole_center_x_input", + "hole_center_y_input", + "hole_center_z_input", + "slot_center_x_input", + "slot_center_y_input", + "slot_center_z_input", + "boss_center_x_input", + "boss_center_y_input", + "boss_center_z_input", + "cone_reference_radius_input", + "sphere_radius_input", + "torus_radius_input", + "face_area_input", + ): + if hasattr(self, target_attr): + getattr(self, target_attr).clear() shell_thickness = _float_or_none(info.get("shell_thickness_estimate")) if hasattr(self, "shell_thickness_input"): if info.get("shell_region_status") == "candidate" and shell_thickness is not None and shell_thickness > 0: @@ -1764,11 +1843,6 @@ class WindowCoreMixin: return None kind = str(target["kind"]) target_id = int(target["target_id"]) - if kind in {"face", "feature"} and 0 <= target_id < len(self.model.faces): - try: - return (kind, self.model.face_region_logical_id(target_id)) - except Exception: - return (kind, target_id) return (kind, target_id) def _show_hover_target(self, target: dict[str, object] | None) -> None: @@ -1792,13 +1866,11 @@ class WindowCoreMixin: face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id] self._highlight_hover_faces(face_ids=face_ids) elif kind == "feature": - face_ids = self.model.connected_same_domain_face_ids(target_id) or [target_id] - self._highlight_hover_faces(face_ids=face_ids) + self._highlight_hover_faces(face_ids=[target_id]) elif kind == "edge": self._highlight_hover_edge(target_id) else: - face_ids = self.model.connected_same_domain_face_ids(target_id) - self._highlight_hover_faces(face_ids=face_ids or [target_id]) + self._highlight_hover_faces(face_ids=[target_id]) self.hover_signature = signature self.render_window.Render() @@ -2078,12 +2150,23 @@ class WindowCoreMixin: return self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览") - def _show_edge_length_preview(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> None: + def _show_edge_length_preview( + self, + edge_id: int, + target_length: float, + anchor_mode: str = "auto", + strategy_mode: str = "auto", + ) -> None: if self.model is None: return self.clear_edit_preview(render=False) try: - polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length, anchor_mode=anchor_mode) + polydata = self.model.straight_edge_length_preview_polydata( + edge_id, + target_length, + anchor_mode=anchor_mode, + strategy_mode=strategy_mode, + ) except Exception as exc: self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}") return diff --git a/step_editor/window_state.py b/step_editor/window_state.py index badb49d..82f410e 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -15,14 +15,18 @@ from PySide6.QtWidgets import ( ) from PySide6.QtGui import QColor -from .constants import SNAPSHOT_FACE_LOGICAL_IDS_KEY +from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY from .model import StepModel from .records import OperationRecord from .ui_helpers import * # noqa: F403 +from .widgets import NoWheelComboBox from .workers import EditWorker, LoadWorker, ScanWorker PROPERTY_VALUE_TOLERANCE = 1e-9 +PROPERTY_SCOPE_COLUMN = 2 +PROPERTY_TARGET_COLUMN = 3 +PROPERTY_ACTION_COLUMN = 4 def _record_message_field(message: str | None, key: str) -> str | None: @@ -105,6 +109,67 @@ class WindowStateMixin: enriched["pick_position"] = pick_position return enriched + def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]: + if self.model is None: + return dict(fallback_info) + 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: + cached_info = None + if cached_info is not None: + info = cached_info + else: + info = dict(fallback_info) + info.setdefault("feature_source_face_id", face_id) + info.setdefault("feature_highlight_face_ids", (face_id,)) + info.setdefault( + "feature_mode", + "快速选择信息;不会在选中时扫描同域面、端盖或底面,点击具体修改时会再计算完整编辑计划。", + ) + if surface == "plane": + 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: + info.setdefault("feature_type", "槽/半孔候选") + info.setdefault("feature_edit_actions", "调整槽/半孔宽度、深度、圆弧参数;完整槽孔配对会在执行时计算") + else: + info.setdefault("feature_type", "圆柱孔/槽候选") + info.setdefault("feature_edit_actions", "调整圆柱孔径;底面/端盖会在执行深度或封堵时计算") + elif feature_guess == "boss/outer-round candidate": + info.setdefault("feature_type", "凸台/外圆候选") + info.setdefault("feature_edit_actions", "调整圆柱凸台直径;高度和端盖会在执行时计算") + elif feature_guess == "round/fillet candidate": + info.setdefault("feature_type", "圆角/倒圆候选") + info.setdefault("feature_edit_actions", "可尝试修改已有圆角半径;支撑面会在执行时计算") + else: + info.setdefault("feature_type", "未明确圆柱特征") + info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认") + elif surface == "cone": + info.setdefault("feature_type", "圆锥面候选") + info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径(整体缩放)") + elif surface == "sphere": + info.setdefault("feature_type", "球面候选") + info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)") + elif surface == "torus": + info.setdefault("feature_type", "环面候选") + info.setdefault("feature_edit_actions", "修改环面主半径/小半径(整体缩放)") + for key in ( + "same_domain_face_ids", + "same_domain_face_count", + "same_domain_note", + "pick_position", + ): + if key in fallback_info: + info.setdefault(key, fallback_info[key]) + return info + def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str: if pick_position is None: return message @@ -571,6 +636,13 @@ class WindowStateMixin: "选择修改Edge长度时尽量固定哪一端。", "请先选择一条Edge。", ) + if hasattr(self, "edge_length_strategy_combo"): + self._set_control_state( + self.edge_length_strategy_combo, + can_resize_edge_length, + "选择修改Edge长度时使用哪种几何语义。", + "请先选择一条Edge。", + ) self._set_control_state( self.resize_edge_length_button, can_resize_edge_length, @@ -796,7 +868,7 @@ class WindowStateMixin: for key, value in action_info.items(): info.setdefault(key, value) - specs = self._property_editor_specs(info, action_info) + specs = self._sort_property_specs_for_display(self._property_editor_specs(info, action_info)) self.property_editor_specs = specs self.property_table_expanded = False self.property_editor_updating = True @@ -804,74 +876,188 @@ class WindowStateMixin: try: self.property_table.setRowCount(len(specs)) for row, spec in enumerate(specs): - editable = bool(spec.get("editable") and spec.get("enabled")) - value_type = str(spec.get("value_type", "number")) + effective_spec = self._effective_property_spec(spec) + editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) + value_type = str(effective_spec.get("value_type", "number")) input_editable = editable and value_type != "command" - label_item = self._property_table_item(str(spec.get("label", "")), editable=False) - current_item = self._property_table_item(str(spec.get("current_text", "")), editable=False) + label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False) + current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False) + scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False) target_item = self._property_table_item( - "" if input_editable else str(spec.get("target_text", "")), + "" if input_editable else str(effective_spec.get("target_text", "")), editable=False, ) - status_text = "" if editable else str(spec.get("status_text", "")) - status_item = self._property_table_item(status_text, editable=False) - target_item.setToolTip(self._property_target_tooltip(spec, editable=editable)) - row_items = (label_item, current_item, target_item, status_item) + action_text = "" if editable else str(effective_spec.get("status_text", "")) + action_item = self._property_table_item(action_text, editable=False) + target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) + row_items = (label_item, current_item, scope_item, target_item, action_item) self._style_property_row_items(row_items, editable=editable) for column, item in enumerate(row_items): item.setToolTip(item.toolTip() or item.text()) self.property_table.setItem(row, column, item) self.property_table.setRowHeight(row, 28 if editable else 24) + if spec.get("scope_modes"): + self._set_property_scope_editor(row, spec) + else: + self.property_table.removeCellWidget(row, PROPERTY_SCOPE_COLUMN) if editable: if input_editable: - self._set_property_target_editor(row, spec) + self._set_property_target_editor(row, effective_spec) else: - self.property_table.removeCellWidget(row, 2) - self._set_property_row_button(row, spec) + self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN) + self._set_property_row_button(row, effective_spec) else: - self.property_table.removeCellWidget(row, 2) - self.property_table.removeCellWidget(row, 3) + self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN) + self.property_table.removeCellWidget(row, PROPERTY_ACTION_COLUMN) self._resize_property_table_height() finally: self.property_table.blockSignals(was_blocked) self.property_editor_updating = False self._update_property_apply_state() + def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]: + def rank(spec: dict[str, object]) -> int: + editable = bool(spec.get("editable")) + enabled = bool(spec.get("enabled")) + has_action = bool(spec.get("action")) + value_type = str(spec.get("value_type", "number")) + if editable and enabled and has_action and value_type != "command": + return 0 + if editable and enabled and has_action: + return 1 + if editable: + return 2 + if str(spec.get("status_text", "")) == "说明": + return 3 + return 4 + + return [spec for _index, spec in sorted(enumerate(specs), key=lambda item: (rank(item[1]), item[0]))] + def _style_property_row_items( self, - items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem], + items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem], *, editable: bool, ) -> None: - label_item, current_item, target_item, status_item = items + label_item, current_item, scope_item, target_item, status_item = items if editable: - row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed") + row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed", "#fff7ed") for item, color in zip(items, row_backgrounds): item.setBackground(QColor(color)) label_item.setForeground(QColor("#7c2d12")) current_item.setForeground(QColor("#431407")) + scope_item.setForeground(QColor("#9a3412")) target_item.setForeground(QColor("#111827")) status_item.setForeground(QColor("#9a3412")) label_font = label_item.font() label_font.setBold(True) label_item.setFont(label_font) + scope_font = scope_item.font() + scope_font.setBold(True) + scope_item.setFont(scope_font) status_font = status_item.font() status_font.setBold(True) status_item.setFont(status_font) return + scope_item.setForeground(QColor("#64748b")) target_item.setBackground(QColor("#eef2f6")) target_item.setForeground(QColor("#8f99a8")) status_item.setForeground(QColor("#64748b")) + def _property_scope_default(self, spec: dict[str, object]) -> str: + modes = spec.get("scope_modes") + if not isinstance(modes, dict) or not modes: + return "" + preferred = str(spec.get("scope_default") or "") + if preferred in modes and isinstance(modes[preferred], dict) and bool(modes[preferred].get("enabled", True)): + return preferred + for scope_key, mode in modes.items(): + if isinstance(mode, dict) and bool(mode.get("enabled", True)): + return str(scope_key) + return str(next(iter(modes))) + + def _property_scope_value(self, row: int, spec: dict[str, object]) -> str: + modes = spec.get("scope_modes") + if not isinstance(modes, dict) or not modes: + return "" + widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) if hasattr(self, "property_table") else None + if isinstance(widget, NoWheelComboBox): + value = widget.currentData() + if value in modes: + return str(value) + return self._property_scope_default(spec) + + def _effective_property_spec(self, spec: dict[str, object], row: int | None = None) -> dict[str, object]: + modes = spec.get("scope_modes") + if not isinstance(modes, dict) or not modes: + return dict(spec) + scope_key = self._property_scope_value(row, spec) if row is not None else self._property_scope_default(spec) + mode = modes.get(scope_key) + if not isinstance(mode, dict): + scope_key = self._property_scope_default(spec) + mode = modes.get(scope_key, {}) + effective = dict(spec) + base_label = str(spec.get("label", "")) + inherited_enabled = bool(spec.get("enabled")) + effective.update(mode) + effective["label"] = base_label + effective["scope_key"] = scope_key + effective["scope_label"] = str(mode.get("label") or scope_key) + effective["scope_text"] = str(mode.get("label") or scope_key) + effective["enabled"] = inherited_enabled and bool(mode.get("enabled", True)) + effective["status_text"] = "可修改" if effective["enabled"] else "不可修改" + return effective + + def _property_scope_tooltip(self, spec: dict[str, object], scope_key: str) -> str: + modes = spec.get("scope_modes") + if not isinstance(modes, dict): + return "" + mode = modes.get(scope_key) + if not isinstance(mode, dict): + return "" + label = str(mode.get("label") or scope_key) + tip_key = "enabled_tip" if bool(mode.get("enabled", True)) else "disabled_tip" + tip = str(mode.get(tip_key) or mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip() + if tip: + return f"影响范围:{label}\n\n{tip}" + return f"影响范围:{label}" + def _set_property_target_editor(self, row: int, spec: dict[str, object]) -> None: editor = QLineEdit(str(spec.get("target_text", ""))) editor.setObjectName("propertyTargetEditor") editor.setToolTip(self._property_target_tooltip(spec, editable=True)) + editor.setPlaceholderText("目标值;清空不会删除模型") editor.setCursor(Qt.CursorShape.IBeamCursor) editor.setFrame(True) editor.textChanged.connect(lambda _text="", _row=row: self._update_property_apply_state()) editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row)) - self.property_table.setCellWidget(row, 2, editor) + self.property_table.setCellWidget(row, PROPERTY_TARGET_COLUMN, editor) + + def _set_property_scope_editor(self, row: int, spec: dict[str, object]) -> None: + modes = spec.get("scope_modes") + if not isinstance(modes, dict) or not modes: + self.property_table.removeCellWidget(row, PROPERTY_SCOPE_COLUMN) + return + combo = NoWheelComboBox() + combo.setObjectName("propertyScopeCombo") + default_scope = self._property_scope_default(spec) + selected_index = 0 + for index, (scope_key, mode) in enumerate(modes.items()): + if not isinstance(mode, dict): + continue + label = str(mode.get("label") or scope_key) + if not bool(mode.get("enabled", True)): + label = f"{label}(不可用)" + combo.addItem(label, scope_key) + tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip() + if tip: + combo.setItemData(index, tip, Qt.ItemDataRole.ToolTipRole) + if scope_key == default_scope: + selected_index = index + combo.setCurrentIndex(selected_index) + combo.setToolTip(self._property_scope_tooltip(spec, default_scope)) + combo.currentIndexChanged.connect(lambda _index=0, target_row=row: self._on_property_scope_changed(target_row)) + self.property_table.setCellWidget(row, PROPERTY_SCOPE_COLUMN, combo) def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None: button = QPushButton("未改动") @@ -884,7 +1070,29 @@ class WindowStateMixin: button.setCursor(Qt.CursorShape.PointingHandCursor) button.setProperty("changed", False) button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) - self.property_table.setCellWidget(row, 3, button) + self.property_table.setCellWidget(row, PROPERTY_ACTION_COLUMN, button) + + def _on_property_scope_changed(self, row: int) -> None: + specs = getattr(self, "property_editor_specs", []) + if row < 0 or row >= len(specs): + return + effective_spec = self._effective_property_spec(specs[row], row=row) + editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) + input_editable = editable and str(effective_spec.get("value_type", "number")) != "command" + scope_widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) + if isinstance(scope_widget, NoWheelComboBox): + scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row]))) + target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) + if isinstance(target_widget, QLineEdit): + target_widget.setEnabled(input_editable) + target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) + target_item = self.property_table.item(row, PROPERTY_TARGET_COLUMN) + if target_item is not None: + target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) + action_item = self.property_table.item(row, PROPERTY_ACTION_COLUMN) + if action_item is not None and not editable: + action_item.setText(str(effective_spec.get("status_text", ""))) + self._update_property_apply_state() def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str: tip_key = "enabled_tip" if editable else "disabled_tip" @@ -1184,6 +1392,20 @@ class WindowStateMixin: ) return hint + def cylinder_owning_scale_hint(current_diameter: object) -> str: + hint = relative_range_hint(current_diameter, 0.2, 0.5) + return ( + f"{hint} 这是另一种修改语义:按目标直径比例均匀缩放所属特征或 Solid," + "高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通直径行。" + ) + + def cylinder_owning_scale_radius_hint(current_radius: object) -> str: + hint = relative_range_hint(current_radius, 0.2, 0.5) + return ( + f"{hint} 这是另一种修改语义:按目标半径换算成直径比例后均匀缩放所属特征或 Solid," + "高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通半径行。" + ) + def add_spec( *, key: str, @@ -1240,6 +1462,345 @@ class WindowStateMixin: ) used_keys.update(used or (key,)) + def add_scoped_spec( + *, + key: str, + label: str, + current_raw: object, + target_text: str, + scope_modes: dict[str, dict[str, object]], + scope_default: str, + value_type: str = "number", + used: tuple[str, ...] = (), + min_value: float | None = None, + max_value: float | None = None, + min_exclusive: bool = False, + max_exclusive: bool = False, + current_text: str | None = None, + ) -> None: + if not scope_modes: + return + preferred = scope_default if scope_default in scope_modes else next(iter(scope_modes)) + if not bool(scope_modes.get(preferred, {}).get("enabled", True)): + for mode_key, mode in scope_modes.items(): + if bool(mode.get("enabled", True)): + preferred = mode_key + break + selected_mode = scope_modes.get(preferred, {}) + any_enabled = any(bool(mode.get("enabled", True)) for mode in scope_modes.values()) + add_spec( + key=key, + label=label, + current_raw=current_raw, + target_text=target_text, + action=str(selected_mode.get("action", "")), + target_attr=selected_mode.get("target_attr"), + target_attrs=selected_mode.get("target_attrs"), + enabled=any_enabled, + enabled_tip=str(selected_mode.get("enabled_tip", "")), + disabled_tip=str(selected_mode.get("disabled_tip", "")), + value_type=value_type, + used=used, + range_hint=str(selected_mode.get("range_hint", "")), + min_value=min_value, + max_value=max_value, + min_exclusive=min_exclusive, + max_exclusive=max_exclusive, + target_transform=selected_mode.get("target_transform"), + transform_context=selected_mode.get("transform_context"), + current_text=current_text, + ) + specs[-1]["scope_modes"] = scope_modes + specs[-1]["scope_default"] = preferred + specs[-1]["scope_text"] = str(selected_mode.get("label", "")) + + def add_readonly_spec( + *, + key: str, + label: str, + text: str, + tip: str, + ) -> None: + specs.append( + { + "key": key, + "label": label, + "current_text": text, + "current_raw": text, + "target_text": "", + "editable": False, + "enabled": False, + "status_text": "说明", + "disabled_tip": tip or text, + } + ) + + if has_face: + if is_hole_or_groove: + if is_slot_or_half_hole: + add_readonly_spec( + key="slot_edit_semantics", + label="编辑方式", + text="槽/半孔:局部扇形槽重建;槽孔总长度会尝试配对另一端。", + tip=( + "槽/半孔宽度、深度和圆弧参数会换算成圆柱半径或圆弧角度," + "然后填旧槽、切新槽;槽孔总长度会优先按长圆槽两端整体重建。" + "轴心坐标有两种语义:局部重建槽/半孔,或平移所属对象。" + ), + ) + else: + add_readonly_spec( + key="hole_edit_semantics", + label="编辑方式", + text="孔:同轴重切孔壁;移动轴心会先填旧孔再切新孔。", + tip=( + "孔径/半径不是缩放整个模型,而是在当前孔轴线上做受限布尔重切;" + "轴心选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动整个特征”时" + "会移动整个所属对象;盲孔/盲槽深度会按底面方向切削或补料。" + ), + ) + elif is_boss: + add_readonly_spec( + key="boss_edit_semantics", + label="编辑方式", + text="凸台:直径重建包络;高度推拉端盖;轴心先移除再补新凸台。", + tip=( + "凸台直径会通过局部布尔补料或移除旧包络后重建目标圆柱;" + "凸台高度会推拉识别到的端盖 Face;凸台轴心会移除旧凸台包络后在目标轴心补出同直径凸台;" + "带“平移所属对象”的轴心坐标会移动整个所属对象。" + ), + ) + elif is_existing_fillet: + add_readonly_spec( + key="existing_fillet_edit_semantics", + label="编辑方式", + text="圆角:移除已有圆角面,再尝试按目标半径重新倒圆。", + tip=( + "已有圆角修改不是恢复 CAD 历史参数;当前会先 defeature 当前圆角面," + "再寻找可重新倒圆的锐边。复杂 blend 或支撑面不稳定时会阻止或回滚。" + ), + ) + elif is_cone or is_sphere or is_torus: + add_readonly_spec( + key="analytic_surface_edit_semantics", + label="编辑方式", + text="解析曲面:围绕中心或轴线缩放所属对象,会影响同对象其它尺寸。", + tip=( + "圆锥、球面、环面这类曲面当前走几何缩放语义," + "不是只替换单个曲面的历史参数;执行前需要确认影响范围。" + ), + ) + elif is_plane or is_shell_candidate: + add_readonly_spec( + key="face_edit_semantics", + label="编辑方式", + text="Face:先改目标值,再用“影响范围”选择只改当前面或调整整个特征。", + tip=( + "面积、面宽、面高、中心和面偏移会尽量合并成一行参数;" + "影响范围下拉框决定这次修改是只作用在当前 Face,还是带动所属特征或 Solid。" + ), + ) + + if has_edge: + add_readonly_spec( + key="edge_edit_semantics", + label="编辑方式", + text="Edge:长度修改可选择局部形变、移动端面或缩放所属对象。", + tip=( + "只变当前Edge会重建局部相邻面;移动端面更像整体尺寸变化;" + "缩放所属会影响同一特征或 Solid 上的其它尺寸。" + ), + ) + + if has_face: + current_face_area = _float_or_none(action_info.get("area")) + current_face_center = ( + _triple_or_none(action_info.get("area_center")) + or _triple_or_none(action_info.get("bbox_center")) + ) + add_scoped_spec( + key="area", + label="面积", + current_raw=current_face_area if current_face_area is not None else "", + target_text=numeric_text(current_face_area), + scope_default="local", + scope_modes={ + "local": { + "label": "只改当前面", + "action": "resize_face_area_local", + "target_attr": "face_area_input", + "enabled": bool( + is_plane + and current_face_area is not None + and current_face_area > 0 + and current_face_center is not None + ), + "enabled_tip": ( + "输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点," + "相邻面会按新边界重建。" + ), + "disabled_tip": "只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。", + "range_hint": ( + "只改当前面时,相邻面可能自然变斜。当前只对简单全平面实体开放," + f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8)}" + ), + }, + "owning": { + "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," + "让该面的面积接近目标值。" + ), + "disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。", + "range_hint": ( + "调整整个特征会影响同一对象上的其它尺寸。" + f" {relative_range_hint(current_face_area, 0.15, 0.8)}" + ), + }, + }, + value_type="positive", + used=("area", "area_center", "bbox_center"), + **positive_minimum(), + ) + if is_plane: + 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 在自身平面内两个稳定方向上的投影尺寸," + "不是全局 X/Y/Z 包围盒尺寸。只修改当前 Face 顶点,相邻面会按新顶点重建。" + ) + face_size_owner_tip = ( + "这里的宽/高是选中 Face 在自身平面内两个稳定方向上的投影尺寸," + "不是全局 X/Y/Z 包围盒尺寸。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。" + ) + add_scoped_spec( + key="local_face_width", + label="面宽", + 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": "只改当前面", + "action": "resize_face_width_local", + "target_attr": "face_width_input", + "enabled": bool( + current_face_width is not None + and current_face_width > 0 + and current_face_center is not None + ), + "enabled_tip": f"输入目标面宽;{face_size_tip}", + "disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。", + "range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8)}", + }, + "owning": { + "label": "调整整个特征", + "action": "resize_face_width_owning_scale", + "target_attr": "face_width_input", + "enabled": bool( + current_face_width is not None + and current_face_width > 0 + 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 缺少稳定面宽、中心坐标或所属对象,不能按面宽缩放所属对象。", + "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5)}", + }, + }, + value_type="positive", + used=("local_face_width", "local_face_width_direction", "local_face_size_center"), + **positive_minimum(), + ) + add_scoped_spec( + key="local_face_height", + label="面高", + 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": "只改当前面", + "action": "resize_face_height_local", + "target_attr": "face_height_input", + "enabled": bool( + current_face_height is not None + and current_face_height > 0 + and current_face_center is not None + ), + "enabled_tip": f"输入目标面高;{face_size_tip}", + "disabled_tip": "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。", + "range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8)}", + }, + "owning": { + "label": "调整整个特征", + "action": "resize_face_height_owning_scale", + "target_attr": "face_height_input", + "enabled": bool( + current_face_height is not None + and current_face_height > 0 + 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 缺少稳定面高、中心坐标或所属对象,不能按面高缩放所属对象。", + "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5)}", + }, + }, + value_type="positive", + used=("local_face_height", "local_face_height_direction", "local_face_size_center"), + **positive_minimum(), + ) + add_scoped_spec( + key="face_center_position", + label="中心", + current_raw=current_face_center if current_face_center is not None else "", + target_text=vector_text(current_face_center), + scope_default="local", + scope_modes={ + "local": { + "label": "只改当前面", + "action": "move_selected_face_center_local", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": bool(is_plane and current_face_center is not None), + "enabled_tip": ( + "输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;" + "程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。" + ), + "disabled_tip": "只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。", + "range_hint": ( + "只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。" + f"{translation_hint()}" + ), + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_face_center}, + }, + "owning": { + "label": "移动整个特征", + "action": "move_selected_face_center", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": current_face_center is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None), + "enabled_tip": ( + "输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;" + "程序会换算成平移量并移动它所属的特征或 Solid。" + ), + "disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。", + "range_hint": ( + "移动整个特征不会改变当前对象形状,但同一对象会整体搬动。" + f"{translation_hint()}" + ), + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_face_center}, + }, + }, + value_type="vector3", + used=("area_center", "bbox_center"), + ) + if is_plane: plane_origin = _triple_or_none(action_info.get("plane_origin")) plane_direction = ( @@ -1248,90 +1809,226 @@ class WindowStateMixin: ) current_plane_position = None if plane_origin is not None and plane_direction is not None: - current_plane_position = ( - plane_origin[0] * plane_direction[0] - + plane_origin[1] * plane_direction[1] - + plane_origin[2] * plane_direction[2] + direction_length = math.sqrt( + plane_direction[0] * plane_direction[0] + + plane_direction[1] * plane_direction[1] + + plane_direction[2] * plane_direction[2] ) - add_spec( + if direction_length > 1e-12: + plane_direction = ( + plane_direction[0] / direction_length, + plane_direction[1] / direction_length, + plane_direction[2] / direction_length, + ) + current_plane_position = ( + plane_origin[0] * plane_direction[0] + + plane_origin[1] * plane_direction[1] + + plane_origin[2] * plane_direction[2] + ) + add_scoped_spec( key="face_target_normal_position", - label="Face目标法向位置", + label="面偏移", current_raw=current_plane_position if current_plane_position is not None else "", target_text=numeric_text(current_plane_position), - action="push_pull_face", - target_attr="offset_input", - enabled=current_plane_position is not None, - enabled_tip="输入当前平面沿推拉方向的目标位置;程序会换算成相对推拉距离执行。", - disabled_tip="当前平面缺少稳定法向或原点,不能换算目标位置。", - range_hint="这是沿当前推拉方向的一维位置坐标,单位同模型;目标值可大于或小于当前值。", - target_transform="plane_target_position_to_offset", - transform_context={"current_plane_position": current_plane_position}, + scope_default="push_pull", + scope_modes={ + "push_pull": { + "label": "推拉当前面", + "action": "push_pull_face", + "target_attr": "offset_input", + "enabled": current_plane_position is not None, + "enabled_tip": "输入目标面偏移;程序会沿当前推拉方向移动面,自动加料或切削。", + "disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。", + "range_hint": ( + "这是沿当前推拉方向测量的位置值,不是 X/Y/Z 坐标;单位同模型。" + "目标值大于当前值通常向外移,小于当前值通常向内移。" + ), + "target_transform": "plane_target_position_to_offset", + "transform_context": {"current_plane_position": current_plane_position}, + }, + "local": { + "label": "只改当前面", + "action": "move_selected_face_plane_position_local", + "target_attr": "offset_input", + "enabled": bool( + current_plane_position is not None + and plane_direction is not None + and current_face_center is not None + ), + "enabled_tip": ( + "输入目标面偏移;程序只把当前 Face 沿平面方向移动到该位置," + "并让相邻平面按新顶点重建。" + ), + "disabled_tip": "当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。", + "range_hint": ( + "只改当前面不会自动加料/切削,相邻面可能自然变斜," + "非共面面可能被拆成三角面。" + f"{translation_hint()}" + ), + "target_transform": "plane_target_position_to_offset", + "transform_context": {"current_plane_position": current_plane_position}, + }, + "owning": { + "label": "移动整个特征", + "action": "move_selected_face_plane_position_by_translation", + "target_attr": "offset_input", + "enabled": bool( + current_plane_position is not None + and plane_direction is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None) + ), + "enabled_tip": ( + "输入目标面偏移;程序会沿当前平面方向平移所属特征或 Solid," + "当前面形状和所属对象内部尺寸不变。" + ), + "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。", + "range_hint": ( + "这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。" + f"{translation_hint()}" + ), + "target_transform": "plane_target_position_to_offset", + "transform_context": {"current_plane_position": current_plane_position}, + }, + }, used=("plane_origin", "push_pull_outward_direction", "normal"), ) add_spec( key="push_pull_distance", - label="Face本次偏移量", + label="偏移距离", current_raw=0.0, target_text="0", action="push_pull_face", target_attr="offset_input", enabled=True, - enabled_tip="输入本次沿当前平面推拉方向的相对偏移距离;执行后几何会更新,下一次本次偏移量会重新从 0 开始。", - disabled_tip="当前对象不是可推拉的平面 Face。", + enabled_tip="输入本次要让这个面沿当前推拉方向移动多远;正负表示两个相反方向,执行后这里会重新从 0 开始。", + disabled_tip="当前对象不是可移动的平面。", range_hint=push_pull_hint(), - current_text="0(相对当前)", + current_text="0(本次未移动)", ) if is_shell_candidate: current = _float_or_none(action_info.get("shell_thickness_estimate")) - add_spec( + signed_thickness = _float_or_none(action_info.get("shell_signed_thickness")) + shell_normal = _triple_or_none(action_info.get("normal")) + shell_origin = _triple_or_none(action_info.get("plane_origin")) + add_scoped_spec( key="shell_thickness_estimate", label="薄壁厚度", current_raw=current if current is not None else "", target_text=numeric_text(current), - action="resize_shell_thickness", - target_attr="shell_thickness_input", - enabled=current is not None, - enabled_tip="输入薄壁/壳体局部区域的目标厚度。", - disabled_tip="当前平面没有稳定识别到可修改的薄壁厚度。", + scope_default="local", + scope_modes={ + "local": { + "label": "推拉当前面", + "action": "resize_shell_thickness", + "target_attr": "shell_thickness_input", + "enabled": current is not None, + "enabled_tip": "输入薄壁/壳体局部区域的目标厚度;程序会推拉当前平面来改变与相对面的距离。", + "disabled_tip": "当前平面没有稳定识别到可修改的薄壁厚度。", + "range_hint": relative_range_hint(current, 0.35, 0.8), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_shell_thickness_owning_scale", + "target_attr": "shell_thickness_input", + "enabled": bool( + current is not None + and current > 0 + and signed_thickness is not None + and abs(signed_thickness) > 1e-9 + and shell_normal is not None + and shell_origin is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None) + ), + "enabled_tip": ( + "输入目标薄壁厚度;程序会沿厚度方向缩放所属特征或 Solid," + "同一对象上的其它厚度方向尺寸会跟随变化。" + ), + "disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。", + "range_hint": ( + "这是整体缩放所属对象,不是推拉当前平面;" + f"如果只想移动当前薄壁平面区域,请把影响范围设为“推拉当前面”。 {relative_range_hint(current, 0.2, 0.5)}" + ), + }, + }, value_type="positive", - range_hint=relative_range_hint(current, 0.35, 0.8), - used=("shell_thickness_estimate", "shell_current_thickness"), + used=("shell_thickness_estimate", "shell_current_thickness", "shell_signed_thickness", "normal", "plane_origin"), **positive_minimum(), ) if is_hole_or_groove: current_diameter = _float_or_none(action_info.get("diameter")) current_radius = _float_or_none(action_info.get("radius")) diameter_upper = hole_diameter_upper_limit(current_diameter) - add_spec( + cylinder_scale_axis_point = _triple_or_none(action_info.get("axis_point")) + cylinder_scale_axis_direction = _triple_or_none(action_info.get("axis")) + add_scoped_spec( key="diameter", - label="孔/圆柱直径", + label="直径", current_raw=current_diameter if current_diameter is not None else "", target_text=numeric_text(current_diameter), - action="resize_hole", - target_attr="hole_diameter_input", - enabled=current_diameter is not None, - enabled_tip="输入当前孔/槽圆柱面的目标直径。", - disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改孔/槽壁", + "action": "resize_hole", + "target_attr": "hole_diameter_input", + "enabled": current_diameter is not None, + "enabled_tip": "输入当前孔/槽圆柱面的目标直径;程序会局部重切或重建孔/槽壁。", + "disabled_tip": "当前圆柱面没有稳定识别为孔/槽候选。", + "range_hint": hole_diameter_hint(current_diameter), + "max_value": diameter_upper, + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_diameter is not None + and cylinder_scale_axis_point is not None + and cylinder_scale_axis_direction is not None + ), + "enabled_tip": "输入目标直径后,按比例均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。", + "disabled_tip": "当前圆柱面缺少稳定直径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_hint(current_diameter), + }, + }, value_type="positive", - range_hint=hole_diameter_hint(current_diameter), - max_value=diameter_upper, **positive_minimum(), ) - add_spec( + add_scoped_spec( key="hole_cylinder_radius", - label="孔/槽圆柱半径", + label="半径", current_raw=current_radius if current_radius is not None else "", target_text=numeric_text(current_radius), - action="resize_hole", - target_attr="hole_diameter_input", - enabled=current_radius is not None, - enabled_tip="输入当前孔/槽圆柱面的目标半径;程序会换算成目标直径后执行。", - disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改孔/槽壁", + "action": "resize_hole", + "target_attr": "hole_diameter_input", + "enabled": current_radius is not None, + "enabled_tip": "输入当前孔/槽圆柱面的目标半径;程序会换算成目标直径后局部重切或重建孔/槽壁。", + "disabled_tip": "当前圆柱面没有稳定识别为孔/槽候选。", + "range_hint": hole_radius_hint(current_radius, current_diameter), + "target_transform": "radius_to_diameter", + "max_value": diameter_upper * 0.5 if diameter_upper is not None else None, + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_radius is not None + and cylinder_scale_axis_point is not None + and cylinder_scale_axis_direction is not None + ), + "enabled_tip": "输入目标半径后,换算成目标直径比例并均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。", + "disabled_tip": "当前圆柱面缺少稳定半径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_radius_hint(current_radius), + "target_transform": "radius_to_diameter", + }, + }, value_type="positive", - range_hint=hole_radius_hint(current_radius, current_diameter), - target_transform="radius_to_diameter", used=("radius",), - max_value=diameter_upper * 0.5 if diameter_upper is not None else None, **positive_minimum(), ) axis_point = _triple_or_none(action_info.get("axis_point")) @@ -1353,77 +2050,208 @@ class WindowStateMixin: axis_point[1] + axis_direction[1] * v_mid, axis_point[2] + axis_direction[2] * v_mid, ) - add_spec( - key="hole_axis_center", - label="孔轴心坐标", - current_raw=current_axis_center if current_axis_center is not None else "", - target_text=vector_text(current_axis_center), - action="move_cylindrical_hole_axis", - target_attrs=("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"), - enabled=bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None), - enabled_tip="输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。", - disabled_tip="当前只对接近完整圆柱的孔放行轴心坐标修改;半孔/开放槽需要保留扇形开口,暂不使用整圆柱工具修改。", - value_type="vector3", - range_hint=translation_hint(), - used=("axis_point", "axis", "same_domain_v_range", "v_range"), - ) - add_spec( - key="suppress_cylindrical_hole", - label="封堵圆柱孔", - current_raw="未封堵", - target_text="无需输入", - action="suppress_hole", - enabled=bool(is_full_cylinder and current_diameter is not None), - enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。", - disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;半孔或槽请先使用槽/孔尺寸修改。", - value_type="command", - range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。", - button_text="封堵", - ) - if is_slot_or_half_hole: - current_slot = _float_or_none(action_info.get("slot_chord_width_estimate")) + if not is_slot_or_half_hole: + add_scoped_spec( + key="hole_axis_center", + label="轴心", + current_raw=current_axis_center if current_axis_center is not None else "", + target_text=vector_text(current_axis_center), + scope_default="local", + scope_modes={ + "local": { + "label": "移动孔", + "action": "move_cylindrical_hole_axis", + "target_attrs": ("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"), + "enabled": bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None), + "enabled_tip": "输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。", + "disabled_tip": "当前只对接近完整圆柱的孔放行轴心坐标修改;槽/半孔会使用扇形槽轴心坐标修改。", + "range_hint": translation_hint(), + }, + "owning": { + "label": "移动整个特征", + "action": "move_selected_axis_center_by_translation", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": bool( + current_axis_center is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None) + ), + "enabled_tip": ( + "输入目标孔轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," + "不会填旧孔再切新孔。" + ), + "disabled_tip": "当前孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "range_hint": ( + "这不是移动孔本身,而是整体搬动所属对象;" + f"{translation_hint()}" + ), + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_axis_center}, + }, + }, + value_type="vector3", + used=("axis_point", "axis", "same_domain_v_range", "v_range"), + ) add_spec( + key="suppress_cylindrical_hole", + label="封堵", + current_raw="未封堵", + target_text="无需输入", + action="suppress_hole", + enabled=bool(is_full_cylinder and current_diameter is not None), + enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。", + disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;槽/半孔请使用槽/孔尺寸或轴心修改。", + value_type="command", + range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。", + button_text="封堵", + ) + if is_slot_or_half_hole: + add_scoped_spec( + key="slot_axis_center", + label="轴心", + current_raw=current_axis_center if current_axis_center is not None else "", + target_text=vector_text(current_axis_center), + scope_default="local", + scope_modes={ + "local": { + "label": "移动槽/半孔", + "action": "move_cylindrical_slot_axis", + "target_attrs": ("slot_center_x_input", "slot_center_y_input", "slot_center_z_input"), + "enabled": bool(current_axis_center is not None and current_diameter is not None), + "enabled_tip": "输入槽/半孔轴心的目标坐标 X, Y, Z;程序会先填旧扇形槽,再按同宽度、同角度切出新槽。", + "disabled_tip": "当前槽/半孔缺少稳定轴心或宽度,不能移动轴心坐标。", + "range_hint": translation_hint(), + }, + "owning": { + "label": "移动整个特征", + "action": "move_selected_axis_center_by_translation", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": bool( + current_axis_center is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None) + ), + "enabled_tip": ( + "输入目标槽/半孔轴心 X, Y, Z 坐标;程序会平移所属特征或 Solid," + "不会填旧槽再切新槽。" + ), + "disabled_tip": "当前槽/半孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "range_hint": ( + "这不是移动槽/半孔本身,而是整体搬动所属对象;" + f"{translation_hint()}" + ), + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_axis_center}, + }, + }, + value_type="vector3", + used=("axis_point", "axis", "same_domain_v_range", "v_range"), + ) + current_slot = _float_or_none(action_info.get("slot_chord_width_estimate")) + slot_metric_angle = ( + _float_or_none(action_info.get("slot_angular_span")) + or _float_or_none(action_info.get("angular_span")) + ) + slot_owning_scale_ready = bool( + current_diameter is not None + and current_diameter > 0 + and slot_metric_angle is not None + and slot_metric_angle > 1e-6 + and slot_metric_angle < math.tau * 0.92 + and cylinder_scale_axis_point is not None + and cylinder_scale_axis_direction is not None + ) + slot_owning_scale_hint = ( + "这是整体缩放所属特征或 Solid,不是局部重切槽;槽宽、槽深、弧长以及同一对象上的其它尺寸都会按比例跟随变化。" + ) + add_scoped_spec( key="slot_chord_width_estimate", - label="槽/半孔宽度", + label="槽宽", current_raw=current_slot if current_slot is not None else "", target_text=numeric_text(current_slot), - action="resize_slot_width", - target_attr="slot_width_input", - enabled=current_slot is not None, - enabled_tip="输入槽或半孔的目标宽度。", - disabled_tip="当前对象不是稳定的槽/半孔候选。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改槽壁", + "action": "resize_slot_width", + "target_attr": "slot_width_input", + "enabled": current_slot is not None, + "enabled_tip": "输入槽或半孔的目标宽度;程序会局部重建槽壁。", + "disabled_tip": "当前对象不是稳定的槽/半孔候选。", + "range_hint": relative_range_hint(current_slot, 0.35, 1.0), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_slot_width_owning_scale", + "target_attr": "slot_width_input", + "enabled": bool(current_slot is not None and slot_owning_scale_ready), + "enabled_tip": f"输入目标槽宽;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}", + "disabled_tip": "当前槽/半孔缺少稳定槽宽、圆弧角度、圆柱直径或缩放中心,不能按槽宽整体缩放所属对象。", + "range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot, 0.2, 0.5)}", + }, + }, value_type="positive", - range_hint=relative_range_hint(current_slot, 0.35, 1.0), + used=("slot_chord_width_estimate", "slot_angular_span", "angular_span"), **positive_minimum(), ) current_slot_depth = _float_or_none(action_info.get("slot_sagitta_depth_estimate")) - add_spec( + add_scoped_spec( key="slot_sagitta_depth_estimate", - label="槽/半孔深度", + label="槽深", current_raw=current_slot_depth if current_slot_depth is not None else "", target_text=numeric_text(current_slot_depth), - action="resize_slot_depth", - target_attr="slot_depth_input", - enabled=current_slot_depth is not None, - enabled_tip="输入槽或半孔的目标凹入深度。", - disabled_tip="当前对象没有稳定的槽/半孔深度估算。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改槽壁", + "action": "resize_slot_depth", + "target_attr": "slot_depth_input", + "enabled": current_slot_depth is not None, + "enabled_tip": "输入槽或半孔的目标凹入深度;程序会局部重建槽壁。", + "disabled_tip": "当前对象没有稳定的槽/半孔深度估算。", + "range_hint": relative_range_hint(current_slot_depth, 0.35, 1.0), + }, + "owning": { + "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), + "enabled_tip": f"输入目标槽深;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}", + "disabled_tip": "当前槽/半孔缺少稳定槽深、圆弧角度、圆柱直径或缩放中心,不能按槽深整体缩放所属对象。", + "range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_depth, 0.2, 0.5)}", + }, + }, value_type="positive", - range_hint=relative_range_hint(current_slot_depth, 0.35, 1.0), + used=("slot_sagitta_depth_estimate", "slot_angular_span", "angular_span"), **positive_minimum(), ) current_slot_arc = _float_or_none(action_info.get("slot_arc_length_estimate")) - add_spec( + add_scoped_spec( key="slot_arc_length_estimate", - label="槽/半孔圆弧长度", + label="弧长", current_raw=current_slot_arc if current_slot_arc is not None else "", target_text=numeric_text(current_slot_arc), - action="resize_slot_arc_length", - target_attr="slot_arc_length_input", - enabled=current_slot_arc is not None, - enabled_tip="输入槽或半孔的目标圆弧长度。", - disabled_tip="当前对象没有稳定的槽/半孔圆弧长度估算。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改槽壁", + "action": "resize_slot_arc_length", + "target_attr": "slot_arc_length_input", + "enabled": current_slot_arc is not None, + "enabled_tip": "输入槽或半孔的目标圆弧长度;程序会局部重建槽壁。", + "disabled_tip": "当前对象没有稳定的槽/半孔圆弧长度估算。", + "range_hint": relative_range_hint(current_slot_arc, 0.35, 1.0), + }, + "owning": { + "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), + "enabled_tip": f"输入目标弧长;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}", + "disabled_tip": "当前槽/半孔缺少稳定弧长、圆弧角度、圆柱直径或缩放中心,不能按弧长整体缩放所属对象。", + "range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_arc, 0.2, 0.5)}", + }, + }, value_type="positive", - range_hint=relative_range_hint(current_slot_arc, 0.35, 1.0), + used=("slot_arc_length_estimate", "slot_angular_span", "angular_span"), **positive_minimum(), ) current_slot_angle = ( @@ -1435,7 +2263,7 @@ class WindowStateMixin: ) add_spec( key="slot_angular_span_degrees", - label="槽/半孔圆弧角度(度)", + label="弧角(度)", current_raw=current_slot_angle_degrees if current_slot_angle_degrees is not None else "", target_text=numeric_text(current_slot_angle_degrees), action="resize_slot_angular_span", @@ -1458,7 +2286,7 @@ class WindowStateMixin: ) add_spec( key="slot_open_angle_degrees", - label="槽/半孔开口角度(度)", + label="开口角(度)", current_raw=current_slot_open_angle_degrees if current_slot_open_angle_degrees is not None else "", target_text=numeric_text(current_slot_open_angle_degrees), action="resize_slot_angular_span", @@ -1476,18 +2304,11 @@ class WindowStateMixin: used=("slot_open_angle",), ) manual_slot_pair_text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else "" - manual_slot_pair_id = None - if manual_slot_pair_text: - try: - manual_slot_pair_id = int(manual_slot_pair_text) - except ValueError: - manual_slot_pair_id = None current_diameter_for_slot = _float_or_none(action_info.get("diameter")) - has_manual_slot_pair = bool(manual_slot_pair_text and manual_slot_pair_id is not None) slot_pair_target_text = manual_slot_pair_text add_spec( key="slot_pair_face_id", - label="槽孔配对端Face ID", + label="配对端Face", current_raw=slot_pair_target_text, target_text=slot_pair_target_text, action="set_manual_slot_pair_face", @@ -1502,34 +2323,32 @@ class WindowStateMixin: ) add_spec( key="slot_total_length_estimate", - label="槽孔总长度", + label="总长度", current_raw="", - current_text="执行时计算", + current_text="执行时自动识别", target_text="", action="resize_slot_total_length", target_attr="slot_total_length_input", - enabled=has_manual_slot_pair, - enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时按手动配对端计算当前长度并执行。", - disabled_tip="为了避免选择时卡顿,当前不会自动扫描配对端;请先在上一行手动填写另一个半圆槽端 Face ID。", + enabled=current_diameter_for_slot is not None, + enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时自动识别另一个半圆槽端,上一行 Face ID 只用于自动识别不准时手动指定。", + disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按槽孔总长度修改。", value_type="positive", - range_hint="需要先填写槽孔配对端 Face ID。点击修改时才会做配对端几何计划,避免选中对象时卡住界面。", + range_hint="目标总长度必须大于当前槽宽/直径;点击修改时才会识别配对端,避免选中对象时卡住界面。", **positive_minimum(), ) add_spec( key="slot_center_distance_estimate", - label="槽孔中心距", + label="中心距", current_raw="", - current_text="执行时计算", + current_text="执行时自动识别", target_text="", - action="resize_slot_total_length", - target_attr="slot_total_length_input", - enabled=has_manual_slot_pair and current_diameter_for_slot is not None, - enabled_tip="输入长圆槽两端半圆中心距;程序会用当前槽宽换算成槽孔总长度后执行。", - disabled_tip="请先填写有效的槽孔配对端 Face ID;当前槽还需要有稳定直径才能按中心距换算。", + action="resize_slot_center_distance", + target_attr="slot_center_distance_input", + enabled=current_diameter_for_slot is not None, + enabled_tip="输入长圆槽两端半圆中心距;程序会保持槽宽不变,并在执行时自动识别配对端。", + disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按中心距修改。", value_type="positive", - range_hint="目标槽孔总长度 = 目标中心距 + 当前槽宽/直径。点击修改时才会计算当前配对几何。", - target_transform="slot_center_distance_to_total_length", - transform_context={"slot_current_diameter": current_diameter_for_slot}, + range_hint="目标中心距必须大于 0;目标总长度会等于目标中心距加当前槽宽/直径。自动配对失败时,可先在上一行手动填写配对端 Face ID。", **positive_minimum(), ) bottom_face_ids = _int_values(action_info.get("feature_bottom_face_ids")) @@ -1544,7 +2363,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 ID", + label="底面Face", current_raw=bottom_target_text, target_text=bottom_target_text, action="set_manual_hole_bottom_face", @@ -1573,52 +2392,118 @@ class WindowStateMixin: pass can_depth = bool((is_blind or manual_bottom_id is not None) and (has_bottom or manual_bottom_id is not None) and current_depth is not None) if is_blind or current_depth is not None: - add_spec( + add_scoped_spec( key="hole_depth_estimate", label="盲孔/盲槽深度", current_raw=current_depth if current_depth is not None else "", target_text=numeric_text(current_depth), - action="resize_hole_depth", - target_attr="hole_depth_input", - enabled=can_depth, - enabled_tip="输入盲孔或盲槽的目标深度。", - disabled_tip="需要识别到盲孔/盲槽底面后才能直接修改深度。", + scope_default="local", + scope_modes={ + "local": { + "label": "改底面深度", + "action": "resize_hole_depth", + "target_attr": "hole_depth_input", + "enabled": can_depth, + "enabled_tip": "输入盲孔或盲槽的目标深度;加深会切削,变浅会补料到新的底面位置。", + "disabled_tip": "需要识别到盲孔/盲槽底面后才能直接修改深度。", + "range_hint": relative_range_hint(current_depth, 0.35, 1.0), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_hole_depth_owning_scale", + "target_attr": "hole_depth_input", + "enabled": bool( + can_depth + and current_depth is not None + and current_depth > 0 + and _triple_or_none(action_info.get("axis_point")) is not None + and _triple_or_none(action_info.get("axis")) is not None + ), + "enabled_tip": "输入目标盲孔/盲槽深度;程序会沿孔/槽轴向整体缩放所属特征或 Solid,不是局部切削或补料。", + "disabled_tip": "需要稳定深度、底面方向、轴线和缩放中心后,才能按深度整体缩放所属对象。", + "range_hint": ( + "这是整体缩放所属对象,同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化;" + f"{relative_range_hint(current_depth, 0.2, 0.5)}" + ), + }, + }, value_type="positive", - range_hint=relative_range_hint(current_depth, 0.35, 1.0), + used=("hole_depth_estimate", "axis_point", "axis", "feature_bottom_face_ids"), **positive_minimum(), ) if is_boss: current_boss = _float_or_none(action_info.get("diameter")) current_boss_radius = _float_or_none(action_info.get("radius")) - add_spec( + boss_scale_axis_point = _triple_or_none(action_info.get("axis_point")) + boss_scale_axis_direction = _triple_or_none(action_info.get("axis")) + add_scoped_spec( key="boss_diameter", - label="凸台直径", + label="直径", current_raw=current_boss if current_boss is not None else "", target_text=numeric_text(current_boss), - action="resize_boss", - target_attr="boss_diameter_input", - enabled=bool(is_full_cylinder and current_boss is not None), - enabled_tip="输入完整圆柱凸台的目标直径。", - disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行直径修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改凸台", + "action": "resize_boss", + "target_attr": "boss_diameter_input", + "enabled": bool(is_full_cylinder and current_boss is not None), + "enabled_tip": "输入完整圆柱凸台的目标直径;程序会重建凸台局部包络,尽量只改变当前凸台。", + "disabled_tip": "当前凸台候选不是接近完整圆柱,暂不放行直径修改。", + "range_hint": relative_range_hint(current_boss, 0.3, 0.8), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_boss is not None + and boss_scale_axis_point is not None + and boss_scale_axis_direction is not None + ), + "enabled_tip": "输入目标直径;程序会按比例均匀缩放所属特征或 Solid,而不是只重建凸台包络。", + "disabled_tip": "当前凸台缺少稳定直径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_hint(current_boss), + }, + }, value_type="positive", - range_hint=relative_range_hint(current_boss, 0.3, 0.8), - used=("diameter",), + used=("diameter", "axis_point", "axis"), **positive_minimum(), ) - add_spec( + add_scoped_spec( key="boss_radius", - label="凸台半径", + label="半径", current_raw=current_boss_radius if current_boss_radius is not None else "", target_text=numeric_text(current_boss_radius), - action="resize_boss", - target_attr="boss_diameter_input", - enabled=bool(is_full_cylinder and current_boss_radius is not None), - enabled_tip="输入完整圆柱凸台的目标半径;程序会换算成目标直径后执行。", - disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行半径修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "只改凸台", + "action": "resize_boss", + "target_attr": "boss_diameter_input", + "enabled": bool(is_full_cylinder and current_boss_radius is not None), + "enabled_tip": "输入完整圆柱凸台的目标半径;程序会换算成目标直径后重建凸台局部包络。", + "disabled_tip": "当前凸台候选不是接近完整圆柱,暂不放行半径修改。", + "range_hint": relative_range_hint(current_boss_radius, 0.3, 0.8), + "target_transform": "radius_to_diameter", + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_boss_radius is not None + and boss_scale_axis_point is not None + and boss_scale_axis_direction is not None + ), + "enabled_tip": "输入目标半径;程序会换算成目标直径比例并均匀缩放所属特征或 Solid。", + "disabled_tip": "当前凸台缺少稳定半径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_radius_hint(current_boss_radius), + "target_transform": "radius_to_diameter", + }, + }, value_type="positive", - range_hint=relative_range_hint(current_boss_radius, 0.3, 0.8), - target_transform="radius_to_diameter", - used=("radius",), + used=("radius", "axis_point", "axis"), **positive_minimum(), ) boss_axis_point = _triple_or_none(action_info.get("axis_point")) @@ -1640,88 +2525,201 @@ class WindowStateMixin: boss_axis_point[1] + boss_axis_direction[1] * v_mid, boss_axis_point[2] + boss_axis_direction[2] * v_mid, ) - add_spec( + add_scoped_spec( key="boss_axis_center", - label="凸台轴心坐标", + label="轴心", current_raw=current_boss_axis_center if current_boss_axis_center is not None else "", target_text=vector_text(current_boss_axis_center), - action="move_cylindrical_boss_axis", - target_attrs=("boss_center_x_input", "boss_center_y_input", "boss_center_z_input"), - enabled=bool(is_full_cylinder and current_boss_axis_center is not None and current_boss is not None), - enabled_tip="输入完整圆柱凸台轴心的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。", - disabled_tip="当前只对接近完整圆柱的凸台候选放行轴心坐标修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "移动凸台", + "action": "move_cylindrical_boss_axis", + "target_attrs": ("boss_center_x_input", "boss_center_y_input", "boss_center_z_input"), + "enabled": bool(is_full_cylinder and current_boss_axis_center is not None and current_boss is not None), + "enabled_tip": "输入完整圆柱凸台轴心的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。", + "disabled_tip": "当前只对接近完整圆柱的凸台候选放行轴心坐标修改。", + "range_hint": translation_hint(), + }, + "owning": { + "label": "移动整个特征", + "action": "move_selected_axis_center_by_translation", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": bool( + current_boss_axis_center is not None + and (self.selected_part_id is not None or self.selected_solid_id is not None) + ), + "enabled_tip": ( + "输入目标凸台轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," + "不会移除旧凸台再补新凸台。" + ), + "disabled_tip": "当前凸台缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "range_hint": ( + "这不是移动凸台本身,而是整体搬动所属对象;" + f"{translation_hint()}" + ), + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_boss_axis_center}, + }, + }, value_type="vector3", - range_hint=translation_hint(), used=("axis_point", "axis", "same_domain_v_range", "v_range"), ) current_boss_height = _float_or_none(action_info.get("same_domain_height_estimate")) if current_boss_height is None: current_boss_height = _float_or_none(action_info.get("height_estimate")) - add_spec( + add_scoped_spec( key="boss_height", - label="凸台高度", + label="高度", current_raw=current_boss_height if current_boss_height is not None else "", target_text=numeric_text(current_boss_height), - action="resize_boss_height", - target_attr="boss_height_input", - enabled=bool(is_full_cylinder and current_boss_height is not None and has_boss_height_cap), - enabled_tip="输入完整圆柱凸台的目标高度;程序会推拉识别到的凸台端盖 Face。", - disabled_tip="当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "推拉端盖", + "action": "resize_boss_height", + "target_attr": "boss_height_input", + "enabled": bool(is_full_cylinder and current_boss_height is not None), + "enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可推拉的凸台端盖 Face。", + "disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", + "range_hint": relative_range_hint(current_boss_height, 0.3, 0.8), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_height_owning_scale", + "target_attr": "boss_height_input", + "enabled": bool( + current_boss_height is not None + and current_boss_height > 0 + and _triple_or_none(action_info.get("axis_point")) is not None + and _triple_or_none(action_info.get("axis")) is not None + ), + "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。", + "disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", + "range_hint": ( + "这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。" + f"{relative_range_hint(current_boss_height, 0.2, 0.5)}" + ), + }, + }, value_type="positive", - range_hint=relative_range_hint(current_boss_height, 0.3, 0.8), - used=("same_domain_height_estimate", "height_estimate"), + used=("same_domain_height_estimate", "height_estimate", "axis_point", "axis"), **positive_minimum(), ) if is_cylinder and not is_hole_or_groove and not is_boss and not is_existing_fillet: current_generic_diameter = _float_or_none(action_info.get("diameter")) current_generic_radius = _float_or_none(action_info.get("radius")) - add_spec( + generic_scale_axis_point = _triple_or_none(action_info.get("axis_point")) + generic_scale_axis_direction = _triple_or_none(action_info.get("axis")) + add_scoped_spec( key="generic_cylinder_diameter", - label="未明确圆柱直径", + label="直径", current_raw=current_generic_diameter if current_generic_diameter is not None else "", target_text=numeric_text(current_generic_diameter), - action="resize_hole", - target_attr="hole_diameter_input", - enabled=current_generic_diameter is not None, - enabled_tip="输入当前未明确圆柱面的目标直径;程序会按高风险圆柱切削/重切兜底路线尝试。", - disabled_tip="当前圆柱面缺少稳定直径,不能直接修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "兜底重切", + "action": "resize_hole", + "target_attr": "hole_diameter_input", + "enabled": current_generic_diameter is not None, + "enabled_tip": "输入当前未明确圆柱面的目标直径;程序会按高风险圆柱切削/重切兜底路线尝试。", + "disabled_tip": "当前圆柱面缺少稳定直径,不能直接修改。", + "range_hint": "高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。", + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_generic_diameter is not None + and generic_scale_axis_point is not None + and generic_scale_axis_direction is not None + ), + "enabled_tip": "输入目标直径后,按比例均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。", + "disabled_tip": "当前圆柱面缺少稳定直径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_hint(current_generic_diameter), + }, + }, value_type="positive", - range_hint="高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。", - used=("diameter",), + used=("diameter", "axis_point", "axis"), **positive_minimum(), ) - add_spec( + add_scoped_spec( key="generic_cylinder_radius", - label="未明确圆柱半径", + label="半径", current_raw=current_generic_radius if current_generic_radius is not None else "", target_text=numeric_text(current_generic_radius), - action="resize_hole", - target_attr="hole_diameter_input", - enabled=current_generic_radius is not None, - enabled_tip="输入当前未明确圆柱面的目标半径;程序会换算成直径后按高风险兜底路线尝试。", - disabled_tip="当前圆柱面缺少稳定半径,不能直接修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "兜底重切", + "action": "resize_hole", + "target_attr": "hole_diameter_input", + "enabled": current_generic_radius is not None, + "enabled_tip": "输入当前未明确圆柱面的目标半径;程序会换算成直径后按高风险兜底路线尝试。", + "disabled_tip": "当前圆柱面缺少稳定半径,不能直接修改。", + "range_hint": "高风险兜底:半径会先换算成直径;建议只做小幅修改并检查结果。", + "target_transform": "radius_to_diameter", + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_generic_radius is not None + and generic_scale_axis_point is not None + and generic_scale_axis_direction is not None + ), + "enabled_tip": "输入目标半径后,换算成目标直径比例并均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。", + "disabled_tip": "当前圆柱面缺少稳定半径或缩放中心,不能缩放所属对象。", + "range_hint": cylinder_owning_scale_radius_hint(current_generic_radius), + "target_transform": "radius_to_diameter", + }, + }, value_type="positive", - range_hint="高风险兜底:半径会先换算成直径;建议只做小幅修改并检查结果。", - target_transform="radius_to_diameter", - used=("radius",), + used=("radius", "axis_point", "axis"), **positive_minimum(), ) current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate")) if current_cylinder_height is None: current_cylinder_height = _float_or_none(action_info.get("height_estimate")) - add_spec( + add_scoped_spec( key="cylinder_height", - label="圆柱高度", + label="高度", current_raw=current_cylinder_height if current_cylinder_height is not None else "", target_text=numeric_text(current_cylinder_height), - action="resize_cylinder_height", - target_attr="boss_height_input", - enabled=bool(is_full_cylinder and current_cylinder_height is not None and has_boss_height_cap), - enabled_tip="输入完整圆柱面的目标高度;程序会推拉识别到的圆柱端盖 Face。", - disabled_tip="当前圆柱面不是完整圆柱,或缺少可推拉端盖信息。", + scope_default="local", + scope_modes={ + "local": { + "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。", + "disabled_tip": "当前圆柱面不是完整圆柱,或缺少稳定高度信息。", + "range_hint": relative_range_hint(current_cylinder_height, 0.3, 0.8), + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_height_owning_scale", + "target_attr": "boss_height_input", + "enabled": bool( + current_cylinder_height is not None + and current_cylinder_height > 0 + and generic_scale_axis_point is not None + and generic_scale_axis_direction is not None + ), + "enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。", + "disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。", + "range_hint": ( + "这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。" + f"{relative_range_hint(current_cylinder_height, 0.2, 0.5)}" + ), + }, + }, value_type="positive", - range_hint=relative_range_hint(current_cylinder_height, 0.3, 0.8), - used=("same_domain_height_estimate", "height_estimate"), + used=("same_domain_height_estimate", "height_estimate", "axis_point", "axis"), **positive_minimum(), ) if is_existing_fillet: @@ -1735,45 +2733,97 @@ class WindowStateMixin: f"{fillet_hint} 目标半径接近 {_format_float(arc_length * 0.5)} " "(圆角圆弧长度估算的一半)时比例会很异常。" ) - add_spec( - key="existing_fillet_radius_estimate", - label="已有圆角半径", - current_raw=current_radius if current_radius is not None else "", - target_text=numeric_text(current_radius), - action="resize_existing_fillet", - target_attr="edge_fillet_radius_input", - enabled=bool(current_radius is not None and has_fillet_support), - enabled_tip="输入已有圆角/倒圆面的目标半径。", - disabled_tip="当前圆角候选缺少稳定支撑面,暂不放行修改。", - value_type="positive", - range_hint=fillet_hint, - used=("existing_fillet_radius_estimate", "radius"), - **positive_minimum(), - ) + fillet_scale_axis_point = _triple_or_none(action_info.get("axis_point")) + fillet_scale_axis_direction = _triple_or_none(action_info.get("axis")) current_fillet_arc = _float_or_none(action_info.get("existing_fillet_arc_length_estimate")) current_fillet_span = _float_or_none(action_info.get("existing_fillet_angular_span")) if current_fillet_span is None: current_fillet_span = _float_or_none(action_info.get("angular_span")) - add_spec( + add_scoped_spec( + key="existing_fillet_radius_estimate", + label="圆角半径", + current_raw=current_radius if current_radius is not None else "", + target_text=numeric_text(current_radius), + scope_default="local", + scope_modes={ + "local": { + "label": "重建圆角", + "action": "resize_existing_fillet", + "target_attr": "edge_fillet_radius_input", + "enabled": bool(current_radius is not None), + "enabled_tip": "输入已有圆角/倒圆面的目标半径;点击修改时程序会再确认支撑面,并尝试移除旧圆角后重建。", + "disabled_tip": "当前圆角候选缺少稳定半径,暂不放行修改。", + "range_hint": fillet_hint, + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_radius is not None + and current_radius > 0 + and fillet_scale_axis_point is not None + and fillet_scale_axis_direction is not None + ), + "enabled_tip": "输入目标圆角半径;程序会换算成目标圆柱直径后整体缩放所属特征或 Solid,不会移除并重建圆角。", + "disabled_tip": "当前圆角缺少稳定半径、轴线或缩放中心,不能按圆角半径整体缩放所属对象。", + "range_hint": ( + "这是整体缩放所属对象,同一对象上的其它尺寸都会跟随变化;" + f"{cylinder_owning_scale_radius_hint(current_radius)}" + ), + "target_transform": "radius_to_diameter", + }, + }, + value_type="positive", + used=("existing_fillet_radius_estimate", "radius", "axis_point", "axis"), + **positive_minimum(), + ) + add_scoped_spec( key="existing_fillet_arc_length_estimate", - label="已有圆角圆弧长度", + label="圆角弧长", current_raw=current_fillet_arc if current_fillet_arc is not None else "", target_text=numeric_text(current_fillet_arc), - action="resize_existing_fillet", - target_attr="edge_fillet_radius_input", - enabled=bool( - current_fillet_arc is not None - and current_fillet_span is not None - and current_fillet_span > 1e-6 - and has_fillet_support - ), - enabled_tip="输入已有圆角/倒圆面的目标圆弧长度;程序会按当前圆弧角度换算成目标半径。", - disabled_tip="当前圆角候选缺少稳定圆弧长度、角度或支撑面,暂不放行修改。", + scope_default="local", + scope_modes={ + "local": { + "label": "重建圆角", + "action": "resize_existing_fillet", + "target_attr": "edge_fillet_radius_input", + "enabled": bool( + current_fillet_arc is not None + and current_fillet_span is not None + and current_fillet_span > 1e-6 + ), + "enabled_tip": "输入已有圆角/倒圆面的目标圆弧长度;程序会按当前圆弧角度换算成目标半径,再尝试重建圆角。", + "disabled_tip": "当前圆角候选缺少稳定圆弧长度或角度,暂不放行修改。", + "range_hint": relative_range_hint(current_fillet_arc, 0.35, 1.0), + "target_transform": "arc_length_to_radius", + "transform_context": {"angular_span": current_fillet_span}, + }, + "owning": { + "label": "调整整个特征", + "action": "resize_cylindrical_owning_scale", + "target_attr": "hole_diameter_input", + "enabled": bool( + current_fillet_arc is not None + and current_fillet_arc > 0 + and current_fillet_span is not None + and current_fillet_span > 1e-6 + and fillet_scale_axis_point is not None + and fillet_scale_axis_direction is not None + ), + "enabled_tip": "输入目标圆角弧长;程序会按当前圆弧角度换算成目标圆柱直径,再整体缩放所属特征或 Solid。", + "disabled_tip": "当前圆角缺少稳定弧长、圆弧角度、轴线或缩放中心,不能按圆角弧长整体缩放所属对象。", + "range_hint": ( + "这是整体缩放所属对象,不会移除旧圆角再重建;同一对象上的其它尺寸也会跟随变化。" + f"{relative_range_hint(current_fillet_arc, 0.2, 0.5)}" + ), + "target_transform": "arc_length_to_diameter", + "transform_context": {"angular_span": current_fillet_span}, + }, + }, value_type="positive", - range_hint=relative_range_hint(current_fillet_arc, 0.35, 1.0), - target_transform="arc_length_to_radius", - transform_context={"angular_span": current_fillet_span}, - used=("existing_fillet_arc_length_estimate",), + used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"), **positive_minimum(), ) if is_cone: @@ -1783,31 +2833,31 @@ class WindowStateMixin: ) add_spec( key="cone_reference_radius", - label="圆锥参考半径", + label="参考半径(整体)", current_raw=current_reference_radius if current_reference_radius is not None else "", target_text=numeric_text(current_reference_radius), action="resize_cone_reference_radius", target_attr="cone_reference_radius_input", enabled=current_reference_radius is not None, - enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴做径向缩放。", + enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴径向缩放所属对象。", disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_reference_radius, 0.25, 0.6), + range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}", used=("reference_radius",), **positive_minimum(), ) add_spec( key="cone_reference_diameter", - label="圆锥参考直径", + label="参考直径(整体)", current_raw=current_reference_diameter if current_reference_diameter is not None else "", target_text=numeric_text(current_reference_diameter), action="resize_cone_reference_radius", target_attr="cone_reference_radius_input", enabled=current_reference_diameter is not None, - enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放。", + enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放所属对象。", disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_reference_diameter, 0.25, 0.6), + range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", used=("feature_reference_diameter",), **positive_minimum(), @@ -1818,16 +2868,16 @@ class WindowStateMixin: ) add_spec( key="cone_semi_angle_degrees", - label="圆锥半角(度)", + label="半角(整体)", current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "", target_text=numeric_text(current_semi_angle_degrees), action="resize_cone_reference_radius", target_attr="cone_reference_radius_input", enabled=current_reference_radius is not None and current_semi_angle is not None, - enabled_tip="输入圆锥面的目标半角;程序会换算为目标参考半径后围绕圆锥轴径向缩放。", + enabled_tip="输入圆锥面的目标半角,单位是度;程序会换算为目标参考半径后围绕圆锥轴径向缩放所属对象。", disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。", value_type="positive", - range_hint="建议先小幅修改;当前版本要求半角大于 0 且小于 89 度。", + range_hint="这是整体径向缩放所属对象,不是只替换单个圆锥面的历史半角参数;建议先小幅修改,当前版本要求半角大于 0 且小于 89 度。", max_value=89.0, max_exclusive=True, target_transform="cone_semi_angle_degrees_to_reference_radius", @@ -1843,31 +2893,31 @@ 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", target_attr="sphere_radius_input", enabled=current_sphere_radius is not None, - enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属零件/Solid。", + enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。", disabled_tip="当前球面缺少稳定半径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_sphere_radius, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史半径参数。 {relative_range_hint(current_sphere_radius, 0.25, 0.6)}", used=("radius",), **positive_minimum(), ) 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", target_attr="sphere_radius_input", enabled=current_sphere_diameter is not None, - enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放。", + enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放所属对象。", disabled_tip="当前球面缺少稳定直径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_sphere_diameter, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史直径参数。 {relative_range_hint(current_sphere_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", used=("diameter", "feature_sphere_diameter"), **positive_minimum(), @@ -1883,62 +2933,62 @@ 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", target_attr="torus_radius_input", enabled=current_major_radius is not None, - enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。", + enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", disabled_tip="当前环面缺少稳定主半径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_major_radius, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只改环面主半径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_radius, 0.25, 0.6)}", used=("major_radius", "feature_torus_major_radius"), **positive_minimum(), ) 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", target_attr="torus_radius_input", enabled=current_major_diameter is not None, - enabled_tip="输入环面的目标主直径;程序会换算为主半径后执行环面修改。", + enabled_tip="输入环面的目标主直径;程序会换算为主半径后整体缩放所属对象。", disabled_tip="当前环面缺少稳定主直径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_major_diameter, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只改环面主直径;小半径和其它尺寸也会跟随变化。 {relative_range_hint(current_major_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", used=("feature_torus_major_radius",), **positive_minimum(), ) 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", target_attr="torus_radius_input", enabled=current_minor_radius is not None, - enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。", + enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", disabled_tip="当前环面缺少稳定小半径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_minor_radius, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只改环面小半径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_radius, 0.25, 0.6)}", used=("minor_radius", "feature_torus_minor_radius"), **positive_minimum(), ) 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", target_attr="torus_radius_input", enabled=current_minor_diameter is not None, - enabled_tip="输入环面的目标小直径;程序会换算为小半径后执行环面修改。", + enabled_tip="输入环面的目标小直径;程序会换算为小半径后整体缩放所属对象。", disabled_tip="当前环面缺少稳定小直径,不能直接修改。", value_type="positive", - range_hint=relative_range_hint(current_minor_diameter, 0.25, 0.6), + range_hint=f"这是整体缩放所属对象,不是只改环面小直径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_diameter, 0.25, 0.6)}", target_transform="diameter_to_radius", used=("feature_torus_minor_radius",), **positive_minimum(), @@ -1947,28 +2997,113 @@ class WindowStateMixin: current_length = _float_or_none(action_info.get("length")) is_circle_edge = curve == "circle" is_ellipse_edge = curve == "ellipse" + def edge_strategy_effect_text(strategy_mode: str, anchor_mode: str) -> str: + anchor_text = { + "auto": "自动基准:默认尽量固定一端,移动另一端。", + "center": "固定中心:两端或所属对象围绕中心变化。", + "keep-start": "固定起点:起点尽量不动。", + "keep-end": "固定终点:终点尽量不动。", + }.get(anchor_mode, "自动基准:程序选择更稳定的一端。") + strategy_text = { + "auto": "自动选择:按局部边形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径。", + "local-edge-only-deform": "只改当前Edge:移动被选Edge端点,相邻面自然变斜,整体端面不一起平移。", + "move-edge-end-plane-by-push-pull": "移动端面/保持垂直:端面和端面上的相关边一起移动,相邻平面尽量保持垂直,正方体会更像变成长方体。", + "resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径:把圆Edge长度换算成圆柱直径,优先改孔/槽/凸台局部特征。", + "scale-owning-shape-from-edge": "缩放所属对象:所属特征或 Solid 整体跟随变化,其它尺寸也会变。", + }.get(strategy_mode, "自动选择:程序会在确认窗口显示实际采用的策略。") + return f"{strategy_text} {anchor_text}" + current_anchor_mode = "auto" current_anchor_label = "自动" if hasattr(self, "edge_length_anchor_combo"): current_anchor_mode = str(self.edge_length_anchor_combo.currentData() or "auto") current_anchor_label = self.edge_length_anchor_combo.currentText() - add_spec( + current_strategy_mode = "auto" + if hasattr(self, "edge_length_strategy_combo"): + current_strategy_mode = str(self.edge_length_strategy_combo.currentData() or "auto") + + def edge_length_strategy_preselect(strategy_mode: str) -> list[dict[str, object]]: + return [{"target_attr": "edge_length_strategy_combo", "value": strategy_mode}] + + add_scoped_spec( key="length", - label="Edge长度", + label="长度", current_raw=current_length if current_length is not None else "", target_text=numeric_text(current_length), - action="resize_any_edge_length", - target_attr="edge_target_length_input", - enabled=current_length is not None, - enabled_tip="输入当前Edge的目标长度。", - disabled_tip="当前Edge没有稳定长度信息。", + scope_default=current_strategy_mode, + scope_modes={ + "auto": { + "label": "自动", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": current_length is not None, + "enabled_tip": "输入当前Edge的目标长度;程序会自动选择局部形变、端面移动、相邻圆柱编辑或缩放所属对象。", + "disabled_tip": "当前Edge没有稳定长度信息。", + "range_hint": ( + f"{edge_strategy_effect_text('auto', current_anchor_mode)} " + f"{relative_range_hint(current_length, 0.25, 0.5)}" + ), + "preselect_combos": edge_length_strategy_preselect("auto"), + }, + "local-edge-only-deform": { + "label": "只改当前Edge", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": bool(is_line_edge and current_length is not None), + "enabled_tip": "输入当前Edge的目标长度;只移动被选直线Edge端点并重建周边平面,相邻面可能变斜。", + "disabled_tip": "只有直线Edge才能使用只改当前Edge的局部形变策略。", + "range_hint": ( + f"{edge_strategy_effect_text('local-edge-only-deform', current_anchor_mode)} " + f"{relative_range_hint(current_length, 0.25, 0.5)}" + ), + "preselect_combos": edge_length_strategy_preselect("local-edge-only-deform"), + }, + "move-edge-end-plane-by-push-pull": { + "label": "移动端面", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": bool(is_line_edge and current_length is not None), + "enabled_tip": "输入当前Edge的目标长度;程序会尝试移动相关端面,让相邻平面尽量保持垂直。", + "disabled_tip": "只有直线Edge才能尝试移动端面/保持垂直策略。", + "range_hint": ( + f"{edge_strategy_effect_text('move-edge-end-plane-by-push-pull', current_anchor_mode)} " + f"{relative_range_hint(current_length, 0.25, 0.5)}" + ), + "preselect_combos": edge_length_strategy_preselect("move-edge-end-plane-by-push-pull"), + }, + "resize-adjacent-cylinder-from-circular-edge-length": { + "label": "相邻圆柱", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": bool(is_circle_edge and current_length is not None), + "enabled_tip": "输入圆形/圆弧Edge的目标长度;程序会优先换算为相邻圆柱孔、槽或凸台的直径修改。", + "disabled_tip": "只有圆形/圆弧Edge才能使用相邻圆柱直径策略。", + "range_hint": ( + f"{edge_strategy_effect_text('resize-adjacent-cylinder-from-circular-edge-length', current_anchor_mode)} " + f"{relative_range_hint(current_length, 0.25, 0.5)}" + ), + "preselect_combos": edge_length_strategy_preselect("resize-adjacent-cylinder-from-circular-edge-length"), + }, + "scale-owning-shape-from-edge": { + "label": "缩放所属", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": current_length is not None, + "enabled_tip": "输入当前Edge的目标长度;程序会缩放所属特征或 Solid,其它尺寸也会跟随变化。", + "disabled_tip": "当前Edge没有稳定长度信息,不能缩放所属对象。", + "range_hint": ( + f"{edge_strategy_effect_text('scale-owning-shape-from-edge', current_anchor_mode)} " + f"{relative_range_hint(current_length, 0.2, 0.5)}" + ), + "preselect_combos": edge_length_strategy_preselect("scale-owning-shape-from-edge"), + }, + }, value_type="positive", - range_hint=relative_range_hint(current_length, 0.25, 0.5), **positive_minimum(), ) add_spec( key="edge_length_anchor_mode", - label="Edge长度基准", + label="长度基准", current_raw=current_anchor_mode, target_text=current_anchor_label, action="set_edge_length_anchor_mode", @@ -2002,7 +3137,7 @@ class WindowStateMixin: can_move_line_endpoint = bool(is_line_edge and edge_start_point is not None and edge_end_point is not None) add_spec( key="edge_start_point", - label="Edge起点坐标", + label="起点", current_raw=edge_start_point if edge_start_point is not None else "", target_text=vector_text(edge_start_point), action="move_edge_start_point", @@ -2016,7 +3151,7 @@ class WindowStateMixin: ) add_spec( key="edge_center_point", - label="Edge中心坐标", + label="中心", current_raw=edge_center_point if edge_center_point is not None else "", target_text=vector_text(edge_center_point), action="move_edge_center_point", @@ -2030,7 +3165,7 @@ class WindowStateMixin: ) add_spec( key="edge_end_point", - label="Edge终点坐标", + label="终点", current_raw=edge_end_point if edge_end_point is not None else "", target_text=vector_text(edge_end_point), action="move_edge_end_point", @@ -2050,79 +3185,138 @@ class WindowStateMixin: "edge_current_length": current_length, "edge_current_radius": current_radius, } - add_spec( + circle_scope_default = ( + current_strategy_mode + if current_strategy_mode + in { + "auto", + "resize-adjacent-cylinder-from-circular-edge-length", + "scale-owning-shape-from-edge", + } + else "auto" + ) + + def add_circle_edge_size_spec( + *, + key: str, + label: str, + current_raw: object, + target_text: str, + enabled: bool, + disabled_tip: str, + transform: str, + used: tuple[str, ...], + ) -> None: + add_scoped_spec( + key=key, + label=label, + current_raw=current_raw, + target_text=target_text, + scope_default=circle_scope_default, + scope_modes={ + "auto": { + "label": "自动", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": enabled, + "enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会优先尝试相邻圆柱,找不到时再按圆边平面或所属对象缩放。", + "disabled_tip": disabled_tip, + "range_hint": ( + "自动:优先重切相邻孔/槽或重建凸台;没有稳定相邻圆柱时,再进入缩放所属对象路径。 " + f"{relative_range_hint(current_raw, 0.25, 0.5)}" + ), + "target_transform": transform, + "transform_context": circle_context, + "preselect_combos": edge_length_strategy_preselect("auto"), + }, + "resize-adjacent-cylinder-from-circular-edge-length": { + "label": "相邻圆柱", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": enabled, + "enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会强制换算成相邻孔/槽或凸台直径修改。", + "disabled_tip": disabled_tip, + "range_hint": ( + "相邻圆柱:只在圆Edge贴着孔/槽/凸台圆柱面时可用;找不到相邻圆柱会阻止,不会偷偷缩放整体。 " + f"{relative_range_hint(current_raw, 0.25, 0.5)}" + ), + "target_transform": transform, + "transform_context": circle_context, + "preselect_combos": edge_length_strategy_preselect( + "resize-adjacent-cylinder-from-circular-edge-length" + ), + }, + "scale-owning-shape-from-edge": { + "label": "缩放所属", + "action": "resize_any_edge_length", + "target_attr": "edge_target_length_input", + "enabled": enabled, + "enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会按圆边平面或所属对象缩放,周边尺寸也可能跟随变化。", + "disabled_tip": disabled_tip, + "range_hint": ( + "缩放所属:这是高风险兜底语义,会影响同一特征或 Solid 的其它尺寸。 " + f"{relative_range_hint(current_raw, 0.2, 0.5)}" + ), + "target_transform": transform, + "transform_context": circle_context, + "preselect_combos": edge_length_strategy_preselect("scale-owning-shape-from-edge"), + }, + }, + value_type="positive", + used=used, + **positive_minimum(), + ) + + add_circle_edge_size_spec( key="circle_edge_radius", - label="圆Edge半径", + label="圆边半径", current_raw=current_radius if current_radius is not None else "", target_text=numeric_text(current_radius), - action="resize_any_edge_length", - target_attr="edge_target_length_input", enabled=can_resize_circle, - enabled_tip="输入圆形/圆弧Edge的目标半径;程序会换算成等比例Edge目标长度后执行。", disabled_tip="当前圆Edge缺少稳定半径或长度信息。", - value_type="positive", - range_hint=relative_range_hint(current_radius, 0.25, 0.5), - target_transform="circle_edge_radius_to_length", - transform_context=circle_context, + transform="circle_edge_radius_to_length", used=("radius",), - **positive_minimum(), ) - add_spec( + add_circle_edge_size_spec( key="circle_edge_diameter", - label="圆Edge直径", + label="圆边直径", current_raw=current_diameter if current_diameter is not None else "", target_text=numeric_text(current_diameter), - action="resize_any_edge_length", - target_attr="edge_target_length_input", enabled=bool(can_resize_circle and current_diameter is not None and current_diameter > 0), - enabled_tip="输入圆形/圆弧Edge的目标直径;程序会换算成等比例Edge目标长度后执行。", disabled_tip="当前圆Edge缺少稳定直径或长度信息。", - value_type="positive", - range_hint=relative_range_hint(current_diameter, 0.25, 0.5), - target_transform="circle_edge_diameter_to_length", - transform_context=circle_context, + transform="circle_edge_diameter_to_length", used=("diameter",), - **positive_minimum(), ) if is_ellipse_edge: current_major = _float_or_none(action_info.get("major_radius")) current_minor = _float_or_none(action_info.get("minor_radius")) - ellipse_context = { - "edge_current_length": current_length, - "edge_current_major_radius": current_major, - "edge_current_minor_radius": current_minor, - } add_spec( key="ellipse_edge_major_radius", - label="椭圆Edge主半径", + label="椭圆主半径", current_raw=current_major if current_major is not None else "", target_text=numeric_text(current_major), - action="resize_any_edge_length", - target_attr="edge_target_length_input", + action="resize_ellipse_edge_major_radius", + target_attr="ellipse_edge_major_radius_input", enabled=bool(current_length is not None and current_length > 0 and current_major is not None and current_major > 0), - enabled_tip="输入椭圆Edge的目标主半径;程序会换算成目标Edge长度并执行平面曲线缩放。", + enabled_tip="输入椭圆Edge的目标主半径;程序会沿椭圆主轴方向单轴缩放所属对象,小半径方向尽量不动。", disabled_tip="当前椭圆Edge缺少稳定主半径或长度信息。", value_type="positive", - range_hint=relative_range_hint(current_major, 0.25, 0.5), - target_transform="ellipse_edge_major_radius_to_length", - transform_context=ellipse_context, + range_hint=f"单轴缩放所属对象,不是恢复 CAD 草图约束;同方向上的其它几何会跟随变化。 {relative_range_hint(current_major, 0.25, 0.5)}", used=("major_radius",), **positive_minimum(), ) add_spec( key="ellipse_edge_minor_radius", - label="椭圆Edge小半径", + label="椭圆小半径", current_raw=current_minor if current_minor is not None else "", target_text=numeric_text(current_minor), - action="resize_any_edge_length", - target_attr="edge_target_length_input", + action="resize_ellipse_edge_minor_radius", + target_attr="ellipse_edge_minor_radius_input", enabled=bool(current_length is not None and current_length > 0 and current_minor is not None and current_minor > 0), - enabled_tip="输入椭圆Edge的目标小半径;程序会换算成目标Edge长度并执行平面曲线缩放。", + enabled_tip="输入椭圆Edge的目标小半径;程序会沿椭圆小轴方向单轴缩放所属对象,主半径方向尽量不动。", disabled_tip="当前椭圆Edge缺少稳定小半径或长度信息。", value_type="positive", - range_hint=relative_range_hint(current_minor, 0.25, 0.5), - target_transform="ellipse_edge_minor_radius_to_length", - transform_context=ellipse_context, + range_hint=f"单轴缩放所属对象,不是恢复 CAD 草图约束;同方向上的其它几何会跟随变化。 {relative_range_hint(current_minor, 0.25, 0.5)}", used=("minor_radius",), **positive_minimum(), ) @@ -2143,7 +3337,7 @@ class WindowStateMixin: ) add_spec( key="new_fillet_radius", - label="新增圆角半径", + label="圆角半径", current_raw="未添加", target_text="", action="fillet_edge", @@ -2159,7 +3353,7 @@ class WindowStateMixin: ) add_spec( key="new_chamfer_distance", - label="新增倒角距离", + label="倒角距离", current_raw="未添加", target_text="", action="chamfer_edge", @@ -2190,7 +3384,7 @@ class WindowStateMixin: current_chamfer_reference = default_chamfer_reference add_spec( key="new_asymmetric_chamfer_distances", - label="新增不等距倒角D1/D2", + label="不等距倒角", current_raw="未添加", target_text="", action="chamfer_edge_asymmetric", @@ -2206,7 +3400,7 @@ class WindowStateMixin: ) add_spec( key="new_distance_angle_chamfer", - label="新增距离+角度倒角D/角度", + label="距离+角度倒角", current_raw="未添加", target_text="", action="chamfer_edge_distance_angle", @@ -2220,7 +3414,7 @@ class WindowStateMixin: ) add_spec( key="chamfer_reference_face_id", - label="倒角参考Face ID", + label="倒角参考Face", current_raw=current_chamfer_reference if current_chamfer_reference is not None else "", target_text=str(current_chamfer_reference) if current_chamfer_reference is not None else "", action="set_chamfer_reference_face", @@ -2260,7 +3454,7 @@ class WindowStateMixin: ) add_spec( key="target_center_position", - label="目标中心坐标", + label="中心坐标", current_raw=current_center if current_center is not None else "", target_text=vector_text(current_center), action=translate_action, @@ -2278,7 +3472,7 @@ class WindowStateMixin: scale_action = "scale_selected_part" if target_is_part else "scale_selected_solid" add_spec( key="bbox_diagonal_scale", - label="目标整体尺寸", + label="整体尺寸", current_raw=current_diagonal if current_diagonal is not None else "", target_text=numeric_text(current_diagonal), action=scale_action, @@ -2294,7 +3488,7 @@ class WindowStateMixin: current_volume = _float_or_none(action_info.get("volume")) add_spec( key="target_volume_scale", - label="目标体积", + label="体积", current_raw=current_volume if current_volume is not None else "", target_text=numeric_text(current_volume), action=scale_action, @@ -2315,7 +3509,7 @@ class WindowStateMixin: current_surface_area = _float_or_none(action_info.get("surface_area")) add_spec( key="target_surface_area_scale", - label="目标表面积", + label="表面积", current_raw=current_surface_area if current_surface_area is not None else "", target_text=numeric_text(current_surface_area), action=scale_action, @@ -2349,7 +3543,7 @@ class WindowStateMixin: current_axis_size = bbox_size[axis_index] add_spec( key=f"bbox_{axis_name.lower()}_size_scale", - label=f"目标{axis_name}向尺寸", + label=f"{axis_name}向尺寸", current_raw=current_axis_size, target_text=numeric_text(current_axis_size), action=f"{axis_scale_action_prefix}_{axis_name.lower()}_size", @@ -2386,7 +3580,7 @@ class WindowStateMixin: ) add_spec( key="rotation_angle_degrees", - label=f"旋转角度({axis}轴)", + label=f"角度({axis}轴)", current_raw=0.0, target_text="0", action=rotate_action, @@ -2411,23 +3605,39 @@ class WindowStateMixin: self.operation_in_progress or self.scan_in_progress or self.load_in_progress ) for row, spec in enumerate(getattr(self, "property_editor_specs", [])): - widget = self.property_table.cellWidget(row, 3) + effective_spec = self._effective_property_spec(spec, row=row) + target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) + if isinstance(target_widget, QLineEdit): + editable = bool(effective_spec.get("editable") and effective_spec.get("enabled")) + input_editable = editable and str(effective_spec.get("value_type", "number")) != "command" + target_widget.setEnabled(input_editable) + target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) + widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN) if isinstance(widget, QPushButton): - if str(spec.get("value_type", "number")) == "command": + if str(effective_spec.get("value_type", "number")) == "command": row_changed = True - widget.setText(str(spec.get("button_text") or "执行")) - enabled = bool(has_model and spec.get("enabled") and spec.get("action")) - tooltip = f"执行“{spec.get('label', '当前操作')}”。" + widget.setText(str(effective_spec.get("button_text") or "执行")) + enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action")) + tooltip = f"执行“{effective_spec.get('label', '当前操作')}”。" else: text = self._property_target_text(row) - row_changed = self._property_target_changed(spec, text) - widget.setText("应用" if row_changed else "未改动") - enabled = bool(has_model and spec.get("enabled") and spec.get("action") and row_changed) - if row_changed: - tooltip = f"应用“{spec.get('label', '当前属性')}”这一行的目标值。" + row_changed = self._property_target_changed(effective_spec, text) + empty_target = not bool(text.strip()) + widget.setText("未输入" if empty_target else ("应用" if row_changed else "未改动")) + enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action") and row_changed) + if empty_target: + tooltip = ( + f"“{effective_spec.get('label', '当前属性')}”的目标值为空。" + "这不会删除模型里的点、边或面,只是暂时没有要应用的目标值;" + "重新选择对象会按当前模型值重新填入。" + ) + elif row_changed: + scope_label = str(effective_spec.get("scope_label") or "").strip() + suffix = f"({scope_label})" if scope_label else "" + tooltip = f"应用“{effective_spec.get('label', '当前属性')}{suffix}”这一行的目标值。" else: - tooltip = f"“{spec.get('label', '当前属性')}”的目标值和当前值相同,无需执行。修改目标值后再应用。" - range_hint = self._property_range_hint(spec) + tooltip = f"“{effective_spec.get('label', '当前属性')}”的目标值和当前值相同,无需执行。修改目标值后再应用。" + range_hint = self._property_range_hint(effective_spec) if range_hint: tooltip = f"{tooltip}\n\n{range_hint}" widget.setProperty("changed", bool(row_changed)) @@ -2455,11 +3665,12 @@ class WindowStateMixin: return [] changed: list[tuple[int, dict[str, object], str]] = [] for row, spec in enumerate(getattr(self, "property_editor_specs", [])): - if not spec.get("action") or not spec.get("enabled"): + effective_spec = self._effective_property_spec(spec, row=row) + if not effective_spec.get("action") or not effective_spec.get("enabled"): continue - if str(spec.get("value_type", "number")) == "command": + if str(effective_spec.get("value_type", "number")) == "command": continue - if spec.get("action") in { + if effective_spec.get("action") in { "set_manual_hole_bottom_face", "set_manual_slot_pair_face", "set_edge_length_anchor_mode", @@ -2468,17 +3679,17 @@ class WindowStateMixin: }: continue text = self._property_target_text(row) - if self._property_target_changed(spec, text): - changed.append((row, spec, text)) + if self._property_target_changed(effective_spec, text): + changed.append((row, effective_spec, text)) return changed def _property_target_text(self, row: int) -> str: if not hasattr(self, "property_table"): return "" - widget = self.property_table.cellWidget(row, 2) + widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if isinstance(widget, QLineEdit): return widget.text().strip() - item = self.property_table.item(row, 2) + item = self.property_table.item(row, PROPERTY_TARGET_COLUMN) return item.text().strip() if item is not None else "" def _display_property_target_changed( @@ -2604,7 +3815,7 @@ class WindowStateMixin: if row < 0 or row >= len(specs): self.statusBar().showMessage("当前属性行已经失效,请重新选择对象。") return - spec = specs[row] + spec = self._effective_property_spec(specs[row], row=row) if not spec.get("action") or not spec.get("enabled"): self.statusBar().showMessage("这一行当前不能直接修改。") return @@ -2616,6 +3827,7 @@ class WindowStateMixin: try: if not is_command: self._sync_property_edit_target(spec, text) + self._sync_property_preselects(spec) except ValueError as exc: QMessageBox.information(self, "目标值无效", str(exc)) return @@ -2648,7 +3860,13 @@ class WindowStateMixin: self._update_action_states() self._refresh_property_editor() label = self.edge_length_anchor_combo.currentText() if hasattr(self, "edge_length_anchor_combo") else "" - self.statusBar().showMessage(f"已设置Edge长度基准:{label or '自动'}。") + self.statusBar().showMessage(f"已设置长度基准:{label or '自动'}。") + + def set_edge_length_strategy_mode(self) -> None: + self._update_action_states() + self._refresh_property_editor() + label = self.edge_length_strategy_combo.currentText() if hasattr(self, "edge_length_strategy_combo") else "" + self.statusBar().showMessage(f"已设置修改策略:{label or '自动策略'}。") def set_chamfer_reference_face(self) -> None: self._update_action_states() @@ -2669,6 +3887,27 @@ class WindowStateMixin: axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z" self.statusBar().showMessage(f"已设置旋转轴:{axis}。") + def _sync_property_preselects(self, spec: dict[str, object]) -> None: + preselects = spec.get("preselect_combos") + if not isinstance(preselects, (list, tuple)): + return + for item in preselects: + if not isinstance(item, dict): + continue + target_attr = str(item.get("target_attr") or "") + value = item.get("value") + if not target_attr: + continue + widget = getattr(self, target_attr, None) + if widget is None or not hasattr(widget, "setCurrentIndex"): + raise ValueError(f"这个属性缺少可同步的选项控件:{target_attr}") + index = widget.findData(value) if hasattr(widget, "findData") else -1 + if index < 0 and hasattr(widget, "findText"): + index = widget.findText(str(value)) + if index < 0: + raise ValueError(f"找不到可用选项:{value}") + widget.setCurrentIndex(index) + def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None: value_type = str(spec.get("value_type", "number")) if value_type == "command": @@ -2801,11 +4040,12 @@ class WindowStateMixin: if current_tangent <= 1e-9 or target_tangent <= 1e-9: raise ValueError("圆锥半角过小,不能稳定换算参考半径。") return current_radius * target_tangent / current_tangent - if transform == "arc_length_to_radius": + if transform in {"arc_length_to_radius", "arc_length_to_diameter"}: angular_span = _float_or_none(context.get("angular_span")) if angular_span is None or angular_span <= 1e-6: raise ValueError("当前对象缺少稳定圆弧角度,不能把圆弧长度换算为半径。") - return value / angular_span + radius = value / angular_span + return radius * 2.0 if transform == "arc_length_to_diameter" else radius if transform == "slot_center_distance_to_total_length": diameter = _float_or_none(context.get("slot_current_diameter")) if diameter is None or diameter <= 0: @@ -2838,15 +4078,6 @@ class WindowStateMixin: if target_radius <= 0: raise ValueError("圆Edge目标半径必须大于 0。") return current_length * target_radius / current_radius - if transform in {"ellipse_edge_major_radius_to_length", "ellipse_edge_minor_radius_to_length"}: - current_length = _float_or_none(context.get("edge_current_length")) - radius_key = "edge_current_major_radius" if transform == "ellipse_edge_major_radius_to_length" else "edge_current_minor_radius" - current_radius = _float_or_none(context.get(radius_key)) - if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0: - raise ValueError("当前椭圆Edge缺少稳定的长度或半径,不能换算目标长度。") - if value <= 0: - raise ValueError("椭圆Edge目标半径必须大于 0。") - return current_length * value / current_radius raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}") def _transform_property_vector3_target( @@ -2936,15 +4167,16 @@ class WindowStateMixin: and current_face_id == self.selected_face_id and str(current_info.get("surface", "") or "") ): - return current_info + return self._feature_info_for_selected_face(self.selected_face_id, current_info) try: if self.selected_kind == "feature" and self.selected_face_id is not None: - return self.model.feature_info(self.selected_face_id) + return self._feature_info_for_selected_face( + self.selected_face_id, + self.model.quick_face_info(self.selected_face_id), + ) if self.selected_kind == "face" and self.selected_face_id is not None: - info = self.model.face_info(self.selected_face_id) - if info.get("surface") in {"cylinder", "plane"}: - return self.model.feature_info(self.selected_face_id) - return info + info = self.model.quick_face_info(self.selected_face_id) + return self._feature_info_for_selected_face(self.selected_face_id, info) if self.selected_kind == "edge" and self.selected_edge_id is not None: return self.model.edge_info(self.selected_edge_id) except Exception: @@ -2961,7 +4193,11 @@ class WindowStateMixin: if record.target_kind in {"face", "feature"} and record.target_id is not None: resolved_face_id = self._resolve_record_face_id(record) if resolved_face_id is not None: - info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id) + info = ( + self.model.feature_info(resolved_face_id) + if record.target_kind == "feature" + else self._feature_info_for_selected_face(resolved_face_id, self.model.face_info(resolved_face_id)) + ) 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"])