From 19364d81b598d1b1e5483114306f5a9f88951c99 Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Tue, 11 Aug 2026 18:28:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=B8=80=E7=BA=A7?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E5=8F=82=E6=95=B0=E5=8C=96=E5=BB=BA=E6=A8=A1?= =?UTF-8?q?=E4=B8=8E=E5=8F=82=E6=95=B0=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 141 ++++++++---- ...rify_face_feature_parameter_consistency.py | 4 +- scripts/verify_face_ui_isolation_contract.py | 3 - scripts/verify_feature_parameter_policy.py | 6 +- scripts/verify_feature_recognition_summary.py | 29 +++ scripts/verify_first_level_acceptance_docs.py | 2 +- scripts/verify_prismatic_feature.py | 3 - scripts/verify_property_card_editor_ui.py | 109 +++++++++- scripts/verify_property_editor_specs.py | 21 +- step_editor/app.py | 69 ++++-- step_editor/export.py | 5 +- step_editor/features.py | 42 +++- step_editor/geometry_utils.py | 10 + step_editor/isolated_edit_worker.py | 25 ++- step_editor/model.py | 19 ++ step_editor/step_io.py | 20 ++ step_editor/window_actions.py | 184 ++++++++++++++-- step_editor/window_core.py | 186 +++++++++++++--- step_editor/window_state.py | 203 +++++++++++++++--- 20 files changed, 917 insertions(+), 167 deletions(-) diff --git a/.gitignore b/.gitignore index 6536e08..b1c8151 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 5275a80..c1e2693 100644 --- a/README.md +++ b/README.md @@ -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 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 @@ -204,8 +266,8 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 近矩形平面 Face 沿长度方向变化,一级相邻面跟随,面积不作为驱动参数。 │ ├── [x] [面内宽度 -> 改宽度] │ │ └── 近矩形平面 Face 沿宽度方向变化,一级相邻面跟随。 -│ ├── [x] [中心位置 -> 移动局部面组] -│ │ └── 可选择局部重建或移动所属对象,避免无提示地把整个模型拖走。 +│ ├── [~] [中心位置 -> 后端保留,界面暂不开放] +│ │ └── 中心移动已有后端验证,但模型工程师判断当前客户价值不高,特征参数表暂不显示。 │ ├── [x] [壳体厚度 -> 改薄壁厚度] │ │ └── 平面相对面可做局部拉伸/切除或厚度方向缩放,并做 Face 结果校验。 │ └── [x] [复杂多边界端盖 -> 边界侧壁重建] @@ -230,8 +292,8 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 两侧壁和圆弧端同步更新,结果仍能识别为槽/半孔。 │ ├── [x] [槽深 -> 改盲槽深度] │ │ └── 槽底移动,槽口边界有效,不把母体切坏。 -│ ├── [x] [矩形槽/口袋长宽/中心/深度 -> 局部重建矩形切除] -│ │ └── 规则矩形槽/口袋可改长宽、中心和深度;中心只支持在基准平面内移动。 +│ ├── [x] [矩形槽/口袋长宽/深度 -> 局部重建矩形切除] +│ │ └── 规则矩形槽/口袋可改长宽和深度;中心移动后端保留但参数表暂不开放。 │ ├── [x] [弧长/弧角/开口角 -> 改圆弧槽段] │ │ └── 保持圆柱槽语义,重切后能回读目标弧长或角度。 │ ├── [x] [总长/中心距 -> 改长圆槽长度] @@ -254,10 +316,10 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 规则矩形凸台顶面和规则矩形口袋底面可按高度/深度推拉,修改后会回读矩形拉伸尺寸。 │ ├── [x] [矩形凸台/口袋长度/宽度 -> 局部重建矩形包络] │ │ └── 规则矩形凸台/口袋可先移除或补回旧包络,再按目标长宽重建;当前不在一次编辑中交换长宽方向。 -│ ├── [x] [矩形凸台/口袋中心位置 -> 平面内移动并重建包络] -│ │ └── 规则矩形凸台/口袋可在基准平面内移动中心,长宽和高度/深度保持不变;沿高度/深度方向移动会提前阻止。 +│ ├── [~] [矩形凸台/口袋中心位置 -> 后端保留,界面暂不开放] +│ │ └── 中心移动后端仍有计划和回归覆盖,但客户参数表暂时只开放长宽、高度/深度这类更常用尺寸。 │ ├── [x] [多台阶矩形凸台顶层规则台阶 -> 独立编辑] -│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽、中心和高度。 +│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽和高度。 │ └── [x] [复杂多台阶凸台整体 -> 识别并快速阻止] │ └── 多台阶中间过渡面会标记为受限,扫描列表、参数表和计划阶段都不暴露伪可改入口;整组联动、异形凸台和复杂融合边界仍未作为稳定能力。 │ @@ -282,8 +344,8 @@ STEP/B-Rep 参数化编辑主线 ├── 6. Edge 一级编辑,补齐底层直接改边能力 │ ├── [x] [边长 -> 改直线 Edge 长度] │ │ └── 支持只改当前边、移动端面、相邻圆柱和缩放所属对象等显式建模意图。 -│ ├── [x] [起点/中心/终点 -> 移动直线 Edge] -│ │ └── 简单全平面模型可移动端点或整条边,并重建相邻面;计划和确认框会明确这是局部 Edge 变形,不等同于端面整体推拉。 +│ ├── [x] [起点/终点 -> 移动直线 Edge] +│ │ └── 简单全平面模型可移动端点并重建相邻面;中心移动后端保留但参数表暂不开放。 │ ├── [x] [圆形 Edge 半径/直径 -> 代理到相邻圆柱] │ │ └── 圆边优先复用孔/槽/凸台直径或轴心修改,而不是裸改曲线。 │ ├── [x] [椭圆 Edge 主/小半径 -> 单轴缩放] @@ -297,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 -> 通用约束编辑] @@ -344,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,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。 @@ -402,11 +464,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | --- | --- | --- | --- | -| Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | -| Face / 面 | 面内长度、面内宽度、中心、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | +| Edge / 边 | 直线边的长度、起点、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边;直线 Edge 中心移动暂不在参数表开放。 | +| Face / 面 | 面内长度、面内宽度、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;按尺寸局部重建相邻面;或缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;Face 中心移动暂不在参数表开放;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | -| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽、中心和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心沿深度方向移动暂不处理。 | -| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 | +| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心移动暂不在参数表开放。 | +| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形中心移动暂不在参数表开放。 | | 圆角 / 倒角 | 部分已有圆角的半径/弧长;简单等半径圆角链半径/弧长;直线边新增圆角或倒角;变半径/复杂圆角链可识别并快速阻止。 | 已有简单圆角和简单等半径圆角链先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;变半径圆角链和复杂 blend 不进入危险重建。 | 大链式 blend、变半径圆角稳定重建、复杂角部补面、支撑面不明确的圆角。 | | Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | @@ -416,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 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | @@ -492,7 +554,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 - 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划。 -- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 +- 模型边线默认渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持两点测量: - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 @@ -602,7 +664,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 - 封堵会用略带半径重叠的圆柱补料体执行 Fuse。 - 绿色半透明预览表示即将补上的材料范围。 - - 当前默认模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。 + - 当前常用测试模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。 - 当前版本支持圆柱凸台直径调整: - 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。 - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 @@ -610,7 +672,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - 当前选中对象表的 `直径` / `半径` 会通过 `建模意图` 选择 `只改凸台` 或 `缩放特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 + - 当前常用测试模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 - 当前版本支持圆柱凸台高度和轴心坐标调整: - 只对已识别为完整圆柱凸台、并找到可拉伸/切除端盖 Face 的候选开放。 - 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖拉伸/切除距离。 @@ -668,6 +730,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。 - 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。 - 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。 +- 可编辑对象和圆柱面候选扫描已经在 `StepModel` 层做参数化缓存;同一模型、同一扫描范围重复打开候选列表时会直接复用结果,编辑、撤销/重做或重新加载模型后缓存会随拓扑刷新清空。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 当前版本支持圆柱孔/圆柱面扩大切削。 @@ -763,14 +826,14 @@ vertices: 3262 Face 阶段的当前验收口径(R1 已收口): -- 已验收:平面 Face 的 `面内长度`、`面内宽度`、`中心`、`偏移`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 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_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度`、`面内宽度`、`偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 孔/槽阶段的当前验收口径(R2/R3 已收口): @@ -873,7 +936,7 @@ Face 阶段的当前验收口径(R1 已收口): - `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。 - `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 - `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 -- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模` 并确认。 +- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模`。 - `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 - 点击任意一行会自动选中并高亮对应Face或Edge。 @@ -931,9 +994,7 @@ Face 阶段的当前验收口径(R1 已收口): - `缩放特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 - `面内长度` / `面内宽度` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。面积会在诊断信息中作为结果值展示。 - `中心`: - - 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `建模意图` 选择改法。 - - `局部重建` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 - - `移动特征` 会把目标中心换算成平移向量,并平移这个 Face 所属的特征或 Solid;Face 自身形状和所属对象内部尺寸不变。 + - 后端保留了 Face 中心移动能力和回归基线,但模型工程师认为当前作用不大,特征参数表暂时不开放这一行。 - `孔直径` + `调整圆柱孔径`: - 先选择一个圆柱面。 - 输入新的直径。 @@ -1164,7 +1225,7 @@ Face 阶段的当前验收口径(R1 已收口): 导出: -- `导出当前完整 STEP`:导出当前完整模型。 +- `导出模型`:导出当前完整模型。 - `导出选中零件`:导出当前选中的零件。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。 @@ -1249,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 小组件 @@ -1343,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`,避免目标文档和实际验证入口脱节。 @@ -1374,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,并给出“不需要修改”的说明。 @@ -1484,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,所以脚本用主/小轴采样半径校验几何结果。 diff --git a/scripts/verify_face_feature_parameter_consistency.py b/scripts/verify_face_feature_parameter_consistency.py index 41de575..0564d80 100644 --- a/scripts/verify_face_feature_parameter_consistency.py +++ b/scripts/verify_face_feature_parameter_consistency.py @@ -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: " diff --git a/scripts/verify_face_ui_isolation_contract.py b/scripts/verify_face_ui_isolation_contract.py index f109295..1c2d7f7 100644 --- a/scripts/verify_face_ui_isolation_contract.py +++ b/scripts/verify_face_ui_isolation_contract.py @@ -133,8 +133,6 @@ PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = { "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", - "move_selected_face_center_keep_relations": "move_face_center_local_keep_relations", "resize_shell_thickness": "resize_shell_thickness", "resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale", "resize_cylinder_height": "resize_cylindrical_height", @@ -283,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", }, ), diff --git a/scripts/verify_feature_parameter_policy.py b/scripts/verify_feature_parameter_policy.py index 5e6d258..dba2126 100644 --- a/scripts/verify_feature_parameter_policy.py +++ b/scripts/verify_feature_parameter_policy.py @@ -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") diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index ac531a0..1d80d3f 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -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) diff --git a/scripts/verify_first_level_acceptance_docs.py b/scripts/verify_first_level_acceptance_docs.py index 3dcca1b..d46b32a 100644 --- a/scripts/verify_first_level_acceptance_docs.py +++ b/scripts/verify_first_level_acceptance_docs.py @@ -59,7 +59,7 @@ def _verify_readme_mentions(readme: str) -> None: "[不能修改 -> 立即说明原因]", "[一级影响范围 -> 明确显示]", "Face 阶段的当前验收口径(R1 已收口)", - "已验收:平面 Face 的 `面内长度`、`面内宽度`、`中心`、`偏移`", + "已验收:平面 Face 的 `面内长度`、`面内宽度`、`偏移`", "未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建", "孔/槽阶段的当前验收口径(R2/R3 已收口)", "已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`", diff --git a/scripts/verify_prismatic_feature.py b/scripts/verify_prismatic_feature.py index e93fb00..b2823e5 100644 --- a/scripts/verify_prismatic_feature.py +++ b/scripts/verify_prismatic_feature.py @@ -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)"), ), ) diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index f60f203..75e68ca 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -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: diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index c45752b..087a3b7 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -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 diff --git a/step_editor/app.py b/step_editor/app.py index b037654..9eb4656 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -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() diff --git a/step_editor/export.py b/step_editor/export.py index 2bf222a..0d92f29 100644 --- a/step_editor/export.py +++ b/step_editor/export.py @@ -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) diff --git a/step_editor/features.py b/step_editor/features.py index 6137e7d..785d1af 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -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): diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py index f30a6de..4ebe7e7 100644 --- a/step_editor/geometry_utils.py +++ b/step_editor/geometry_utils.py @@ -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.") diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index 30182e6..429878a 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -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, diff --git a/step_editor/model.py b/step_editor/model.py index 96d16f5..ecb9c90 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -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 diff --git a/step_editor/step_io.py b/step_editor/step_io.py index 908b26c..6387407 100644 --- a/step_editor/step_io.py +++ b/step_editor/step_io.py @@ -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 diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 10d6460..c6c7e09 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -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: @@ -7651,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) @@ -7691,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( @@ -7707,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) @@ -7724,6 +7807,7 @@ class WindowActionMixin: after_quality, after_model=self.model, ) + timings["validate"] = time.perf_counter() - started after_geometry = {} except Exception as exc: try: @@ -7738,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, @@ -7762,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 @@ -7776,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() @@ -7789,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, }, @@ -7811,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, @@ -7823,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: @@ -7856,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) @@ -7879,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 崩溃,主程序不会被带崩。", @@ -7908,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]: @@ -8710,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) @@ -8742,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) @@ -8768,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: diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 638f333..4d9635f 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -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 diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 64db0b6..a1cc80f 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -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()