Compare commits

...

3 Commits

20 changed files with 1001 additions and 185 deletions
+3
View File
@@ -24,6 +24,9 @@ dist/
assets/screenshots/
local/
tmp.md
data.json
Analysis-Component/
# Local reference docs; keep them on disk, never commit them.
Face一级关系专项测试说明.md
+137 -50
View File
@@ -2,17 +2,79 @@
这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。
仓库里的 `assets/models/geom_extract.step`默认测试模型。程序入口是 `main.py`
仓库里的 `assets/models/geom_extract.step`常用测试模型。程序入口是 `main.py`
## 快速读懂
- 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。
- 主入口是 `python main.py`默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。
- 主入口是 `python main.py`无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点。
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
## 软件整体架构
当前项目大致是这样分层的:
```text
python-occt
├── main.py
│ └── 程序入口,支持普通启动、烟测、隔离子进程 worker 入口
├── step_editor/
│ ├── app.py
│ │ └── 主窗口初始化、左侧操作面板、按钮和布局
│ │
│ ├── window_core.py
│ │ └── STEP 加载、VTK 视图、模型显示、拾取、高亮、坐标轴、FPS、显示刷新
│ │
│ ├── window_actions.py
│ │ └── 导出、参数化建模动作、后台任务、隔离子进程、操作历史
│ │
│ ├── window_state.py
│ │ └── 选择状态、参数表状态、按钮启用/禁用、撤销/重做、历史定位
│ │
│ ├── model.py
│ │ └── StepModel 核心对象,保存 shape、Face、Edge、Solid、拓扑缓存和 mixin 组合
│ │
│ ├── features.py
│ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面
│ │
│ ├── operations.py
│ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角
│ │
│ ├── geometry_utils.py
│ │ └── 通用 OCCT 几何工具、布尔结果处理、B-Rep 校验、拓扑辅助
│ │
│ ├── polydata.py
│ │ └── 把 OCCT Shape / Face / Edge 转成 VTK polydata,用于显示和拾取
│ │
│ ├── step_io.py
│ │ └── STEP/XCAF 读取、STEP 导出、内部 BREP 临时交换
│ │
│ ├── export.py
│ │ └── 导出完整模型、零件、Solid、Face、Edge,以及质量检查
│ │
│ ├── isolated_edit_worker.py
│ │ └── 高风险几何编辑的隔离子进程入口
│ │
│ ├── workers.py
│ │ └── Qt 后台任务 worker,避免主界面被计算堵死
│ │
│ ├── ui_helpers.py / widgets.py / info_panel.py
│ │ └── UI 辅助函数、自定义控件、属性信息显示
│ │
│ └── recognition_priority.py
│ └── 特征识别优先级,让常用、稳定、语义清楚的特征排前面
├── scripts/
│ └── 各类专项验证脚本和一级编辑总回归入口
└── assets/models/
└── 仓库自带测试 STEP 模型
```
## 怎么运行
你第一次配置或运行这个项目时,可以按下面步骤来。命令默认在 Windows PowerShell 里执行。
@@ -42,19 +104,19 @@ conda activate pyocc
python main.py --smoke-test
```
5. 启动程序并打开默认测试模型
5. 快速启动程序。此时不读取默认模型,窗口会先进入空场景
```powershell
python main.py
```
6. 启动时直接打开指定 STEP 文件:
6. 启动时直接打开指定 STEP 文件。只有显式传入路径时,程序才会启动后自动读取该模型
```powershell
python main.py path\to\model.step
```
7. 也可以先启动程序,在左侧 `STEP文件` 区域点击 `导入几何模型` 选择 `.step` / `.stp` 文件,再点击 `读取模型`
7. 也可以先启动程序,在左侧 `STEP文件` 区域点击 `导入几何模型` 选择 `.step` / `.stp` 文件;选择后会立即读取模型。`读取模型` 用于按当前路径重新加载
8. 打开仓库自带的简单立方体测试模型:
@@ -157,8 +219,8 @@ git diff --check
- 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 识别结果会区分 `当前可改``当前受限修改``识别限制``受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/中心/偏移”会被标成受限修改。
- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度、简单等半径圆角链半径/弧长并通过 `建模意图` 选择重建圆角或整体调整、已有等距倒角距离、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 识别结果会区分 `当前可改``当前受限修改``识别限制``受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/偏移”会被标成受限修改。
- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。Face/直线 Edge 的 `中心` 移动后端能力暂时保留,但不在特征参数表开放。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度、简单等半径圆角链半径/弧长并通过 `建模意图` 选择重建圆角或整体调整、已有等距倒角距离、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
@@ -175,6 +237,12 @@ git diff --check
- `[~]` 部分实现/进行中:已有识别、计划或部分模型编辑能力,但真实 STEP 上还需要继续补守门、补失败解释或补局部重建。
- `[ ]` 未实现:作为后续目标保留,当前不应在 UI 里伪装成可稳定修改。
验收口径:
- `[x]` 不是“所有 CAD 形态都能改”,而是“当前声明支持的模型范围已经有 UI/后端/回归测试闭环”。
- R1 Face 是当前主线的第一阶段收口对象;孔、槽、凸台、圆角、壳体和 Edge 已有各自一级基线,但仍按模型工程师给出的真实 STEP 失败项继续补边界。
- R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里,不能拿它反向判定 R1 Face 没完成。
```text
STEP/B-Rep 参数化编辑主线
├── 0. 先让用户知道“能不能改”
@@ -198,8 +266,8 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 近矩形平面 Face 沿长度方向变化,一级相邻面跟随,面积不作为驱动参数。
│ ├── [x] [面内宽度 -> 改宽度]
│ │ └── 近矩形平面 Face 沿宽度方向变化,一级相邻面跟随。
│ ├── [x] [中心位置 -> 移动局部面组]
│ │ └── 可选择局部重建或移动所属对象,避免无提示地把整个模型拖走
│ ├── [~] [中心位置 -> 后端保留,界面暂不开放]
│ │ └── 中心移动已有后端验证,但模型工程师判断当前客户价值不高,特征参数表暂不显示
│ ├── [x] [壳体厚度 -> 改薄壁厚度]
│ │ └── 平面相对面可做局部拉伸/切除或厚度方向缩放,并做 Face 结果校验。
│ └── [x] [复杂多边界端盖 -> 边界侧壁重建]
@@ -224,8 +292,8 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 两侧壁和圆弧端同步更新,结果仍能识别为槽/半孔。
│ ├── [x] [槽深 -> 改盲槽深度]
│ │ └── 槽底移动,槽口边界有效,不把母体切坏。
│ ├── [x] [矩形槽/口袋长宽/中心/深度 -> 局部重建矩形切除]
│ │ └── 规则矩形槽/口袋可改长宽、中心和深度;中心只支持在基准平面内移动。
│ ├── [x] [矩形槽/口袋长宽/深度 -> 局部重建矩形切除]
│ │ └── 规则矩形槽/口袋可改长宽和深度;中心移动后端保留但参数表暂不开放
│ ├── [x] [弧长/弧角/开口角 -> 改圆弧槽段]
│ │ └── 保持圆柱槽语义,重切后能回读目标弧长或角度。
│ ├── [x] [总长/中心距 -> 改长圆槽长度]
@@ -248,10 +316,10 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 规则矩形凸台顶面和规则矩形口袋底面可按高度/深度推拉,修改后会回读矩形拉伸尺寸。
│ ├── [x] [矩形凸台/口袋长度/宽度 -> 局部重建矩形包络]
│ │ └── 规则矩形凸台/口袋可先移除或补回旧包络,再按目标长宽重建;当前不在一次编辑中交换长宽方向。
│ ├── [x] [矩形凸台/口袋中心位置 -> 平面内移动并重建包络]
│ │ └── 规则矩形凸台/口袋可在基准平面内移动中心,长宽高度/深度保持不变;沿高度/深度方向移动会提前阻止
│ ├── [~] [矩形凸台/口袋中心位置 -> 后端保留,界面暂不开放]
│ │ └── 中心移动后端仍有计划和回归覆盖,但客户参数表暂时只开放长宽高度/深度这类更常用尺寸
│ ├── [x] [多台阶矩形凸台顶层规则台阶 -> 独立编辑]
│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽、中心和高度。
│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽和高度。
│ └── [x] [复杂多台阶凸台整体 -> 识别并快速阻止]
│ └── 多台阶中间过渡面会标记为受限,扫描列表、参数表和计划阶段都不暴露伪可改入口;整组联动、异形凸台和复杂融合边界仍未作为稳定能力。
@@ -276,8 +344,8 @@ STEP/B-Rep 参数化编辑主线
├── 6. Edge 一级编辑,补齐底层直接改边能力
│ ├── [x] [边长 -> 改直线 Edge 长度]
│ │ └── 支持只改当前边、移动端面、相邻圆柱和缩放所属对象等显式建模意图。
│ ├── [x] [起点/中心/终点 -> 移动直线 Edge]
│ │ └── 简单全平面模型可移动端点或整条边,并重建相邻面;计划和确认框会明确这是局部 Edge 变形,不等同于端面整体推拉
│ ├── [x] [起点/终点 -> 移动直线 Edge]
│ │ └── 简单全平面模型可移动端点并重建相邻面;中心移动后端保留但参数表暂不开放
│ ├── [x] [圆形 Edge 半径/直径 -> 代理到相邻圆柱]
│ │ └── 圆边优先复用孔/槽/凸台直径或轴心修改,而不是裸改曲线。
│ ├── [x] [椭圆 Edge 主/小半径 -> 单轴缩放]
@@ -291,7 +359,7 @@ STEP/B-Rep 参数化编辑主线
│ ├── [x] [平行/垂直 -> Face/Edge 一级事实识别]
│ │ └── Face 和 Edge 一级事实图会记录直接邻域内的平行、垂直、斜交关系数量和摘要;Face 事实图还会明确同域/共面碎片和一级同轴圆柱关系,供 UI 说明、计划守门和后续约束求解复用。
│ ├── [~] [平行/垂直 -> 通用可选约束]
│ │ └── Face 偏移可走受限拉伸/切除,Face 面内长度/宽度的“保持关系”会转成所属对象单向缩放,Face 中心会转成所属对象平移,Edge 长度可走端面推拉;这些路径都会要求一级平面关系可验证并做结果反查。跨多面、跨特征的通用约束求解仍需继续泛化。
│ │ └── Face 偏移可走受限拉伸/切除,Face 面内长度/宽度的“保持关系”会转成所属对象单向缩放,Edge 长度可走端面推拉;这些路径都会要求一级平面关系可验证并做结果反查。跨多面、跨特征的通用约束求解仍需继续泛化。
│ ├── [x] [复杂曲线 Edge -> 可编辑入口守门]
│ │ └── B-spline / Bezier / 其它复杂曲线不再在扫描列表或参数表里伪装成稳定“改长度”入口。
│ └── [ ] [任意曲线 Edge -> 通用约束编辑]
@@ -338,7 +406,7 @@ STEP/B-Rep 参数化编辑主线
Creo 资料的参考价值不只是统一术语,还要用来组织功能入口和操作流程。界面不应优先暴露底层 B-Rep 字段,而应先把选中对象映射到 CAD 用户熟悉的建模形式,例如拉伸/切除、偏移、孔、槽、倒圆角、倒角、抽壳、拔模、移动几何和缩放特征;只有当识别结果足够稳定,才把对应的可改尺寸放出来。
当前 Face 参数化修改已经接入隔离子进程执行,特征参数表面向用户开放 `拉伸/切除``偏移(局部重建/移动特征)``面内长度/面内宽度(局部重建或缩放特征)``中心(局部重建或移动特征)``壳体厚度(拉伸/切除或缩放特征)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径;面积仍作为诊断和校验结果保留,但不再作为表格里的可编辑驱动参数。主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。仍未归类到明确 Face、孔槽或 Edge 语义的复杂 fallback 会继续被稳定性保护阻止,后续按特征逐步开放。
当前 Face 参数化修改已经接入隔离子进程执行,特征参数表面向用户开放 `拉伸/切除``偏移(局部重建/移动特征)``面内长度/面内宽度(局部重建或缩放特征)``壳体厚度(拉伸/切除或缩放特征)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径;`中心` 移动后端能力暂时保留但不在特征参数表开放。面积仍作为诊断和校验结果保留,但不再作为表格里的可编辑驱动参数。主程序和 helper 进程之间优先使用内部 BREP 临时文件传递当前 B-Rep,避免每次参数化建模都先做一轮 STEP 导出/导入;对外导入和导出仍然保持 STEP。helper 进程负责执行危险 OCCT 计算并写回结果文件,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。仍未归类到明确 Face、孔槽或 Edge 语义的复杂 fallback 会继续被稳定性保护阻止,后续按特征逐步开放。
孔/槽一级关系的主要布尔/重建入口也开始复用同一条隔离执行通道,包括孔径、圆柱尺寸缩放、孔轴心、孔封堵、盲孔/盲槽深度、槽/半孔轴心、槽宽、槽深、圆弧长度、圆弧角度、长圆槽总长度和中心距。这样即使孔/槽局部重切在复杂 STEP 上失败、超时或触发 OCCT 崩溃,主界面和原模型也应保持不变。圆柱直径整体缩放和盲孔/盲槽深度整体缩放会在模型层做结果校验;如果结果虽然是有效 B-Rep,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。
@@ -396,11 +464,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
| 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 |
| --- | --- | --- | --- |
| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 |
| Face / 面 | 面内长度、面内宽度、中心、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 |
| Edge / 边 | 直线边的长度、起点、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边;直线 Edge 中心移动暂不在参数表开放。 |
| Face / 面 | 面内长度、面内宽度、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;按尺寸局部重建相邻面;或缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;Face 中心移动暂不在参数表开放;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 |
| 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 |
| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽、中心和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心沿深度方向移动暂不处理。 |
| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 |
| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心移动暂不在参数表开放。 |
| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形中心移动暂不在参数表开放。 |
| 圆角 / 倒角 | 部分已有圆角的半径/弧长;简单等半径圆角链半径/弧长;直线边新增圆角或倒角;变半径/复杂圆角链可识别并快速阻止。 | 已有简单圆角和简单等半径圆角链先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;变半径圆角链和复杂 blend 不进入危险重建。 | 大链式 blend、变半径圆角稳定重建、复杂角部补面、支撑面不明确的圆角。 |
| Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 |
@@ -410,13 +478,13 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 |
| --- | --- | --- |
| Face | 平面 Face 可以改 `面内长度``面内宽度``中心``偏移`;这些行会在 `建模意图` 里选择 `局部重建``拉伸/切除``移动特征``缩放特征`。面积是结果变量,只在诊断信息里展示。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除``缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面内长度/面内宽度` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动特征` 会平移所属特征或 Solid`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 |
| Face | 平面 Face 可以改 `面内长度``面内宽度``偏移`;这些行会在 `建模意图` 里选择 `局部重建``拉伸/切除``移动特征``缩放特征`。面积是结果变量,只在诊断信息里展示。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除``缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面内长度/面内宽度` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;`中心` 移动后端能力暂时保留,但客户参数表不开放;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 |
| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径``半径`,并通过 `建模意图` 选择 `只改孔/槽壁``缩放特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`缩放特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 |
| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `建模意图` 选择 `改底面深度``缩放特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`缩放特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`缩放特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁``缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽、中心和深度。复杂/交叉槽会识别为受限,不暴露伪可改槽参数。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心沿深度方向移动暂不处理`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁``缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽和深度。复杂/交叉槽会识别为受限,不暴露伪可改槽参数。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心移动暂不在参数表开放`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `建模意图` 里选择 `只改凸台``拉伸/切除端盖``移动凸台``缩放特征``移动特征`。多台阶矩形凸台的顶层规则台阶可以独立修改;中间过渡面会识别为复杂多台阶凸台并禁用伪可改参数。 | 复杂凸台、异形凸台、多台阶凸台整组联动和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 / 简单倒角面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,简单等半径圆角链可以改整链半径/弧长,并通过 `建模意图` 选择 `重建圆角``缩放特征``重建圆角` 会先移除旧圆角面或链上圆角面,再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。选中简单等距倒角斜面时可以改 `倒角距离`,当前同样走“移除旧斜面 -> 恢复锐边 -> 重新倒角”。相邻不同半径圆角 Face 会被标成变半径圆角链,计划阶段直接提示暂未实现稳定重建。 | 大链式 blend、复杂角部补面、支撑面不明确、变半径圆角稳定重建、复杂倒角链、不等距已有倒角或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍未作为稳定能力开放;复杂曲线、B-spline、Bezier 和无法确定局部变形区域的 Edge 不会在扫描列表或参数表里显示为可改长度入口;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍未作为稳定能力开放;复杂曲线、B-spline、Bezier 和无法确定局部变形区域的 Edge 不会在扫描列表或参数表里显示为可改长度入口;直线 Edge 中心坐标后端保留但参数表暂不开放;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径``参考直径``半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(缩放特征)``直径(缩放特征)`;环面可以改 `主半径(缩放特征)``主直径(缩放特征)``小半径(缩放特征)``小直径(缩放特征)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 |
@@ -486,7 +554,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。
- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划。
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 模型边线默认渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。
- 支持两点测量:
- 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。
@@ -596,7 +664,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。
- 封堵会用略带半径重叠的圆柱补料体执行 Fuse。
- 绿色半透明预览表示即将补上的材料范围。
- 当前默认模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。
- 当前常用测试模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。
- 当前版本支持圆柱凸台直径调整:
- 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。
- 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。
@@ -604,7 +672,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。
- 当前选中对象表的 `直径` / `半径` 会通过 `建模意图` 选择 `只改凸台``缩放特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。
- 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
- 当前常用测试模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
- 当前版本支持圆柱凸台高度和轴心坐标调整:
- 只对已识别为完整圆柱凸台、并找到可拉伸/切除端盖 Face 的候选开放。
- 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖拉伸/切除距离。
@@ -662,6 +730,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。
- 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。
- 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。
- 可编辑对象和圆柱面候选扫描已经在 `StepModel` 层做参数化缓存;同一模型、同一扫描范围重复打开候选列表时会直接复用结果,编辑、撤销/重做或重新加载模型后缓存会随拓扑刷新清空。
- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
- 当前版本支持圆柱孔/圆柱面扩大切削。
@@ -748,25 +817,45 @@ vertices: 3262
当前整体验证基线:
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage face`,覆盖 Face 专项套件和 Face isolated worker。
- 2026-08-11,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage face`,覆盖 R1 Face 专项套件、Face isolated worker、保持关系隔离契约和 Face 阶段收口口径。
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖孔/槽一级拓扑、局部重建和 isolated worker。
- 2026-08-11,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径。
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算和一级验收文档一致性。
Face 阶段的当前验收口径:
Face 阶段的当前验收口径R1 已收口)
- 当前阶段的 `一级关系` 定义为:选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为当前阶段的自动传播范围
- 选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示
- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度``面内宽度``中心``偏移` 这些用户可理解的名称,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词
- 已验收:平面 Face 的 `面内长度``面内宽度``偏移`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑;支持 `局部重建``拉伸/切除``移动特征``缩放特征` 和受限的 `保持关系``中心` 移动后端保留,但特征参数表暂不开放
- 已验收:Face 的 `一级关系` 定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为 R1 自动传播范围
- 已验收:选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域、同域/共面碎片、一级平行/垂直关系、一级同轴圆柱事实、边界 Edge/Vertex、共享边相邻 Face,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示
- 可用但受限:带内孔平面、曲面 Solid 上的平面、自由曲面、非矩形面、复杂端盖、接近切穿的内切等场景会按明确守门执行;能稳定改就走隔离/回滚,不能稳定改就提前 blocked 并说明原因
- 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动;这些属于 R8,不算 R1 未完成项。
- `python scripts\verify_first_level_edit_suites.py --stage face` 必须通过,集中运行 R1 Face 专项套件和 Face isolated worker。
- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、保持关系、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度``面内宽度``偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。
- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。
- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。一级关系用于计划、校验和诊断,不再占用特征参数表;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。
- `python scripts\verify_hole_slot_edit_suite.py` 必须通过,集中覆盖孔/槽一级拓扑、孔径/孔轴心/孔封堵、盲孔/盲槽深度、槽宽/槽深/弧长/弧角、长圆槽总长度/中心距、隔离执行、逻辑 Face ID 保持和属性表分组。
- `python scripts\verify_boss_edit_suite.py` 必须通过,集中覆盖凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理到凸台轴心移动,以及属性表分组。
- `python scripts\verify_round_chamfer_edit_suite.py` 必须通过,集中覆盖新增圆角/倒角、已有圆角重建、简单等半径圆角链重建、四面连通链重建、结果反查、失败回滚和一级邻域守门
- `python scripts\verify_shell_edit_suite.py` 必须通过,集中覆盖壳体厚度的局部拉伸/切除、缩放特征语义、Face 一级关系计划字段和结果校验
- 对不能稳定做 `局部重建` 的 Face,要禁用该建模意图,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形
- Face 一级关系闭环后,当前优先把槽/孔一级关系阶段补成独立闭环;槽/孔的一级邻域、局部重建和隔离执行稳定后,再继续推进 Edge 与其它特征阶段
孔/槽阶段的当前验收口径(R2/R3 已收口):
- 已验收:圆柱孔/盲孔的 `直径``半径``轴心``盲孔深度``封堵/删除孔`。孔径按同轴重切孔壁执行,轴心移动会先补旧孔再切新孔,盲孔深度会按加深切削或变浅补料处理,结果必须仍能回读目标孔特征
- 已验收:槽/半孔/长圆槽的 `槽宽``槽深``圆弧长度``圆弧角度``开口角度``轴心``总长度``中心距`。普通半圆槽按局部圆柱槽重切,长圆槽按两端圆弧配对后重建,修改后必须仍能识别为目标槽或半孔
- 已验收:孔/槽的 `一级关系` 定义为当前圆柱侧壁或槽端 Face、边界 Edge/Vertex、共享边直接相邻 Face,以及开口面、底面、槽边界面等一跳角色;孔/槽计划会明确二级、三级关系暂不自动传播
- 已验收:孔/槽局部布尔和重建会走隔离 worker,修改后会校验 B-Rep、单 Solid、目标几何回读和逻辑 Face ID 保持;失败时回滚,不破坏原模型
- 可用但受限:当前只承诺单个稳定孔、单个盲孔、单个半圆槽和可稳定配对的长圆槽;锥孔/沉孔只处理简单解析或局部重切路径,复杂浅锥、螺纹孔、组合孔、底面识别不可靠的盲孔会提前阻止。
- 可用但受限:复杂槽、交叉槽、多槽端配对不唯一时,只做识别和快速阻止,不在参数表暴露槽宽、槽深、弧长、弧角或轴心这些伪可改入口。
- 未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动、螺纹孔语义编辑、任意草图槽、跨复杂面槽、孔底/槽底带动台阶的二级/三级传播;这些属于 R8 或后续阶段,不算 R2/R3 未完成项。
- `python scripts\verify_first_level_edit_suites.py --stage hole-slot` 必须通过,集中运行 R2/R3 孔槽专项套件。
- `python scripts\verify_hole_slot_edit_suite.py` 必须通过,覆盖孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。
- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征会暴露侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。
- `python scripts\verify_hole_slot_isolated_edit.py` 必须通过,确保孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距都能通过隔离执行通道验证。
0~7 其它阶段的当前基线:
- R2/R3 孔槽已经拆成独立验收口径;后续只在真实 STEP 失败项上补识别、补守门、补局部重建,不把孔组、阵列孔、多槽组联动提前混进 0~7。
- `python scripts\verify_first_level_edit_suites.py --stage edge` 覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。任意曲线 Edge 通用约束编辑仍未开放。
- `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体厚度和解析曲面当前可用范围。复杂链式特征、通用抽壳历史恢复和跨特征传播仍不承诺。
- R2/R3 孔槽收口后,下一步优先把 Edge、凸台、圆角/倒角、壳体和解析曲面也按“已验收 / 可用但受限 / 未实现”继续收口。
## 怎么使用
@@ -847,7 +936,7 @@ Face 阶段的当前验收口径:
- `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模` 并确认
- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模`
- `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。
- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。
- 点击任意一行会自动选中并高亮对应Face或Edge。
@@ -905,9 +994,7 @@ Face 阶段的当前验收口径:
- `缩放特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。
- `面内长度` / `面内宽度` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。面积会在诊断信息中作为结果值展示。
- `中心`
- 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `建模意图` 选择改法
- `局部重建` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。
- `移动特征` 会把目标中心换算成平移向量,并平移这个 Face 所属的特征或 Solid;Face 自身形状和所属对象内部尺寸不变。
- 后端保留了 Face 中心移动能力和回归基线,但模型工程师认为当前作用不大,特征参数表暂时不开放这一行
- `孔直径` + `调整圆柱孔径`
- 先选择一个圆柱面。
- 输入新的直径。
@@ -1138,7 +1225,7 @@ Face 阶段的当前验收口径:
导出:
- `导出当前完整 STEP`:导出当前完整模型。
- `导出模型`:导出当前完整模型。
- `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。
@@ -1223,7 +1310,7 @@ pythonocc-step-editor/
operations.py # 拉伸/切除、孔径、孔深、圆角、倒角、边长等实际编辑操作
polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成
records.py # 操作历史记录数据结构
step_io.py # STEP/XCAF 读取、产品名解析STEP 写出
step_io.py # STEP/XCAF 读取、产品名解析STEP 写出和内部 BREP 临时交换
transforms.py # 零件 / Solid 平移和旋转
ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具
widgets.py # 自定义 Qt 小组件
@@ -1317,7 +1404,7 @@ git diff --check
### 项目结构提示
- `main.py`:入口,保持 `python main.py` 可启动。
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。常用测试模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`
- `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。
@@ -1348,7 +1435,7 @@ git diff --check
- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面内尺寸、偏移和壳体厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把偏移移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。
- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体面内尺寸、局部/整体中心、局部/整体偏移和拉伸/切除正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面;历史面积路径只作为后端结果绑定回归保留。
- `scripts/verify_selection_identity_ui.py`:验证编辑后当前拓扑 Face ID 改变时,Face / 特征标题会显示 `逻辑 Face ID(拓扑 Face ID`,复制 ID 会优先复制可再次选择的逻辑 Face ID,避免把用户带到重建后的临时拓扑编号上。
- `scripts/verify_face_feature_parameter_consistency.py`:加载默认模型 Face 594,验证轻量 `当前特征` 选择在 full feature 缓存前后都显示同一组基础 Face 参数,避免先改其它对象或触发深层识别后,同一个 Face 的属性列表突然缩水或膨胀。
- `scripts/verify_face_feature_parameter_consistency.py`:加载常用测试模型 Face 594,验证轻量 `当前特征` 选择在 full feature 缓存前后都显示同一组基础 Face 参数,避免先改其它对象或触发深层识别后,同一个 Face 的属性列表突然缩水或膨胀。
- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `局部重建``缩放特征``移动特征``拉伸/切除` 语义,在中心、面内尺寸和偏移场景下都能保持单 Solid;同时生成斜顶楔块,验证斜交一级相邻平面会提前阻止 `保持关系`
- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面内长度/面内宽度、中心坐标和偏移在 `当前面` 局部编辑、`整体` 所属对象编辑、`拉伸/切除``保持关系` 之间不会互相混淆;面积路径只作为后端缩放语义回归保留。
- `scripts/verify_face_noop_guards.py`:验证 Face 的拉伸/切除、偏移、中心、面内尺寸和壳体厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。
@@ -1458,7 +1545,7 @@ Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过
壳体厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3``2 -> 1``拉伸/切除` 会保持相对面不动,`缩放特征` 会保持厚度中心基本不动。
解析曲面基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过五条路线:简单圆锥参考半径 `4 -> 5`、简单圆锥半角 `11.3099° -> 16°`、球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;简单圆锥优先解析重建,嵌入式锥孔/沉孔的半角和参考半径由 `scripts/verify_cone_semi_angle_isolation.py` 覆盖局部重切与隔离执行;默认模型里的复杂浅锥/拔模面 `1° -> 10°``1° -> 50°` 会被提前禁用或阻断,避免生成无效 B-Rep。
解析曲面基线验证:`scripts/verify_analytic_surface_resize.py` 会临时生成圆锥、球和环面。当前验证通过五条路线:简单圆锥参考半径 `4 -> 5`、简单圆锥半角 `11.3099° -> 16°`、球面半径 `5 -> 6.25`、环面主半径 `8 -> 10`、环面小半径 `2 -> 3`。球面和环面缩放后仍能识别为解析面;简单圆锥优先解析重建,嵌入式锥孔/沉孔的半角和参考半径由 `scripts/verify_cone_semi_angle_isolation.py` 覆盖局部重切与隔离执行;常用测试模型里的复杂浅锥/拔模面 `1° -> 10°``1° -> 50°` 会被提前禁用或阻断,避免生成无效 B-Rep。
椭圆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,所以脚本用主/小轴采样半径校验几何结果。
@@ -17,10 +17,10 @@ FACE_ID = 594
BASE_FACE_KEYS = (
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
)
RESULT_ONLY_KEYS = ("area",)
TEMPORARILY_HIDDEN_KEYS = ("face_center_position",)
class _Probe(WindowStateMixin):
@@ -130,6 +130,7 @@ def main() -> int:
before_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(before_cached_rows, BASE_FACE_KEYS, "Face 594 before full feature cache")
_assert_absent(before_cached_rows, RESULT_ONLY_KEYS, "Face 594 before full feature cache")
_assert_absent(before_cached_rows, TEMPORARILY_HIDDEN_KEYS, "Face 594 before full feature cache")
full_info = model.feature_info(FACE_ID)
if full_info.get("shell_region_status") == "candidate":
@@ -142,6 +143,7 @@ def main() -> int:
after_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(after_cached_rows, BASE_FACE_KEYS, "Face 594 after full feature cache")
_assert_absent(after_cached_rows, RESULT_ONLY_KEYS, "Face 594 after full feature cache")
_assert_absent(after_cached_rows, TEMPORARILY_HIDDEN_KEYS, "Face 594 after full feature cache")
if before_cached_rows != after_cached_rows:
raise AssertionError(
"Face 594 current-only feature rows changed after full recognition cache: "
+8 -2
View File
@@ -19,8 +19,10 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_local_keep_relations",
"resize_face_size_owning_scale",
"move_face_center_local",
"move_face_center_local_keep_relations",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
@@ -127,9 +129,10 @@ PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
"move_selected_face_center_local": "move_face_center_local",
"resize_shell_thickness": "resize_shell_thickness",
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
"resize_cylinder_height": "resize_cylindrical_height",
@@ -157,8 +160,12 @@ ACTION_IMPLEMENTATION_METHOD = {
"push_pull_face_keep_relations": "_push_pull_face_action",
"resize_face_width_local": "_resize_face_size_local",
"resize_face_height_local": "_resize_face_size_local",
"resize_face_width_keep_relations": "_resize_face_size_local",
"resize_face_height_keep_relations": "_resize_face_size_local",
"resize_face_width_owning_scale": "_resize_face_size_owning_scale",
"resize_face_height_owning_scale": "_resize_face_size_owning_scale",
"move_selected_face_center_local": "_move_selected_face_center_local",
"move_selected_face_center_keep_relations": "_move_selected_face_center_local",
"resize_torus_major_radius": "_resize_torus_radius",
"resize_torus_minor_radius": "_resize_torus_radius",
}
@@ -274,7 +281,6 @@ def _face_property_actions_from_specs() -> set[str]:
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
},
),
+2 -4
View File
@@ -51,12 +51,11 @@ def main() -> int:
"feature_guess": "round/fillet candidate",
"angular_span": math.pi / 2.0,
},
("existing_fillet_radius_estimate",),
("existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"),
)
assert_keys(
{"surface": "plane"},
(
"area",
"local_face_width",
"local_face_height",
"face_center_position",
@@ -66,7 +65,6 @@ def main() -> int:
assert_keys(
{"surface": "plane", "shell_region_status": "candidate"},
(
"area",
"local_face_width",
"local_face_height",
"face_center_position",
@@ -123,7 +121,7 @@ def main() -> int:
},
)
filtered_keys = tuple(str(spec.get("key")) for spec in filtered)
if filtered_keys != ("diameter", "hole_edit_semantics"):
if filtered_keys != ("diameter",):
raise AssertionError(f"unexpected filtered feature parameters: {filtered_keys}")
print("feature parameter policy ok")
@@ -272,6 +272,34 @@ def _verify_user_priority_scan_order(root: Path) -> None:
)
def _verify_candidate_scan_cache(root: Path) -> None:
path = root / "candidate_cache.step"
_write_through_hole_model(path)
model = StepModel.load(path)
first = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
_assert(first, "editable candidate cache probe returned no candidates")
cache = getattr(model, "_editable_feature_candidates_cache", {})
_assert(cache, "editable candidate scan should populate the model-level cache")
first[0]["operation_key"] = "mutated-by-caller"
second = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
_assert(
second[0].get("operation_key") != "mutated-by-caller",
"editable candidate cache should return defensive copies",
)
cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
_assert(cylinders, "cylindrical candidate cache probe returned no candidates")
cylinder_cache = getattr(model, "_cylindrical_feature_candidates_cache", {})
_assert(cylinder_cache, "cylindrical candidate scan should populate the model-level cache")
cylinders[0]["feature_guess"] = "mutated-by-caller"
second_cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
_assert(
second_cylinders[0].get("feature_guess") != "mutated-by-caller",
"cylindrical candidate cache should return defensive copies",
)
def _verify_ellipse_edge_scan_entries(root: Path) -> None:
path = root / "ellipse_edge_scan.step"
_write_ellipse_face_model(path)
@@ -405,6 +433,7 @@ def main() -> int:
_verify_boss_summary(root)
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_candidate_scan_cache(root)
_verify_ellipse_edge_scan_entries(root)
_verify_complex_slot_guard(root)
_verify_mixed_radius_fillet_chain_guard(root)
@@ -52,9 +52,22 @@ def _verify_readme_mentions(readme: str) -> None:
"`[x]` 已实现",
"`[~]` 部分实现/进行中",
"`[ ]` 未实现",
"`[x]` 不是“所有 CAD 形态都能改”",
"R1 Face 是当前主线的第一阶段收口对象",
"R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里",
"STEP/B-Rep 参数化编辑主线",
"[不能修改 -> 立即说明原因]",
"[一级影响范围 -> 明确显示]",
"Face 阶段的当前验收口径(R1 已收口)",
"已验收:平面 Face 的 `面内长度`、`面内宽度`、`偏移`",
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
"孔/槽阶段的当前验收口径(R2/R3 已收口)",
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
"已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`",
"未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动",
"2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`",
"覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径",
"0~7 其它阶段的当前基线",
"verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot",
-3
View File
@@ -417,7 +417,6 @@ def main() -> int:
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 2)"),
),
)
@@ -438,7 +437,6 @@ def main() -> int:
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 8)"),
),
)
@@ -499,7 +497,6 @@ def main() -> int:
("local_face_width", "长度", "6"),
("local_face_height", "宽度", "4"),
("shell_thickness_estimate", "高度/深度", "2"),
("face_center_position", "中心", "(15, 10, 10)"),
),
)
+105 -4
View File
@@ -1,15 +1,18 @@
from __future__ import annotations
import json
import math
import os
from pathlib import Path
import sys
import tempfile
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QEvent, QObject
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
@@ -30,8 +33,10 @@ from step_editor.window_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import (
PROPERTY_CURRENT_COLUMN,
PROPERTY_INPUT_COLUMN,
PROPERTY_LABEL_COLUMN,
PROPERTY_SCOPE_COLUMN,
PROPERTY_TABLE_HEADERS,
PROPERTY_TARGET_COLUMN,
WindowStateMixin,
)
@@ -66,8 +71,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
layout = QVBoxLayout(self)
self.object_edit_box = self
self.property_table = QTableWidget(0, 4)
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"])
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS))
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
layout.addWidget(self.property_table)
self.property_card_scroll = QScrollArea()
@@ -85,6 +90,7 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.property_command_help_label = QLabel()
self.current_capability_headline = QLabel()
self.apply_property_button = QPushButton()
self.export_parameters_button = QPushButton()
@staticmethod
def _plane_info() -> dict[str, object]:
@@ -121,6 +127,44 @@ class _ActionMessageProbe(WindowActionMixin):
pass
class _ParameterExportActionProbe(WindowActionMixin):
def __init__(self, output_path: Path) -> None:
self.output_path = output_path
self.status_bar = _StatusBarProbe()
self.info_text = ""
self.export_state_updates = 0
def _parameter_export_output_path(self) -> Path:
return self.output_path
def _selected_parameter_export_rows(self) -> list[dict[str, str]]:
return [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "151",
},
{
"name": "偏移",
"displayName": "偏移",
"type": "number",
"ioRole": "input",
"default": "57.5",
},
]
def _update_parameter_export_state(self) -> None:
self.export_state_updates += 1
def statusBar(self) -> _StatusBarProbe:
return self.status_bar
def set_plain_info(self, text: str) -> None:
self.info_text = text
class _TimerProbe:
def stop(self) -> None:
pass
@@ -240,7 +284,7 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
probe.property_table.horizontalHeaderItem(column).text()
for column in range(probe.property_table.columnCount())
]
_assert(headers == ["尺寸参数", "当前值", "建模意图", "目标值"], f"unexpected table headers: {headers}")
_assert(headers == list(PROPERTY_TABLE_HEADERS), f"unexpected table headers: {headers}")
_assert(probe.property_table.rowCount() == len(probe.property_editor_specs), "table row count should match specs")
header_height = int(probe.property_table.horizontalHeader().height())
frame = int(probe.property_table.frameWidth()) * 2
@@ -262,8 +306,10 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()]
for label in ("面内长度", "面内宽度", "中心", "偏移"):
for label in ("面内长度", "面内宽度", "偏移"):
_assert(label in actionable_labels, f"feature parameter table did not expose {label}")
_assert("中心" not in actionable_labels, "center should be temporarily hidden from editable parameters")
_assert("中心" not in table_labels, "center should not appear in the feature parameter table")
for legacy_label in ("面积", "U向尺寸", "V向尺寸", "偏移变换"):
_assert(legacy_label not in actionable_labels, f"{legacy_label} should not be exposed as an editable parameter")
_assert(legacy_label not in table_labels, f"{legacy_label} should not appear in the feature parameter table")
@@ -273,13 +319,38 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
target_row = _row_by_label(probe, "面内长度")
target_widget = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN)
scope_widget = probe.property_table.cellWidget(target_row, PROPERTY_SCOPE_COLUMN)
input_checkbox = probe._property_input_checkbox(target_row)
_assert(isinstance(target_widget, QLineEdit), "editable table row should have a target editor")
_assert(isinstance(scope_widget, NoWheelComboBox), "editable table row should have a modeling-intent combo")
_assert(isinstance(input_checkbox, QCheckBox), "editable table row should have an input-parameter checkbox")
_assert(not input_checkbox.isChecked(), "input-parameter checkbox should be unchecked by default")
_assert(
probe.property_table.cellWidget(target_row, PROPERTY_INPUT_COLUMN) is not None,
"input-parameter checkbox should be hosted in the input column",
)
_assert(not probe.export_parameters_button.isEnabled(), "parameter export button should start disabled")
_assert(
not probe.property_table.findChildren(QPushButton),
"feature parameter table should not contain per-row apply buttons",
)
input_checkbox.setChecked(True)
QApplication.processEvents()
selected_rows = probe._selected_parameter_export_rows()
_assert(probe.export_parameters_button.isEnabled(), "parameter export button should enable after a row is checked")
_assert(
selected_rows == [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "10",
}
],
f"unexpected parameter export payload: {selected_rows}",
)
target_widget.setText("12")
probe._update_property_apply_state()
changed = probe._changed_property_rows()
@@ -470,6 +541,35 @@ def _assert_user_facing_failure_messages() -> None:
_assert("隔离子进程" not in empty_message and "子进程" not in empty_message, "empty internal failure should stay user-facing")
def _assert_parameter_export_action() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "data.json"
probe = _ParameterExportActionProbe(output_path)
probe.export_selected_parameters()
payload = json.loads(output_path.read_text(encoding="utf-8"))
_assert(
payload == [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "151",
},
{
"name": "偏移",
"displayName": "偏移",
"type": "number",
"ioRole": "input",
"default": "57.5",
},
],
f"parameter export action wrote unexpected JSON: {payload}",
)
_assert(probe.status_bar.messages and "已导出 2 个输入参数" in probe.status_bar.messages[-1], "parameter export status should mention exported count")
_assert("data.json" in probe.info_text and "参数数量:2" in probe.info_text, "parameter export info panel summary should be useful")
def main() -> int:
app = QApplication.instance() or QApplication([])
top_level_label_probe = _TopLevelPropertyLabelProbe()
@@ -497,6 +597,7 @@ def main() -> int:
_assert_mouse_selection_guards()
_assert_quick_blind_depth_spec()
_assert_user_facing_failure_messages()
_assert_parameter_export_action()
print("property table editor UI ok")
if QApplication.instance() is app:
+13 -8
View File
@@ -335,17 +335,21 @@ def _assert_target_change_detection() -> None:
def _assert_property_table_column_widths() -> None:
for width in (320, 340, 360, 400, 520):
columns = _property_table_column_widths(width)
if len(columns) != 4:
raise SystemExit(f"property table should have four column widths, got {columns}")
if len(columns) != 5:
raise SystemExit(f"property table should have five column widths, got {columns}")
if sum(columns) != width:
raise SystemExit(f"property table widths should fill viewport {width}, got {columns} sum={sum(columns)}")
label_width, current_width, scope_width, target_width = columns
if current_width < 96:
label_width, current_width, scope_width, target_width, input_width = columns
if label_width < 58:
raise SystemExit(f"dimension name column should stay visible at {width}: {columns}")
if current_width < 64:
raise SystemExit(f"current value column should stay readable at {width}: {columns}")
if target_width < 56:
if target_width < 54:
raise SystemExit(f"target value column should stay usable at {width}: {columns}")
if scope_width < 44:
if scope_width < 52:
raise SystemExit(f"modeling-intent column should stay usable at {width}: {columns}")
if input_width < 48:
raise SystemExit(f"input-parameter checkbox column should stay usable at {width}: {columns}")
def _assert_holed_plane_local_scopes_disabled() -> None:
@@ -465,6 +469,8 @@ def main() -> int:
plane_display_specs = _display_specs(plane_info)
if any(str(spec.get("key", "")) == "area" for spec in plane_display_specs):
raise SystemExit("Face property table should keep area in diagnostics, not in the parameter table")
if any(str(spec.get("key", "")) == "face_center_position" for spec in plane_display_specs):
raise SystemExit("Face property table should temporarily hide center editing")
_assert_keys_absent(
plane_display_specs,
(
@@ -473,6 +479,7 @@ def main() -> int:
"face_first_level_topology",
"face_edit_semantics",
"feature_context_note",
"face_center_position",
),
"plane Face display specs",
)
@@ -530,7 +537,6 @@ def main() -> int:
(
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
),
"plane Face display order",
@@ -555,7 +561,6 @@ def main() -> int:
expected_plane_feature_keys = {
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
}
missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys
+52 -17
View File
@@ -54,7 +54,7 @@ from .info_panel import InfoPanelMixin
from .ui_helpers import * # noqa: F403
from .window_actions import WindowActionMixin
from .window_core import WindowCoreMixin
from .window_state import WindowStateMixin
from .window_state import PROPERTY_TABLE_HEADERS, WindowStateMixin
_CRASH_LOG_HANDLE = None
@@ -145,7 +145,7 @@ def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = False):
def __init__(self, step_path: str | Path | None = None, *, background_load: bool = False):
_suppress_vtk_output_window()
super().__init__()
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
@@ -154,7 +154,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.resize(1280, 820)
self.model: StepModel | None = None
self.step_path = Path(step_path)
self.step_path = Path(step_path) if step_path else None
self.selected_kind: str | None = None
self.selected_part_id: int | None = None
self.selected_solid_id: int | None = None
@@ -585,6 +585,32 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #8f99a8;
font-weight: 650;
}
QPushButton#exportParametersButton {
background: #f0fdfa;
border: 1px solid #0f766e;
border-bottom-color: #115e59;
border-radius: 6px;
color: #134e4a;
font-weight: 750;
min-height: 30px;
padding: 6px 11px;
}
QPushButton#exportParametersButton:hover {
background: #ccfbf1;
border-color: #0d9488;
}
QPushButton#exportParametersButton:pressed {
background: #99f6e4;
border-color: #0f766e;
padding-top: 7px;
padding-bottom: 5px;
}
QPushButton#exportParametersButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
font-weight: 650;
}
QPushButton#propertyRowEditButton {
background: #ea580c;
border: 1px solid #c2410c;
@@ -885,12 +911,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.open_button.setMinimumWidth(96)
help_tip(self.open_button, "选择并导入一个 .step 或 .stp 几何模型。打开失败时会保留当前模型。")
self.open_button.clicked.connect(self.open_step)
self.path_label = QLineEdit(str(self.step_path))
self.path_label = QLineEdit(str(self.step_path) if self.step_path is not None else "")
self.path_label.setObjectName("stepPathDisplay")
self.path_label.setReadOnly(True)
self.path_label.setMinimumWidth(120)
help_tip(self.path_label, "当前 STEP 文件的完整路径。可以选中文字复制路径。")
self.path_label.setToolTip(str(self.step_path))
self.path_label.setPlaceholderText("未选择 STEP 文件")
self.path_label.setToolTip(str(self.step_path) if self.step_path is not None else "未选择 STEP 文件")
self.path_label.setCursorPosition(0)
self.reload_button = QPushButton("读取模型")
self.reload_button.setMinimumWidth(78)
@@ -1086,7 +1113,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_box.setObjectName("exportSection")
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
export_layout = QVBoxLayout(export_box)
self.export_all_button = QPushButton("导出当前完整 STEP")
self.export_all_button = QPushButton("导出模型")
help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。")
self.export_all_button.clicked.connect(self.export_all)
self.export_part_button = QPushButton("导出选中零件")
@@ -1132,9 +1159,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
object_edit_layout = QVBoxLayout(self.object_edit_box)
object_edit_layout.setContentsMargins(0, 8, 0, 4)
object_edit_layout.setSpacing(4)
self.property_table = QTableWidget(0, 4, self.object_edit_box)
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS), self.object_edit_box)
self.property_table.setObjectName("propertyTable")
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"])
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True)
@@ -1153,11 +1180,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 148)
self.property_table.setColumnWidth(1, 92)
self.property_table.setColumnWidth(2, 112)
self.property_table.setColumnWidth(3, 96)
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
self.property_table.setColumnWidth(0, 112)
self.property_table.setColumnWidth(1, 104)
self.property_table.setColumnWidth(2, 88)
self.property_table.setColumnWidth(3, 82)
self.property_table.setColumnWidth(4, 66)
self.property_table.installEventFilter(self)
help_tip(
self.property_table,
@@ -1217,14 +1245,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的一个参数;一次只执行一个几何修改,成功后可撤销。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出当前完整STEP")
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
self.quick_export_all_button.setMinimumHeight(34)
self.quick_export_all_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。")
self.quick_export_all_button.clicked.connect(self.export_all)
self.export_parameters_button = QPushButton("导出参数")
self.export_parameters_button.setObjectName("exportParametersButton")
self.export_parameters_button.setMinimumHeight(34)
self.export_parameters_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.export_parameters_button, "把已勾选为输入参数的尺寸行导出为 data.json。")
self.export_parameters_button.clicked.connect(self.export_selected_parameters)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
property_action_row.addWidget(self.export_parameters_button)
object_edit_layout.addLayout(property_action_row)
edit_box = QGroupBox(panel)
@@ -1741,10 +1776,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def _parse_args(argv: list[str]) -> tuple[Path | None, bool]:
smoke_test = "--smoke-test" in argv
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
path = Path(paths[0]) if paths else DEFAULT_MODEL_PATH
path = Path(paths[0]) if paths else None
return path, smoke_test
@@ -1764,7 +1799,7 @@ def main() -> int:
app.setApplicationDisplayName("几何参数化")
app.setOrganizationName("GeometryParametric")
app.setWindowIcon(_application_icon())
window = StepEditorWindow(path, background_load=not smoke_test)
window = StepEditorWindow(path, background_load=bool(path) and not smoke_test)
if smoke_test:
print("smoke test ok")
window.close()
+4 -1
View File
@@ -61,7 +61,7 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .step_io import _prepare_shape_for_step_export, _write_step
from .step_io import _prepare_shape_for_step_export, _write_brep, _write_step
class ExportMixin:
@@ -71,6 +71,9 @@ class ExportMixin:
)
_write_step(export_shape, Path(filename))
def export_internal_brep(self, filename: str | Path) -> None:
_write_brep(self.shape, Path(filename))
def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]:
if scope == "all":
return _shape_quality_info("当前完整模型", self.shape, expect_solid=False)
+36 -6
View File
@@ -288,6 +288,19 @@ class FeatureMixin:
max_scan_edges: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
normalized_max_edges = None if max_scan_edges is None else max(0, int(max_scan_edges))
cache_key = (
"editable",
limit,
bool(detailed),
normalized_max_faces,
normalized_max_edges,
)
cache = getattr(self, "_editable_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
per_type_limit = max(1, limit // 5)
candidates: list[dict[str, object]] = []
@@ -315,7 +328,7 @@ class FeatureMixin:
for item in self.cylindrical_feature_candidates(
limit=cylinder_scan_limit,
include_end_info=True,
max_scan_faces=max_scan_faces,
max_scan_faces=normalized_max_faces,
progress_callback=progress_callback,
):
feature_guess = str(item["feature_guess"])
@@ -680,7 +693,7 @@ class FeatureMixin:
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
shell_candidate_rows: list[dict[str, object]] = []
shell_scan_pool_limit = max(shell_thickness_limit * 4, 12)
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
@@ -832,7 +845,7 @@ class FeatureMixin:
ellipse_edge_major_radius_count = 0
ellipse_edge_minor_radius_count = 0
edge_type_limit = max(1, per_type_limit // 3)
edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges)))
edge_scan_limit = len(self.edges) if normalized_max_edges is None else min(len(self.edges), normalized_max_edges)
for edge_id, edge in enumerate(self.edges[:edge_scan_limit]):
if progress_callback is not None and edge_id % 80 == 0:
progress_callback()
@@ -995,7 +1008,10 @@ class FeatureMixin:
for candidate in candidates:
candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0]
candidates.sort(key=feature_recognition_sort_key)
return candidates[:limit]
result = [dict(item) for item in candidates[:limit]]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_feature_candidates(
self,
@@ -1004,8 +1020,19 @@ class FeatureMixin:
max_scan_faces: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
cache_key = (
"cylinder",
limit,
bool(include_end_info),
normalized_max_faces,
)
cache = getattr(self, "_cylindrical_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
candidates: list[dict[str, object]] = []
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
@@ -1075,7 +1102,10 @@ class FeatureMixin:
candidates.append(candidate)
if len(candidates) >= limit:
break
return candidates
result = [dict(item) for item in candidates]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
+10
View File
@@ -625,7 +625,16 @@ def _axis_aligned_edge_candidates(
return [edge for _score, edge in candidates]
def _enable_occt_builder_parallel(builder) -> None:
if hasattr(builder, "SetRunParallel"):
try:
builder.SetRunParallel(True)
except Exception:
pass
def _finalize_boolean_result(op, operation_name: str, *, use_glue: bool | None = None) -> TopoDS_Shape:
_enable_occt_builder_parallel(op)
op.SetNonDestructive(True)
glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue)
if glue_enabled and hasattr(op, "SetGlue"):
@@ -668,6 +677,7 @@ def _simplify_boolean_builder(builder) -> None:
def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape:
_enable_occt_builder_parallel(builder)
builder.Build()
if hasattr(builder, "IsDone") and not builder.IsDone():
raise RuntimeError(f"{operation_name} operation failed.")
+23 -2
View File
@@ -144,6 +144,24 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
raise ValueError(f"Unsupported isolated edit operation: {operation}")
def _load_request_model(path: Path, file_format: str) -> StepModel:
if file_format == "brep":
return StepModel.load_internal_brep(path)
if file_format == "step":
return StepModel.load(path)
raise ValueError(f"Unsupported isolated edit input format: {file_format}")
def _export_response_model(model: StepModel, path: Path, file_format: str) -> None:
if file_format == "brep":
model.export_internal_brep(path)
return
if file_format == "step":
model.export_all(path)
return
raise ValueError(f"Unsupported isolated edit output format: {file_format}")
def run_request(request: str | Path) -> int:
request_path = Path(request)
response_path = request_path.with_suffix(".response.json")
@@ -153,10 +171,12 @@ def run_request(request: str | Path) -> int:
output_path = Path(str(request["output_path"]))
operation = str(request["operation"])
args = list(request.get("args") or [])
input_format = str(request.get("input_format") or "step").strip().lower()
output_format = str(request.get("output_format") or input_format).strip().lower()
model = StepModel.load(input_path)
model = _load_request_model(input_path, input_format)
message = _execute(model, operation, args)
model.export_all(output_path)
_export_response_model(model, output_path, output_format)
response_path.write_text(
json.dumps(
{
@@ -164,6 +184,7 @@ def run_request(request: str | Path) -> int:
"message": message,
"stats": model.stats().__dict__,
"output_path": str(output_path),
"output_format": output_format,
},
ensure_ascii=False,
indent=2,
+19
View File
@@ -90,6 +90,7 @@ from .transforms import TransformMixin
from .geometry_utils import * # noqa: F403
from .model_types import PartNode, TopologyStats
from .step_io import (
_load_brep_shape,
_load_plain_step,
_load_with_xcaf,
_parse_product_names,
@@ -130,6 +131,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
self._editable_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
self._cylindrical_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
self._face_polydata_cache: dict[tuple[object, ...], object] = {}
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
self._polydata_cache_limit = 96
@@ -137,6 +140,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_mesh_deflections: dict[int, float] = {}
self.refresh_topology()
@classmethod
def from_shape(cls, filename: str | Path, shape: TopoDS_Shape, *, name: str | None = None) -> "StepModel":
path = Path(filename)
part_name = name or path.stem or "model"
parts = [PartNode(1, part_name, "part", shape, path=part_name)]
return cls(path, parts, shape)
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
@@ -151,6 +161,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
@classmethod
def load_internal_brep(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
return cls.from_shape(path, _load_brep_shape(path), name=path.stem)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
@@ -220,6 +237,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
self._same_domain_duplicate_edge_ids_cache = None
self._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear()
self._face_polydata_cache.clear()
self._edge_polydata_cache.clear()
self._mesh_deflection = None
+20
View File
@@ -4,6 +4,8 @@ import re
from pathlib import Path
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRep import BRep_Builder
from OCC.Core.BRepTools import breptools
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
@@ -126,6 +128,24 @@ def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
raise IOError(f"Could not write STEP file: {filename}")
def _load_brep_shape(filename: str | Path) -> TopoDS_Shape:
path = Path(filename)
shape = TopoDS_Shape()
builder = BRep_Builder()
if not breptools.Read(shape, str(path), builder) or shape.IsNull():
raise IOError(f"Could not read BREP file: {path}")
return shape
def _write_brep(shape: TopoDS_Shape, filename: str | Path) -> None:
if shape.IsNull():
raise ValueError("Cannot export a null shape.")
path = Path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
if not breptools.Write(shape, str(path)):
raise IOError(f"Could not write BREP file: {path}")
def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape:
if shape.IsNull():
return shape
+193 -29
View File
@@ -7,9 +7,10 @@ from pathlib import Path
import subprocess
import sys
import tempfile
import time
import vtk
from PySide6.QtCore import Qt, QThread, Slot
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
@@ -84,7 +85,78 @@ def _compact_user_message(value: object, limit: int = 360) -> str:
return f"{text[: max(0, limit - 1)].rstrip()}..."
def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
if not isinstance(timings, dict) or not timings:
return ""
labels = {
"snapshot": "快照",
"precheck": "预检",
"edit_geometry": "几何计算",
"isolated_export": "内部传模",
"isolated_worker": "子进程计算",
"isolated_result_load": "读取结果",
"validate": "结果校验",
"display_faces": "面显示",
"display_edges": "边线",
"finish_ui": "界面刷新",
"total": "总计",
}
rows: list[tuple[str, float]] = []
for key, value in timings.items():
if key == "total":
continue
try:
seconds = float(value)
except (TypeError, ValueError):
continue
if seconds >= 0.05:
rows.append((labels.get(str(key), str(key)), seconds))
rows.sort(key=lambda item: item[1], reverse=True)
parts = [f"{label} {seconds:.1f}s" for label, seconds in rows[:limit]]
try:
total = float(timings.get("total"))
except (TypeError, ValueError):
total = 0.0
if total >= 0.05:
parts.append(f"总计 {total:.1f}s")
return "".join(parts)
class WindowActionMixin:
def _empty_edge_polydata(self):
polydata = vtk.vtkPolyData()
polydata.SetPoints(vtk.vtkPoints())
polydata.SetLines(vtk.vtkCellArray())
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
polydata.GetCellData().AddArray(edge_arr)
polydata.GetCellData().AddArray(part_arr)
return polydata
def _parameter_export_output_path(self) -> Path:
return Path(__file__).resolve().parent.parent / "data.json"
def export_selected_parameters(self) -> None:
rows = self._selected_parameter_export_rows() if hasattr(self, "_selected_parameter_export_rows") else []
if not rows:
if hasattr(self, "_update_parameter_export_state"):
self._update_parameter_export_state()
self.statusBar().showMessage("请先在“输入参数”列勾选至少一个尺寸参数。")
return
output_path = self._parameter_export_output_path()
try:
output_path.write_text(json.dumps(rows, ensure_ascii=False, indent=4), encoding="utf-8")
except OSError as exc:
QMessageBox.warning(self, "导出参数失败", f"无法写入 {output_path.name}{exc}")
return
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数到 {output_path.name}")
if hasattr(self, "set_plain_info"):
names = "".join(str(row.get("displayName", "")) for row in rows[:8] if row.get("displayName"))
suffix = "……" if len(rows) > 8 else ""
self.set_plain_info(f"已导出参数文件:{output_path}\n参数数量:{len(rows)}\n参数:{names}{suffix}")
def export_all(self) -> None:
if self.model is None:
return
@@ -92,10 +164,11 @@ class WindowActionMixin:
return
if not self._confirm_export_quality("all"):
return
source_path = self.step_path if isinstance(getattr(self, "step_path", None), Path) else Path.cwd() / "model.step"
target, _ = QFileDialog.getSaveFileName(
self,
"导出当前完整 STEP",
str(self.step_path.parent / f"{self.step_path.stem}_edited.step"),
"导出模型",
str(source_path.parent / f"{source_path.stem}_edited.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
@@ -5999,6 +6072,19 @@ class WindowActionMixin:
return self.model.resize_face_size_local_keep_relations(face_id, target_size, axis_key)
return self.model.resize_face_size_local(face_id, target_size, axis_key)
if keep_relations:
isolation = self._isolation_for_plan(
plan,
"resize_face_size_local_keep_relations",
[face_id, target_size, axis_key],
)
else:
isolation = self._isolation_for_plan(
plan,
"resize_face_size_local",
[face_id, target_size, axis_key],
)
self._run_edit_action(
action,
operation_name=f"修改{mode_label}",
@@ -6044,11 +6130,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(
plan,
"resize_face_size_local_keep_relations" if keep_relations else "resize_face_size_local",
[face_id, target_size, axis_key],
),
isolation=isolation,
)
def _resize_face_size_owning_scale(self, axis: str) -> None:
@@ -6453,6 +6535,19 @@ class WindowActionMixin:
return self.model.move_face_center_local_keep_relations(face_id, target_center)
return self.model.move_face_center_local(face_id, target_center)
if keep_relations:
isolation = self._isolation_for_plan(
plan,
"move_face_center_local_keep_relations",
[face_id, list(target_center)],
)
else:
isolation = self._isolation_for_plan(
plan,
"move_face_center_local",
[face_id, list(target_center)],
)
self._run_edit_action(
action,
operation_name=mode_label,
@@ -6488,11 +6583,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(
plan,
"move_face_center_local_keep_relations" if keep_relations else "move_face_center_local",
[face_id, list(target_center)],
),
isolation=isolation,
)
def move_selected_axis_center_by_translation(self) -> None:
@@ -7633,6 +7724,7 @@ class WindowActionMixin:
"pick_position": self.selected_pick_position,
"show_same_domain_internal_edges": self._show_same_domain_internal_edges(),
"edit_result_deflection": result_deflection,
"defer_edge_polydata": True,
"isolation": dict(isolation or {}),
}
blocker = self._edit_preflight_blocker(context)
@@ -7673,12 +7765,16 @@ class WindowActionMixin:
def job():
if self.model is None:
raise RuntimeError("Model is not loaded.")
timings: dict[str, float] = {}
total_started = time.perf_counter()
started = time.perf_counter()
snapshot = self.model.snapshot()
target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
before_geometry = {}
timings["snapshot"] = time.perf_counter() - started
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation:
return self._run_isolated_edit_job(
@@ -7689,9 +7785,14 @@ class WindowActionMixin:
before_part_stats=before_part_stats,
before_quality=before_quality,
before_geometry=before_geometry,
base_timings=timings,
total_started=total_started,
)
try:
started = time.perf_counter()
result = action()
timings["edit_geometry"] = time.perf_counter() - started
started = time.perf_counter()
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
@@ -7706,6 +7807,7 @@ class WindowActionMixin:
after_quality,
after_model=self.model,
)
timings["validate"] = time.perf_counter() - started
after_geometry = {}
except Exception as exc:
try:
@@ -7720,17 +7822,27 @@ class WindowActionMixin:
) from exc
model_polydata = None
edge_polydata = None
edge_deferred = bool(context.get("defer_edge_polydata", True))
try:
deflection = float(context.get("edit_result_deflection", 1.6))
started = time.perf_counter()
face_polydata = self.model.build_face_polydata(deflection=deflection)
model_polydata = _smooth_surface_polydata(face_polydata)
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
timings["display_faces"] = time.perf_counter() - started
if edge_deferred:
edge_polydata = self._empty_edge_polydata()
timings["display_edges"] = 0.0
else:
started = time.perf_counter()
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_edges"] = time.perf_counter() - started
except Exception:
model_polydata = None
edge_polydata = None
timings["total"] = time.perf_counter() - total_started
return {
"message": str(result),
"snapshot": snapshot,
@@ -7744,6 +7856,8 @@ class WindowActionMixin:
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
"edge_polydata_deferred": edge_deferred,
"timings": timings,
}
return job
@@ -7758,9 +7872,14 @@ class WindowActionMixin:
before_part_stats,
before_quality: dict[str, object] | None,
before_geometry: dict[str, object],
base_timings: dict[str, float] | None = None,
total_started: float | None = None,
) -> dict[str, object]:
if self.model is None:
raise RuntimeError("Model is not loaded.")
timings = dict(base_timings or {})
if total_started is None:
total_started = time.perf_counter()
target_part_id = self._edit_context_part_id(context)
timeout_seconds = float(isolation.get("timeout_seconds") or 180.0)
operation = str(isolation.get("operation") or "").strip()
@@ -7771,15 +7890,26 @@ class WindowActionMixin:
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_edit_") as temp_dir:
temp_root = Path(temp_dir)
input_path = temp_root / "input.step"
output_path = temp_root / "output.step"
exchange_format = str(isolation.get("exchange_format") or "brep").strip().lower()
if exchange_format not in {"brep", "step"}:
exchange_format = "brep"
suffix = ".brep" if exchange_format == "brep" else ".step"
input_path = temp_root / f"input{suffix}"
output_path = temp_root / f"output{suffix}"
request_path = temp_root / "request.json"
self.model.export_all(input_path)
started = time.perf_counter()
if exchange_format == "brep":
self.model.export_internal_brep(input_path)
else:
self.model.export_all(input_path)
timings["isolated_export"] = time.perf_counter() - started
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"input_format": exchange_format,
"output_format": exchange_format,
"operation": operation,
"args": args,
},
@@ -7793,6 +7923,7 @@ class WindowActionMixin:
self.isolated_edit_cancel_requested = False
process: subprocess.Popen[str] | None = None
try:
started = time.perf_counter()
process = subprocess.Popen(
command,
cwd=project_root,
@@ -7805,6 +7936,7 @@ class WindowActionMixin:
self.active_isolated_edit_process = process
stdout, stderr = process.communicate(timeout=timeout_seconds)
completed = subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
timings["isolated_worker"] = time.perf_counter() - started
except subprocess.TimeoutExpired as exc:
self._terminate_isolated_edit_process(process)
try:
@@ -7838,15 +7970,21 @@ class WindowActionMixin:
f"{self._edit_failure_diagnostics(context)}"
)
if not output_path.exists():
raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。")
raise RuntimeError("隔离子进程报告成功,但没有生成结果文件;原模型保持不变。")
new_model = StepModel.load(output_path)
started = time.perf_counter()
if exchange_format == "brep":
new_model = StepModel.load_internal_brep(output_path)
else:
new_model = StepModel.load(output_path)
timings["isolated_result_load"] = time.perf_counter() - started
try:
new_model.filename = self.step_path
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
self._preserve_isolated_face_logical_id(new_model, context, child_message)
started = time.perf_counter()
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
after_part_stats = self._part_stats_or_none_for_model(new_model, target_part_id)
@@ -7861,20 +7999,31 @@ class WindowActionMixin:
after_quality,
after_model=new_model,
)
timings["validate"] = time.perf_counter() - started
after_geometry: dict[str, object] = {}
model_polydata = None
edge_polydata = None
edge_deferred = bool(context.get("defer_edge_polydata", True))
try:
deflection = float(context.get("edit_result_deflection", 1.6))
started = time.perf_counter()
face_polydata = new_model.build_face_polydata(deflection=deflection)
model_polydata = _smooth_surface_polydata(face_polydata)
edge_polydata = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_faces"] = time.perf_counter() - started
if edge_deferred:
edge_polydata = self._empty_edge_polydata()
timings["display_edges"] = 0.0
else:
started = time.perf_counter()
edge_polydata = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_edges"] = time.perf_counter() - started
except Exception:
model_polydata = None
edge_polydata = None
timings["total"] = time.perf_counter() - total_started
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
@@ -7890,6 +8039,8 @@ class WindowActionMixin:
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
"edge_polydata_deferred": edge_deferred,
"timings": timings,
}
def _isolated_edit_command(self, request_path: Path) -> list[str]:
@@ -8692,6 +8843,7 @@ class WindowActionMixin:
if self.model is None:
self._end_edit_task(clear_preview=True)
return
finish_started = time.perf_counter()
self._set_edit_status_text("布尔计算已完成,正在刷新模型显示和历史记录...")
if not isinstance(result, dict):
self._end_edit_task(clear_preview=True)
@@ -8724,16 +8876,22 @@ class WindowActionMixin:
)
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
if model_polydata is None or edge_polydata is None:
edge_deferred = bool(result.get("edge_polydata_deferred"))
if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection)
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
elif edge_polydata is None:
edge_polydata = self._empty_edge_polydata()
self.clear_edit_preview(render=False)
self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
timings = result.get("timings")
if isinstance(timings, dict):
timings["finish_ui"] = time.perf_counter() - finish_started
locator_note = self._locate_operation_record(record)
except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
@@ -8750,13 +8908,19 @@ class WindowActionMixin:
self._clear_cylinder_candidates()
self._refresh_history_list()
self._end_edit_task(clear_preview=False)
timing_text = _edit_timing_summary(result.get("timings"))
if bool(result.get("edge_polydata_deferred")):
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else:
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
self.statusBar().showMessage(f"{message}{selection_note}")
timing_note = f";耗时 {timing_text}" if timing_text else ""
edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else ""
self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}")
if self.selected_kind is None:
self.set_plain_info(f"{record.detail}\n\n{locator_note}")
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}")
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
+155 -31
View File
@@ -47,11 +47,59 @@ def _safe_int_or_none(value: object) -> int | None:
return None
def _empty_edge_polydata():
polydata = vtk.vtkPolyData()
polydata.SetPoints(vtk.vtkPoints())
polydata.SetLines(vtk.vtkCellArray())
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
polydata.GetCellData().AddArray(edge_arr)
polydata.GetCellData().AddArray(part_arr)
return polydata
def _timing_summary(timings: dict[str, object] | None, *, limit: int = 4) -> str:
if not isinstance(timings, dict) or not timings:
return ""
labels = {
"step_load": "STEP读取",
"stats": "统计",
"display_faces": "面显示",
"display_edges": "边线",
"apply_loaded": "界面刷新",
"total": "总计",
}
rows: list[tuple[str, float]] = []
for key, value in timings.items():
if key == "total":
continue
try:
seconds = float(value)
except (TypeError, ValueError):
continue
if seconds >= 0.05:
rows.append((labels.get(str(key), str(key)), seconds))
rows.sort(key=lambda item: item[1], reverse=True)
parts = [f"{label} {seconds:.1f}s" for label, seconds in rows[:limit]]
try:
total = float(timings.get("total"))
except (TypeError, ValueError):
total = 0.0
if total >= 0.05:
parts.append(f"总计 {total:.1f}s")
return "".join(parts)
class WindowCoreMixin:
@Slot(object)
def _run_ui_task(self, callback) -> None:
callback()
def _empty_edge_polydata(self):
return _empty_edge_polydata()
def showEvent(self, event) -> None:
super().showEvent(event)
if getattr(self, "first_show_handled", False):
@@ -62,7 +110,7 @@ class WindowCoreMixin:
@Slot()
def _after_first_show(self) -> None:
self._ensure_vtk_interactor_started()
if getattr(self, "auto_load_on_show", False):
if getattr(self, "auto_load_on_show", False) and getattr(self, "step_path", None) is not None:
self.auto_load_on_show = False
self.load_step(self.step_path, background=True)
@@ -927,23 +975,41 @@ class WindowCoreMixin:
deflection: float,
show_internal_edges: bool,
build_polydata: bool = True,
build_edges: bool = True,
) -> dict[str, object]:
timings: dict[str, float] = {}
total_started = time.perf_counter()
started = time.perf_counter()
new_model = StepModel.load(path)
timings["step_load"] = time.perf_counter() - started
started = time.perf_counter()
stats = new_model.stats()
timings["stats"] = time.perf_counter() - started
result = {
"path": path,
"model": new_model,
"stats": stats,
"deflection": deflection,
"show_internal_edges": show_internal_edges,
"timings": timings,
}
if build_polydata:
started = time.perf_counter()
face_polydata = new_model.build_face_polydata(deflection=deflection)
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_faces"] = time.perf_counter() - started
if build_edges:
started = time.perf_counter()
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_edges"] = time.perf_counter() - started
else:
result["edge_polydata"] = _empty_edge_polydata()
result["edge_polydata_deferred"] = True
timings["display_edges"] = 0.0
timings["total"] = time.perf_counter() - total_started
return result
def _load_step_background_or_sync(self, path: str | Path, *, background: bool) -> None:
@@ -995,6 +1061,7 @@ class WindowCoreMixin:
deflection: float,
show_internal_edges: bool,
status_prefix: str,
defer_edges: bool = True,
) -> bool:
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
@@ -1004,6 +1071,7 @@ class WindowCoreMixin:
new_path,
deflection=deflection,
show_internal_edges=show_internal_edges,
build_edges=not defer_edges,
)
result["display"] = "ready"
except Exception as exc:
@@ -1013,7 +1081,9 @@ class WindowCoreMixin:
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
timing_text = _timing_summary(result.get("timings") if isinstance(result, dict) else None)
suffix = f"{timing_text}" if timing_text else ""
self.statusBar().showMessage(f"Loaded {self.step_path.name}{suffix}")
return True
@Slot(object)
@@ -1026,6 +1096,7 @@ class WindowCoreMixin:
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
defer_edges=True,
)
finally:
self._end_load_task()
@@ -1047,6 +1118,7 @@ class WindowCoreMixin:
return detached
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
apply_started = time.perf_counter()
new_path = Path(result["path"])
stats = result["stats"]
self.model = result["model"]
@@ -1062,7 +1134,8 @@ class WindowCoreMixin:
self._reset_selection()
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
if model_polydata is None or edge_polydata is None:
edge_deferred = bool(result.get("edge_polydata_deferred"))
if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(result.get("deflection", 0.8))
show_internal_edges = bool(result.get("show_internal_edges", self._show_same_domain_internal_edges()))
model_polydata = self.model.build_face_polydata(deflection=deflection)
@@ -1070,6 +1143,8 @@ class WindowCoreMixin:
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
elif edge_polydata is None:
edge_polydata = _empty_edge_polydata()
self._rebuild_scene_from_polydata(
model_polydata,
edge_polydata,
@@ -1077,18 +1152,28 @@ class WindowCoreMixin:
)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self.set_info(
{
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": result.get("display", "quick preview" if self.load_in_progress else "ready"),
}
)
timings = result.get("timings")
if isinstance(timings, dict):
timings["apply_loaded"] = time.perf_counter() - apply_started
display_state = result.get("display", "quick preview" if self.load_in_progress else "ready")
if edge_deferred:
display_state = f"{display_state}; edge display pending"
info_payload = {
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": display_state,
}
timing_text = _timing_summary(timings if isinstance(timings, dict) else None)
if timing_text:
info_payload["load_performance"] = timing_text
self.set_info(info_payload)
self._update_action_states()
if edge_deferred:
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
@@ -1177,10 +1262,12 @@ class WindowCoreMixin:
def open_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再打开文件。"):
return
current_path = getattr(self, "step_path", None)
start_dir = current_path.parent if isinstance(current_path, Path) else Path.cwd()
path, _ = QFileDialog.getOpenFileName(
self,
"打开 STEP 文件",
str(self.step_path.parent if self.step_path else Path.cwd()),
str(start_dir),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if path:
@@ -1200,6 +1287,9 @@ class WindowCoreMixin:
return
path_text = self.path_label.text().strip() if hasattr(self, "path_label") else ""
path = Path(path_text) if path_text else self.step_path
if path is None:
self.statusBar().showMessage("请先点击“导入几何模型”选择 STEP 文件。")
return
self._ensure_vtk_interactor_started()
self.load_step(path)
@@ -1620,6 +1710,50 @@ class WindowCoreMixin:
edge_id = int(self.edge_id_array.GetValue(cell_id))
self.edge_cell_ids_by_edge.setdefault(edge_id, []).append(cell_id)
def _install_edge_polydata(self, edge_polydata, *, render: bool = True) -> None:
if edge_polydata is None:
edge_polydata = _empty_edge_polydata()
old_actor = getattr(self, "edge_actor", None)
if old_actor is not None:
try:
self.renderer.RemoveActor(old_actor)
except Exception:
pass
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
self.edge_actor = vtk.vtkActor()
self.edge_actor.SetMapper(edge_mapper)
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
self.edge_actor.GetProperty().SetLineWidth(1.0)
self.edge_actor.GetProperty().LightingOff()
self.renderer.AddActor(self.edge_actor)
self.edge_overlay_polydata_cache.clear()
if render:
self.render_window.Render()
@Slot()
def _rebuild_deferred_edge_display(self) -> None:
if self.model is None or self.operation_in_progress or self.load_in_progress:
return
started = time.perf_counter()
self.statusBar().showMessage("模型已显示,正在补充边线...")
QApplication.processEvents()
try:
edge_polydata = self.model.build_edge_polydata(
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
show_same_domain_internal_edges=self._show_same_domain_internal_edges(),
)
self._install_edge_polydata(edge_polydata, render=True)
except Exception as exc:
self.statusBar().showMessage(f"模型已显示;边线补充失败:{exc}")
return
elapsed = time.perf_counter() - started
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object:
if len(cache) >= self.overlay_cache_limit:
try:
@@ -1744,18 +1878,8 @@ class WindowCoreMixin:
self.model_actor.SetBackfaceProperty(backface_property)
self.renderer.AddActor(self.model_actor)
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
self.edge_actor = vtk.vtkActor()
self.edge_actor.SetMapper(edge_mapper)
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
self.edge_actor.GetProperty().SetLineWidth(1.0)
self.edge_actor.GetProperty().LightingOff()
self.renderer.AddActor(self.edge_actor)
self.edge_actor = None
self._install_edge_polydata(edge_polydata, render=False)
self.highlight_actor = None
self.edge_highlight_actor = None
+176 -27
View File
@@ -7,6 +7,7 @@ import time
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QFrame,
QHBoxLayout,
@@ -36,8 +37,15 @@ PROPERTY_LABEL_COLUMN = 0
PROPERTY_CURRENT_COLUMN = 1
PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (72, 118, 72, 82)
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (118, 168, 108, 104)
PROPERTY_INPUT_COLUMN = 4
PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "建模意图", "目标值", "输入参数")
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 66, 62, 50)
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 88, 82, 66)
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS = {
"face_center_position",
"edge_center_point",
}
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS = {"中心"}
PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics")
PROPERTY_COMMAND_LABELS = {
"offset": "偏移",
@@ -98,7 +106,7 @@ PROPERTY_EXPLANATION_TOOLTIPS = {
}
def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int]:
def _property_table_column_widths(available_width: int) -> tuple[int, ...]:
"""Prefer current value visibility while keeping modeling intent and target usable."""
column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS)
available = max(int(available_width or 0), column_count * 44)
@@ -110,7 +118,7 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int,
if available >= preferred_total:
widths = [int(value) for value in preferred]
extra = available - preferred_total
weights = (0.26, 0.38, 0.18, 0.18)
weights = (0.30, 0.24, 0.18, 0.16, 0.12)
for index, weight in enumerate(weights):
addition = int(extra * weight)
widths[index] += addition
@@ -126,10 +134,10 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int,
widths[1] += available - sum(widths)
return tuple(widths) # type: ignore[return-value]
floors = (48, 96, 44, 56)
floors = (44, 44, 44, 44, 44)
widths = [int(value) for value in minimum]
deficit = min_total - available
for index in (0, 2, 3, 1):
for index in (0, 2, 3, 1, 4):
if deficit <= 0:
break
reducible = max(0, widths[index] - floors[index])
@@ -149,6 +157,16 @@ def _compact_property_card_text(text: str, limit: int = 220) -> str:
return f"{compact[: max(0, limit - 1)].rstrip()}"
def _property_parameter_is_visible(spec: dict[str, object]) -> bool:
key = str(spec.get("key", "") or "")
label = str(spec.get("label", "") or "").strip()
if key in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS:
return False
if label in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS:
return False
return True
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
"""Return the independent, user-facing dimensions for a feature candidate."""
surface = str(action_info.get("surface", "") or "")
@@ -817,7 +835,7 @@ class WindowStateMixin:
self._set_control_state(
self.quick_export_all_button,
has_model,
"导出当前完整 STEP 模型。",
"导出当前模型。",
wait_or_load_tip,
)
if hasattr(self, "part_tree"):
@@ -852,7 +870,7 @@ class WindowStateMixin:
self._set_control_state(
self.export_all_button,
has_model,
"导出当前完整 STEP 模型。",
"导出当前模型。",
wait_or_load_tip,
)
self._set_control_state(
@@ -1548,11 +1566,11 @@ class WindowStateMixin:
headline = "当前支持:Face、孔、槽、凸台、圆角/倒角、壳体、Edge"
detail = ""
tooltip = (
"Face:面内长度/宽度、中心、偏移、壳体厚度。\n"
"Face:面内长度/宽度、偏移、壳体厚度。\n"
"孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n"
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、中心、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
"圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n"
"Edge/解析曲面:直线 Edge 长度/端点/中心、圆/椭圆 Edge、简单圆锥/球/环面。\n"
"Edge/解析曲面:直线 Edge 长度/端点、圆/椭圆 Edge、简单圆锥/球/环面。\n"
"一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n"
"受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。"
)
@@ -1985,7 +2003,9 @@ class WindowStateMixin:
editable=False,
)
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
row_items = (label_item, current_item, scope_item, target_item)
input_item = self._property_table_item("", editable=False)
input_item.setToolTip("勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。")
row_items = (label_item, current_item, scope_item, target_item, input_item)
self._style_property_row_items(row_items, editable=editable, spec=effective_spec)
for column, item in enumerate(row_items):
item.setToolTip(item.toolTip() or item.text())
@@ -2002,6 +2022,10 @@ class WindowStateMixin:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
else:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
self._set_property_input_checkbox(
row,
exportable=bool(input_editable and effective_spec.get("label")),
)
self._clear_property_command_bar()
self._clear_property_cards()
self._update_current_capability_panel()
@@ -2032,12 +2056,13 @@ class WindowStateMixin:
def _style_property_row_items(
self,
items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem],
items: tuple[QTableWidgetItem, ...],
*,
editable: bool,
spec: dict[str, object] | None = None,
) -> None:
label_item, current_item, scope_item, target_item = items
label_item, current_item, scope_item, target_item = items[:4]
input_item = items[4] if len(items) > 4 else None
if spec is not None and bool(spec.get("pin_top")):
for item in items:
item.setBackground(QColor("#eef6ff"))
@@ -2045,6 +2070,8 @@ class WindowStateMixin:
current_item.setForeground(QColor("#0f172a"))
scope_item.setForeground(QColor("#0369a1"))
target_item.setForeground(QColor("#64748b"))
if input_item is not None:
input_item.setForeground(QColor("#64748b"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
@@ -2053,13 +2080,15 @@ class WindowStateMixin:
current_item.setFont(current_font)
return
if editable:
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed")
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed", "#fff7ed")
for item, color in zip(items, row_backgrounds):
item.setBackground(QColor(color))
label_item.setForeground(QColor("#7c2d12"))
current_item.setForeground(QColor("#431407"))
scope_item.setForeground(QColor("#9a3412"))
target_item.setForeground(QColor("#111827"))
if input_item is not None:
input_item.setForeground(QColor("#7c2d12"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
@@ -2070,6 +2099,9 @@ class WindowStateMixin:
scope_item.setForeground(QColor("#64748b"))
target_item.setBackground(QColor("#eef2f6"))
target_item.setForeground(QColor("#8f99a8"))
if input_item is not None:
input_item.setBackground(QColor("#eef2f6"))
input_item.setForeground(QColor("#8f99a8"))
def _property_scope_default(self, spec: dict[str, object]) -> str:
modes = spec.get("scope_modes")
@@ -2145,6 +2177,39 @@ class WindowStateMixin:
editor.returnPressed.connect(self.apply_current_property_edit)
self.property_table.setCellWidget(row, PROPERTY_TARGET_COLUMN, editor)
def _set_property_input_checkbox(self, row: int, *, exportable: bool) -> None:
if not hasattr(self, "property_table"):
return
container = QWidget()
container.setObjectName("propertyInputParameterCell")
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox = QCheckBox(container)
checkbox.setObjectName("propertyInputParameterCheckbox")
checkbox.setChecked(False)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
tip = "勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。"
if not exportable:
tip = "当前行不是可导出的尺寸输入参数。"
checkbox.setToolTip(tip)
container.setToolTip(tip)
checkbox.toggled.connect(lambda _checked=False: self._update_parameter_export_state())
layout.addWidget(checkbox)
self.property_table.setCellWidget(row, PROPERTY_INPUT_COLUMN, container)
def _property_input_checkbox(self, row: int) -> QCheckBox | None:
if not hasattr(self, "property_table"):
return None
widget = self.property_table.cellWidget(row, PROPERTY_INPUT_COLUMN)
if isinstance(widget, QCheckBox):
return widget
if isinstance(widget, QWidget):
return widget.findChild(QCheckBox)
return None
def _set_property_scope_editor(self, row: int, spec: dict[str, object]) -> None:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
@@ -2213,6 +2278,23 @@ class WindowStateMixin:
target_item = self.property_table.item(row, PROPERTY_TARGET_COLUMN)
if target_item is not None:
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
checkbox = self._property_input_checkbox(row)
if checkbox is not None:
exportable = bool(input_editable and effective_spec.get("label"))
if not exportable and checkbox.isChecked():
was_blocked = checkbox.blockSignals(True)
try:
checkbox.setChecked(False)
finally:
checkbox.blockSignals(was_blocked)
tip = (
"勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。"
if exportable
else "当前行不是可导出的尺寸输入参数。"
)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
checkbox.setToolTip(tip)
self._update_current_capability_panel()
self._update_property_apply_state()
@@ -2345,6 +2427,7 @@ class WindowStateMixin:
and bool(spec.get("enabled"))
and bool(spec.get("action"))
and str(spec.get("value_type", "number")) != "command"
and _property_parameter_is_visible(spec)
]
def _feature_property_specs(
@@ -2381,6 +2464,7 @@ class WindowStateMixin:
or not bool(spec.get("editable"))
or (not bool(spec.get("enabled")) and not prismatic_size_key and not prismatic_center_key)
or str(spec.get("value_type", "number")) == "command"
or not _property_parameter_is_visible(spec)
):
continue
dimension = dict(spec)
@@ -4546,7 +4630,7 @@ class WindowStateMixin:
"action": "resize_boss_height",
"target_attr": "boss_height_input",
"enabled": boss_height_can_local,
"enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可拉伸/切除的凸台端盖 Face。",
"enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并校验可拉伸/切除的凸台端盖 Face。",
"disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。",
"range_hint": relative_range_hint(current_boss_height, 0.3, 0.8),
},
@@ -4663,7 +4747,7 @@ class WindowStateMixin:
"action": "resize_cylinder_height",
"target_attr": "boss_height_input",
"enabled": bool(is_full_cylinder and current_cylinder_height is not None),
"enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并确认可拉伸/切除的圆柱端盖 Face。",
"enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并校验可拉伸/切除的圆柱端盖 Face。",
"disabled_tip": "当前圆柱面不是完整圆柱,或缺少稳定高度信息。",
"range_hint": relative_range_hint(current_cylinder_height, 0.3, 0.8),
},
@@ -5775,17 +5859,70 @@ class WindowStateMixin:
card.style().unpolish(card)
card.style().polish(card)
setattr(widget, "_geom_param_button_state", button_state)
if not hasattr(self, "apply_property_button"):
if hasattr(self, "apply_property_button"):
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用当前被修改的参数。",
disabled_tip,
)
self._update_parameter_export_state(has_model)
def _selected_parameter_export_rows(self) -> list[dict[str, str]]:
if not hasattr(self, "property_table"):
return []
rows: list[dict[str, str]] = []
specs = getattr(self, "property_editor_specs", [])
row_count = min(self.property_table.rowCount(), len(specs))
for row in range(row_count):
checkbox = self._property_input_checkbox(row)
if checkbox is None or not checkbox.isEnabled() or not checkbox.isChecked():
continue
spec = self._effective_property_spec(specs[row], row=row)
if str(spec.get("value_type", "number")) == "command":
continue
label_item = self.property_table.item(row, PROPERTY_LABEL_COLUMN)
current_item = self.property_table.item(row, PROPERTY_CURRENT_COLUMN)
label = (label_item.text() if label_item is not None else str(spec.get("label", ""))).strip()
current = (
current_item.text()
if current_item is not None
else str(spec.get("current_text", spec.get("current_raw", "")))
).strip()
if not label:
continue
rows.append(
{
"name": label,
"displayName": label,
"type": "number",
"ioRole": "input",
"default": current,
}
)
return rows
def _update_parameter_export_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "export_parameters_button"):
return
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
if has_model is None:
has_model = self.model is not None and not (
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
)
selected_rows = self._selected_parameter_export_rows()
if selected_rows:
disabled_tip = "请等待当前后台任务完成后再导出参数。"
else:
disabled_tip = "请先在“输入参数”列勾选至少一个尺寸参数。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用当前被修改的参数",
self.export_parameters_button,
bool(has_model and selected_rows),
f"导出已勾选的 {len(selected_rows)} 个输入参数到 data.json",
disabled_tip,
)
@@ -6060,7 +6197,19 @@ class WindowStateMixin:
if action is None:
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
return
action()
self._run_parametric_property_action_directly(action)
def _run_parametric_property_action_directly(self, action) -> None:
original_question = QMessageBox.question
def auto_yes(*_args, **_kwargs):
return QMessageBox.StandardButton.Yes
try:
QMessageBox.question = auto_yes
action()
finally:
QMessageBox.question = original_question
def set_manual_hole_bottom_face(self) -> None:
self._update_action_states()