feat: 完善参数化编辑语义和槽孔中心距

This commit is contained in:
2026-07-30 17:54:01 +08:00
parent c937e13d43
commit 27e4f7236c
22 changed files with 8769 additions and 668 deletions
+2
View File
@@ -22,5 +22,7 @@ dist/
assets/screenshots/ assets/screenshots/
教学.txt
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+55
View File
@@ -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',
)
+250 -70
View File
@@ -71,6 +71,14 @@ python scripts\generate_cube_step.py
```powershell ```powershell
python scripts\verify_edge_length_resize.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_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`,你可以直接使用完整路径运行: 11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行:
@@ -96,13 +104,31 @@ git diff --check
- 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的特征,比如平面推拉和目标法向位置、孔径/半径调整、完整圆柱孔轴心坐标修改、槽/半孔宽度、深度、圆弧长度、圆弧角、开口角和槽孔总长度调整、盲孔/盲槽深度调整、凸台直径/半径/高度/轴心坐标调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度调整、圆锥半角、环面直径、Edge倒圆、Edge倒角、圆Edge半径/直径、修改Edge长度和直线Edge起点/终点坐标。 - 用户可以修改可控的特征,比如面偏移、偏移距离、面积、面宽/面高、中心,并通过 `影响范围` 选择推拉当前面、只改当前面、移动整个特征或调整整个特征;还可以做孔径/半径调整、孔/槽轴心并通过 `影响范围` 选择移动局部特征或移动整个特征、槽宽/槽深/弧长并通过 `影响范围` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、薄壁厚度(局部推拉或整体缩放)、凸台直径/半径/高度/轴心并通过 `影响范围` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `影响范围` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `影响范围` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `影响范围` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。 - 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 需要注意: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 软件里的完整建模历史参数。 这张表描述的是当前版本已经接入到界面或编辑链路里的能力。它们都是基于 STEP 的 B-Rep 结果几何做局部重建,不等于原 CAD 软件里的完整建模历史参数。
| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 |
| --- | --- | --- | | --- | --- | --- |
| 平面 Face | `Face目标法向位置``Face本次偏移量`识别到相对平面时可以改薄壁厚度。 | `Face面积` 目前只读;自由曲面、复杂壳体局部区域和带复杂边界的面不能直接按面积修改。 | | Face | 平面 Face 可以改 `面偏移``偏移距离``面积``面宽``面高``中心`;这些行会在 `影响范围` 里选择 `只改当前面``推拉当前面``移动整个特征``调整整个特征`识别到相对平面时可以改 `薄壁厚度`,并通过 `影响范围` 选择 `推拉当前面``调整整个特征`。 | `推拉当前面` 会加料或切削;`只改当前面` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动整个特征` 会平移所属特征或 Solid`调整整个特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面、复杂壳体局部区域和带内孔/复杂边界的面不能稳定做局部尺寸编辑。 |
| 圆柱孔 / 圆柱面 | 孔或圆柱直径、半径;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚。 | | 圆柱孔 / 圆柱面 | 孔或圆柱`直径``半径`,并通过 `影响范围` 选择 `只改孔/槽壁``调整整个特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`调整整个特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 |
| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助。 | | 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `影响范围` 选择 `改底面深度``调整整个特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`调整整个特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助`调整整个特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 |
| 槽 / 半孔 | 可以改槽/半孔宽度、深度、圆弧长度、圆弧角度和开口角度;长圆槽识别到成对半圆端时,可以改槽孔总长度和中心距,也可以手动填写配对端 Face ID。 | 复杂草图槽、非圆柱槽、槽的整体迁移/轴心移动、链式槽和跨多个不规则面的槽还不是稳定能力。 | | 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `影响范围` 里选择 `只改槽壁``调整整个特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`调整整个特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心坐标;普通完整圆柱也有高度兜底修改。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `影响范围` 里选择 `只改凸台``推拉端盖``移动凸台``调整整个特征``移动整个特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`调整整个特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改半径或圆弧长度,使用先移除旧圆角再重建新圆角的路线。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚。 | | 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `影响范围` 选择 `重建圆角``调整整个特征``重建圆角` 会先移除旧圆角再重建新圆角;`调整整个特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`调整整个特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败。 | | Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径,并通过 `影响范围` 选择 `自动``相邻圆柱``缩放所属`;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改参考半径、直径或半角;球可以改半径或直径;环面可以改主/副半径或直径。 | 这些属于几何缩放或局部重建能力,不是原始特征历史恢复;复杂相邻拓扑可能失败。 | | 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径(整体)``参考直径(整体)``半角(整体)`;球可以改 `半径(整体)``直径(整体)`;环面可以改 `主半径(整体)``主直径(整体)``小半径(整体)``小直径(整体)`。 | 这些当前都是几何缩放所属特征或 Solid,不是只替换单个曲面的 CAD 历史参数;圆锥会围绕轴线径向缩放,球面和环面会围绕中心均匀缩放,复杂相邻拓扑可能失败。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
@@ -155,7 +195,7 @@ git diff --check
- Edge - Edge
- 特征 - 特征
- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。
- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮 - 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别
- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID``拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID``拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。
- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。
@@ -192,18 +232,18 @@ git diff --check
- 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。
- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮 - 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。
- 支持两点测量: - 支持两点测量:
- 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。
- 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。
- 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。
- 特征模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face边界Edge和当前可用操作 - 特征模式会显示特征来源Face、基础几何参数和当前可用的快速编辑入口;侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face边界Edge等深层信息会在扫描候选、执行编辑计划或历史定位时再计算
- 支持导出当前完整模型为 STEP。 - 支持导出当前完整模型为 STEP。
- 支持导出选中的零件为 STEP。 - 支持导出选中的零件为 STEP。
- 支持导出选中的Solid为 STEP。 - 支持导出选中的Solid为 STEP。
- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 - 支持导出选中的Face为 STEP;如果当前编辑计划、扫描候选或历史定位已经解析出明确特征区域,也可以导出这片相关区域。
- 支持导出选中特征区域为 STEP。 - 支持导出选中特征区域为 STEP。
- 支持导出选中的Edge为 STEP。 - 支持导出选中的Edge为 STEP。
- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 - 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
@@ -215,7 +255,7 @@ git diff --check
- `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。
- 可推拉的平面Face。 - 可推拉的平面Face。
- 可调整孔径的圆柱Face候选。 - 可调整孔径的圆柱Face候选。
- 可调整槽/半孔宽度、深度圆弧长度的局部圆柱孔/槽候选。 - 可调整槽/半孔宽度、深度圆弧长度和轴心坐标的局部圆柱孔/槽候选。
- 可调整盲孔/盲槽深度的圆柱Face候选。 - 可调整盲孔/盲槽深度的圆柱Face候选。
- 可调整圆柱凸台直径和高度的凸台候选。 - 可调整圆柱凸台直径和高度的凸台候选。
- 可添加新圆角的直线Edge候选。 - 可添加新圆角的直线Edge候选。
@@ -279,13 +319,15 @@ git diff --check
- 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。 - 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。
- 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。 - 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。
- 当前版本支持长圆槽/槽孔总长度和中心距调整: - 当前版本支持长圆槽/槽孔总长度和中心距调整:
- 自动识别到成对半圆槽端时,程序会保持槽宽不变,先补旧长圆槽体,再切出目标总长度 - 你可以直接在当前选中对象表里修改 `槽孔总长度``槽孔中心距`,不需要先填写配对端 Face ID
- 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度和总长度调整会优先尝试使用这个手动配对端 - 点击修改时,程序会自动寻找另一个半圆槽端;识别成功后保持槽宽不变,先补旧长圆槽体,再切出目标总长度
- 当前版本支持薄壁/壳体局部厚度调整: - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度、总长度和中心距调整会优先尝试使用这个手动配对端。
- 当前版本支持薄壁/壳体厚度调整:
- 对已找到投影重叠相对平面的平面候选开放。 - 对已找到投影重叠相对平面的平面候选开放。
- 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。 - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。
- 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。 - 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。
- 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。 - 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。
- 当前选中对象表的 `薄壁厚度` 会通过 `影响范围` 选择 `推拉当前面``调整整个特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。
- 当前版本支持已有圆角/倒圆候选结构化识别: - 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate` - 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。 - 会估算已有圆角半径、圆弧角度和圆弧长度。
@@ -293,6 +335,7 @@ git diff --check
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `影响范围` 选择 `重建圆角``调整整个特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。
- 当前版本支持圆柱孔封堵: - 当前版本支持圆柱孔封堵:
- 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。
- 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。
@@ -304,6 +347,7 @@ git diff --check
- 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。
- 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。
- 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。
- 当前选中对象表的 `直径` / `半径` 会通过 `影响范围` 选择 `只改凸台``调整整个特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。
- 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
- 当前版本支持圆柱凸台高度和轴心坐标调整: - 当前版本支持圆柱凸台高度和轴心坐标调整:
@@ -326,18 +370,23 @@ git diff --check
- 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。
- STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
- 当前版本支持修改Edge长度: - 当前版本支持修改Edge长度:
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 - 选中Edge后,`长度` 会自动填入当前Edge长度。
- 对圆形/圆弧Edge,当前选中对象表会额外提供圆Edge半径和圆Edge直径可编辑行,内部会换算为等比例Edge目标长度。 - 对圆形/圆弧Edge,当前选中对象表会额外提供圆边半径和圆边直径可编辑行,内部会换算为等比例Edge长度,并通过 `影响范围` 选择自动、相邻圆柱或缩放所属
- 可以选择Edge长度基准:自动、中心、固定起点、固定终点。 - `长度基准`可以选择自动、中心、固定起点、固定终点。
- 对直线Edge,当前选中对象表会额外提供 `Edge起点坐标``Edge中心坐标``Edge终点坐标`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边 - `长度` 行的 `影响范围` 可以选择 `自动``只改当前Edge``移动端面``相邻圆柱``缩放所属`
- `只变当前Edge` 会强制使用局部边形变;如果当前Edge不满足简单全平面多面体条件,会直接阻止,不会偷偷改成整体缩放。
- `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面推拉;对正方体这类模型,会得到更像“变成长方体”的结果。如果找不到端面推拉路径,会直接阻止。
- `缩放所属` 会强制对所属特征或 Solid 做轴向/整体缩放,是高风险兜底语义,会影响其它尺寸。
- 对直线Edge,当前选中对象表会额外提供 `起点``中心``终点`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。
- 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。
- 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。 - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。
- 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。 - 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。
- `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。
- 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。
- 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑。 - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge长度按比例换算成目标圆柱直径再执行局部编辑。
- 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。
- 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback - 椭圆 Edge 的 `椭圆主半径` / `椭圆小半径` 是单独的属性行:主半径会沿椭圆主轴方向单轴缩放,小半径会沿小轴方向单轴缩放;另一个半径方向尽量不动
- 如果直接改椭圆 Edge 的 `长度`,或选择其它可稳定采样为平面的 B-spline / Bezier 等非直线边,仍会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。
- 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。
- `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。
- 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 - 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
@@ -361,7 +410,11 @@ git diff --check
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
- 当前版本支持圆柱孔/圆柱面扩大切削。 - 当前版本支持圆柱孔/圆柱面扩大切削。
- 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 - 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。
- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心坐标`,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽暂不放行这条路线,避免整圆柱工具误补材料 - 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动孔`,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽会走 `移动槽/半孔` 语义,避免整圆柱工具误补材料。把 `影响范围` 设为 `移动整个特征` 时,会平移所属特征或 Solid
- 当前版本支持孔/槽 `直径` / `半径``调整整个特征` 影响范围:
- 这条路线不会重切孔壁,而是按目标直径或半径与当前值的比例均匀缩放所属特征或 Solid。
- 高度、厚度和同一对象上的其它尺寸会同比例变化。
- 如果你只想让孔径变大/变小,优先在 `直径``半径` 行把 `影响范围` 设为 `只改孔/槽壁`;如果你的建模意图是让所属圆柱区域整体缩放,再设为 `调整整个特征`
- 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - 圆柱孔/圆柱面孔径调整现在会先生成切削计划:
- 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。
- 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。
@@ -421,7 +474,7 @@ vertices: 3262
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、长圆槽总长度/中心距调整和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 - 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底推拉或通过 `影响范围` 做轴向整体缩放,以及平面薄壁/壳体相对面厚度估算、局部厚度推拉和通过 `影响范围` 做厚度方向整体缩放;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。
@@ -437,11 +490,12 @@ vertices: 3262
建议下一步按这个顺序推进: 建议下一步按这个顺序推进:
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。 1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。
2. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败 2. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略,优先覆盖 Edge 长度、Face 编辑、孔/槽、凸台和圆角。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选 3. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整(含整体缩放)、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败
4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略 4. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选
5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则 5. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离 6. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则
7. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
## 怎么使用 ## 怎么使用
@@ -489,11 +543,11 @@ vertices: 3262
左侧 `显示` 用来临时缩小三维视图范围: 左侧 `显示` 用来临时缩小三维视图范围:
- `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。 - `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge - Face 模式下 `只显示选中`优先隔离当前命中的 Face,避免普通点选时触发同域面重计算;如果当前选择来自编辑计划、扫描候选或历史定位,并且已经解析出特征区域,则会使用这片已知区域
- `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。 - `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。
- `显示全部`:恢复显示完整模型。 - `显示全部`:恢复显示完整模型。
- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 - `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域区域,而不是只导出鼠标点到的单个拓扑Face - `导出选中面区域` 会导出当前选中 Face;如果当前选择已经带有明确的特征区域或同域区域信息,则导出这片已知区域
- Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 - Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。
- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 - 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。
@@ -513,7 +567,7 @@ vertices: 3262
- 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。
- 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。
- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
- `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移``推拉平面` 使用。 - `推拉平面` 行表示这个Face是平面,可以配合 `偏移距离``推拉平面` 使用。
- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径``调整圆柱孔径` 使用。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径``调整圆柱孔径` 使用。
- `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 - `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。
- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 - `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。
@@ -529,8 +583,8 @@ vertices: 3262
选择对象后: 选择对象后:
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
-`Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题 -`Face` 模式下,普通点选只做轻量拾取和单个对象高亮,保证旋转、悬停和选中更流畅;同域面、端盖、底面和槽端配对等深层识别会延后到扫描候选、执行编辑计划或历史定位时进行
- 如果处于 `特征` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 如果处于 `特征` 模式,普通点选会先高亮命中的基础对象;扫描候选、执行编辑计划或历史定位时,程序会尽量解析并高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
- 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face` - 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face`
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face` - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face`
- 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 - 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。
@@ -542,7 +596,7 @@ vertices: 3262
- `复制信息` 会复制当前信息面板里的完整文本。 - `复制信息` 会复制当前信息面板里的完整文本。
- 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 - 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。
- 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。 - 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。
- 如果选中直线Edge,会自动给 `圆角半径``倒角距离` 填一个保守的建议值,并把 `Edge目标长度` 填成当前Edge长度。 - 如果选中直线Edge,会自动给 `圆角半径``倒角距离` 填一个保守的建议值,并把 `长度` 填成当前Edge长度。
圆柱面候选: 圆柱面候选:
@@ -564,7 +618,7 @@ vertices: 3262
编辑: 编辑:
- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 - 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。
- `Face偏移` + `推拉平面` - `偏移距离` + `推拉平面`
- 先选择一个平面Face。 - 先选择一个平面Face。
- 输入偏移距离。 - 输入偏移距离。
- 正数表示沿程序判断的外法向加料。 - 正数表示沿程序判断的外法向加料。
@@ -577,6 +631,21 @@ vertices: 3262
- 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向``推拉向内方向``推拉方向置信度` - 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向``推拉向内方向``推拉方向置信度`
- 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 - 当前方向判断通过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 原始建模历史里的“孔深”参数。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。
- 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。
- `直径` / `半径``调整整个特征`
- 先选择一个圆柱 Face,然后在当前选中对象表里修改 `直径``半径`,并把 `影响范围` 设为 `调整整个特征`
- 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。
- 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。
- `槽/半孔宽度` + `调整槽/半孔宽度` - `槽/半孔宽度` + `调整槽/半孔宽度`
- 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate` - 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`
- 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。
@@ -610,22 +683,42 @@ vertices: 3262
- 先选择一个槽/半孔候选 Face。 - 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模` - 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
- `槽宽` / `槽深` / `弧长``调整整个特征`
- 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改对应属性,并把 `影响范围` 设为 `调整整个特征`
- 程序会把目标槽宽、槽深或弧长换算成目标圆柱直径,然后按比例整体缩放所属特征或 Solid。
- 这不是局部重切槽;同一所属对象上的高度、厚度和其它尺寸也会跟随变化。
- `槽/半孔圆弧角度` / `槽/半孔开口角度` - `槽/半孔圆弧角度` / `槽/半孔开口角度`
- 先选择一个槽/半孔候选 Face。 - 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改角度值,角度输入单位是度。 - 在当前选中对象表里修改角度值,角度输入单位是度。
- 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角` - 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角`
- 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 - 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。
- `轴心``移动槽/半孔`
- 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改目标坐标,格式是 `X, Y, Z`,并把 `影响范围` 设为 `移动槽/半孔`
- 程序会先填充旧的局部扇形槽,再在目标轴心位置按同宽度、同圆弧角度切出新槽。
- 这一步只移动选中的局部扇形槽面,不等于完整 CAD 草图里的槽整体迁移;长圆槽、链式槽和复杂槽需要确认结果,失败会回滚。
- `轴心``移动整个特征`
- 同样输入目标 X, Y, Z 坐标。
-`影响范围` 设为 `移动整个特征`
- 程序会把目标轴心换算成平移向量,并平移所属特征或 Solid;不会填旧槽,也不会切新槽。
- 这适合“整个对象搬动到新位置”的意图,不适合只想移动槽本身的场景。
- `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距` - `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距`
- 对长圆槽或成对半圆槽,程序会自动寻找另一个槽端。 - 对长圆槽或成对半圆槽,程序会在点击修改时自动寻找另一个槽端,避免选择对象时扫描过重
- 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置` - 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`
- 修改 `槽孔总长度``槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。 - 修改 `槽孔总长度``槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。
- `薄壁厚度` + `调整薄壁厚度` - `薄壁厚度` `推拉当前面`
- 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入 - 先选择一个已识别到相对平面的薄壁/壳体平面 Face。
- 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值 - 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `推拉当前面`
- 输入目标厚度后点击 `调整薄壁厚度` - 输入目标厚度后点击该行的 `可修改` 或点击 `参数化建模`
- 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。
- 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。 - 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。
- 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。 - 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。
- `薄壁厚度``调整整个特征`
- 先选择一个已识别到相对平面的薄壁/壳体平面 Face。
- 在当前选中对象表里修改 `薄壁厚度`,并把 `影响范围` 设为 `调整整个特征`
- 程序会沿薄壁厚度方向缩放所属特征或 Solid,让估算厚度接近目标值。
- 这不是推拉当前面;同一对象上的孔距、台阶位置和其它厚度方向尺寸也会跟随变化。
- `凸台直径` + `调整圆柱凸台直径` - `凸台直径` + `调整圆柱凸台直径`
- 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。
- 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。
@@ -633,14 +726,38 @@ vertices: 3262
- 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。
- 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。
- `直径` / `半径``调整整个特征`
- 先选择一个圆柱凸台 Face,然后在当前选中对象表里修改这一行。
-`影响范围` 设为 `调整整个特征`
- 它会按目标直径或半径比例均匀缩放所属特征或 Solid,不走凸台包络移除/重建路线。
- 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。
- `凸台高度` - `凸台高度`
- 先选择一个完整圆柱凸台 Face。 - 先选择一个完整圆柱凸台 Face。
- 在当前选中对象表里修改 `凸台高度`,点击该行的 `可修改` 或点击 `参数化建模` - 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `推拉端盖`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。 - 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。
- `凸台轴心坐标` - `高度``调整整个特征`
- 先选择一个完整圆柱凸台或普通完整圆柱 Face。
- 在当前选中对象表里修改 `高度`,并把 `影响范围` 设为 `调整整个特征`
- 程序会沿圆柱轴向缩放所属特征或 Solid,让圆柱高度接近目标值。
- 这不是端盖推拉;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。
- `轴心`
- 先选择一个完整圆柱凸台 Face。 - 先选择一个完整圆柱凸台 Face。
- 在当前选中对象表里修改 `凸台轴心坐标`,点击该行的 `应用` 或点击 `参数化建模` - 在当前选中对象表里修改 `轴心`,并把 `影响范围` 设为 `移动凸台`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。
- `轴心``移动整个特征`
- 同样输入目标 X, Y, Z 坐标。
-`影响范围` 设为 `移动整个特征`
- 程序会整体平移凸台所属特征或 Solid;不会移除旧凸台,也不会补新凸台。
- 这适合移动整个所属对象,不适合只改凸台相对基体的位置。
- `轴心``移动孔`
- 先选择一个完整圆柱孔 Face。
- 在当前选中对象表里修改目标 X, Y, Z 坐标,并把 `影响范围` 设为 `移动孔`
- 程序会先填补旧孔,再按同直径在目标轴心位置切出新孔。
- `轴心``移动整个特征`
- 同样输入目标 X, Y, Z 坐标。
-`影响范围` 设为 `移动整个特征`
- 程序会整体平移孔所属特征或 Solid;不会填旧孔,也不会切新孔。
- 这适合移动整个所属对象,不适合只改孔相对基体的位置。
- `封堵圆柱孔` - `封堵圆柱孔`
- 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。 - 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。
- 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。
@@ -648,10 +765,10 @@ vertices: 3262
- 通过检查后,会显示绿色半透明补料体预览。 - 通过检查后,会显示绿色半透明补料体预览。
- 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。 - 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。
- 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。 - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。
- `深度` + `调整盲孔深度` - `盲孔/盲槽深度` `改底面深度`
- 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。
- 选中符合条件的盲孔/盲槽后,`孔深度` 会自动填入一个比当前估算深度略大的建议值 - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `改底面深度`
- 输入目标深度后点击 `调整盲孔深度` - 输入目标深度后点击该行的 `可修改` 或点击 `参数化建模`
- 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。
- 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。
- 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。
@@ -659,6 +776,11 @@ vertices: 3262
- 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。
- 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。
- 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。 - 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。
- `盲孔/盲槽深度``调整整个特征`
- 先选择一个已经识别出底面的盲孔或盲槽圆柱面;自动底面不准时,可以先填写 `底面Face`
- 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `影响范围` 设为 `调整整个特征`
- 程序会沿孔/槽轴向缩放所属特征或 Solid,让深度接近目标值。
- 这不是加深切削,也不是变浅补料;同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化。
- `圆角半径` + `给Edge添加圆角` - `圆角半径` + `给Edge添加圆角`
- 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。 - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。
- 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。 - 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。
@@ -668,15 +790,19 @@ vertices: 3262
- 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。 - 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。
- 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 这是“给直线边添加新圆角”,不是修改已有圆角面。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。
- `圆角半径` + `修改已有圆角半径` - `圆角半径` / `圆角弧长``重建圆角`
- 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值 - 在当前选中对象表里修改 `圆角半径``圆角弧长`,并把 `影响范围` 设为 `重建圆角`
- 输入目标半径后点击 `修改已有圆角半径` - 输入目标后点击该行的 `修改` 或点击 `参数化建模`
- 也可以在当前选中对象表里修改 `已有圆角圆弧长度`,程序会按当前圆弧角度换算成目标半径后执行同一条修改路线。
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
- 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。
- 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。
- 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。
- `圆角半径` / `圆角弧长``调整整个特征`
- 先选择一个已有圆角/倒圆候选 Face。
- 在当前选中对象表里修改 `圆角半径``圆角弧长`,并把 `影响范围` 设为 `调整整个特征`
- 程序会按目标圆角半径或圆弧长度换算出的比例整体缩放所属特征或 Solid,不会移除旧圆角再重建。
- 这会连带改变同一对象上的其它尺寸。
- `倒角距离` + `给Edge添加倒角` - `倒角距离` + `给Edge添加倒角`
- 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。 - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。
- 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。 - 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。
@@ -689,18 +815,19 @@ vertices: 3262
- 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。
- 当前支持对称距离倒角、两侧不同距离倒角和距离+角度倒角。 - 当前支持对称距离倒角、两侧不同距离倒角和距离+角度倒角。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。
- `Edge目标长度` + `修改Edge长度` - `长度` + `影响范围`
- 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。 - 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 - 选中Edge后,`长度` 会自动填入当前Edge长度。
- 右侧Edge长度基准下拉框可以选择 `自动``中心``固定起点``固定终点` - 当前选中对象表中的 `长度基准`可以选择 `自动``中心``固定起点``固定终点`
- 把它改成目标长度后点击 `修改Edge长度` - `长度` 行的 `影响范围` 下拉框可以选择 `自动``只改当前Edge``移动端面``相邻圆柱``缩放所属`
- 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `Edge起点坐标``Edge中心坐标``Edge终点坐标`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。 - `长度` 改成目标值后点击这一行的按钮。
- 修改 `Edge中心坐标` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点 - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点``中心``终点`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮
- `assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面 - 修改 `中心` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点
- 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。 - `assets/models/cube_10mm.step` 这类全平面多面体,选择 `只变当前Edge` 时会只移动被选Edge的端点并重建周边平面。这时正方体不会变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。
- 对同一个正方体,选择 `移动端面` 时会把改边长转成端面推拉,相关平行边会一起变化,结果更像从正方体变成长方体。
- 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。
- 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。
- 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心坐标”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心”的局部形变路径。
- 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。
- 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。
- 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。
@@ -752,7 +879,7 @@ vertices: 3262
- `导出当前完整 STEP`:导出当前完整模型。 - `导出当前完整 STEP`:导出当前完整模型。
- `导出选中零件`:导出当前选中的零件。 - `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。
- `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。 - `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。
- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
@@ -775,7 +902,15 @@ pythonocc-step-editor/
screenshots/ # 调试截图和问题截图,默认不提交 screenshots/ # 调试截图和问题截图,默认不提交
scripts/ scripts/
generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 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_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/ step_editor/
__init__.py # 包导出 __init__.py # 包导出
app.py # 主窗口初始化、UI 搭建和程序启动 app.py # 主窗口初始化、UI 搭建和程序启动
@@ -840,6 +975,10 @@ python main.py
python main.py assets\models\cube_10mm.step python main.py assets\models\cube_10mm.step
python scripts\generate_cube_step.py python scripts\generate_cube_step.py
python scripts\verify_edge_length_resize.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 python main.py --smoke-test
``` ```
@@ -862,7 +1001,14 @@ git diff --check
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step` - `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step` - `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_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 用户直接创建环境。 - `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 - `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。 - `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。
@@ -886,28 +1032,62 @@ git diff --check
### 当前关键能力 ### 当前关键能力
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别
- 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。
- 编辑:平面推拉和目标法向位置、孔径/半径、槽/半孔宽度、深度、圆弧长度、圆弧角、开口角度、槽孔总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心坐标、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度当前版本修改、圆锥半角、环面直径、Edge圆角、Edge倒角、圆Edge半径/直径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积当前作为只读属性展示,不再伪装成直接可修改参数。 - 编辑:面偏移、偏移距离、面积、面宽/面高、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角(整体)、球面半径/直径(整体)、环面主/小半径或直径(整体)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 的面偏移、面积、面宽/面高和中心现在统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也统一显示为单行属性,通过 `影响范围` 选择“只改孔/槽壁”或“调整整个特征”;孔/槽轴心也统一显示为单行属性,通过 `影响范围` 选择“移动孔”“移动槽/半孔”或“移动整个特征”;槽宽/槽深/弧长也统一显示为单行属性,通过 `影响范围` 选择“只改槽壁”或“调整整个特征”;凸台直径/半径/高度/轴心也统一显示为单行属性,通过 `影响范围` 选择“只改凸台”“推拉端盖”“移动凸台”“调整整个特征”或“移动整个特征”;普通完整圆柱的直径、半径和高度也统一显示为单行属性,通过 `影响范围` 选择“兜底重切”“推拉端盖”或“调整整个特征”;盲孔/盲槽深度也统一显示为单行属性,通过 `影响范围` 选择“改底面深度”或“调整整个特征”;薄壁厚度也统一显示为单行属性,通过 `影响范围` 选择“推拉当前面”或“调整整个特征”;已有圆角半径/弧长也统一显示为单行属性,通过 `影响范围` 选择“重建圆角”或“调整整个特征”;圆Edge半径/直径也统一显示为单行属性,通过 `影响范围` 选择“自动”“相邻圆柱”或“缩放所属”;椭圆Edge主半径/小半径会沿主轴/小轴单轴缩放所属对象;Edge 长度也统一显示为单行属性,通过 `影响范围` 选择“自动”“只改当前Edge”“移动端面”“相邻圆柱”或“缩放所属”;圆锥、球面和环面解析曲面当前明确标成 `整体`,因为它们会缩放所属对象而不是只替换单个曲面历史参数。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
### 编辑语义
所有几何参数修改都必须先明确语义、影响范围和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分面推拉、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `影响范围` 提供显式改法选择;Face 当前把 `面偏移``面积``面宽``面高``中心` 显示为单行属性,并通过 `影响范围` 下拉框表达“推拉当前面”“只改当前面”“移动整个特征”或“调整整个特征”;孔/槽直径和半径也通过 `影响范围` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `影响范围` 区分“局部重建/端盖推拉/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `影响范围` 区分“兜底重切/端盖推拉”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `影响范围` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `影响范围` 区分“局部切削/补料”和“轴向缩放所属对象”;薄壁厚度也通过 `影响范围` 区分“局部推拉当前薄壁平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `影响范围` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、薄壁厚度现在会显示并记录当前采用的编辑方式,后续还要继续把更多多语义场景做成用户可选策略。
必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。
### 修改Edge长度 ### 修改Edge长度
这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。 这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。
当前策略顺序 当前支持的长度修改策略:
1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体 1. `auto`:自动策略。按局部边形变、端面推拉、相邻圆柱直径、几何缩放的顺序寻找可用路径
2. `local-edge-endpoint-deform`直线Edge起点/终点坐标直接修改,同样只在简单全平面多面体上移动单个端点并重建周边平面 2. `local-edge-only-deform`只变当前Edge。直线Edge位于简单全平面多面体时,只移动被选Edge的端点并重建所属Solid/特征。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体
3. `move-edge-end-plane-by-push-pull`如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 3. `move-edge-end-plane-by-push-pull`移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉;端面和相关边跟随移动,相邻平面尽量保持垂直,对正方体会更像变成长方体
4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 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长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。 确认窗口和操作历史里要尽量记录策略、目标范围、目标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,说明没有整体拉成长方体,而是局部重建并拆分了一个非共面面。 最近一次基线验证:在 `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,将长度放大 1.25 倍,计划策略为 `radial-affine`,通过预变换Edge长度把缩放比例从 `1.25` 微调到约 `1.25354`,结果边长贴合目标。另一个圆锥圆边先径向缩放后会变成 B-spline,再选这个平面 B-spline Edge 放大 1.1 倍,计划会走 `sampled edge best-fit plane`,采样偏差约 `3.6e-15`,结果边长贴合目标。
后置校验基线:修改Edge长度完成后,结果消息会包含 `match``nearest_edge``nearest_length``target_error``relative_error``endpoint_error` 和校验范围;局部边形变、端面推拉和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length``target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 后置校验基线:修改Edge长度完成后,结果消息会包含 `match``nearest_edge``nearest_length``target_error``relative_error``endpoint_error` 和校验范围;局部边形变、端面推拉和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length``target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。
+227
View File
@@ -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())
+255
View File
@@ -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())
+31 -5
View File
@@ -35,11 +35,23 @@ def _length_distribution(model: StepModel) -> dict[float, int]:
def main() -> 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("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("--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("--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("--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.") parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.")
args = parser.parse_args() args = parser.parse_args()
@@ -50,12 +62,25 @@ def main() -> int:
edge_id = edge_ids[0] edge_id = edge_ids[0]
before = model.stats() 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", "")) strategy = str(plan.get("resize_strategy", ""))
if strategy != "local-edge-only-deform": expected_strategy = args.expect_strategy or args.strategy
raise SystemExit(f"expected local-edge-only-deform, got {strategy or '<none>'}") if expected_strategy == "auto":
expected_strategy = strategy
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
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() after = model.stats()
lengths = [_edge_length(model, item) for item in range(len(model.edges))] lengths = [_edge_length(model, item) for item in range(len(model.edges))]
nearest = min(lengths, key=lambda value: abs(value - args.target_length)) 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"model={Path(args.model)}")
print(f"edge_id={edge_id}") print(f"edge_id={edge_id}")
print(f"requested_strategy={args.strategy}")
print(f"strategy={strategy}") print(f"strategy={strategy}")
print(f"anchor_mode={args.anchor}") print(f"anchor_mode={args.anchor}")
print(f"start_move={plan.get('local_edge_deform_start_move')}") print(f"start_move={plan.get('local_edge_deform_start_move')}")
+314
View File
@@ -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 '<none>'}")
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())
+90
View File
@@ -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())
+105
View File
@@ -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 '<none>'}")
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())
+339
View File
@@ -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())
+157
View File
@@ -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())
+448
View File
@@ -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())
+88 -12
View File
@@ -108,8 +108,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.pending_hover_position: tuple[int, int] | None = None self.pending_hover_position: tuple[int, int] | None = None
self.last_hover_pick_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None
self.camera_interaction_active = False 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.pointer_button_down = False
self.left_button_press_position: tuple[int, int] | None = None self.left_button_press_position: tuple[int, int] | None = None
self.left_button_dragged = False self.left_button_dragged = False
@@ -132,6 +130,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.model_part_id_array = None self.model_part_id_array = None
self.model_solid_id_array = None self.model_solid_id_array = None
self.edge_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.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
self.edge_overlay_polydata_cache: dict[int, object] = {} self.edge_overlay_polydata_cache: dict[int, object] = {}
self.overlay_cache_limit = 160 self.overlay_cache_limit = 160
@@ -164,6 +166,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.scan_worker: ScanWorker | None = None self.scan_worker: ScanWorker | None = None
self.pending_scan_kind: str | None = None self.pending_scan_kind: str | None = None
self.pending_scan_context: dict[str, object] | None = None self.pending_scan_context: dict[str, object] | None = None
self.scan_wait_cursor_active = False
self.load_in_progress = False self.load_in_progress = False
self.load_thread: QThread | None = None self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None self.load_worker: LoadWorker | None = None
@@ -331,11 +334,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
background: #fff8ef; background: #fff8ef;
border: 1px solid #edc98e; border: 1px solid #edc98e;
border-left: 5px solid #d97706; border-left: 5px solid #d97706;
padding-left: 5px;
padding-right: 3px;
padding-bottom: 7px;
} }
QGroupBox#editSection::title { QGroupBox#editSection::title {
background: #fff0d8; background: #fff0d8;
border-color: #edc98e; border-color: #edc98e;
color: #a45303; color: #a45303;
left: 10px;
padding-left: 6px;
padding-right: 6px;
} }
QGroupBox#editableSection, QGroupBox#editableSection,
QGroupBox#candidateSection { QGroupBox#candidateSection {
@@ -441,6 +450,28 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border: 1px dashed #bcc7d4; border: 1px dashed #bcc7d4;
color: #8f99a8; 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 { QLineEdit#propertyTargetEditor {
background: #fff7ed; background: #fff7ed;
border: 2px solid #f97316; border: 2px solid #f97316;
@@ -532,6 +563,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
font-weight: 700; font-weight: 700;
padding: 4px 6px; 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 { QTabWidget::pane {
border: 1px solid #d8e0eb; border: 1px solid #d8e0eb;
border-radius: 6px; border-radius: 6px;
@@ -779,8 +822,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。", "选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。",
) )
object_edit_layout = QVBoxLayout(self.object_edit_box) object_edit_layout = QVBoxLayout(self.object_edit_box)
self.property_table = QTableWidget(0, 4) object_edit_layout.setContentsMargins(2, 8, 2, 4)
self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "目标值", "状态"]) 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.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True) 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(0, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
self.property_table.setColumnWidth(0, 106) property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(1, 92) self.property_table.setColumnWidth(0, 82)
self.property_table.setColumnWidth(2, 92) self.property_table.setColumnWidth(1, 78)
self.property_table.setColumnWidth(3, 62) self.property_table.setColumnWidth(2, 104)
self.property_table.setColumnWidth(3, 82)
self.property_table.setColumnWidth(4, 58)
help_tip( help_tip(
self.property_table, self.property_table,
"当前选中对象的属性表。可修改的行可以在目标值列输入新值,并点击状态列里的修改按钮只应用这一行。", "当前选中对象的属性表。可修改的行可以选择影响范围、输入目标值,并点击操作列里的按钮只应用这一行。",
) )
self.property_table.itemChanged.connect(self._on_property_table_item_changed) self.property_table.itemChanged.connect(self._on_property_table_item_changed)
object_edit_layout.addWidget(self.property_table) object_edit_layout.addWidget(self.property_table)
@@ -834,7 +882,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_box.setVisible(False) edit_box.setVisible(False)
self.hidden_edit_controls_box = edit_box self.hidden_edit_controls_box = edit_box
edit_layout = QGridLayout(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") self.offset_input = QLineEdit("5.0")
help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。")
edit_layout.addWidget(self.offset_input, 0, 1) 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 = QLineEdit("", edit_box)
self.slot_total_length_input.setVisible(False) self.slot_total_length_input.setVisible(False)
help_tip(self.slot_total_length_input, "长圆槽/槽孔的目标总长度。只有识别到成对半圆槽端时才会启用。") 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 = QLineEdit("", edit_box)
self.slot_center_x_input.setVisible(False) self.slot_center_x_input.setVisible(False)
self.slot_center_y_input = QLineEdit("", edit_box) self.slot_center_y_input = QLineEdit("", edit_box)
@@ -930,7 +981,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。") help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。")
self.face_area_input = QLineEdit("", edit_box) self.face_area_input = QLineEdit("", edit_box)
self.face_area_input.setVisible(False) 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("调整圆柱凸台直径") self.resize_boss_button = QPushButton("调整圆柱凸台直径")
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
self.resize_boss_button.clicked.connect(self.resize_boss) 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_x_input = QLineEdit("", edit_box)
self.edge_end_y_input = QLineEdit("", edit_box) self.edge_end_y_input = QLineEdit("", edit_box)
self.edge_end_z_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 ( for endpoint_input in (
self.edge_start_x_input, self.edge_start_x_input,
self.edge_start_y_input, self.edge_start_y_input,
@@ -1017,8 +1076,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.edge_end_x_input, self.edge_end_x_input,
self.edge_end_y_input, self.edge_end_y_input,
self.edge_end_z_input, self.edge_end_z_input,
self.ellipse_edge_major_radius_input,
self.ellipse_edge_minor_radius_input,
): ):
endpoint_input.setVisible(False) 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 = NoWheelComboBox()
self.edge_length_anchor_combo.addItem("自动", "auto") self.edge_length_anchor_combo.addItem("自动", "auto")
self.edge_length_anchor_combo.addItem("中心", "center") self.edge_length_anchor_combo.addItem("中心", "center")
@@ -1030,8 +1093,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.edge_length_anchor_combo, self.edge_length_anchor_combo,
"选择修改Edge长度时尽量固定哪里:自动默认固定起点并移动终点;中心会两端各动一半;固定起点/终点会明确控制局部形变端点。", "选择修改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_target_length_input, stretch=2)
edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1) 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) edit_layout.addWidget(edge_length_inputs, 18, 1)
self.resize_edge_length_button = QPushButton("修改Edge长度") self.resize_edge_length_button = QPushButton("修改Edge长度")
help_tip( help_tip(
+2
View File
@@ -40,6 +40,8 @@ SURFACE_TYPES = {
SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__" SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__"
FACE_SELECTION_FEATURE_INFO_SURFACES = {"plane", "cylinder", "cone", "sphere", "torus"}
CURVE_TYPES = { CURVE_TYPES = {
GeomAbs_Line: "line", GeomAbs_Line: "line",
GeomAbs_Circle: "circle", GeomAbs_Circle: "circle",
+156 -19
View File
@@ -744,6 +744,11 @@ class FeatureMixin:
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" 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) 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 {} fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {}
edit_semantics = (
"缩小孔/槽直径:先用同轴圆柱补料封住旧孔壁,再按目标直径同轴重切;保持当前轴线和估算高度。"
if resize_mode == "shrink"
else "扩大孔/槽直径:沿当前圆柱轴线用有限长度圆柱 cutter 切到目标直径;保持当前轴线和估算高度。"
)
return { return {
"status": readiness["resize_status"], "status": readiness["resize_status"],
"risk": readiness["resize_risk"], "risk": readiness["resize_risk"],
@@ -773,6 +778,9 @@ class FeatureMixin:
"same_domain_range_source": axis_range["range_source"], "same_domain_range_source": axis_range["range_source"],
"material_vote_summary": info.get("material_vote_summary"), "material_vote_summary": info.get("material_vote_summary"),
"material_sample_count": info.get("material_sample_count"), "material_sample_count": info.get("material_sample_count"),
"resize_strategy": "same-axis-bounded-cylinder-recut",
"edit_strategy_label": "同轴圆柱重切",
"edit_semantics": edit_semantics,
**cutter_plan, **cutter_plan,
**fill_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_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""), "same_domain_range_source": axis_range.get("range_source", ""),
"resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder", "resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder",
"edit_strategy_label": "填旧孔并切新孔",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。",
} }
def cylindrical_slot_resize_plan( def cylindrical_slot_resize_plan(
@@ -1034,6 +1044,8 @@ class FeatureMixin:
"slot_resize_mode": mode_key, "slot_resize_mode": mode_key,
"slot_resize_label": mode_labels.get(mode_key, "slot value"), "slot_resize_label": mode_labels.get(mode_key, "slot value"),
"slot_resize_strategy": "local-sector-fixed-angular-span", "slot_resize_strategy": "local-sector-fixed-angular-span",
"edit_strategy_label": "局部扇形槽重建",
"edit_semantics": "保持当前槽/半孔圆弧角度,把目标宽度、深度或圆弧长度换算成圆柱直径,再填旧槽并切出新的局部扇形槽。",
"slot_kind": slot_kind, "slot_kind": slot_kind,
"slot_status": slot_status, "slot_status": slot_status,
"slot_target_value": target_value, "slot_target_value": target_value,
@@ -1084,7 +1096,11 @@ class FeatureMixin:
resize_plan, resize_plan,
pair_face_id=pair_face_id, pair_face_id=pair_face_id,
) )
edit_strategy_label = "局部扇形槽重建"
edit_semantics = "保持当前槽/半孔圆弧角度,把目标值换算成圆柱直径,再填旧槽并切出新的局部扇形槽。"
if paired_slot_plan: if paired_slot_plan:
edit_strategy_label = "配对长圆槽重建"
edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同一槽中心线和目标宽度重切完整长圆槽。"
risk = _max_risk(risk, str(paired_slot_plan.get("slot_pair_risk", "medium"))) risk = _max_risk(risk, str(paired_slot_plan.get("slot_pair_risk", "medium")))
pair_warning = str(paired_slot_plan.get("slot_pair_warning", "")) pair_warning = str(paired_slot_plan.get("slot_pair_warning", ""))
if pair_warning: if pair_warning:
@@ -1104,6 +1120,8 @@ class FeatureMixin:
**resize_plan, **resize_plan,
**slot_plan, **slot_plan,
**paired_slot_plan, **paired_slot_plan,
"edit_strategy_label": edit_strategy_label,
"edit_semantics": edit_semantics,
"status": status, "status": status,
"risk": risk, "risk": risk,
"message": message, "message": message,
@@ -1235,6 +1253,8 @@ class FeatureMixin:
"slot_resize_mode": "angular_span", "slot_resize_mode": "angular_span",
"slot_resize_label": "slot angular span", "slot_resize_label": "slot angular span",
"slot_resize_strategy": "local-sector-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", "resize_mode": "widen" if (delta_span or 0.0) > 0 else "narrow",
"feature_type": feature.get("feature_type"), "feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"), "feature_guess": info.get("feature_guess"),
@@ -1388,11 +1408,51 @@ class FeatureMixin:
except Exception as exc: except Exception as exc:
blockers.append(f"Could not build slot axis move tool plan: {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: if pair_plan:
risk = _max_risk(risk, "high") center_1 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_1"))
warnings.append( center_2 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_2"))
"A paired obround slot end was detected; this axis move edits the selected local sector, not the whole CAD slot sketch." 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_radius = current_diameter * 0.5 if current_diameter is not None else None
current_width = ( current_width = (
@@ -1412,6 +1472,7 @@ class FeatureMixin:
return { return {
**cutter_plan, **cutter_plan,
**fill_plan, **fill_plan,
**pair_plan,
"status": status, "status": status,
"risk": "blocked" if blockers else risk, "risk": "blocked" if blockers else risk,
"message": message, "message": message,
@@ -1455,7 +1516,9 @@ class FeatureMixin:
"same_domain_face_count": axis_range.get("same_domain_face_count", 0), "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_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""), "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( def _paired_obround_slot_plan(
@@ -1767,6 +1830,8 @@ class FeatureMixin:
"slot_resize_mode": "total_length", "slot_resize_mode": "total_length",
"slot_resize_label": "slot total length", "slot_resize_label": "slot total length",
"slot_resize_strategy": "paired-obround-slot-length-prism", "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", "resize_mode": "lengthen" if (delta_length or 0.0) > 0 else "shorten",
"slot_current_total_length": current_total_length, "slot_current_total_length": current_total_length,
"slot_target_total_length": target_total_length, "slot_target_total_length": target_total_length,
@@ -1778,6 +1843,68 @@ class FeatureMixin:
"slot_capsule_target_start_center_2": target_center_2, "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]: 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): if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}") raise ValueError(f"Unknown face id {face_id}")
@@ -1806,6 +1933,11 @@ class FeatureMixin:
height_estimate = float(scoped_info.get("height_estimate", 0.0)) height_estimate = float(scoped_info.get("height_estimate", 0.0))
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" 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) tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter)
edit_semantics = (
"扩大凸台直径:按当前凸台轴线和估算高度生成目标圆柱补料体,并与所属特征做局部 Fuse。"
if resize_mode == "enlarge"
else "缩小凸台直径:先移除旧凸台包络,再补回目标直径圆柱;这是局部重建,不是缩放整个零件。"
)
return { return {
"status": readiness["boss_resize_status"], "status": readiness["boss_resize_status"],
"risk": readiness["boss_resize_risk"], "risk": readiness["boss_resize_risk"],
@@ -1834,6 +1966,9 @@ class FeatureMixin:
"material_sample_count": info.get("material_sample_count"), "material_sample_count": info.get("material_sample_count"),
"feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"), "feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"),
"feature_boundary_edge_ids": feature.get("feature_boundary_edge_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, **tool_plan,
} }
@@ -1980,6 +2115,8 @@ class FeatureMixin:
"same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""), "same_domain_range_source": axis_range.get("range_source", ""),
"resize_strategy": "remove-old-boss-and-fuse-moved-cylinder", "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]: 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)) scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_suppress_readiness(scoped_info) readiness = _cylinder_suppress_readiness(scoped_info)
fill_plan = self._bounded_cylinder_fill_plan(face_id) fill_plan = self._bounded_cylinder_fill_plan(face_id)
fill_plan["fill_strategy"] = "bounded-hole-suppress-fill"
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
return { return {
"status": readiness["suppress_status"], "status": readiness["suppress_status"],
"risk": readiness["suppress_risk"], "risk": readiness["suppress_risk"],
@@ -2029,6 +2168,9 @@ class FeatureMixin:
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
"feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"), "feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "fill-cylindrical-hole-volume",
"edit_strategy_label": "圆柱补料封堵",
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
**fill_plan, **fill_plan,
} }
@@ -2106,6 +2248,9 @@ class FeatureMixin:
"feature_bottom_confidence": feature.get("feature_bottom_confidence"), "feature_bottom_confidence": feature.get("feature_bottom_confidence"),
"feature_bottom_detection": feature.get("feature_bottom_detection"), "feature_bottom_detection": feature.get("feature_bottom_detection"),
"feature_bottom_note": feature.get("feature_bottom_note"), "feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "bounded-blind-depth-cut-or-fill",
"edit_strategy_label": "盲孔/盲槽深度切削或补料",
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
} }
plan.update(context) plan.update(context)
return plan return plan
@@ -2407,23 +2552,14 @@ class FeatureMixin:
old_radius = cyl.Radius() old_radius = cyl.Radius()
new_radius = new_diameter / 2.0 new_radius = new_diameter / 2.0
axis_range = self._cylindrical_axis_range(face_id, surf) 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_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"]) v_max = float(axis_range["v_max"])
span = max(v_max - v_min, 1e-6) 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) resize_mode = _resize_mode(old_radius * 2.0, new_diameter)
if resize_mode == "enlarge": axial_margin = 0.0
axial_margin = 0.0 start_parameter = v_min
start_parameter = v_min end_parameter = v_max
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
radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05)
height = max(end_parameter - start_parameter, 1e-6) height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis() axis = cyl.Axis()
@@ -2436,7 +2572,7 @@ class FeatureMixin:
"boss_tool_note": ( "boss_tool_note": (
"扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;" "扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;"
"缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。" "缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。"
"当前版本会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并" "补新凸台时使用原凸台轴向范围,避免直径或轴心修改时悄悄改变高度"
), ),
"boss_tool_scope_face_ids": axis_range["same_domain_face_ids"], "boss_tool_scope_face_ids": axis_range["same_domain_face_ids"],
"boss_tool_scope_face_count": axis_range["same_domain_face_count"], "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_start_point": _point_tuple(start),
"boss_tool_exact_start_point": _point_tuple(exact_start), "boss_tool_exact_start_point": _point_tuple(exact_start),
"boss_tool_exact_height": max(v_max - v_min, 1e-6), "boss_tool_exact_height": max(v_max - v_min, 1e-6),
**end_info,
} }
def _cylinder_end_opening_info( def _cylinder_end_opening_info(
+6 -4
View File
@@ -590,7 +590,9 @@ def _axis_aligned_edge_candidates(
) -> list[TopoDS_Shape]: ) -> list[TopoDS_Shape]:
candidates: list[tuple[float, TopoDS_Shape]] = [] candidates: list[tuple[float, TopoDS_Shape]] = []
length_reference = max(expected_length, reference_radius, 1.0) 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(): for edge in TopologyExplorer(shape, ignore_orientation=True).edges():
try: try:
@@ -623,10 +625,10 @@ def _axis_aligned_edge_candidates(
return [edge for _score, edge in 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) op.SetNonDestructive(True)
use_glue = "cut" not in operation_name.lower() glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue)
if use_glue and hasattr(op, "SetGlue"): if glue_enabled and hasattr(op, "SetGlue"):
try: try:
op.SetGlue(BOPAlgo_GlueFull) op.SetGlue(BOPAlgo_GlueFull)
except Exception: except Exception:
+135
View File
@@ -97,6 +97,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self.edge_part_ids: list[int] = [] self.edge_part_ids: list[int] = []
self.edge_solid_ids: list[int] = [] self.edge_solid_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = [] 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._face_info_cache: dict[int, dict[str, object]] = {}
self._feature_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]] = {} 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_part_ids.clear()
self.edge_solid_ids.clear() self.edge_solid_ids.clear()
self.solids.clear() self.solids.clear()
self._quick_face_info_cache.clear()
self._face_info_cache.clear() self._face_info_cache.clear()
self._feature_info_cache.clear() self._feature_info_cache.clear()
self._edge_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) logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces): 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.face_logical_ids = [int(item) for item in logical_ids]
self._quick_face_info_cache.clear()
self._face_info_cache.clear() self._face_info_cache.clear()
self._feature_info_cache.clear() self._feature_info_cache.clear()
self._same_domain_face_ids_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]: def face_info(self, face_id: int) -> dict[str, object]:
if face_id in self._face_info_cache: if face_id in self._face_info_cache:
return dict(self._face_info_cache[face_id]) 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_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"] info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"] 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: elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder() cyl = surf.Cylinder()
axis = cyl.Axis() axis = cyl.Axis()
@@ -389,6 +517,10 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
except Exception: except Exception:
return None 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]: def feature_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces): if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}") raise ValueError(f"Unknown face id {face_id}")
@@ -1030,6 +1162,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return return
for face_id in valid_face_ids: for face_id in valid_face_ids:
self.face_logical_ids[face_id] = int(logical_id) 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._face_info_cache.pop(face_id, None)
self._feature_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() ellipse = curve.Ellipse()
info["center"] = _point_tuple(ellipse.Location()) info["center"] = _point_tuple(ellipse.Location())
info["axis"] = _dir_tuple(ellipse.Axis().Direction()) 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["major_radius"] = ellipse.MajorRadius()
info["minor_radius"] = ellipse.MinorRadius() info["minor_radius"] = ellipse.MinorRadius()
adjacent_face_ids = self._edge_adjacent_face_ids(edge_id) adjacent_face_ids = self._edge_adjacent_face_ids(edge_id)
+2356 -61
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -481,6 +481,25 @@ INFO_LABELS = {
"center_of_mass": "重心", "center_of_mass": "重心",
"surface_center": "表面积中心", "surface_center": "表面积中心",
"area_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": "长度中心", "length_center": "长度中心",
"bbox_min": "包围盒最小点", "bbox_min": "包围盒最小点",
"bbox_max": "包围盒最大点", "bbox_max": "包围盒最大点",
@@ -613,6 +632,8 @@ INFO_LABELS = {
"feature_edit_actions": "当前可用操作", "feature_edit_actions": "当前可用操作",
"resize_status": "切削状态", "resize_status": "切削状态",
"resize_strategy": "编辑策略", "resize_strategy": "编辑策略",
"edit_strategy_label": "编辑策略说明",
"edit_semantics": "编辑方式",
"resize_mode": "调整模式", "resize_mode": "调整模式",
"resize_risk": "切削风险", "resize_risk": "切削风险",
"resize_warnings": "切削警告", "resize_warnings": "切削警告",
File diff suppressed because it is too large Load Diff
+155 -72
View File
@@ -24,6 +24,23 @@ from .workers import EditWorker, LoadWorker, ScanWorker
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0) 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: class WindowCoreMixin:
@Slot(object) @Slot(object)
def _run_ui_task(self, callback) -> None: def _run_ui_task(self, callback) -> None:
@@ -121,37 +138,11 @@ class WindowCoreMixin:
return return
self.camera_interaction_active = True self.camera_interaction_active = True
self._cancel_hover_tracking(render=False) 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: def _end_camera_interaction(self) -> None:
if not getattr(self, "camera_interaction_active", False): if not getattr(self, "camera_interaction_active", False):
return return
self.camera_interaction_active = False 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: def closeEvent(self, event) -> None:
load_thread_running = bool( load_thread_running = bool(
@@ -669,13 +660,15 @@ class WindowCoreMixin:
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id) edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
label = f"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: 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] 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")) edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
label = f"特征 Face {self.selected_face_id}" label = f"特征 Face {self.selected_face_id}"
elif self.selected_kind == "face" and self.selected_face_id is not None: 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_ids = [self.selected_face_id]
edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id) edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id)
label = f"Face {self.selected_face_id}" label = f"Face {self.selected_face_id}"
elif self.selected_kind == "edge" and self.selected_edge_id is not None: elif self.selected_kind == "edge" and self.selected_edge_id is not None:
info = self.model.edge_info(self.selected_edge_id) 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) face_polydata = self.model.build_face_polydata(face_ids=face_ids)
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_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: 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] 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")) edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
face_polydata = self.model.build_face_polydata(face_ids=face_ids) face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) 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: 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._cached_face_overlay_polydata(face_ids=[self.selected_face_id], smooth=False)
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
elif self.selected_kind == "edge" and self.selected_edge_id is not None: 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) 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] 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) self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "feature" and self.selected_face_id is not None: 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] face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids) self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None: 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=[self.selected_face_id])
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "edge" and self.selected_edge_id is not None: elif self.selected_kind == "edge" and self.selected_edge_id is not None:
self._highlight_edge(self.selected_edge_id) self._highlight_edge(self.selected_edge_id)
@@ -786,6 +782,33 @@ class WindowCoreMixin:
self.face_overlay_polydata_cache.clear() self.face_overlay_polydata_cache.clear()
self.edge_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: def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object:
if len(cache) >= self.overlay_cache_limit: if len(cache) >= self.overlay_cache_limit:
try: try:
@@ -826,8 +849,22 @@ class WindowCoreMixin:
part_arr = self.model_part_id_array part_arr = self.model_part_id_array
if face_arr is None or part_arr is None: if face_arr is None or part_arr is None:
return 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() 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: if face_set is not None and int(face_arr.GetValue(cell_id)) not in face_set:
continue continue
if part_set is not None and int(part_arr.GetValue(cell_id)) not in part_set: 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): def _extract_visible_edge_polydata(self, edge_id: int):
if self.edge_polydata is None or self.edge_id_array is None: if self.edge_polydata is None or self.edge_id_array is None:
return 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() 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: if int(self.edge_id_array.GetValue(cell_id)) == edge_id:
ids.InsertNextId(cell_id) ids.InsertNextId(cell_id)
return self._extract_cells_as_polydata(self.edge_polydata, ids) 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_face_id_array = self.model_polydata.GetCellData().GetArray("face_id")
self.model_part_id_array = self.model_polydata.GetCellData().GetArray("part_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.model_solid_id_array = self.model_polydata.GetCellData().GetArray("solid_id")
self._rebuild_polydata_cell_indexes()
mapper = vtk.vtkPolyDataMapper() mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(self.model_polydata) mapper.SetInputData(self.model_polydata)
_prepare_static_mapper(mapper)
self.model_actor = vtk.vtkActor() self.model_actor = vtk.vtkActor()
self.model_actor.SetMapper(mapper) self.model_actor.SetMapper(mapper)
self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73) self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73)
@@ -886,8 +928,10 @@ class WindowCoreMixin:
self.edge_polydata = edge_polydata self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id") self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper() edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata) edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
self.edge_actor = vtk.vtkActor() self.edge_actor = vtk.vtkActor()
self.edge_actor.SetMapper(edge_mapper) self.edge_actor.SetMapper(edge_mapper)
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1) self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
@@ -941,7 +985,7 @@ class WindowCoreMixin:
self.pending_hover_position = None self.pending_hover_position = None
self.last_hover_pick_position = None self.last_hover_pick_position = None
self.hover_timer.stop() self.hover_timer.stop()
self._clear_hover(render=True) self._clear_hover(render=False)
def on_left_button_release(self, _obj, _event) -> None: def on_left_button_release(self, _obj, _event) -> None:
if getattr(self, "skip_next_vtk_left_release", False): if getattr(self, "skip_next_vtk_left_release", False):
@@ -999,10 +1043,12 @@ class WindowCoreMixin:
target = self._pick_selection_target(mode, x, y) target = self._pick_selection_target(mode, x, y)
if target is None: if target is None:
self._cancel_hover_tracking(render=False) self._cancel_hover_tracking(render=False)
self._reset_selection(clear_highlight=True)
if hasattr(self, "render_window"): if hasattr(self, "render_window"):
self.render_window.Render() self.render_window.Render()
self.statusBar().showMessage("已取消选择") if self.selected_kind is not None:
self.statusBar().showMessage("未命中对象,已保持当前选择")
else:
self.statusBar().showMessage("未命中对象")
return return
self._clear_hover(render=False) self._clear_hover(render=False)
@@ -1019,7 +1065,7 @@ class WindowCoreMixin:
self.pending_hover_position = None self.pending_hover_position = None
self.last_hover_pick_position = None self.last_hover_pick_position = None
self.hover_timer.stop() self.hover_timer.stop()
self._clear_hover(render=True) self._clear_hover(render=False)
def on_pointer_button_release(self, _obj, _event) -> None: def on_pointer_button_release(self, _obj, _event) -> None:
if not self._is_ui_thread(): if not self._is_ui_thread():
@@ -1083,6 +1129,8 @@ class WindowCoreMixin:
if not self._is_ui_thread(): if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y)) self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y))
return return
if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False):
return
position = (int(x), int(y)) position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
if threshold > 0: if threshold > 0:
@@ -1093,10 +1141,14 @@ class WindowCoreMixin:
if max(dx, dy) < threshold: if max(dx, dy) < threshold:
return return
self.pending_hover_position = position self.pending_hover_position = position
if not self.hover_timer.isActive(): if self.hover_timer.isActive():
self.hover_timer.start(self.hover_interval_ms) self.hover_timer.stop()
self.hover_timer.start(self.hover_interval_ms)
def _update_hover_target(self) -> None: 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 ( if (
self.operation_in_progress self.operation_in_progress
or self.scan_in_progress or self.scan_in_progress
@@ -1109,9 +1161,21 @@ class WindowCoreMixin:
return return
x, y = self.pending_hover_position x, y = self.pending_hover_position
self.last_hover_pick_position = (x, y) 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) 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: def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
if self.model is None: if self.model is None:
return None return None
@@ -1510,29 +1574,26 @@ class WindowCoreMixin:
self.selected_kind = "feature" if feature_mode else "face" self.selected_kind = "feature" if feature_mode else "face"
self.selected_face_id = face_id self.selected_face_id = face_id
self.selected_pick_position = pick_position self.selected_pick_position = pick_position
info = self.model.face_info(face_id) info = self.model.quick_face_info(face_id)
if feature_mode: if feature_mode:
info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征" info = self._feature_info_for_selected_face(face_id, info)
highlight_face_ids = [face_id] info["kind"] = "feature"
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
else: else:
highlight_face_ids = self.model.connected_same_domain_face_ids(face_id) highlight_face_ids = [face_id]
if len(highlight_face_ids) > 1: logical_id = int(info.get("logical_face_id", face_id))
info["same_domain_face_ids"] = tuple(highlight_face_ids) input_info = dict(info)
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
self._sync_cylindrical_edit_inputs(input_info) self._sync_cylindrical_edit_inputs(input_info)
self.selected_part_id = int(info["part_id"]) 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.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._highlight_faces(face_ids=highlight_face_ids or [face_id])
self._show_pick_marker(pick_position) self._show_pick_marker(pick_position)
self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id) self._sync_id_picker("Feature" if feature_mode else "Face", 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() 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 "" 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)) 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: def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
@@ -1540,7 +1601,8 @@ class WindowCoreMixin:
return return
if self._edit_busy("编辑计算中,暂时不能选择特征。"): if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return 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._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature" self.selected_kind = "feature"
self.selected_face_id = face_id self.selected_face_id = face_id
@@ -1559,6 +1621,23 @@ class WindowCoreMixin:
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = 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")) shell_thickness = _float_or_none(info.get("shell_thickness_estimate"))
if hasattr(self, "shell_thickness_input"): if hasattr(self, "shell_thickness_input"):
if info.get("shell_region_status") == "candidate" and shell_thickness is not None and shell_thickness > 0: 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 return None
kind = str(target["kind"]) kind = str(target["kind"])
target_id = int(target["target_id"]) 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) return (kind, target_id)
def _show_hover_target(self, target: dict[str, object] | None) -> None: 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] 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) self._highlight_hover_faces(face_ids=face_ids)
elif kind == "feature": elif kind == "feature":
face_ids = self.model.connected_same_domain_face_ids(target_id) or [target_id] self._highlight_hover_faces(face_ids=[target_id])
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge": elif kind == "edge":
self._highlight_hover_edge(target_id) self._highlight_hover_edge(target_id)
else: else:
face_ids = self.model.connected_same_domain_face_ids(target_id) self._highlight_hover_faces(face_ids=[target_id])
self._highlight_hover_faces(face_ids=face_ids or [target_id])
self.hover_signature = signature self.hover_signature = signature
self.render_window.Render() self.render_window.Render()
@@ -2078,12 +2150,23 @@ class WindowCoreMixin:
return return
self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览") 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: if self.model is None:
return return
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
try: 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: except Exception as exc:
self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}") self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}")
return return
+1611 -375
View File
File diff suppressed because it is too large Load Diff