feat: 完善特征编辑和选择稳定性

This commit is contained in:
2026-07-29 15:43:28 +08:00
parent 720c2dc970
commit c937e13d43
13 changed files with 8294 additions and 290 deletions
+172 -82
View File
@@ -8,10 +8,84 @@
- 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。
- 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板按工作流排列STEP 文件、模型结构树、鼠标选择模式按 ID 选择、编辑、导出、显示、测量、可编辑对象、圆柱面候选、操作历史和对象信息
- 左侧操作面板当前优先保留最小建模链路STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
## 怎么运行
你第一次配置或运行这个项目时,可以按下面步骤来。命令默认在 Windows PowerShell 里执行。
1. 进入项目目录。如果你的仓库不在这个路径,把命令里的路径换成你的实际目录:
```powershell
cd C:\Users\admin\Desktop\python-occt
```
2. 如果你已经有 `pyocc` 环境,直接激活:
```powershell
conda activate pyocc
```
3. 如果你是第一次配置环境,先用仓库里的 `environment.yml` 创建环境,再激活:
```powershell
conda env create -f environment.yml
conda activate pyocc
```
4. 先跑一次烟测,确认入口和依赖能正常加载。这个命令不会进入交互窗口:
```powershell
python main.py --smoke-test
```
5. 启动程序并打开默认测试模型:
```powershell
python main.py
```
6. 启动时直接打开指定 STEP 文件:
```powershell
python main.py path\to\model.step
```
7. 也可以先启动程序,在左侧 `STEP文件` 区域点击 `导入几何模型` 选择 `.step` / `.stp` 文件,再点击 `读取模型`
8. 打开仓库自带的简单立方体测试模型:
```powershell
python main.py assets\models\cube_10mm.step
```
9. 重新生成简单立方体测试模型:
```powershell
python scripts\generate_cube_step.py
```
10. 验证立方体上的“只改一条 Edge 长度”基线:
```powershell
python scripts\verify_edge_length_resize.py
```
11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行:
```powershell
C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py
```
开发时常用的本地检查:
```powershell
python -m compileall -q main.py step_editor scripts
git diff --check
```
## 最终要实现什么
最终目标是做一个面向 STEP 模型的交互式工具:
@@ -22,7 +96,7 @@
- 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度
- 用户可以修改可控的特征,比如平面推拉和目标法向位置、孔径/半径调整、完整圆柱孔轴心坐标修改、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度和槽孔总长度调整、盲孔/盲槽深度调整、凸台直径/半径/高度/轴心坐标调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度调整、圆锥半角、环面直径、Edge倒圆、Edge倒角、圆Edge半径/直径、修改Edge长度和直线Edge起点/终点坐标
- 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
@@ -44,6 +118,23 @@
- 能撤销/重做编辑。
- 先实现少量可控编辑操作,用于验证后续特征编辑路线。
## 当前可修改范围
这张表描述的是当前版本已经接入到界面或编辑链路里的能力。它们都是基于 STEP 的 B-Rep 结果几何做局部重建,不等于原 CAD 软件里的完整建模历史参数。
| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 |
| --- | --- | --- |
| 平面 Face | `Face目标法向位置``Face本次偏移量`;识别到相对平面时可以改薄壁厚度。 | `Face面积` 目前只读;自由曲面、复杂壳体局部区域和带复杂边界的面不能直接按面积修改。 |
| 圆柱孔 / 圆柱面 | 孔或圆柱直径、半径;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚。 |
| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助。 |
| 槽 / 半孔 | 可以改槽/半孔宽度、深度、圆弧长度、圆弧角度和开口角度;长圆槽识别到成对半圆端时,可以改槽孔总长度和中心距,也可以手动填写配对端 Face ID。 | 复杂草图槽、非圆柱槽、槽的整体迁移/轴心移动、链式槽和跨多个不规则面的槽还不是稳定能力。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心坐标;普通完整圆柱也有高度兜底修改。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改半径或圆弧长度,使用先移除旧圆角再重建新圆角的路线。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败。 |
| 圆锥 / 球 / 环面 | 圆锥可以改参考半径、直径或半角;球可以改半径或直径;环面可以改主/副半径或直径。 | 这些属于几何缩放或局部重建能力,不是原始特征历史恢复;复杂相邻拓扑可能失败。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
## 现在已经实现了什么
当前已经实现:
@@ -124,14 +215,16 @@
- `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。
- 可推拉的平面Face。
- 可调整孔径的圆柱Face候选。
- 可调整槽/半孔宽度的局部圆柱孔/槽候选。
- 可调整盲孔深度的圆柱Face候选。
- 可调整槽/半孔宽度、深度和圆弧长度的局部圆柱孔/槽候选。
- 可调整盲孔/盲槽深度的圆柱Face候选。
- 可调整圆柱凸台直径和高度的凸台候选。
- 可添加新圆角的直线Edge候选。
- 可添加倒角的直线Edge候选。
- 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变。
- 可调整圆Edge半径/直径的圆形或圆弧Edge候选。
- 表格会显示当前值、状态、风险、置信度和说明。
- 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。
- 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、Edge倒圆、Edge倒角或修改Edge长度。
- 盲孔/盲槽深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。
- 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度/深度/圆弧长度调整、凸台高度调整、盲孔/盲槽深度调整、Edge倒圆、Edge倒角或修改Edge长度。
- 支持列出圆柱面候选特征。
- 圆柱面候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。
- 圆柱面候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。
@@ -172,6 +265,22 @@
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
- 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。
- 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。
- 当前版本支持槽/半孔深度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
- 输入目标槽深后,会按当前圆弧角度把槽深换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。
- 这仍是 B-Rep 几何近似,不是完整 CAD 槽深参数编辑。
- 当前版本支持槽/半孔圆弧长度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
- 输入目标圆弧长度后,会按当前圆弧角度把圆弧长度换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。
- 这仍是 B-Rep 几何近似,不是完整 CAD 槽参数编辑。
- 当前版本支持槽/半孔圆弧角度和开口角度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
- 输入目标圆弧角度时,程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。
- 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。
- 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。
- 当前版本支持长圆槽/槽孔总长度和中心距调整:
- 自动识别到成对半圆槽端时,程序会保持槽宽不变,先补旧长圆槽体,再切出目标总长度。
- 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度和总长度调整会优先尝试使用这个手动配对端。
- 当前版本支持薄壁/壳体局部厚度调整:
- 对已找到投影重叠相对平面的平面候选开放。
- 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。
@@ -183,7 +292,7 @@
- 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 当前版本支持圆柱孔封堵:
- 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。
- 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。
@@ -197,6 +306,11 @@
- 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。
- 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
- 当前版本支持圆柱凸台高度和轴心坐标调整:
- 只对已识别为完整圆柱凸台、并找到可推拉端盖 Face 的候选开放。
- 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖推拉距离。
- 输入目标轴心坐标后,程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。
- 这不是 CAD 历史里的拉伸特征高度参数;端盖识别不稳定或推拉计划被阻止时会拒绝执行。
- 当前版本支持直线Edge添加圆角:
- 只对直线Edge开放。
- 会先检查边长、相邻Face数量和目标半径相对边长的比例。
@@ -207,14 +321,18 @@
- 当前版本支持直线Edge添加倒角:
- 只对直线Edge开放。
- 会先检查边长、相邻Face数量和倒角距离相对边长的比例。
- 当前选中对象表支持 `新增倒角距离` 的对称距离倒角、`新增不等距倒角D1/D2` 的两侧不同距离倒角,以及 `新增距离+角度倒角D/角度`
- 橙色半透明预览表示目标Edge和输入倒角距离的大致范围。
- 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。
- STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
- 当前版本支持修改Edge长度:
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。
- 对圆形/圆弧Edge,当前选中对象表会额外提供圆Edge半径和圆Edge直径可编辑行,内部会换算为等比例Edge目标长度。
- 可以选择Edge长度基准:自动、中心、固定起点、固定终点。
- 对直线Edge,当前选中对象表会额外提供 `Edge起点坐标``Edge中心坐标``Edge终点坐标`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。
- 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。
- 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。
- 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。
- `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。
- 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。
- 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑。
@@ -243,6 +361,7 @@
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
- 当前版本支持圆柱孔/圆柱面扩大切削。
- 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。
- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `孔轴心坐标`,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽暂不放行这条路线,避免整圆柱工具误补材料。
- 圆柱孔/圆柱面孔径调整现在会先生成切削计划:
- 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。
- 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。
@@ -277,7 +396,7 @@
- 鼠标拾取模型时会显示三维拾取点坐标。
- 编辑历史会记录目标对象和当时的拾取点。
- 支持把本软件加载后的编辑历史导出为 JSON v2;除摘要和详情外,也会保存 `operation_name``target``parameters``result_message`,为后续安全复放做准备。
- 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记
- 点击操作历史记录时,程序会尝试重新高亮目标对象,并在详情里保留当时的拾取点坐标;3D 视图里不会再额外显示拾取点小球
- 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。
- 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。
@@ -302,7 +421,7 @@ vertices: 3262
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、长圆槽总长度/中心距调整和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。
@@ -318,75 +437,12 @@ vertices: 3262
建议下一步按这个顺序推进:
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。
2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
2. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。
4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。
5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
## 怎么运行
先打开 PowerShell,进入项目目录:
```powershell
cd C:\Users\admin\Desktop\python-occt
```
激活环境:
```powershell
conda activate pyocc
```
如果是在一台新电脑上第一次配置环境,可以使用仓库里的 `environment.yml` 创建环境:
```powershell
conda env create -f environment.yml
conda activate pyocc
```
运行默认模型:
```powershell
python main.py
```
打开指定 STEP 文件:
```powershell
python main.py path\to\model.step
```
打开简单立方体测试模型:
```powershell
python main.py assets\models\cube_10mm.step
```
重新生成简单立方体测试模型:
```powershell
python scripts\generate_cube_step.py
```
如果当前 PowerShell 没识别 `conda`,可以直接使用完整路径运行:
```powershell
C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py
```
启动烟测,不进入交互窗口:
```powershell
python main.py --smoke-test
```
验证立方体上的“只改一条 Edge 长度”基线:
```powershell
python scripts\verify_edge_length_resize.py
```
## 怎么使用
打开程序后,右侧是 3D 模型窗口,左侧是操作面板。
@@ -459,10 +515,10 @@ python scripts\verify_edge_length_resize.py
- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
- `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移``推拉平面` 使用。
- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径``调整圆柱孔径` 使用。
- `调整槽/半孔宽度` 行表示这个Face是槽/半孔候选,可以配合 `槽/半孔度` `调整槽/半孔度` 使用
- `调整圆柱凸台直径` 行表示这个Face是较明确的完整圆柱凸台候选,可以配合 `凸台直径``调整圆柱凸台直径` 使用
- `调整槽/半孔宽度` / `调整槽/半孔度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度
- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标
- `封堵圆柱孔` 行表示这个Face是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`
- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用
- `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
@@ -546,6 +602,23 @@ python scripts\verify_edge_length_resize.py
- 输入目标槽宽后点击 `调整槽/半孔宽度`
- 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
- 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。
- `槽/半孔深度`
- 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改 `槽/半孔深度`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会按当前圆弧角度把目标槽深换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
- `槽/半孔圆弧长度`
- 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
- `槽/半孔圆弧角度` / `槽/半孔开口角度`
- 先选择一个槽/半孔候选 Face。
- 在当前选中对象表里修改角度值,角度输入单位是度。
- 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角`
- 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。
- `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距`
- 对长圆槽或成对半圆槽,程序会自动寻找另一个槽端。
- 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`
- 修改 `槽孔总长度``槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。
- `薄壁厚度` + `调整薄壁厚度`
- 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入。
- 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值。
@@ -560,6 +633,14 @@ python scripts\verify_edge_length_resize.py
- 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。
- 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。
- `凸台高度`
- 先选择一个完整圆柱凸台 Face。
- 在当前选中对象表里修改 `凸台高度`,点击该行的 `可修改` 或点击 `参数化建模`
- 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。
- `凸台轴心坐标`
- 先选择一个完整圆柱凸台 Face。
- 在当前选中对象表里修改 `凸台轴心坐标`,点击该行的 `应用` 或点击 `参数化建模`
- 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。
- `封堵圆柱孔`
- 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。
- 点击 `封堵圆柱孔` 后,程序会先生成补料计划。
@@ -591,6 +672,7 @@ python scripts\verify_edge_length_resize.py
- 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。
- 输入目标半径后点击 `修改已有圆角半径`
- 也可以在当前选中对象表里修改 `已有圆角圆弧长度`,程序会按当前圆弧角度换算成目标半径后执行同一条修改路线。
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
- 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。
- 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。
@@ -599,19 +681,26 @@ python scripts\verify_edge_length_resize.py
- 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。
- 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。
- 输入目标倒角距离后点击 `给Edge添加倒角`
- 如果需要两侧不同距离,在当前选中对象表里填写 `新增不等距倒角D1/D2`,格式为 `D1, D2`
- 如果需要距离+角度倒角,在当前选中对象表里填写 `新增距离+角度倒角D/角度`,格式为 `距离, 角度`,角度单位是度。
- `倒角参考Face ID` 决定 D1/D2 的方向;默认使用当前Edge的第一个相邻Face,也可以改成另一个相邻Face后点击 `设置`
- 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。
- 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。
- 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。
- 当前支持对称距离倒角,不支持两侧不同距离距离+角度倒角。
- 当前支持对称距离倒角两侧不同距离倒角和距离+角度倒角。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。
- `Edge目标长度` + `修改Edge长度`
- 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。
- 右侧Edge长度基准下拉框可以选择 `自动``中心``固定起点``固定终点`
- 把它改成目标长度后点击 `修改Edge长度`
- 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `Edge起点坐标``Edge中心坐标``Edge终点坐标`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。
- 修改 `Edge中心坐标` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。
-`assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面。
- 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。
- 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。
- 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。
- 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心坐标”的局部形变路径。
- 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。
- 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。
- 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。
@@ -653,7 +742,7 @@ python scripts\verify_edge_length_resize.py
- `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。
- `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘;当前格式是 `step-editor-operation-history-v2`,包含机器可读的动作名、目标和参数。
- 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并用青色小点标出当时的拾取位置
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并在详情里显示当时的拾取位置;3D 视图里不会额外画拾取点小球
- 由于布尔编辑后拓扑 ID 可能重新分配,历史里的Face ID只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。
- 撤销后对应记录会从列表里退回。
- 重做后对应记录会重新显示。
@@ -799,7 +888,7 @@ git diff --check
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。
- 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。
- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、零件/Solid 平移和旋转
- 编辑:平面推拉和目标法向位置、孔径/半径、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度、槽孔总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心坐标、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度当前版本修改、圆锥半角、环面直径、Edge圆角、Edge倒角、圆Edge半径/直径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积当前作为只读属性展示,不再伪装成直接可修改参数
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
@@ -810,9 +899,10 @@ git diff --check
当前策略顺序:
1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。
2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉
3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑
4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸
2. `local-edge-endpoint-deform`:直线Edge起点/终点坐标直接修改,同样只在简单全平面多面体上移动单个端点并重建周边平面
3. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉
4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑
5. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。
确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。
+8 -1
View File
@@ -1,6 +1,13 @@
from __future__ import annotations
from .app import StepEditorWindow, main
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"]
def __getattr__(name: str):
if name in {"StepEditorWindow", "main"}:
from .app import StepEditorWindow, main
return {"StepEditorWindow": StepEditorWindow, "main": main}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+223 -37
View File
@@ -103,11 +103,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None
self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None
self.hover_interval_ms = 85
self.hover_move_threshold_px = 5
self.hover_interval_ms = 140
self.hover_move_threshold_px = 8
self.pending_hover_position: tuple[int, int] | None = None
self.last_hover_pick_position: tuple[int, int] | None = None
self.camera_interaction_active = False
self.edge_actor_visible_before_interaction = True
self.model_interpolation_before_interaction: int | None = None
self.pointer_button_down = False
self.left_button_press_position: tuple[int, int] | None = None
self.left_button_dragged = False
self.left_click_drag_threshold_px = 6
self.skip_next_vtk_left_press = False
self.skip_next_vtk_left_release = False
self.pick_marker_actor = None
self.edit_preview_actor = None
self.edit_preview_actors: list[object] = []
@@ -143,6 +151,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.cylinder_candidate_cache: list[dict[str, object]] = []
self.cylinder_candidates_loaded = False
self.operation_in_progress = False
self.edit_busy_timer = QTimer(self)
self.edit_busy_timer.timeout.connect(self._pulse_edit_busy_status)
self.edit_busy_base_text = ""
self.edit_busy_started_at: datetime | None = None
self.edit_busy_phase = 0
self.edit_thread: QThread | None = None
self.edit_worker: EditWorker | None = None
self.pending_edit_context: dict[str, object] | None = None
@@ -158,6 +171,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.load_refine_worker: LoadWorker | None = None
self.pending_load_path: Path | None = None
self.initial_load_deflection = 2.0
self.edit_result_deflection = 1.6
self.last_id_kind = "Face"
self.property_editor_updating = False
self.property_editor_specs: list[dict[str, object]] = []
@@ -394,23 +408,76 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
font-weight: 500;
}
QPushButton#propertyRowEditButton {
background: #fff3d9;
border: 1px solid #d97706;
background: #ea580c;
border: 1px solid #c2410c;
border-radius: 5px;
color: #92400e;
color: #ffffff;
font-weight: 700;
min-height: 18px;
padding: 1px 6px;
}
QPushButton#propertyRowEditButton:hover {
background: #ffe6b8;
border-color: #b45309;
background: #f97316;
border-color: #9a3412;
}
QPushButton#propertyRowEditButton:pressed {
background: #9a3412;
border-color: #7c2d12;
color: #ffffff;
padding-top: 2px;
padding-left: 7px;
}
QPushButton#propertyRowEditButton[changed="true"] {
background: #dcfce7;
border: 1px solid #16a34a;
color: #14532d;
}
QPushButton#propertyRowEditButton[changed="true"]:hover {
background: #bbf7d0;
border-color: #15803d;
}
QPushButton#propertyRowEditButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QLineEdit#propertyTargetEditor {
background: #fff7ed;
border: 2px solid #f97316;
border-radius: 5px;
color: #111827;
min-height: 18px;
padding: 0px 5px;
selection-background-color: #bfdbfe;
}
QLineEdit#propertyTargetEditor:hover {
background: #ffedd5;
border-color: #ea580c;
}
QLineEdit#propertyTargetEditor:focus {
background: #ffffff;
border: 2px solid #2563eb;
padding: 0px 4px;
}
QPushButton#propertyExpandBar {
background: #f8fafc;
border: 1px solid #d7dee8;
border-radius: 4px;
color: #475569;
font-weight: 600;
min-height: 14px;
max-height: 20px;
padding: 1px 6px;
}
QPushButton#propertyExpandBar:hover {
background: #eef6ff;
border-color: #9dbbe0;
color: #1e3a8a;
}
QPushButton#propertyExpandBar:pressed {
background: #dbeafe;
padding-top: 2px;
}
QLineEdit:disabled,
QComboBox:disabled,
QPlainTextEdit:disabled {
@@ -743,14 +810,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
object_edit_layout.addWidget(self.property_table)
self.property_expand_button = QPushButton("展开全部属性")
self.property_expand_button.setObjectName("propertyExpandBar")
help_tip(self.property_expand_button, "当前选中对象属性较多时,默认只显示前 6 行;点击这里展开或收起属性表。")
self.property_expand_button.clicked.connect(self.toggle_property_table_expanded)
self.property_expand_button.setVisible(False)
self.property_expand_button.setMaximumHeight(22)
self.property_expand_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
object_edit_layout.addWidget(self.property_expand_button)
property_action_row = QHBoxLayout()
property_action_row.setContentsMargins(0, 0, 0, 0)
self.apply_property_button = QPushButton("参数化建模")
help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
object_edit_layout.addWidget(self.apply_property_button)
self.quick_export_all_button = QPushButton("导出当前完整STEP")
help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。")
self.quick_export_all_button.clicked.connect(self.export_all)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
object_edit_layout.addLayout(property_action_row)
edit_box = QGroupBox(panel)
edit_box.setObjectName("editSection")
@@ -770,6 +847,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hole_diameter_input = QLineEdit("")
help_tip(self.hole_diameter_input, "圆柱孔/槽的目标直径。选中候选后会自动填一个参考值,可以手动改。")
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
self.hole_center_x_input = QLineEdit("", edit_box)
self.hole_center_y_input = QLineEdit("", edit_box)
self.hole_center_z_input = QLineEdit("", edit_box)
for hole_center_input in (
self.hole_center_x_input,
self.hole_center_y_input,
self.hole_center_z_input,
):
hole_center_input.setVisible(False)
help_tip(self.hole_center_x_input, "圆柱孔轴心目标 X 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。")
help_tip(self.hole_center_y_input, "圆柱孔轴心目标 Y 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。")
help_tip(self.hole_center_z_input, "圆柱孔轴心目标 Z 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。")
self.resize_button = QPushButton("调整圆柱孔径")
help_tip(self.resize_button, "修改孔或圆柱槽的直径。扩大时切削,缩小时会先补料再重切。")
self.resize_button.clicked.connect(self.resize_hole)
@@ -784,21 +873,75 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.resize_slot_button.clicked.connect(self.resize_slot_width)
edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2)
edit_layout.addWidget(QLabel("凸台直径"), 6, 0)
edit_layout.addWidget(QLabel("槽/半孔深度"), 6, 0)
self.slot_depth_input = QLineEdit("")
help_tip(self.slot_depth_input, "槽或半孔的目标凹入深度。程序会按当前圆弧角度换算成对应圆柱直径来执行。")
edit_layout.addWidget(self.slot_depth_input, 6, 1)
self.resize_slot_depth_button = QPushButton("调整槽/半孔深度")
help_tip(self.resize_slot_depth_button, "修改已识别槽/半孔候选的凹入深度。当前版本是几何近似,失败会回滚。")
self.resize_slot_depth_button.clicked.connect(self.resize_slot_depth)
edit_layout.addWidget(self.resize_slot_depth_button, 7, 0, 1, 2)
self.slot_arc_length_input = QLineEdit("", edit_box)
self.slot_arc_length_input.setVisible(False)
help_tip(self.slot_arc_length_input, "槽或半孔的目标圆弧长度。当前选中对象表会使用这个隐藏输入执行圆弧长度修改。")
self.slot_angular_span_input = QLineEdit("", edit_box)
self.slot_angular_span_input.setVisible(False)
help_tip(self.slot_angular_span_input, "槽或半孔的目标圆弧角度。当前选中对象表会使用这个隐藏输入执行角度修改。")
self.slot_pair_face_input = QLineEdit("", edit_box)
self.slot_pair_face_input.setVisible(False)
help_tip(self.slot_pair_face_input, "长圆槽/槽孔的另一个半圆端 Face ID。自动配对不准时可在当前选中对象表里手动设置。")
self.slot_total_length_input = QLineEdit("", edit_box)
self.slot_total_length_input.setVisible(False)
help_tip(self.slot_total_length_input, "长圆槽/槽孔的目标总长度。只有识别到成对半圆槽端时才会启用。")
self.slot_center_x_input = QLineEdit("", edit_box)
self.slot_center_x_input.setVisible(False)
self.slot_center_y_input = QLineEdit("", edit_box)
self.slot_center_y_input.setVisible(False)
self.slot_center_z_input = QLineEdit("", edit_box)
self.slot_center_z_input.setVisible(False)
help_tip(self.slot_center_x_input, "槽/半孔目标轴心坐标 X。当前选中对象表会使用这个隐藏输入执行轴心移动。")
help_tip(self.slot_center_y_input, "槽/半孔目标轴心坐标 Y。当前选中对象表会使用这个隐藏输入执行轴心移动。")
help_tip(self.slot_center_z_input, "槽/半孔目标轴心坐标 Z。当前选中对象表会使用这个隐藏输入执行轴心移动。")
edit_layout.addWidget(QLabel("凸台直径"), 8, 0)
self.boss_diameter_input = QLineEdit("")
help_tip(self.boss_diameter_input, "圆柱凸台的目标直径。选中凸台候选后会自动填一个参考值。")
edit_layout.addWidget(self.boss_diameter_input, 6, 1)
edit_layout.addWidget(self.boss_diameter_input, 8, 1)
self.boss_height_input = QLineEdit("", edit_box)
self.boss_height_input.setVisible(False)
help_tip(self.boss_height_input, "圆柱凸台的目标高度。当前选中对象表会使用这个隐藏输入执行高度修改。")
self.boss_center_x_input = QLineEdit("", edit_box)
self.boss_center_x_input.setVisible(False)
self.boss_center_y_input = QLineEdit("", edit_box)
self.boss_center_y_input.setVisible(False)
self.boss_center_z_input = QLineEdit("", edit_box)
self.boss_center_z_input.setVisible(False)
help_tip(self.boss_center_x_input, "圆柱凸台目标轴心坐标 X。当前选中对象表会使用这个隐藏输入执行轴心移动。")
help_tip(self.boss_center_y_input, "圆柱凸台目标轴心坐标 Y。当前选中对象表会使用这个隐藏输入执行轴心移动。")
help_tip(self.boss_center_z_input, "圆柱凸台目标轴心坐标 Z。当前选中对象表会使用这个隐藏输入执行轴心移动。")
self.cone_reference_radius_input = QLineEdit("", edit_box)
self.cone_reference_radius_input.setVisible(False)
help_tip(self.cone_reference_radius_input, "圆锥面的目标参考半径。当前选中对象表会使用这个隐藏输入执行圆锥面修改。")
self.sphere_radius_input = QLineEdit("", edit_box)
self.sphere_radius_input.setVisible(False)
help_tip(self.sphere_radius_input, "球面的目标半径。当前选中对象表会使用这个隐藏输入执行球面修改。")
self.torus_radius_input = QLineEdit("", edit_box)
self.torus_radius_input.setVisible(False)
help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。")
self.face_area_input = QLineEdit("", edit_box)
self.face_area_input.setVisible(False)
help_tip(self.face_area_input, "复杂曲面的目标Face面积。当前选中对象表会使用这个隐藏输入执行兜底面积缩放。")
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
self.resize_boss_button.clicked.connect(self.resize_boss)
edit_layout.addWidget(self.resize_boss_button, 7, 0, 1, 2)
edit_layout.addWidget(self.resize_boss_button, 9, 0, 1, 2)
self.suppress_button = QPushButton("封堵圆柱孔")
help_tip(self.suppress_button, "用补料体填住完整圆柱孔。适合通孔/盲孔,不适合半孔或槽。")
self.suppress_button.clicked.connect(self.suppress_hole)
edit_layout.addWidget(self.suppress_button, 8, 0, 1, 2)
edit_layout.addWidget(self.suppress_button, 10, 0, 1, 2)
edit_layout.addWidget(QLabel("孔深度"), 9, 0)
edit_layout.addWidget(QLabel("孔深度"), 11, 0)
depth_inputs = QWidget()
depth_layout = QHBoxLayout(depth_inputs)
depth_layout.setContentsMargins(0, 0, 0, 0)
@@ -810,41 +953,72 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hole_bottom_face_input.textChanged.connect(lambda _text: self._update_action_states())
depth_layout.addWidget(self.hole_depth_input, stretch=2)
depth_layout.addWidget(self.hole_bottom_face_input, stretch=1)
edit_layout.addWidget(depth_inputs, 9, 1)
self.resize_depth_button = QPushButton("调整盲孔深度")
edit_layout.addWidget(depth_inputs, 11, 1)
self.resize_depth_button = QPushButton("调整盲孔/盲槽深度")
help_tip(self.resize_depth_button, "加深或变浅盲孔/盲槽。需要识别到底面,或手动填写底面 Face ID。")
self.resize_depth_button.clicked.connect(self.resize_hole_depth)
edit_layout.addWidget(self.resize_depth_button, 10, 0, 1, 2)
edit_layout.addWidget(self.resize_depth_button, 12, 0, 1, 2)
edit_layout.addWidget(QLabel("圆角半径"), 11, 0)
edit_layout.addWidget(QLabel("圆角半径"), 13, 0)
self.edge_fillet_radius_input = QLineEdit("")
help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中Edge/圆角候选后会自动填参考值。")
edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1)
edit_layout.addWidget(self.edge_fillet_radius_input, 13, 1)
self.fillet_edge_button = QPushButton("给Edge添加圆角")
help_tip(self.fillet_edge_button, "给当前直线Edge新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。")
self.fillet_edge_button.clicked.connect(self.fillet_edge)
edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2)
edit_layout.addWidget(self.fillet_edge_button, 14, 0, 1, 2)
self.resize_existing_fillet_button = QPushButton("修改已有圆角半径")
help_tip(self.resize_existing_fillet_button, "尝试修改已识别圆角面的半径。会先移除原圆角再重建,复杂圆角可能失败并回滚。")
self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet)
edit_layout.addWidget(self.resize_existing_fillet_button, 13, 0, 1, 2)
edit_layout.addWidget(self.resize_existing_fillet_button, 15, 0, 1, 2)
edit_layout.addWidget(QLabel("倒角距离"), 14, 0)
edit_layout.addWidget(QLabel("倒角距离"), 16, 0)
self.edge_chamfer_distance_input = QLineEdit("")
help_tip(self.edge_chamfer_distance_input, "给Edge添加倒角时使用的距离。选中直线Edge后会填一个较小参考值。")
edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1)
self.edge_chamfer_distance1_input = QLineEdit("", edit_box)
self.edge_chamfer_distance2_input = QLineEdit("", edit_box)
self.edge_chamfer_angle_distance_input = QLineEdit("", edit_box)
self.edge_chamfer_angle_degrees_input = QLineEdit("", edit_box)
self.edge_chamfer_reference_face_input = QLineEdit("", edit_box)
self.edge_chamfer_distance1_input.setVisible(False)
self.edge_chamfer_distance2_input.setVisible(False)
self.edge_chamfer_angle_distance_input.setVisible(False)
self.edge_chamfer_angle_degrees_input.setVisible(False)
self.edge_chamfer_reference_face_input.setVisible(False)
edit_layout.addWidget(self.edge_chamfer_distance_input, 16, 1)
self.chamfer_edge_button = QPushButton("给Edge添加倒角")
help_tip(self.chamfer_edge_button, "给当前直线Edge新增对称倒角。会先预览,再后台执行,失败会回滚。")
self.chamfer_edge_button.clicked.connect(self.chamfer_edge)
edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2)
edit_layout.addWidget(self.chamfer_edge_button, 17, 0, 1, 2)
edit_layout.addWidget(QLabel("Edge目标长度"), 16, 0)
edit_layout.addWidget(QLabel("Edge目标长度"), 18, 0)
edge_length_inputs = QWidget()
edge_length_layout = QHBoxLayout(edge_length_inputs)
edge_length_layout.setContentsMargins(0, 0, 0, 0)
self.edge_target_length_input = QLineEdit("")
help_tip(self.edge_target_length_input, "当前Edge的目标长度。选中Edge后会自动填当前长度,改成新长度再执行。")
self.edge_start_x_input = QLineEdit("", edit_box)
self.edge_start_y_input = QLineEdit("", edit_box)
self.edge_start_z_input = QLineEdit("", edit_box)
self.edge_center_x_input = QLineEdit("", edit_box)
self.edge_center_y_input = QLineEdit("", edit_box)
self.edge_center_z_input = QLineEdit("", edit_box)
self.edge_end_x_input = QLineEdit("", edit_box)
self.edge_end_y_input = QLineEdit("", edit_box)
self.edge_end_z_input = QLineEdit("", edit_box)
for endpoint_input in (
self.edge_start_x_input,
self.edge_start_y_input,
self.edge_start_z_input,
self.edge_center_x_input,
self.edge_center_y_input,
self.edge_center_z_input,
self.edge_end_x_input,
self.edge_end_y_input,
self.edge_end_z_input,
):
endpoint_input.setVisible(False)
self.edge_length_anchor_combo = NoWheelComboBox()
self.edge_length_anchor_combo.addItem("自动", "auto")
self.edge_length_anchor_combo.addItem("中心", "center")
@@ -858,31 +1032,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
)
edge_length_layout.addWidget(self.edge_target_length_input, stretch=2)
edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1)
edit_layout.addWidget(edge_length_inputs, 16, 1)
edit_layout.addWidget(edge_length_inputs, 18, 1)
self.resize_edge_length_button = QPushButton("修改Edge长度")
help_tip(
self.resize_edge_length_button,
"修改当前Edge长度。直线Edge优先局部形变;圆、椭圆和平面曲线会优先用局部/径向策略,必要时再用后备缩放。",
)
self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length)
edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2)
edit_layout.addWidget(self.resize_edge_length_button, 19, 0, 1, 2)
edit_layout.addWidget(QLabel("薄壁厚度"), 18, 0)
edit_layout.addWidget(QLabel("薄壁厚度"), 20, 0)
self.shell_thickness_input = QLineEdit("")
help_tip(
self.shell_thickness_input,
"薄壁/壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。",
)
edit_layout.addWidget(self.shell_thickness_input, 18, 1)
edit_layout.addWidget(self.shell_thickness_input, 20, 1)
self.resize_shell_thickness_button = QPushButton("调整薄壁厚度")
help_tip(
self.resize_shell_thickness_button,
"移动当前平面区域来达到目标厚度。当前版本基于相对平面估算,执行前会预览并可回滚。",
)
self.resize_shell_thickness_button.clicked.connect(self.resize_shell_thickness)
edit_layout.addWidget(self.resize_shell_thickness_button, 19, 0, 1, 2)
edit_layout.addWidget(self.resize_shell_thickness_button, 21, 0, 1, 2)
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 20, 0)
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 22, 0)
translate_inputs = QWidget()
translate_layout = QHBoxLayout(translate_inputs)
translate_layout.setContentsMargins(0, 0, 0, 0)
@@ -898,17 +1072,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
translate_layout.addWidget(self.translate_x_input)
translate_layout.addWidget(self.translate_y_input)
translate_layout.addWidget(self.translate_z_input)
edit_layout.addWidget(translate_inputs, 20, 1)
edit_layout.addWidget(translate_inputs, 22, 1)
self.translate_part_button = QPushButton("平移选中零件")
help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。")
self.translate_part_button.clicked.connect(self.translate_selected_part)
self.translate_solid_button = QPushButton("平移选中Solid")
help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前Solid。单Solid零件中相当于移动整个零件。")
self.translate_solid_button.clicked.connect(self.translate_selected_solid)
edit_layout.addWidget(self.translate_part_button, 21, 0, 1, 2)
edit_layout.addWidget(self.translate_solid_button, 22, 0, 1, 2)
edit_layout.addWidget(self.translate_part_button, 23, 0, 1, 2)
edit_layout.addWidget(self.translate_solid_button, 24, 0, 1, 2)
edit_layout.addWidget(QLabel("旋转轴/角度"), 23, 0)
edit_layout.addWidget(QLabel("旋转轴/角度"), 25, 0)
rotate_inputs = QWidget()
rotate_layout = QHBoxLayout(rotate_inputs)
rotate_layout.setContentsMargins(0, 0, 0, 0)
@@ -921,15 +1095,27 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。")
rotate_layout.addWidget(self.rotate_axis_combo)
rotate_layout.addWidget(self.rotate_angle_input)
edit_layout.addWidget(rotate_inputs, 23, 1)
edit_layout.addWidget(rotate_inputs, 25, 1)
self.scale_target_diagonal_input = QLineEdit("", edit_box)
self.scale_target_diagonal_input.setVisible(False)
help_tip(self.scale_target_diagonal_input, "零件或Solid的目标包围盒对角线。当前选中对象表会使用这个隐藏输入执行整体等比缩放。")
self.scale_x_size_input = QLineEdit("", edit_box)
self.scale_x_size_input.setVisible(False)
help_tip(self.scale_x_size_input, "零件或Solid的目标 X 向包围盒尺寸。")
self.scale_y_size_input = QLineEdit("", edit_box)
self.scale_y_size_input.setVisible(False)
help_tip(self.scale_y_size_input, "零件或Solid的目标 Y 向包围盒尺寸。")
self.scale_z_size_input = QLineEdit("", edit_box)
self.scale_z_size_input.setVisible(False)
help_tip(self.scale_z_size_input, "零件或Solid的目标 Z 向包围盒尺寸。")
self.rotate_part_button = QPushButton("旋转选中零件")
help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。")
self.rotate_part_button.clicked.connect(self.rotate_selected_part)
self.rotate_solid_button = QPushButton("旋转选中Solid")
help_tip(self.rotate_solid_button, "绕所选轴旋转当前Solid。单Solid零件中相当于旋转整个零件。")
self.rotate_solid_button.clicked.connect(self.rotate_selected_solid)
edit_layout.addWidget(self.rotate_part_button, 24, 0, 1, 2)
edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2)
edit_layout.addWidget(self.rotate_part_button, 26, 0, 1, 2)
edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2)
panel_layout.addWidget(self.object_edit_box)
if ENABLE_EXPORT_PANEL:
+1361 -12
View File
File diff suppressed because it is too large Load Diff
+68 -3
View File
@@ -8,7 +8,7 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Defeaturing
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepGProp import brepgprop
@@ -34,7 +34,7 @@ from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCC.Core.gp import gp_Ax1, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ
from OCC.Extend.TopologyUtils import TopologyExplorer
from .constants import CURVE_TYPES, ORIENTATION_TYPES
@@ -341,6 +341,58 @@ def _rotated_shape(
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _scaled_shape(shape: TopoDS_Shape, scale: float, center: tuple[float, float, float]) -> TopoDS_Shape:
if abs(scale - 1.0) <= 1e-12:
return shape
if scale <= 0:
raise ValueError("Scale factor must be greater than 0.")
trsf = gp_Trsf()
trsf.SetScale(gp_Pnt(*center), float(scale))
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _axis_scaled_shape(
shape: TopoDS_Shape,
axis_name: str,
scale: float,
center: tuple[float, float, float],
) -> TopoDS_Shape:
if abs(scale - 1.0) <= 1e-12:
return shape
if scale <= 0:
raise ValueError("Scale factor must be greater than 0.")
axis = axis_name.upper()
axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis)
if axis_index is None:
raise ValueError("Scale axis must be X, Y or Z.")
matrix = [
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]
matrix[axis_index][axis_index] = float(scale)
cx, cy, cz = center
moved_center = (
matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz,
matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz,
matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz,
)
translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2])
transform = gp_GTrsf()
for row in range(3):
for column in range(3):
transform.SetValue(row + 1, column + 1, matrix[row][column])
transform.SetTranslationPart(gp_XYZ(*translation))
builder = BRepBuilderAPI_GTransform(shape, transform, True)
builder.Build()
if not builder.IsDone():
raise RuntimeError("Axis scale transform failed.")
result = builder.Shape()
if result.IsNull():
raise RuntimeError("Axis scale transform produced an empty shape.")
return result
def _axis_dir_from_name(axis_name: str) -> gp_Dir:
axis = axis_name.upper()
if axis == "X":
@@ -365,10 +417,23 @@ def _tuple_or_none(value: object) -> tuple[float, float, float] | None:
return None
def _float_or_none(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]:
return (left[0] - right[0], left[1] - right[1], left[2] - right[2])
def _tuple_add(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]:
return (left[0] + right[0], left[1] + right[1], left[2] + right[2])
def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]:
return (values[0] * scale, values[1] * scale, values[2] * scale)
@@ -937,7 +1002,7 @@ def _cylinder_depth_readiness(
status = "caution"
if not warnings and not blockers:
note = "可以尝试调整盲孔深度。"
note = "可以尝试调整盲孔/盲槽深度。"
else:
note = " ".join(blockers + warnings)
return {
+2
View File
@@ -29,6 +29,8 @@ class InfoPanelMixin:
self.info_tabs.setCurrentWidget(self.info_tree)
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
if hasattr(self, "_update_selected_object_title"):
self._update_selected_object_title()
if hasattr(self, "mode_combo"):
self._update_action_states()
+160 -10
View File
@@ -98,6 +98,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self.edge_solid_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = []
self._face_info_cache: dict[int, dict[str, object]] = {}
self._feature_info_cache: dict[int, dict[str, object]] = {}
self._edge_info_cache: dict[int, dict[str, object]] = {}
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
@@ -176,6 +177,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self.edge_solid_ids.clear()
self.solids.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._edge_info_cache.clear()
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
@@ -268,6 +270,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
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_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
def face_info(self, face_id: int) -> dict[str, object]:
@@ -356,15 +359,54 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_info_cache[face_id] = dict(info)
return dict(info)
def face_surface_kind(self, face_id: int) -> str:
if face_id < 0 or face_id >= len(self.faces):
return ""
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("surface") is not None:
return str(cached.get("surface"))
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
surface_type = surf.GetType()
except Exception:
return ""
return SURFACE_TYPES.get(surface_type, f"type {surface_type}")
def face_cylinder_diameter(self, face_id: int) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
return None
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder":
try:
return float(cached["diameter"])
except (TypeError, ValueError):
pass
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
return None
return float(surf.Cylinder().Radius()) * 2.0
except Exception:
return None
def feature_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if face_id in self._feature_info_cache:
return dict(self._feature_info_cache[face_id])
info = self.face_info(face_id)
surface = str(info.get("surface", ""))
if surface == "cylinder":
return self._cylindrical_feature_info(face_id, info)
if surface == "plane":
return self._planar_feature_info(face_id, info)
result = self._cylindrical_feature_info(face_id, info)
elif surface == "plane":
result = self._planar_feature_info(face_id, info)
elif surface == "cone":
result = self._conical_feature_info(face_id, info)
elif surface == "sphere":
result = self._spherical_feature_info(face_id, info)
elif surface == "torus":
result = self._toroidal_feature_info(face_id, info)
else:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
result = dict(info)
result.update(
@@ -375,8 +417,82 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "当前版本只能查看该局部曲面,暂不支持直接编辑。",
"feature_mode": "Feature 模式会把选中的Face解释为局部几何特征候选。",
"feature_edit_actions": "当前只读;复杂曲面局部面积修改暂未开放",
"feature_mode": (
"Feature 模式会把选中的Face解释为局部几何特征候选;"
"复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。"
),
}
)
self._feature_info_cache[face_id] = dict(result)
return dict(result)
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0)
minor_radius = float(info.get("minor_radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "环面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": (
"这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid,"
"主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。"
),
"feature_torus_major_radius": major_radius,
"feature_torus_minor_radius": minor_radius,
}
)
return result
def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
radius = float(info.get("radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "球面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改球面半径/直径",
"feature_mode": (
"这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid,"
"不是 CAD 历史里的球面或圆角参数。"
),
"feature_sphere_radius": radius,
"feature_sphere_diameter": radius * 2.0 if radius > 0 else "",
}
)
return result
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "圆锥面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_reference_radius": reference_radius,
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
"feature_edit_actions": "修改圆锥参考半径/直径",
"feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放,"
"不是 CAD 历史里的锥孔或倒角参数。"
),
}
)
return result
@@ -549,12 +665,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度"
else:
feature_type = "圆柱孔候选"
edit_actions = "调整圆柱孔径"
if info.get("cylinder_end_type") == "blind" and bottom_face_ids:
edit_actions = "调整圆柱孔径;调整盲孔深度"
edit_actions += ";调整盲孔/盲槽深度"
else:
edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面"
edit_actions += ";孔深调整需要明确盲孔底面"
if angular_span >= math.tau * 0.92:
edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate":
@@ -562,7 +680,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。"
elif guess == "boss/outer-round candidate":
feature_type = "凸台/外圆候选"
edit_actions = "调整圆柱凸台直径。"
edit_actions = "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标"
else:
feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。"
@@ -704,6 +822,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
start_end_face_ids: list[int] = []
end_end_face_ids: list[int] = []
cap_scan_skipped = False
for adjacent_id in adjacent_face_ids:
match = self._axis_end_match_for_planar_face(
adjacent_id,
@@ -720,6 +839,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
end_end_face_ids.append(adjacent_id)
if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids):
if len(self.faces) <= 1000:
scanned = self._axis_cap_face_candidates(
face_id,
axis_point,
@@ -734,6 +854,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
start_end_face_ids.extend(scanned["start"])
if not end_end_face_ids:
end_end_face_ids.extend(scanned["end"])
else:
cap_scan_skipped = True
bottom_face_ids: list[int] = []
opening_face_ids: list[int] = []
@@ -755,6 +877,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high"
if not end_face_ids:
note = "没有在圆柱边界附近找到平面端面。"
if cap_scan_skipped:
note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿;如需改盲孔/盲槽深度,可以手动填写底面 Face ID。"
elif bottom_face_ids:
if bottom_detection == "axis-cap-scan":
note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
@@ -762,6 +886,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
else:
note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。"
if cap_scan_skipped:
note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿。"
return {
"end_face_ids": end_face_ids,
"start_end_face_ids": sorted(set(start_end_face_ids)),
@@ -905,6 +1031,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
for face_id in valid_face_ids:
self.face_logical_ids[face_id] = int(logical_id)
self._face_info_cache.pop(face_id, None)
self._feature_info_cache.pop(face_id, None)
def nearest_edge_id_to_point(
self,
@@ -961,9 +1088,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return valid_face_ids[0]
point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex()
exact_face_ids = valid_face_ids
if len(valid_face_ids) > 48:
scored_face_ids: list[tuple[float, int]] = []
for face_id in valid_face_ids:
scored_face_ids.append((self._face_bounds_distance_sq(face_id, point), face_id))
scored_face_ids.sort(key=lambda item: item[0])
exact_face_ids = [face_id for _distance, face_id in scored_face_ids[:48]]
best_face_id: int | None = None
best_distance = math.inf
for face_id in valid_face_ids:
for face_id in exact_face_ids:
try:
extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id])
extrema.Perform()
@@ -981,7 +1116,22 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if distance < best_distance:
best_distance = distance
best_face_id = face_id
return best_face_id if best_face_id is not None else valid_face_ids[0]
return best_face_id if best_face_id is not None else exact_face_ids[0]
def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float:
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
try:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id])
dx = max(xmin - px, 0.0, px - xmax)
dy = max(ymin - py, 0.0, py - ymax)
dz = max(zmin - pz, 0.0, pz - zmax)
return dx * dx + dy * dy + dz * dz
except Exception:
try:
center = _surface_center(self.faces[face_id])
return _point_distance_sq((px, py, pz), _point_tuple(center))
except Exception:
return math.inf
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids:
+1917 -2
View File
File diff suppressed because it is too large Load Diff
+297
View File
@@ -262,3 +262,300 @@ class TransformMixin:
f"risk={plan['risk']}."
)
def scale_part_plan(self, part_id: int, target_diagonal: float) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
return self._scale_shape_plan(
target_kind="part",
part_id=part.id,
solid_id=-1,
shape=part.shape,
target_diagonal=target_diagonal,
part_solid_count=len(_explore(part.shape, TopAbs_SOLID)),
)
def scale_solid_plan(self, solid_id: int, target_diagonal: float) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
plan = self._scale_shape_plan(
target_kind="solid",
part_id=part_id,
solid_id=solid_id,
shape=solid,
target_diagonal=target_diagonal,
part_solid_count=part_solid_count,
)
if part_solid_count <= 1 and plan["status"] != "blocked":
plan["warnings"] = _join_nonempty(
plan.get("warnings", ""),
"当前零件只有一个Solid,缩放Solid实际会缩放整个零件 shape。",
)
if plan["risk"] == "low":
plan["risk"] = "medium"
plan["status"] = "caution"
plan["message"] = _join_nonempty(plan.get("message", ""), plan.get("warnings", ""))
return plan
def scale_part_axis_plan(self, part_id: int, axis: str, target_size: float) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
return self._axis_scale_shape_plan(
target_kind="part",
part_id=part.id,
solid_id=-1,
shape=part.shape,
axis=axis,
target_size=target_size,
part_solid_count=len(_explore(part.shape, TopAbs_SOLID)),
)
def scale_solid_axis_plan(self, solid_id: int, axis: str, target_size: float) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
plan = self._axis_scale_shape_plan(
target_kind="solid",
part_id=part_id,
solid_id=solid_id,
shape=solid,
axis=axis,
target_size=target_size,
part_solid_count=part_solid_count,
)
if part_solid_count <= 1 and plan["status"] != "blocked":
plan["warnings"] = _join_nonempty(
plan.get("warnings", ""),
"当前零件只有一个Solid,按轴缩放Solid实际会缩放整个零件shape。",
)
if plan["risk"] == "low":
plan["risk"] = "medium"
plan["status"] = "caution"
plan["message"] = _join_nonempty(plan.get("message", ""), plan.get("warnings", ""))
return plan
def _scale_shape_plan(
self,
*,
target_kind: str,
part_id: int,
solid_id: int,
shape: TopoDS_Shape,
target_diagonal: float,
part_solid_count: int,
) -> dict[str, object]:
blockers: list[str] = []
warnings: list[str] = []
try:
target_diagonal = float(target_diagonal)
except (TypeError, ValueError):
target_diagonal = 0.0
blockers.append("目标包围盒对角线必须是数字。")
current_diagonal = _shape_diagonal(shape)
center = _shape_center(shape)
if current_diagonal <= 1e-9:
blockers.append("当前对象包围盒对角线无效,不能缩放。")
if target_diagonal <= 1e-9:
blockers.append("目标包围盒对角线必须大于 0。")
scale = target_diagonal / max(current_diagonal, 1e-9)
delta_ratio = abs(target_diagonal - current_diagonal) / max(current_diagonal, 1e-9)
risk = "low"
status = "ready"
if abs(target_diagonal - current_diagonal) <= max(current_diagonal * 1e-6, 1e-6):
blockers.append("目标包围盒对角线与当前值几乎相同,不需要缩放。")
elif delta_ratio > 1.0:
risk = "high"
status = "caution"
warnings.append("目标尺寸变化超过当前包围盒对角线的 100%,请确认单位和目标。")
elif delta_ratio > 0.35:
risk = "medium"
status = "caution"
warnings.append("目标尺寸变化超过当前包围盒对角线的 35%,请确认缩放结果。")
if blockers:
risk = "blocked"
status = "blocked"
return {
"status": status,
"risk": risk,
"message": _join_nonempty(" ".join(blockers), " ".join(warnings)) or "可以按目标包围盒对角线等比缩放当前对象。",
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"target_kind": target_kind,
"part_id": part_id,
"solid_id": solid_id,
"part_solid_count": part_solid_count,
"current_bbox_diagonal": current_diagonal,
"target_bbox_diagonal": target_diagonal,
"scale_factor": scale,
"scale_delta_ratio": delta_ratio,
"scale_center": center,
"resize_strategy": "uniform-scale-selected-shape-to-bbox-diagonal",
}
def _axis_scale_shape_plan(
self,
*,
target_kind: str,
part_id: int,
solid_id: int,
shape: TopoDS_Shape,
axis: str,
target_size: float,
part_solid_count: int,
) -> dict[str, object]:
axis = str(axis or "X").strip().upper()
axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis)
blockers: list[str] = []
warnings: list[str] = [
"按单轴尺寸缩放是 B-Rep 仿射编辑,可能把解析几何转换成 B-spline 或影响同一对象上的其它特征。"
]
try:
target_size = float(target_size)
except (TypeError, ValueError):
target_size = 0.0
blockers.append("目标轴向尺寸必须是数字。")
bounds = _shape_bounds_info(shape)
bbox_size = tuple(bounds.get("bbox_size", (0.0, 0.0, 0.0)))
current_size = float(bbox_size[axis_index]) if axis_index is not None else 0.0
center = _shape_center(shape)
if axis_index is None:
blockers.append("缩放轴必须是 X、Y 或 Z。")
if current_size <= 1e-9:
blockers.append("当前对象在该轴向的包围盒尺寸无效,不能按轴缩放。")
if target_size <= 1e-9:
blockers.append("目标轴向尺寸必须大于 0。")
scale = target_size / max(current_size, 1e-9)
delta_ratio = abs(target_size - current_size) / max(current_size, 1e-9)
risk = "medium"
status = "caution"
if abs(target_size - current_size) <= max(current_size * 1e-6, 1e-6):
blockers.append("目标轴向尺寸与当前值几乎相同,不需要缩放。")
elif delta_ratio > 1.0:
risk = "high"
warnings.append("目标轴向尺寸变化超过当前尺寸的 100%,请确认单位和目标。")
elif delta_ratio <= 0.2:
risk = "medium"
if blockers:
risk = "blocked"
status = "blocked"
return {
"status": status,
"risk": risk,
"message": _join_nonempty(" ".join(blockers), " ".join(warnings)) or "可以按目标轴向尺寸缩放当前对象。",
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"target_kind": target_kind,
"part_id": part_id,
"solid_id": solid_id,
"part_solid_count": part_solid_count,
"scale_axis": axis,
"current_axis_size": current_size,
"target_axis_size": target_size,
"axis_scale_factor": scale,
"axis_scale_delta_ratio": delta_ratio,
"scale_center": center,
"bbox_size": bbox_size,
"resize_strategy": "axis-scale-selected-shape-to-bbox-size",
}
def scale_part(self, part_id: int, target_diagonal: float) -> str:
plan = self.scale_part_plan(part_id, target_diagonal)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part.shape = _scaled_shape(part.shape, float(plan["scale_factor"]), plan["scale_center"])
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part scaled: part {part_id}, "
f"bbox_diagonal={float(plan['current_bbox_diagonal']):g}->{float(plan['target_bbox_diagonal']):g}, "
f"scale={float(plan['scale_factor']):g}, risk={plan['risk']}."
)
def scale_part_axis(self, part_id: int, axis: str, target_size: float) -> str:
plan = self.scale_part_axis_plan(part_id, axis, target_size)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part.shape = _axis_scaled_shape(part.shape, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"])
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part axis-scaled: part {part_id}, axis={plan['scale_axis']}, "
f"size={float(plan['current_axis_size']):g}->{float(plan['target_axis_size']):g}, "
f"scale={float(plan['axis_scale_factor']):g}, risk={plan['risk']}."
)
def scale_solid(self, solid_id: int, target_diagonal: float) -> str:
plan = self.scale_solid_plan(solid_id, target_diagonal)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = _scaled_shape(part.shape, float(plan["scale_factor"]), plan["scale_center"])
else:
scaled = _scaled_shape(solid, float(plan["scale_factor"]), plan["scale_center"])
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(scaled)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Solid scaled: solid {solid_id}, part {part_id}, "
f"bbox_diagonal={float(plan['current_bbox_diagonal']):g}->{float(plan['target_bbox_diagonal']):g}, "
f"scale={float(plan['scale_factor']):g}, risk={plan['risk']}."
)
def scale_solid_axis(self, solid_id: int, axis: str, target_size: float) -> str:
plan = self.scale_solid_axis_plan(solid_id, axis, target_size)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = _axis_scaled_shape(part.shape, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"])
else:
scaled = _axis_scaled_shape(solid, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"])
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(scaled)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Solid axis-scaled: solid {solid_id}, part {part_id}, axis={plan['scale_axis']}, "
f"size={float(plan['current_axis_size']):g}->{float(plan['target_axis_size']):g}, "
f"scale={float(plan['axis_scale_factor']):g}, risk={plan['risk']}."
)
+24
View File
@@ -459,6 +459,8 @@ INFO_LABELS = {
"target_to_height_ratio": "目标直径/估算高度",
"major_radius": "主半径",
"minor_radius": "小半径",
"ellipse_edge_major_radius": "椭圆Edge主半径",
"ellipse_edge_minor_radius": "椭圆Edge小半径",
"reference_radius": "参考半径",
"semi_angle": "半角",
"angular_span": "角度跨度",
@@ -583,6 +585,28 @@ INFO_LABELS = {
"existing_fillet_kind": "已有圆角类型",
"existing_fillet_status": "已有圆角识别状态",
"existing_fillet_radius_estimate": "已有圆角半径估算",
"feature_reference_radius": "特征参考半径",
"feature_reference_diameter": "特征参考直径",
"feature_sphere_radius": "球面半径",
"feature_sphere_diameter": "球面直径",
"feature_torus_major_radius": "环面主半径",
"feature_torus_minor_radius": "环面小半径",
"current_major_radius": "当前主半径",
"target_major_radius": "目标主半径",
"current_minor_radius": "当前小半径",
"target_minor_radius": "目标小半径",
"torus_radius_mode": "环面半径模式",
"current_area": "当前面积",
"target_area": "目标面积",
"area_delta": "面积变化量",
"area_delta_ratio": "面积变化比例",
"area_center": "面积中心",
"current_reference_radius": "当前参考半径",
"target_reference_radius": "目标参考半径",
"current_reference_diameter": "当前参考直径",
"target_reference_diameter": "目标参考直径",
"delta_reference_radius": "参考半径变化量",
"reference_radius_delta_ratio": "参考半径变化比例",
"existing_fillet_angular_span": "已有圆角圆弧角度",
"existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算",
"existing_fillet_note": "已有圆角识别说明",
File diff suppressed because it is too large Load Diff
+261 -23
View File
@@ -43,15 +43,35 @@ class WindowCoreMixin:
if watched is getattr(self, "vtk_widget", None):
event_type = event.type()
if event_type == QEvent.Type.MouseButtonRelease:
if event.button() == Qt.MouseButton.LeftButton:
x, y = self._vtk_position_from_qt_event(event)
self.skip_next_vtk_left_release = True
self._handle_left_button_release(x, y)
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
elif event_type == QEvent.Type.MouseButtonPress:
if event.button() == Qt.MouseButton.LeftButton:
x, y = self._vtk_position_from_qt_event(event)
self.skip_next_vtk_left_press = True
self._handle_left_button_press(x, y)
self.pointer_button_down = True
elif event_type == QEvent.Type.MouseMove:
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
if event.buttons() & Qt.MouseButton.LeftButton:
x, y = self._vtk_position_from_qt_event(event)
self._update_left_button_drag_state(x, y)
if not self.pointer_button_down:
self._queue_hover_from_qt_event(event)
elif event_type == QEvent.Type.Leave:
self._cancel_hover_tracking(render=True)
return super().eventFilter(watched, event)
def _vtk_position_from_qt_event(self, event) -> tuple[int, int]:
position = event.position() if hasattr(event, "position") else event.pos()
scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0
x = int(round(float(position.x()) * scale))
y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale))
return x, y
def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
@@ -62,13 +82,19 @@ class WindowCoreMixin:
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
self.picker = vtk.vtkCellPicker()
self.picker.SetTolerance(0.003)
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click)
self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_button_press)
self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_left_button_release)
self.interactor.AddObserver("MiddleButtonPressEvent", self.on_pointer_button_press)
self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press)
self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start)
self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end)
if hasattr(self.interactor, "SetDesiredUpdateRate"):
self.interactor.SetDesiredUpdateRate(24.0)
if hasattr(self.interactor, "SetStillUpdateRate"):
self.interactor.SetStillUpdateRate(0.2)
light = vtk.vtkLight()
light.SetLightTypeToHeadlight()
@@ -78,6 +104,55 @@ class WindowCoreMixin:
self.interactor.Initialize()
def on_camera_interaction_start(self, _obj, _event) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(self._begin_camera_interaction)
return
self._begin_camera_interaction()
def on_camera_interaction_end(self, _obj, _event) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(self._end_camera_interaction)
return
self._end_camera_interaction()
def _begin_camera_interaction(self) -> None:
if getattr(self, "camera_interaction_active", False):
return
self.camera_interaction_active = True
self._cancel_hover_tracking(render=False)
if self.edge_actor is not None:
try:
self.edge_actor_visible_before_interaction = bool(self.edge_actor.GetVisibility())
self.edge_actor.SetVisibility(False)
except Exception:
self.edge_actor_visible_before_interaction = True
if self.model_actor is not None:
try:
prop = self.model_actor.GetProperty()
self.model_interpolation_before_interaction = int(prop.GetInterpolation())
prop.SetInterpolationToGouraud()
except Exception:
self.model_interpolation_before_interaction = None
def _end_camera_interaction(self) -> None:
if not getattr(self, "camera_interaction_active", False):
return
self.camera_interaction_active = False
if self.edge_actor is not None:
try:
self.edge_actor.SetVisibility(bool(getattr(self, "edge_actor_visible_before_interaction", True)))
except Exception:
pass
if self.model_actor is not None and self.model_interpolation_before_interaction is not None:
try:
self.model_actor.GetProperty().SetInterpolation(int(self.model_interpolation_before_interaction))
except Exception:
pass
self.model_interpolation_before_interaction = None
if hasattr(self, "render_window"):
self.render_window.Render()
def closeEvent(self, event) -> None:
load_thread_running = bool(
(self.load_thread is not None and self.load_thread.isRunning())
@@ -839,18 +914,73 @@ class WindowCoreMixin:
if hasattr(self, "_update_id_select_title"):
self._update_id_select_title(mode)
def on_left_click(self, _obj, _event) -> None:
def _cancel_hover_tracking(self, render: bool = True) -> None:
self.pending_hover_position = None
self.last_hover_pick_position = None
if hasattr(self, "hover_timer"):
self.hover_timer.stop()
self._clear_hover(render=render)
def on_left_button_press(self, _obj, _event) -> None:
if getattr(self, "skip_next_vtk_left_press", False):
self.skip_next_vtk_left_press = False
return
try:
x, y = self.interactor.GetEventPosition()
except Exception:
x, y = 0, 0
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_click(x, y))
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y))
return
self._handle_left_click(int(x), int(y))
self._handle_left_button_press(int(x), int(y))
def _handle_left_button_press(self, x: int, y: int) -> None:
self.pointer_button_down = True
self.left_button_press_position = (int(x), int(y))
self.left_button_dragged = False
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
self._clear_hover(render=True)
def on_left_button_release(self, _obj, _event) -> None:
if getattr(self, "skip_next_vtk_left_release", False):
self.skip_next_vtk_left_release = False
return
try:
x, y = self.interactor.GetEventPosition()
except Exception:
x, y = 0, 0
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_release(x, y))
return
self._handle_left_button_release(int(x), int(y))
def _handle_left_button_release(self, x: int, y: int) -> None:
self._update_left_button_drag_state(x, y)
was_dragged = bool(getattr(self, "left_button_dragged", False))
self.pointer_button_down = False
self.left_button_press_position = None
self.left_button_dragged = False
self.pending_hover_position = None
self.last_hover_pick_position = None
if getattr(self, "camera_interaction_active", False):
self._end_camera_interaction()
if was_dragged:
return
self._handle_left_click(x, y)
def _update_left_button_drag_state(self, x: int, y: int) -> None:
press_position = getattr(self, "left_button_press_position", None)
if press_position is None:
return
threshold = int(getattr(self, "left_click_drag_threshold_px", 6) or 6)
dx = abs(int(x) - int(press_position[0]))
dy = abs(int(y) - int(press_position[1]))
if max(dx, dy) > threshold:
self.left_button_dragged = True
def _handle_left_click(self, x: int, y: int) -> None:
self.pointer_button_down = True
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
@@ -868,7 +998,11 @@ class WindowCoreMixin:
mode = self._current_selection_mode()
target = self._pick_selection_target(mode, x, y)
if target is None:
self.statusBar().showMessage("未选中对象")
self._cancel_hover_tracking(render=False)
self._reset_selection(clear_highlight=True)
if hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已取消选择")
return
self._clear_hover(render=False)
@@ -897,6 +1031,7 @@ class WindowCoreMixin:
self.pointer_button_down = False
self.pending_hover_position = None
self.last_hover_pick_position = None
self._end_camera_interaction()
def on_mouse_move(self, _obj, _event) -> None:
try:
@@ -909,8 +1044,15 @@ class WindowCoreMixin:
self._handle_mouse_move(int(x), int(y))
def _handle_mouse_move(self, x: int, y: int) -> None:
if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
buttons = QApplication.mouseButtons()
if buttons != Qt.MouseButton.NoButton:
self.pointer_button_down = True
if buttons & Qt.MouseButton.LeftButton:
self._update_left_button_drag_state(x, y)
if getattr(self, "left_button_dragged", False):
self._begin_camera_interaction()
else:
self._begin_camera_interaction()
return
self.pointer_button_down = False
if (
@@ -934,10 +1076,7 @@ class WindowCoreMixin:
):
self._clear_hover(render=True)
return
position = event.position() if hasattr(event, "position") else event.pos()
scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0
x = int(round(float(position.x()) * scale))
y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale))
x, y = self._vtk_position_from_qt_event(event)
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
@@ -1213,10 +1352,26 @@ class WindowCoreMixin:
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
elif action == "resize_boss_height":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台高度候选Face {target_id}")
elif action == "move_boss_axis":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可移动凸台轴心候选Face {target_id}")
elif action == "resize_slot_width":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
elif action == "resize_slot_depth":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔深度候选Face {target_id}")
elif action == "resize_slot_arc_length":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔圆弧长度候选Face {target_id}")
elif action == "resize_shell_thickness":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
@@ -1228,7 +1383,7 @@ class WindowCoreMixin:
elif action == "resize_depth":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}")
self.statusBar().showMessage(f"已选择可调整盲孔/盲槽深度候选Face {target_id}")
elif action == "inspect_existing_fillet":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
@@ -1305,7 +1460,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择零件。"):
return
info = self.model.part_info(part_id)
self._reset_selection(clear_highlight=False)
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "part"
self.selected_part_id = part_id
self.selected_pick_position = pick_position
@@ -1328,7 +1483,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
return
info = self.model.solid_info(solid_id)
self._reset_selection(clear_highlight=False)
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "solid"
self.selected_part_id = part_id
self.selected_solid_id = solid_id
@@ -1351,7 +1506,7 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
return
self._reset_selection(clear_highlight=False)
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature" if feature_mode else "face"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
@@ -1386,7 +1541,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return
info = self.model.feature_info(face_id)
self._reset_selection(clear_highlight=False)
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
@@ -1417,7 +1572,21 @@ class WindowCoreMixin:
self.hole_diameter_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
if hasattr(self, "slot_depth_input"):
self.slot_depth_input.clear()
if hasattr(self, "slot_arc_length_input"):
self.slot_arc_length_input.clear()
if hasattr(self, "slot_total_length_input"):
self.slot_total_length_input.clear()
self.boss_diameter_input.setText(suggested_diameter)
if hasattr(self, "boss_height_input"):
boss_height = _float_or_none(info.get("same_domain_height_estimate"))
if boss_height is None:
boss_height = _float_or_none(info.get("height_estimate"))
if boss_height is not None and boss_height > 0:
self.boss_height_input.setText(_format_float(boss_height * 1.2))
else:
self.boss_height_input.clear()
elif feature_guess == "round/fillet candidate":
radius = _float_or_none(info.get("existing_fillet_radius_estimate"))
if radius is None:
@@ -1427,22 +1596,54 @@ class WindowCoreMixin:
self.hole_diameter_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
if hasattr(self, "slot_depth_input"):
self.slot_depth_input.clear()
if hasattr(self, "slot_arc_length_input"):
self.slot_arc_length_input.clear()
if hasattr(self, "slot_total_length_input"):
self.slot_total_length_input.clear()
self.boss_diameter_input.clear()
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
else:
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
self.hole_diameter_input.setText(suggested_diameter)
self.boss_diameter_input.clear()
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
if hasattr(self, "slot_width_input"):
slot_width = _float_or_none(info.get("slot_chord_width_estimate"))
if slot_width is not None and slot_width > 0:
self.slot_width_input.setText(_format_float(slot_width * 1.2))
else:
self.slot_width_input.clear()
if hasattr(self, "slot_depth_input"):
slot_depth = _float_or_none(info.get("slot_sagitta_depth_estimate"))
if slot_depth is not None and slot_depth > 0:
self.slot_depth_input.setText(_format_float(slot_depth * 1.2))
else:
self.slot_depth_input.clear()
if hasattr(self, "slot_arc_length_input"):
slot_arc = _float_or_none(info.get("slot_arc_length_estimate"))
if slot_arc is not None and slot_arc > 0:
self.slot_arc_length_input.setText(_format_float(slot_arc * 1.2))
else:
self.slot_arc_length_input.clear()
if hasattr(self, "slot_total_length_input"):
self.slot_total_length_input.clear()
else:
self.hole_diameter_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
if hasattr(self, "slot_depth_input"):
self.slot_depth_input.clear()
if hasattr(self, "slot_arc_length_input"):
self.slot_arc_length_input.clear()
if hasattr(self, "slot_total_length_input"):
self.slot_total_length_input.clear()
self.boss_diameter_input.clear()
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info:
self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2))
else:
@@ -1469,7 +1670,15 @@ class WindowCoreMixin:
self.shell_thickness_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
if hasattr(self, "slot_depth_input"):
self.slot_depth_input.clear()
if hasattr(self, "slot_arc_length_input"):
self.slot_arc_length_input.clear()
if hasattr(self, "slot_total_length_input"):
self.slot_total_length_input.clear()
self.boss_diameter_input.clear()
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
self.hole_depth_input.clear()
if hasattr(self, "hole_bottom_face_input"):
self.hole_bottom_face_input.clear()
@@ -1491,7 +1700,7 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
return
self._reset_selection(clear_highlight=False)
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "edge"
self.selected_edge_id = edge_id
self.selected_pick_position = pick_position
@@ -1583,8 +1792,7 @@ class WindowCoreMixin:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "feature":
info = self.model.feature_info(target_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id]
face_ids = self.model.connected_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
@@ -1699,6 +1907,7 @@ class WindowCoreMixin:
polydata,
color: tuple[float, float, float],
opacity: float | None = None,
position: tuple[float, float, float] | None = None,
) -> None:
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
@@ -1708,16 +1917,24 @@ class WindowCoreMixin:
actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity)
actor.GetProperty().SetSpecular(0.28)
actor.GetProperty().SetLineWidth(1)
if position is not None:
actor.SetPosition(float(position[0]), float(position[1]), float(position[2]))
self.edit_preview_actors.append(actor)
self.edit_preview_actor = self.edit_preview_actor or actor
self.renderer.AddActor(actor)
def _show_push_pull_preview(self, face_id: int, distance: float) -> None:
def _show_push_pull_preview(self, face_id: int, distance: float, plan: dict[str, object] | None = None) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.push_pull_preview_polydata(face_id, distance)
plan = dict(plan or self.model.push_pull_plan(face_id, distance))
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
polydata = self._cached_face_overlay_polydata(face_ids=scope_face_ids, smooth=False)
if polydata is None:
polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False)
if polydata is None:
raise RuntimeError("当前显示网格里没有可复用的选中 Face 预览数据")
except Exception as exc:
self.statusBar().showMessage(f"推拉预览不可用:{exc}")
return
@@ -1725,7 +1942,15 @@ class WindowCoreMixin:
color = (0.0, 0.86, 0.34)
else:
color = (1.0, 0.18, 0.06)
self._add_edit_preview_actor(polydata, color)
outward = plan.get("outward_direction") if isinstance(plan, dict) else None
if not isinstance(outward, (tuple, list)) or len(outward) != 3:
outward = (0.0, 0.0, 0.0)
position = (
float(outward[0]) * float(distance),
float(outward[1]) * float(distance),
float(outward[2]) * float(distance),
)
self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position)
self._start_edit_preview_pulse()
self.render_window.Render()
@@ -1776,6 +2001,19 @@ class WindowCoreMixin:
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.cylindrical_boss_height_preview_polydata(face_id, target_height)
except Exception as exc:
self.statusBar().showMessage(f"凸台高度调整预览不可用:{exc}")
return
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_cylinder_suppress_preview(self, face_id: int) -> None:
if self.model is None:
return
File diff suppressed because it is too large Load Diff