diff --git a/README.md b/README.md index 43dc28b..97ab665 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,133 @@ # STEP 零件与特征查看编辑原型 -这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。 - -仓库里的 `assets/models/geom_extract.step` 是常用测试模型。程序入口是 `main.py`。 +这是一个基于 Python、pythonocc-core/OCCT、VTK、PySide6/Qt 和 SpaceClaim/SCDM 的 STEP 模型查看、选择、受限参数化编辑与导出原型。程序入口是 `main.py`。 ## 快速读懂 -- 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。 -- 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 -- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。 -- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 -- 当前统一走 `SCDM-first` 主线:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚、结果映射、参数导出和关系式管理。 -- 当前开发顺序按一条路线闭环:SCDM 识别与缓存 -> 能力字典和参数表 -> 参数化建模任务 -> 结果校验和 ID 续接 -> 按 Face、孔、槽、凸台、圆角/倒角、阵列、壳体逐项适配;本地 OCCT / Analysis Situs 只作为兜底和对照。 -- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 +- 本软件处理的是 STEP 里的 B-Rep 结果几何,不恢复原 CAD 软件的完整建模历史,也不等于 CAD 级完成。 +- 当前统一走 `SCDM-first` 主线:SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责 UI、选择、能力字典、参数表、关系式、任务 JSON、`/RunScript` 脚本生成、后台执行、结果校验、失败回滚、ID 续接、参数导出和本地 OCCT 兜底。 +- SCDM 能人工修改很多内容,不代表本软件可以立刻全部开放。只有能拿到结构化对象、当前值、可用命令和结果校验方式的能力,才会进入客户参数表。 +- 本软件不是给成千上万个 Face/Edge/一级/二级/三级情况各写一套代码,而是走 `capability key -> 中文参数 -> scdm_edit_job.json -> SCDM 脚本模板 -> 结果校验`。新增能力通常补一类能力模板,不补某个 Face ID 特判。 +- Analysis Situs 和本地 OCCT 现在只做辅助定位、兜底识别、对照验证和历史回归,不再作为新增能力主线。 +- 启动 `python main.py` 会快速进入空场景,不默认读取大 STEP;点击“导入几何模型”后才加载模型。 +- README 的阅读顺序:先看 `SCDM-first 主路线`、`本软件与 SCDM 如何交互`、`怎么运行` 和 `验证`。后面的本地 OCCT 口径只是兜底基线。 ## 软件整体架构 -当前项目大致是这样分层的。行数按当前 Git 跟踪的可读源码/脚本统计,排除 README、STEP 模型、图标、`.git`、`local`、`third_party` 和临时文件。 +行数是本次交付前刷新过的源码/脚本文本统计值。统计口径包含 `.py`、`.md`、`.json`、`.ps1` 等文本文件,不包含 `.git`、`local/`、`dist/`、第三方参考目录、截图和二进制 STEP 模型。 ```text -python-occt(源码/脚本 84,740 行) +python-occt(92597 行) ├── main.py(7 行) │ └── 程序入口,支持普通启动、烟测、隔离子进程 worker 入口 │ -├── step_editor/(62,303 行) -│ ├── app.py(1,976 行) +├── step_editor/(66988 行) +│ ├── app.py(2007 行) │ │ └── 主窗口初始化、左侧操作面板、按钮和布局 -│ │ -│ ├── window_core.py(4,171 行) +│ ├── window_core.py(4369 行) │ │ └── STEP 加载、VTK 视图、模型显示、拾取、高亮、坐标轴、FPS、显示刷新 -│ │ -│ ├── window_actions.py(9,677 行) +│ ├── window_actions.py(9677 行) │ │ └── 导出、参数化建模动作、后台任务、隔离子进程、操作历史 -│ │ -│ ├── window_state.py(10,169 行) +│ ├── window_state.py(10613 行) │ │ └── 选择状态、参数表状态、按钮启用/禁用、撤销/重做、历史定位 -│ │ -│ ├── model.py(5,713 行) +│ ├── model.py(5796 行) │ │ └── StepModel 核心对象,保存 shape、Face、Edge、Solid、拓扑缓存和 mixin 组合 -│ │ -│ ├── features.py(3,591 行) -│ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面 -│ │ -│ ├── recognition_graph.py(539 行) -│ │ └── 内部 Face 几何图和通孔拆面识别缓存,记录平面/圆柱、邻接、共面、平行、垂直、同轴等基础关系 -│ │ -│ ├── asitus_bridge.py(308 行) -│ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号 -│ │ │ ├── scdm_backend.py(623 行) -│ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测 -│ │ -│ ├── scdm_probe.py(680 行) -│ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定 -│ │ -│ ├── scdm_edit_runner.py(1,145 行) -│ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json -│ │ -│ ├── scdm_result_validator.py(770 行) -│ │ └── SCDM 结果校验和 ID 续接工具,负责输出文件检查、目标值回测、旧对象到新对象的唯一匹配和关系式 ID 重写 -│ │ -│ ├── scdm_schema.py / scdm_capabilities.py / scdm_feature_mapper.py / scdm_property_specs.py(1,821 行) -│ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI -│ │ -│ ├── operations.py(14,604 行) -│ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角 -│ │ -│ ├── geometry_utils.py(1,427 行) -│ │ └── 通用 OCCT 几何工具、布尔结果处理、B-Rep 校验、拓扑辅助 -│ │ -│ ├── polydata.py(425 行) -│ │ └── 把 OCCT Shape / Face / Edge 转成 VTK polydata,用于显示和拾取 -│ │ -│ ├── step_io.py(160 行) -│ │ └── STEP/XCAF 读取、STEP 导出、内部 BREP 临时交换 -│ │ -│ ├── export.py(179 行) -│ │ └── 导出完整模型、零件、Solid、Face、Edge,以及质量检查 -│ │ -│ ├── isolated_edit_worker.py(238 行) -│ │ └── 高风险几何编辑的隔离子进程入口 -│ │ -│ ├── workers.py(51 行) -│ │ └── Qt 后台任务 worker,避免主界面被计算堵死 -│ │ -│ ├── ui_helpers.py / widgets.py / info_panel.py(1,682 行) -│ │ └── UI 辅助函数、自定义控件、属性信息显示 -│ │ -│ └── recognition_priority.py(238 行) -│ └── 特征识别优先级,让常用、稳定、语义清楚的特征排前面 +│ │ └── 自动发现并缓存 SpaceClaim.exe,生成 `/RunScript` 命令并做最小烟测 +│ ├── scdm_probe.py(972 行) +│ │ └── 生成 SCDM 识别任务和 probe 脚本,约定 raw 识别输出 +│ ├── scdm_feature_mapper.py(2389 行) +│ │ └── 把 SCDM raw 结果映射成 UI 可用的特征、参数和对象 locator +│ ├── scdm_capabilities.py(380 行) / scdm_property_specs.py(814 行) / scdm_schema.py(122 行) +│ │ └── 把 SCDM raw 结果翻译成产品化能力、中文参数和参数表 spec +│ ├── scdm_edit_runner.py(2503 行) +│ │ └── 生成 SCDM 修改任务、修改脚本、结果 STEP 和错误 JSON +│ ├── scdm_result_validator.py(1086 行) +│ │ └── 结果文件检查、目标值回测、对象漂移守门、ID 续接和关系式重写 +│ ├── scdm_status.py(568 行) +│ │ └── SCDM 可用性、cache 状态和软件进度摘要 +│ ├── relation_formulas.py(307 行) +│ │ └── 关系式解析、补全、求值、依赖检查和单位字面量 +│ ├── parametric_component.py(609 行) +│ │ └── 导出参数 schema 和组件目录 main.py +│ ├── recognition_graph.py(539 行) / asitus_bridge.py(308 行) / recognition_priority.py(240 行) +│ │ └── 本地几何图、Analysis Situs 辅助桥接和兜底识别排序 +│ ├── features.py(3591 行) / operations.py(14604 行) / geometry_utils.py(1427 行) +│ │ └── 本地 OCCT 兜底识别、计划和几何编辑实现 +│ ├── polydata.py(425 行) / step_io.py(160 行) / export.py(179 行) +│ │ └── OCCT 与 VTK 显示数据转换、STEP 读写和导出 +│ ├── workers.py(51 行) / widgets.py(11 行) / ui_helpers.py(1401 行) / info_panel.py(284 行) +│ │ └── Qt worker、自定义控件和 UI 辅助 +│ └── 其它小模块:transforms.py(565 行)、isolated_edit_worker.py(238 行)、records.py(20 行)、model_types.py(25 行)、constants.py(70 行) │ -├── scripts/(21,988 行) -│ └── 各类专项验证脚本和一级编辑总回归入口 +├── scripts/(24721 行) +│ └── 自动化验证脚本和 `verify_first_level_edit_suites.py` 总入口 │ -├── tools/asitus_probe/(386 行) -│ └── Analysis Situs probe 的 C++ 源码和 CMake 配置 -│ -├── environment.yml / .gitignore(56 行) -│ └── Conda 环境和 Git 忽略规则 +├── tools/asitus_probe/(47 行) +│ └── Analysis Situs probe 的 C++ 源码,构建产物不提交 │ └── assets/models/ - └── 仓库自带测试 STEP 模型 + └── 仓库自带 STEP 测试模型 ``` ## SCDM-first 主路线 -本阶段开始,README 里过去分散写的 SCDM 后端、开源辅助识别、参数化编辑和关系式内容统一收敛成一条 SCDM-first 路线,不再并行推进多套方案。 +本阶段只有一条主线:SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责 UI、选择、参数表、关系式、任务生成、后台执行、校验、回滚、ID 续接和参数导出。SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM。本软件不再把手写一级、二级、三级传播当成新增能力主线。 -统一主线是:SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责显示、选择、能力映射、参数表、关系式、任务生成、异步执行、结果校验、回滚、ID 续接和参数导出。Analysis Situs 和本地 OCCT 识别不再作为主路线和 SCDM 抢“谁说了算”,只作为兜底、对照验证和轻量几何关系辅助。SCDM 不接管本软件 UI,也不把 SCDM 原始界面文字直接展示给客户。 +### 模型树和 cache -交付判断不是“调用了 SCDM”,而是下面这条闭环能稳定跑通: +SCDM 界面里的模型树主要表达装配和显示层级,比如 `Design -> Pattern -> Component -> Geom -> Solid`。它对阵列、组件、实体定位很有价值,但它不是本软件可以直接展示给客户的参数 cache。 -```text -导入 STEP --> 自动找到可用的 SpaceClaim.exe,并缓存路径、来源、版本和验证结果 --> 本软件显示模型并建立当前 Face / Edge / Solid 基础索引 --> 本软件把“请识别这个 STEP”的任务写给 SCDM,并用 /RunScript 启动 SpaceClaim --> SCDM 在后台打开 STEP,识别可编辑对象、参数、命令候选和限制原因,并把结果写回给本软件 --> 本软件把 SCDM 结果翻译成能力字典和中文参数,并缓存起来,后续点击对象直接查缓存 --> 用户点击对象时,参数表只显示已经产品化、可执行、可校验的参数 --> 用户修改目标值后,本软件把“改哪个对象、改成多少、失败怎么回滚”的修改任务写给 SCDM --> SpaceClaim.exe /RunScript 在后台执行修改,成功写出新的 STEP,失败写出错误原因 --> 本软件用 OCCT 重新读取新的 STEP,做 B-Rep 校验、目标值回测和非预期变形检查 --> 校验通过后替换当前模型,重新让 SCDM 识别并刷新缓存 --> 用新缓存更新 Face / Edge / 特征 ID 映射和关系式引用 -``` +本软件仍然需要自己的 `scdm_feature_cache.json`,原因有四个: + +- 要把 SCDM 对象映射回当前 UI 里的 `Face`、`Edge`、`Solid`、`Feature` ID。 +- 要把 SCDM 原始对象翻译成客户看得懂的参数,例如 `偏移`、`直径`、`位置`、`阵列间距`。 +- 要知道每个参数应该生成哪类 SCDM 修改脚本,以及失败时怎么解释。 +- 要在修改后用新 STEP、新 cache 和 OCCT 校验结果,并续接关系式里的对象 ID。 + +所以正确关系是:SCDM 模型树和 raw 识别结果是证据来源;本软件 cache 是 UI、脚本生成、校验和回滚的中间表示。 ### 本软件与 SCDM 如何交互 -本软件不是把 SCDM 界面嵌进来,也不是让客户写脚本。客户看到的仍然是本软件:导入 STEP、点击 Face/Feature、修改“偏移、孔径、位置、阵列间距”等中文参数,然后点击“参数化建模”。SCDM 只是在后台作为几何编辑后端运行。 +本软件不是把 SCDM 界面嵌进来,也不是让客户写脚本。客户看到的仍然是本软件:导入 STEP、点击对象、修改中文参数、点击“参数化建模”。SCDM 只在后台作为几何编辑后端运行。 -两边的沟通方式是“文件中转 + 命令启动”,不是网络接口,也不是共享内存。`RunScript` 不是我们生成的文件名,而是 SpaceClaim 的启动参数;本软件真正生成的是临时脚本文件,然后用类似下面的命令把脚本交给 SCDM 执行: - -```powershell -SpaceClaim.exe /RunScript=local\scdm\<模型名>_<指纹>\scdm_probe.py -SpaceClaim.exe /RunScript=local\scdm\<模型名>_<指纹>\edit\scdm_edit.py -``` - -这两个脚本文件由本软件自动生成。第一个脚本负责“识别这个 STEP 能改什么”,第二个脚本负责“按用户输入修改模型”。脚本旁边会有一份任务 JSON,里面写着要打开哪个 STEP、要识别或修改哪个对象、目标值是多少、结果写到哪里。 +两边通过“文件中转 + 命令启动”交互。`RunScript` 不是我们生成的文件名,而是 SpaceClaim 的启动参数;本软件真正生成的是临时 Python 脚本和任务 JSON,然后用 `/RunScript` 交给 SCDM 执行。 ```text -识别阶段: -本软件写识别任务 JSON + 识别脚本 scdm_probe.py --> 用 SpaceClaim.exe /RunScript=scdm_probe.py 启动 SCDM --> SCDM 打开 STEP,并把“识别到什么、哪些参数可改、有哪些限制”写回 JSON --> 本软件读取结果,翻译成中文能力和参数表,并缓存起来 - -修改阶段: -本软件写修改任务 JSON + 修改脚本 scdm_edit.py --> 用 SpaceClaim.exe /RunScript=scdm_edit.py 启动 SCDM --> SCDM 按任务修改模型,并输出新的 STEP 或失败原因 --> 本软件读取新 STEP,校验通过才替换当前模型;失败就回滚 +导入 STEP +-> 本软件显示模型并建立 Face / Edge / Solid 基础索引 +-> 本软件自动找到 SpaceClaim.exe,并缓存路径、来源、版本和验证结果 +-> 本软件写 scdm_probe_job.json,说明要打开哪个 STEP、输出写到哪里 +-> 本软件生成 scdm_probe.py,并用 SpaceClaim.exe /RunScript=scdm_probe.py 启动 SCDM +-> SCDM 打开 STEP,识别对象、参数、命令候选、模型树和限制原因 +-> SCDM 写回 scdm_raw_features.json +-> 本软件读取 raw 结果,翻译为 scdm_feature_cache.json 并缓存,以便点击对象时直接查 +-> 用户在参数表修改目标值并点击“参数化建模” +-> 本软件写 scdm_edit_job.json 和 scdm_edit.py +-> SpaceClaim.exe /RunScript=scdm_edit.py 在后台执行修改 +-> SCDM 输出 result.step 或 error.json +-> 本软件用 OCCT 回读 result.step,做 B-Rep 校验、目标值回测、对象漂移检查和失败回滚 +-> 校验通过后替换当前模型,重新 probe,刷新 cache、ID 映射和关系式引用 ``` -这些中转文件都放在项目的 `local/scdm/` 工作目录里,只用于本软件和 SCDM 后台沟通、调试和失败回放;清掉后可以重新生成,不提交到 Git。以 `ICEPAK-NATURAL.stp` 为例,当前本机路径类似: +中转文件放在 `local/scdm/<模型名>_<模型指纹>/`,不提交 Git,删掉后可以重新生成。典型目录如下: ```text local/scdm/ICEPAK-NATURAL_19ff9d1ede49/ -├── scdm_probe.py # 本软件生成,喂给 SCDM 做识别 -├── scdm_probe_job.json # 本软件生成,告诉识别脚本要打开哪个 STEP、结果写哪 -├── scdm_raw_features.json # SCDM 写回,原始识别结果 -├── scdm_feature_cache.json# 本软件生成,翻译后的中文参数和能力缓存 +├── scdm_probe.py # 本软件生成,喂给 SCDM 做识别 +├── scdm_probe_job.json # 本软件生成,说明识别任务 +├── scdm_raw_features.json # SCDM 写回的原始识别结果 +├── scdm_feature_cache.json # 本软件生成的产品化能力缓存 └── edit/ - ├── scdm_edit.py # 本软件生成,喂给 SCDM 做修改 - ├── scdm_edit_job.json # 本软件生成,告诉修改脚本改哪个对象、目标值是多少 - ├── result.step # SCDM 修改成功后输出的新 STEP - └── error.json # SCDM 修改失败时输出的错误原因 + ├── scdm_edit.py # 本软件生成,喂给 SCDM 做修改 + ├── scdm_edit_job.json # 本软件生成,说明修改任务 + ├── result.step # SCDM 修改成功后输出 + └── error.json # SCDM 修改失败时输出 ``` -模型文件变更后会重新计算指纹,目录名也会变,旧识别结果不会误用到新模型上。`导出参数` 生成的外部流程组件是另一条链路,不属于本软件和 SCDM 的后台通信。 +模型文件变更后会重新计算指纹;mapper 版本变化后旧 cache 也会丢弃,避免旧识别结果误用到新模型或新规则上。 + +### 能力开放规则 这里说的“SCDM 能做”,不是指 SCDM 界面里人工能点的所有按钮,而是指本软件能通过脚本拿到下面四类证据: @@ -177,9 +136,9 @@ local/scdm/ICEPAK-NATURAL_19ff9d1ede49/ 3. 当前对象有可用的当前值或定位信息,比如直径、位置、半径、Face locator、实例中心。 4. 修改后能用新 STEP、新 cache、目标值回测或“目标特征消失”证明结果是对的。 -不满足这四点的内容,只能进入诊断、日志或“待适配能力”,不能直接展示成客户可修改参数。 +不满足这四点的内容,只能进入诊断、日志或待适配能力,不能直接展示成客户可修改参数。 -SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM。本软件不再把手写一级、二级、三级传播当成新增能力主线,只负责把 SCDM 能力适配成可见参数、可执行任务和可校验结果;SCDM 做不到或当前脚本拿不到证据的能力,先不开放。 +大模型上的歧义圆柱面要优先等 SCDM cache。比如 `assets/models/geom_extract.step` 的 Face1722,SCDM raw 识别为 `round`,不是普通圆柱孔;本地 OCCT 快速兜底曾经会把它误猜成孔/槽候选。当前策略是:SCDM cache 未就绪时,先不开放这类圆柱孔/槽参数;cache 就绪后按 SCDM 的结构化对象决定展示圆角、槽、孔或只读诊断。 状态标记: @@ -187,7 +146,7 @@ SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交 - `[~]` 部分适配:已有识别、cache、脚本入口或假 runner,但真实 STEP 样例、命令细节或结果守门还要继续补。 - `[ ]` 待适配:SCDM 可能能做,但本软件还没有完成结构化识别、命令脚本和结果校验闭环。 -核心路线只保留下面这一棵树。前半段是 SCDM 后端闭环,后半段是按客户高频和 SCDM 可验证能力开放参数: +核心路线只保留下面这一棵树。前半段是 SCDM 后端闭环,中间是参数表和关系式入口,后半段是按客户高频和 SCDM 可验证能力开放参数: ```text SCDM-first 主路线 @@ -199,10 +158,11 @@ SCDM-first 主路线 │ ├── 1. SCDM 识别与缓存 │ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP] -│ ├── [x] [scdm_raw_features.json -> 保存 SCDM 结构化对象、几何属性、命令候选和限制原因] +│ ├── [x] [scdm_raw_features.json -> 保存 SCDM 结构化对象、几何属性、模型树、命令候选和限制原因] │ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数] │ ├── [x] [cache 命中 -> 点击对象只查缓存,不重复启动 SCDM] -│ ├── [~] [probe 深度 -> Face / Edge / Body 已稳定遍历,Hole / Round API 真实样例继续补] +│ ├── [x] [cache 守门 -> 模型指纹或 mapperRevision 不匹配就丢弃,避免旧缓存把圆角/槽误当孔] +│ ├── [~] [SCDM 模型树 -> 已读取 Pattern / Component / Solid 层级证据;下一步用于阵列和组件参数定位] │ └── [x] [不能产品化的 raw 结果 -> 只进诊断,不进客户参数表] │ ├── 2. 能力字典、参数表和用户入口 @@ -211,23 +171,31 @@ SCDM-first 主路线 │ ├── [x] [不可改 -> UI 阶段直接说明是未实现、命令不可用、识别不完整还是风险过高] │ ├── [x] [参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮] │ ├── [x] [导出参数 -> 勾选输入参数后生成组件目录 main.py 和参数 schema] -│ └── [~] [关系式 -> 作为参数表上游输入,计算后回填目标值,已阻止自引用/重复目标/循环依赖] +│ └── [x] [参数变更收集 -> 用户可一次修改多个目标值,统一交给执行层] │ -├── 3. SCDM 参数化建模执行 +├── 3. 关系式与参数联动 +│ ├── [x] [公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全] +│ ├── [x] [添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式] +│ ├── [~] [公式求值 -> 添加公式后立即计算目标值并触发 SCDM 修改;启停/删除公式后按公式快照回滚或按剩余启用公式重算,真实 SCDM 多公式样例待补] +│ ├── [~] [批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务] +│ ├── [~] [ID 续接 -> 修改后按新 cache 重写公式里的 Face / Edge / 特征 ID;复杂拆分/合并需要提示重新选择] +│ └── [~] [关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接;优先级和更细错误定位待补] +│ +├── 4. SCDM 参数化建模执行 │ ├── [x] [scdm_edit_job.json -> 记录模型、对象签名、能力、目标值、输出路径和超时] │ ├── [x] [/RunScript 后台执行 -> 成功写 result.step/result.json,失败写 error.json] │ ├── [x] [face.offset / hole.diameter / hole.position -> ICEPAK 真实 SCDM 回测通过] │ ├── [~] [slot.width / slot.depth / slot.position / boss.diameter / boss.height / boss.position -> runner、UI gate 和目标校验已接,真实样例待补] │ └── [~] [round.radius / chamfer.distance / feature.fill / feature.delete_round_or_chamfer -> 脚本和假 runner 已接,真实样例待补] │ -├── 4. 结果校验、回滚和 ID 续接 +├── 5. 结果校验、回滚和 ID 续接 │ ├── [x] [OCCT 回读 result.step -> 检查输出文件、B-Rep 和基础模型规模] │ ├── [~] [目标回测 -> 已覆盖数值目标、位置目标、目标特征消失和未编辑对象漂移] │ ├── [~] [ID 续接 -> 简单唯一匹配已接,歧义/拆分/合并样例继续补] │ ├── [~] [关系式刷新 -> 修改后按新 cache 重写 Face / Edge / 特征 ID] │ └── [x] [失败 -> 回滚原模型并给出明确原因] │ -├── 5. 当前已开放或正在开放的 SCDM 能力 +├── 6. 当前已开放或正在开放的 SCDM 能力 │ ├── [x] [Face 偏移 -> face.offset / OffsetFaces] │ ├── [x] [孔直径 -> hole.diameter / StandardHoles 或同轴 OffsetFaces] │ ├── [x] [孔位置 -> hole.position / Move] @@ -242,13 +210,14 @@ SCDM-first 主路线 │ ├── [~] [倒角距离 -> chamfer.distance / Chamfer,只开放 SCDM 明确返回等距倒角证据的倒角] │ ├── [~] [删除圆角/倒角 -> feature.delete_round_or_chamfer / Fill 或 Delete,真实 STEP 回测待补] │ ├── [x] [阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死] -│ └── [~] [局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心] +│ ├── [~] [局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项] +│ ├── [~] [阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补] +│ └── [~] [壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补] │ -├── 6. 下一批只按 SCDM 能力适配 -│ ├── [ ] [阵列实例位置 -> pattern.instance_position] -│ └── [~] [壳体厚度 -> shell.thickness,薄壁候选配对已进 cache,真实命令和 STEP 回测待补] +├── 7. 下一批只按 SCDM 能力适配 +│ └── [ ] [SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估] │ -└── 7. 交付与兜底边界 +└── 8. 交付与兜底边界 ├── [x] [软件进度 -> 只展示能识别、不能识别、能修改、不能修改] ├── [x] [日志 -> 记录本次使用 SCDM / OCCT / Analysis Situs 哪个后端] ├── [x] [Analysis Situs -> 只做辅助定位、兜底识别和开源对照] @@ -257,146 +226,103 @@ SCDM-first 主路线 └── [ ] [SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估] ``` -短期开发只做第一批能力,不追求把 SCDM 识别到的所有候选都立即开放。`scdm_raw_features.json` 保留 SCDM 发现的全部结构化候选,供后续扩展;`scdm_feature_cache.json` 只保存已经映射到能力字典、能执行、能校验、能解释失败原因的产品化能力。`geometry_candidate_hints` 只表示“有几何证据值得继续分类”,不能当作可编辑参数展示给客户。`软件进度` 弹窗只展示客户关心的能力边界:哪些能识别、哪些不能稳定识别、哪些能修改、哪些暂不能修改;cache 数量、probe 证据、Runner 开放数等后台细节留在日志和开发诊断里。这样既能利用 SCDM 的专业识别和直接建模能力,也不会把未验证的内部候选暴露给客户。 +## 关系式实现流程 -### Analysis Situs 辅助定位(非主线后端) +关系式是 SCDM-first 的上层参数驱动入口,不绕过 SCDM 手写几何。公式只负责把 `Face87.直径 = Face85.半径`、`Face92.位置 = Face85.位置 + (0, 0, -3.5)` 这类用户意图求值成一组目标参数;真正改模型仍然生成 `scdm_edit_job.json`,交给 SCDM 执行,再由本软件做校验、回滚和 ID 续接。 -一句话状态:Analysis Situs 不再作为当前主识别路线继续接入;它保留为 SCDM-first 路线里的辅助定位、兜底识别和开源对照验证。当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”,能把拆面圆柱孔组用于整孔高亮、参数表兜底和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py` 的 `external_*` 关系证据。 +当前策略: -当前已完成: +- 输入框与选择模式解耦,关系式可以引用当前模型里任意已知 `Face`、`Edge`、`Solid` 或能力对象。 +- 补全只补对象和参数名,不要求用户理解底层 capability key。 +- 添加公式后会立即求值并尝试执行;失败时公式保留原因,模型不被破坏。 +- 修改后若对象 ID 能唯一续接,公式文本会自动改成新 ID;若发生拆分、合并或歧义匹配,会提示重新选择。 +- 已支持公式启停、删除后按启用公式重算或恢复公式基准;公式数字可写 `mm`、`cm`、`m` 或“毫米/厘米/米”并统一换算为当前参数表使用的毫米值;可把当前公式组导出为 JSON,也可从 JSON 批量导入并整组重算;后续要补更细错误定位和多公式优先级。 -- `[x]` 本地 CLI 桥接:`step_editor/asitus_bridge.py` 会优先查找 `third_party/asitus_probe_tools_build/*/recognize_holes.exe`,再兜底查找旧的 `third_party/asitus_probe_build/*/recognize_holes.exe`,也支持通过 `STEP_EDITOR_ASITUS_RECOGNIZE_HOLES` 指定路径;找不到时不会影响主程序,会退回内部识别。 -- `[x]` 可提交 probe 源码:`tools/asitus_probe/` 是当前 Analysis Situs probe 的受控源码,构建输出仍放在被忽略的 `third_party/asitus_probe_tools_build/`。 -- `[x]` JSON 解析:支持读取 Analysis Situs 输出的 `holes[].faceIds` / `holeFaceIds`,并去重成孔组。 -- `[x]` Face 编号映射:Analysis Situs AAG Face 通常是 1-based,程序会映射回 Python/Qt 里使用的 0-based Face 编号。 -- `[x]` UI/模型使用:加载 STEP 后会后台预识别孔组;识别完成后,点击半片圆柱 Face 可以按完整孔组高亮和生成参数。 -- `[x]` 缓存与回退:`StepModel` 会缓存外部孔组结果;如果外部 CLI 不存在或失败,会使用 `recognition_graph.py` 的内部 Face 几何图做通孔拆面识别兜底。 -- `[x]` AAG 轻量关系摘要:新 probe 会输出 `faces`、`adjacency`、`surfaceSummary` 和 `angleSummary`;Python 会缓存每个 Face 的外部邻接 Face、曲面类型和邻接角度类型,作为识别证据和后续关系增强入口。 -- `[x]` 几何关系摘要:新 probe 会输出 `geometricRelations`、`geometricRelationSummary` 和 `geometricRelationMode`,当前覆盖 `coplanar`、`parallel`、`perpendicular`、`coaxial`、`parallel_axis` 和基于 AAG smooth 角度的 `tangent`;Python 会映射回本软件 Face 编号,并在 `recognition_graph.py` 中作为 `external_*` 关系参与统计、拆分圆柱合组和通孔置信度加权。为避免大 STEP 生成巨大 JSON,超过当前阈值时会从全量 pair 扫描降级为只保留 AAG smooth/tangent 关系。 -- `[x]` 候选置信度和排序增强:`recognition_priority.py` 会按 `coaxial/tangent/coplanar/parallel/perpendicular/parallel_axis` 给外部关系加权;候选扫描会把 Analysis Situs 关系摘要带到候选行里,同一类特征内部优先显示外部关系证据更强的候选。 -- `[x]` 槽 / 凸台 / 圆角辅助识别:`StepModel` 会把 AAG 角度和几何关系汇总成 `analysis_situs_feature_hint_*`,用于提示外部关系更支持槽、凸台还是圆角;这只影响识别摘要、候选置信度和排序,不直接开放新的编辑执行路径。 -- `[x]` 回归守门:`scripts/verify_asitus_hole_bridge.py` 已加入 `--quick`,验证孔组 JSON 解析、Face 编号映射、异步安装结果和整孔选择。 +## 交互稳定性和大模型策略 -本地重新编译 probe: +大模型上最容易出问题的不是单一计算慢,而是显示 actor 更新、后台线程结束、SCDM cache 刷新和 VTK 相机旋转同时发生。当前守门原则: -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\build_asitus_probe.ps1 -``` +- STEP 读取和显示网格生成走 `LoadWorker` 后台线程,导入失败不替换当前模型。 +- 大模型首屏优先显示面片,默认延后全量边线和全量识别预热。 +- 鼠标旋转时不做自动选择;悬停和普通点选只做轻量拾取。 +- 边线 overlay、局部 actor 替换和后台显示刷新会在相机交互结束后再装入场景,降低“导入后旋转一下偶发闪退”的概率。 +- SCDM probe 对大模型按需启动:用户点击对象后才补识别;识别完成前不把本地圆柱兜底猜测当成已确认孔/槽能力。 -当前不再作为主线推进的内容: +如果仍出现偶发闪退,优先收集:模型路径、导入后是否立刻旋转、是否正在补边线、是否正在 SCDM probe、控制台输出和 `local/scdm/` 对应目录。 -- `[ ]` 不再把 Analysis Situs 的完整 AAG 关系图接成主识别数据源;主识别和主修改优先走 SCDM,Analysis Situs 只增强孔、槽、凸台、圆角候选的置信度和排序。 -- `[ ]` 不再优先用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;这些高频能力按 SCDM capability 字典逐项产品化。 -- `[ ]` 不再让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;它们只作为外部证据,真正能不能改由 SCDM cache、能力字典、编辑计划和结果守门共同决定。 -- `[ ]` 还没有把 Analysis Situs CLI 编译产物纳入打包流程;本地没有 `recognize_holes.exe` 时,程序只走内部识别,不会报错退出。 -- `[ ]` 暂不做 pybind11 直连;当前 Python 调外部 CLI 的方式更适合作为兜底工具,避免把开源 C++ 分析库直接塞进主 GUI 进程。 +## Analysis Situs 辅助定位(非主线后端) -后续只在两种情况下继续动 Analysis Situs: +Analysis Situs 不再作为当前主识别路线继续接入;它保留为辅助定位、兜底识别和开源对照验证。当前已经完成孔组识别桥接、AAG 轻量关系摘要和基础几何关系摘要,可用于整孔高亮、参数表兜底、候选置信度和 SCDM 结果对照。 -1. SCDM 不可用,需要无商业后端兜底识别孔组或轻量关系。 -2. 需要开源结果和 SCDM probe 结果做对照,帮助判断 SCDM 识别是否漏掉明显几何关系。 +边界很明确: + +- `[x]` 可读取外部孔组 JSON,并把 1-based AAG Face 映射成本软件 0-based Face。 +- `[x]` 可缓存邻接、曲面类型、共面、同轴、平行、垂直和相切摘要。 +- `[x]` 可作为 `recognition_graph.py` 的外部证据,提高孔、槽、凸台、圆角候选排序。 +- `[ ]` 不把 Analysis Situs 的完整 AAG 图接成主识别数据源。 +- `[ ]` 不让 Analysis Situs 直接驱动一级/二级/三级编辑计划。 +- `[ ]` 不把 C++ CLI 编译产物纳入当前打包;找不到 CLI 时主程序不报错退出。 + +## 本地 OCCT 兜底能力的当前基线 + +本地 OCCT 兜底能力继续保留,用来在没有 SCDM 或 SCDM 失败时处理已经验证过的少量快路径,也用于自动化回归。它不再代表新增能力主线。 + +| 对象 | 本地兜底现在能做 | 主要边界 | +| --- | --- | --- | +| Face | 平面 Face 的 `偏移`、部分矩形/简单全平面 Face 的面内长度和面内宽度、壳体厚度、圆柱端盖/完整圆柱侧面高度。 | 自由曲面、复杂内孔面、非矩形复杂局部重建、二级/三级传播不承诺。 | +| 孔/槽 | 规则圆柱孔直径/半径/位置、盲孔/盲槽深度、槽宽、槽深、弧长、弧角、长圆槽总长度和中心距。 | 螺纹孔、复杂槽、多槽联动、孔组阵列联动不走本地新扩展。 | +| Edge | 直线 Edge 长度/端点、圆边半径/相邻轴心、椭圆主/小半径、部分圆角/倒角。 | 任意复杂曲线 Edge、复杂链式圆角/倒角不承诺。 | +| 凸台/圆角/壳体/解析面 | 有专项回归覆盖的简单样例。 | 新客户入口优先等 SCDM capability 适配。 | + +Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口): + +- 已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑。 +- 已验收:Face 的一级关系定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。 +- 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、手写孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动。 + +孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口): + +- 已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度` 和 `封堵/删除孔`。 +- 已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`、`圆弧角度`、`开口角度`、`轴心`、`总长度` 和 `中心距`。 +- 未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动、螺纹孔语义编辑、任意草图槽、跨复杂面槽、手写孔底/槽底带动台阶的二级/三级传播。 ## 怎么运行 -你第一次配置或运行这个项目时,可以按下面步骤来。命令默认在 Windows PowerShell 里执行。 - -1. 进入项目目录。如果你的仓库不在这个路径,把命令里的路径换成你的实际目录: +Windows PowerShell: ```powershell cd C:\Users\admin\Desktop\python-occt -``` - -2. 如果你已经有 `pyocc` 环境,直接激活: - -```powershell conda activate pyocc +python main.py ``` -3. 如果你是第一次配置环境,先用仓库里的 `environment.yml` 创建环境,再激活: +第一次配置环境: ```powershell conda env create -f environment.yml conda activate pyocc ``` -4. 先跑一次烟测,确认入口和依赖能正常加载。这个命令不会进入交互窗口: +烟测: ```powershell python main.py --smoke-test ``` -5. 快速启动程序。此时不读取默认模型,窗口会先进入空场景: - -```powershell -python main.py -``` - -6. 启动时直接打开指定 STEP 文件。只有显式传入路径时,程序才会启动后自动读取该模型: - -```powershell -python main.py path\to\model.step -``` - -7. 也可以先启动程序,在左侧 `STEP文件` 区域点击 `导入几何模型` 选择 `.step` / `.stp` 文件;选择后会立即读取模型。`读取模型` 用于按当前路径重新加载。 - -8. 打开仓库自带的简单立方体测试模型: +直接打开指定 STEP: ```powershell python main.py assets\models\cube_10mm.step +python main.py assets\models\geom_extract.step ``` -9. 重新生成简单立方体测试模型: - -```powershell -python scripts\generate_cube_step.py -``` - -10. 验证几何修改基线。当前主线按 SCDM capability 适配推进;下面这个入口仍保留历史 OCCT/一级编辑回归,用来证明本地兜底能力、UI 和文档守门没有退化: - -```powershell -python scripts\verify_first_level_edit_suites.py -``` - -只想先跑轻量检查时: - -```powershell -python scripts\verify_first_level_edit_suites.py --quick -``` - -`--quick` 会检查 smoke test、属性表规格、参数表 UI、参数导出组件生成、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 - -只验证某个阶段时,例如 Edge: - -```powershell -python scripts\verify_first_level_edit_suites.py --stage edge -``` - -也可以按阶段手动运行: - -```powershell -python scripts\verify_face_edit_suite.py -python scripts\verify_isolated_face_edit.py -python scripts\verify_hole_slot_edit_suite.py -python scripts\verify_edge_edit_suite.py -python scripts\verify_edge_isolated_edit.py -python scripts\verify_boss_edit_suite.py -python scripts\verify_round_chamfer_edit_suite.py -python scripts\verify_shell_edit_suite.py -python scripts\verify_analytic_surface_resize.py -python scripts\verify_boss_resize.py -python scripts\verify_hole_resize.py -python scripts\verify_slot_resize.py -python scripts\verify_hole_slot_isolated_edit.py -python scripts\verify_cylindrical_first_level_topology.py -``` - -11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行: +如果当前 shell 里没有 `conda`: ```powershell C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py ``` -如果用 `conda run` 跑验证脚本时出现 Conda 自身的 `UnicodeEncodeError`,先设置 UTF-8 输出再重跑: +如果 `conda run` 输出中文时报 `UnicodeEncodeError`: ```powershell $env:PYTHONIOENCODING='utf-8' @@ -404,1257 +330,149 @@ $env:PYTHONUTF8='1' C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python scripts\verify_first_level_edit_suites.py --quick ``` -## Windows 文件夹版打包 +## 验证 -如果你要把软件发给没有 Python 环境的人,当前先打包成文件夹版。推荐先激活 `pyocc`,再运行打包脚本: +当前整体验证基线: + +- `python scripts\verify_first_level_edit_suites.py --quick` 是一级编辑总验证入口的轻量回归,覆盖 smoke test、属性表规格、参数表 UI、关系式规则、参数导出组件生成、SCDM 后端发现、SCDM 状态、SCDM probe/cache、SCDM edit runner、SCDM result validator、Analysis Situs 孔组桥接、ICEPAK 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算和 README 守门。 +- `python scripts\verify_first_level_edit_suites.py --stage face` 覆盖 Face 阶段兜底基线。 +- `python scripts\verify_first_level_edit_suites.py --stage hole-slot` 覆盖孔/槽阶段兜底基线。2026-08-11,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径。 +- `python scripts\verify_first_level_edit_suites.py --stage edge` 覆盖 Edge 阶段兜底基线。 +- `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体和解析曲面兜底基线。 + +`--quick` 直接或间接检查的脚本: + +```text +main.py --smoke-test +verify_property_editor_specs.py +verify_property_card_editor_ui.py +verify_relation_formula_rules.py +verify_parametric_component_export.py +verify_scdm_backend.py +verify_scdm_status.py +verify_scdm_probe_pipeline.py +verify_scdm_edit_runner.py +verify_scdm_result_validator.py +verify_asitus_hole_bridge.py +verify_icepak_cylindrical_region_selection.py +verify_feature_recognition_summary.py +verify_first_level_fact_graph.py +verify_associated_features.py +verify_first_level_acceptance_docs.py +``` + +完整阶段脚本: + +```text +verify_face_edit_suite.py +verify_isolated_face_edit.py +verify_hole_slot_edit_suite.py +verify_edge_edit_suite.py +verify_edge_isolated_edit.py +verify_boss_edit_suite.py +verify_round_chamfer_edit_suite.py +verify_shell_edit_suite.py +verify_analytic_surface_resize.py +verify_cone_semi_angle_isolation.py +``` + +真实 STEP 失败项要优先补到对应 SCDM 或本地兜底回归里,不能只靠手动操作说明“这次能过”。 + +开发时常用静态检查: + +```powershell +python -m compileall -q main.py step_editor scripts +git diff --check +``` + +## 怎么使用 + +- 右侧是 3D 模型窗口,左侧是操作面板。 +- 鼠标左键拖动旋转,滚轮缩放,中键或 VTK 默认组合平移。 +- 只有明确单击模型才选择对象;拖动旋转后鼠标落到某个 Face 上不会自动改选。 +- 选择模式使用英文:`Part`、`Solid`、`Face`、`Edge`、`Feature`。 +- `Feature` 表示把点到的对象交给 SCDM/cache/兜底识别去解释成孔、槽、凸台、圆角、阵列、壳体等候选;它不是说 STEP 文件里原本带有 CAD 历史特征。 +- `特征参数` 只显示可修改输入参数;面积、识别摘要、一级关系、关联探测和其它诊断不混入参数表。 +- 用户可一次修改多个目标值,统一点击“参数化建模”。 +- “软件进度”只显示客户关心的能识别、不能识别、能改、不能改;SCDM cache 数、probe 细节和内部对象数量留在日志。 + +## 当前版本边界 + +- 不恢复原 CAD 历史树。 +- 不把 SCDM/SpaceClaim 商业内核打包进本软件;只检测客户本机安装和许可证。 +- 不承诺 SCDM 人工界面里能做的所有操作都已经产品化到本软件。 +- 不把 SCDM raw 技术名直接暴露给客户;必须先进入能力字典并有中文参数、脚本和校验。 +- 不把 Analysis Situs 或本地 OCCT 继续扩成主识别/主编辑后端。 +- 复杂 STEP 上仍可能出现 SCDM 命令失败、对象 ID 无法唯一续接、关系式目标歧义或结果漂移;此时必须回滚并说明原因。 + +## 交付检查清单 + +交付前先确认四件事: + +1. 本地运行入口正常: + +```powershell +conda activate pyocc +python main.py --smoke-test +``` + +2. 轻量回归通过: + +```powershell +python scripts\verify_first_level_edit_suites.py --quick +``` + +3. Git diff 没有空白错误,CRLF 提示可以接受但不要出现真实 whitespace error: + +```powershell +git diff --check +``` + +4. 只提交源码、脚本、README 和明确需要交付的测试模型: + +```powershell +git status --short +``` + +不提交 `local/`、`dist/`、`third_party/`、`assets/screenshots/`、临时截图、临时 markdown、第三方参考项目、SCDM 运行中转文件和本地打包产物。 + +## Windows 文件夹版打包 ```powershell conda activate pyocc powershell -ExecutionPolicy Bypass -File .\scripts\package_windows.ps1 ``` -脚本会先跑源码烟测,再用 PyInstaller 生成 `dist\GeometryParametric\main.exe`,并自动把仓库自带 STEP 模型复制到 `dist\GeometryParametric\model\`。如果本机存在 `local\几何参数化测试说明.txt`,打包后的文件夹里也会带同名说明文档,方便模型工程师直接按说明测试。 - -最终压缩包固定生成在: +输出: ```text dist\GeometryParametric_windows_x64.zip ``` -这个 `dist` 目录和 zip 包是本地构建产物,默认不提交到 Git。 +`dist` 是本地构建产物,默认不提交 Git。 -开发时常用的本地检查: +## 提交与忽略 + +- `local/`、`dist/`、`third_party/`、`assets/screenshots/`、临时截图、临时 markdown 和第三方参考项目不提交。 +- 仓库自带可复现测试 STEP 可以提交,例如 `assets/models/ICEPAK-NATURAL.stp`。 +- 本次 SCDM-first 交付版建议提交命令: ```powershell -python -m compileall -q main.py step_editor scripts -git diff --check -``` - -## 最终要实现什么 - -最终目标是做一个面向 STEP 模型的交互式工具: - -- 加载 `.step` / `.stp` 模型文件。 -- 展示三维模型,支持旋转、缩放、平移。 -- 读取 STEP 中的零件、装配、Solid 等结构。 -- 用户可以选择零件、Solid、Face、Edge 和几何特征。 -- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 -- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/偏移”会被标成受限修改。 -- 用户可以修改可控的特征,比如 Face 的偏移、稳定矩形/简单全平面 Face 的面内长度和面内宽度、壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。Face/直线 Edge 的 `中心` 移动后端能力暂时保留,但不在特征参数表开放。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度、简单等半径圆角链半径/弧长并通过 `建模意图` 选择重建圆角或整体调整、已有等距倒角距离、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 -- 用户可以撤销/重做修改,避免一步编辑做坏。 -- 用户可以导出修改后的完整模型。 -- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 - -需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 - -## 几何修改语义原则 - -所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 - -所以每个可编辑行都应尽量说明本次采用的建模意图、执行影响和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、Face 拉伸/切除、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 - -Creo 资料的参考价值不只是统一术语,还要用来组织功能入口和操作流程。界面不应优先暴露底层 B-Rep 字段,而应先把选中对象映射到 CAD 用户熟悉的建模形式,例如拉伸/切除、偏移、孔、槽、倒圆角、倒角、抽壳、拔模、移动几何和缩放特征;只有当识别结果足够稳定,才把对应的可改尺寸放出来。 - -当前 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,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。 - -Edge 一级关系的主要高风险入口也接入同一条隔离执行通道,包括修改 Edge 长度、直线 Edge 起点/中心/终点移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主/小半径、已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有简单倒角距离、直线 Edge 新增圆角、对称倒角、不等距倒角和距离+角度倒角。Edge 操作仍然不是通用 CAD 历史特征编辑;复杂曲线、变半径圆角链、复杂倒角链、无法确认一级邻域或 OCCT 倒圆/倒角失败时会回滚或提示失败,但这些危险计算不应再拖垮主界面进程。 - -Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示底层 OCCT 异常。当前会尽量附带诊断信息:操作对象、Face 类型、目标值/目标位置、一级边界 Edge/Vertex、共享边相邻 Face、内孔/内边界数量、已识别的端盖专用路径,以及是否可能因为孔壁、槽底、台阶终点等二级/三级关系暂未传播而失败。 - -复杂大 STEP 里的多边界平面端盖不再在 UI 线程同步生成完整 `push_pull_plan`。界面只做轻量预检查并标记高风险,然后把完整一级关系识别、解析重建或快速阻止放到后台隔离进程里执行,避免用户点击 `参数化建模` 后先卡住几秒到几十秒。 - -如果轻量预检查已经能判断出风险不可接受,比如复杂大模型里的多内孔端盖向内切除已经接近切穿,或需要判断孔底、槽底、台阶背后的二级关系,界面会直接阻止并说明原因,不再提交隔离子进程等待失败返回。浅范围内的多内孔端盖向内收缩会走后台/隔离子进程里的一级边界侧壁重建。 - -复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面内长度/面内宽度(局部重建)`、`中心(局部重建)` 或 `偏移(局部重建)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`壳体厚度(拉伸/切除)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和拉伸/切除距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 - -普通平面 Face 的核心修改路径现在还会在模型层做结果守门:执行后必须通过 B-Rep 校验、能重新找到目标 Face 的目标值,并且能重新识别该 Face 的一级共享边邻域。矩形、多边形 Face 会检查边界 Edge 和 Vertex;圆形端盖这类解析边界会允许 1 条闭合圆 Edge。圆锥参考半径/半角、球面半径、环面半径这类解析 Face 也会强制校验目标值和 B-Rep。若目标值没达到、一级邻域丢失或 B-Rep 无效,模型层会恢复操作前 shape,不把坏结果交给界面或导出流程。 - -`拉伸/切除` 对一次性较大的向外拉伸不会按绝对距离一刀切阻止;如果距离相对当前 Face 或所属实体跨度较大,会标为高风险并走隔离子进程执行。这样可以允许用户做类似 CAD 里的大距离拉伸,同时如果 OCCT 布尔失败、超时或返回无效 B-Rep,主程序和原模型仍保持不变。 - -实心圆柱或同轴空心筒体的端盖拉伸/切除会优先走解析/局部重建:选中圆柱上/下底面或环形端面时,程序会识别相邻外圆柱面、内孔圆柱面和端盖一级关系。纯简单圆柱/筒体会直接重建新的完整高度;如果同一 Solid 上还有不碰端盖边界的侧向缺口、台阶等额外特征,向外拉伸会只生成旧端盖到新端盖之间的局部延长段去补料,向内切除会只切除旧端盖到新端盖之间的局部段,避免拿整根新圆柱/新筒体或通用平面 prism 和原模型做大范围布尔。同轴阶梯/盲孔式环形端盖允许内外圆柱侧面的旧深度不同;向外拉伸时会保留旧侧面的复杂底部裁剪,只删除旧端盖,并补上旧端盖到新端盖之间的侧面段和新端盖,结果校验会优先检查新增端盖而不是全模型扫描。如果端盖边界本身连接了槽、缺口或台阶等额外一级相邻 Face,向外拉伸会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长;向内切除如果没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短;如果越过槽底/台阶内侧终点,会快速阻止并说明原因。无法确认同轴圆柱/筒体一级关系时,才退回隔离子进程里的通用拉伸/切除和回滚保护。 - -带多个内孔/内边界的大平面端盖如果不能归类成同轴圆柱/筒体端盖,会优先改走 `boundary-shell-rebuild`:移动当前端面,并把该端面的一级边界 Edge 沿拉伸/切除方向重建侧壁后局部缝合,避免复杂 STEP 退回 180s 通用布尔慢路径。向外拉伸会生成延长侧壁;浅范围向内收缩会重建缩短后的侧壁并保留内孔线圈;如果向内距离接近切穿,或需要判断槽底、台阶终点、孔壁背后的二级/三级关系,会快速阻止并把原因写清楚。 - -选中普通完整圆柱侧面修改 `高度` 时,局部语义会换算成端盖拉伸/切除:执行后必须重新检测到同轴、同半径、目标高度的圆柱侧面,并且圆柱一级边界/相邻 Face 关系没有丢失,否则自动回滚。`缩放特征` 语义会沿圆柱轴向缩放所属特征或 Solid,执行后按轴向跨度校验目标高度;这条语义可能影响同一对象上的孔距、台阶位置和其它轴向尺寸。 - -产品目标里必须明确保留“用户选择建模意图”的能力。以正方体改一条 Edge 为例,用户应该能区分并选择:只拉长这一条 Edge 让相邻面自然变斜、移动相关端面让正方体变成长方体、保持某些面垂直、固定某些边/点不动,或者让某些相邻边跟随变化。这个原则不是 Edge 专属,Face、孔、槽、凸台、圆角、Solid 和整件特征也一样:当一个目标值存在多种合理 CAD 语义时,界面应让用户选择“改法、约束、影响对象和哪些几何跟随变化”,不能无脑套用一个默认结果。当前 Edge 的 `移动端面/保持垂直` 就是这类语义:它不是只拉歪一条边,而是让端面和相关边跟随移动,使相邻平面尽量保持垂直。 - -设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。 - -界面里的 `特征参数` 只显示能直接修改的输入参数,不再混入 `建模形式`、`推荐操作`、`一级关系`、`关联探测` 这类诊断说明。程序内部仍会计算这些信息,用来决定哪些参数可显示、哪些操作要阻止,以及失败时该给出什么原因。对于同一个尺寸存在多种语义的情况,例如圆柱直径/半径,用户仍通过 `建模意图` 明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 - -## 开源复用原则 - -三维几何计算要优先复用成熟开源库和几何内核,不要自己从零实现已经可靠存在的底层能力。当前项目已经通过 `pythonocc` 复用 OpenCascade,用它处理 STEP 读写、B-Rep 拓扑、布尔、倒角/圆角、曲面和基础几何计算;后续如果发现合适的开源库,也可以接入用于网格、约束求解、几何分析或文件格式转换。 - -但不能直接从 GitHub 随便复制功能代码。引入外部项目之前必须先看许可证、维护状态、依赖体积、Windows/Conda 兼容性,以及它是否能接进本项目的选择状态、属性表、确认窗口、后台执行、失败回滚、操作历史和验证脚本。MIT/BSD/Apache 这类宽松许可证通常更容易复用;GPL/AGPL 代码要非常谨慎,避免影响整个项目授权。优先原则是“复用库和算法能力,少复制产品代码;能通过依赖调用就不要粘贴实现”。 - -## 最小系统先实现什么 - -最小系统目标是先把完整工作链路打通: - -- 能打开 STEP。 -- 能显示模型。 -- 能读取 STEP 中的零件/装配标签。 -- 能选择零件、Solid、Face、Edge 和特征。 -- 能显示选中对象的基本属性。 -- 能高亮选中对象。 -- 能导出整个当前模型。 -- 能导出选中的零件。 -- 能撤销/重做编辑。 -- 先实现少量可控编辑操作,用于验证后续特征编辑路线。 -- 当前开发顺序固定按上面的 `SCDM-first 主路线` 推进:先打通 SCDM 识别、能力字典、参数表、执行、校验和 ID 续接闭环,再按 SCDM 已能结构化表达的 Face、孔、槽、凸台、圆角/倒角、阵列、壳体能力逐项适配。历史 OCCT/一级编辑套件继续作为兜底回归,不再作为新增能力主线。 - -## 能改哪些,一分钟版 - -如果你只想快速判断一个 STEP 模型现在能不能改,先看这张表;更细的参数和风险在下一节。 - -| 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | -| --- | --- | --- | --- | -| Edge / 边 | 直线边的长度、起点、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边;直线 Edge 中心移动暂不在参数表开放。 | -| Face / 面 | 偏移、壳体厚度;稳定矩形/简单全平面 Face 的面内长度、面内宽度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;稳定矩形特征按尺寸局部重建相邻面;或缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;Face 中心移动暂不在参数表开放;带复杂内孔的面、普通曲面、复杂壳体局部区域、非完整圆柱侧面高度。 | -| 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | -| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心移动暂不在参数表开放。 | -| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形中心移动暂不在参数表开放。 | -| 圆角 / 倒角 | 部分已有圆角的半径/弧长;简单等半径圆角链半径/弧长;直线边新增圆角或倒角;变半径/复杂圆角链可识别并快速阻止。 | 已有简单圆角和简单等半径圆角链先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;变半径圆角链和复杂 blend 不进入危险重建。 | 大链式 blend、变半径圆角稳定重建、复杂角部补面、支撑面不明确的圆角。 | -| Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | - -## 当前可修改范围 - -这张表描述的是当前版本已经接入到界面或编辑链路里的能力。它们都是基于 STEP 的 B-Rep 结果几何做局部重建,不等于原 CAD 软件里的完整建模历史参数。 - -| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | -| --- | --- | --- | -| Face | 平面 Face 总是优先开放 `偏移`;只有识别为稳定矩形凸台/口袋或简单全平面实体时,才显示 `面内长度` 和 `面内宽度`。这些行会在 `建模意图` 里选择 `局部重建`、`拉伸/切除`、`移动特征` 或 `缩放特征`。面积是结果变量,只在诊断信息里展示。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除` 或 `缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面内长度/面内宽度` 是矩形平面特征在两个主方向上的驱动尺寸,不是任意曲面尺寸,不是面积,也不是模型高度;不满足稳定识别时这两行不会出现在特征参数表里。`偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会重建当前矩形特征,相邻面按新边界更新;`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;`中心` 移动后端能力暂时保留,但客户参数表不开放;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 | -| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `建模意图` 选择 `只改孔/槽壁` 或 `缩放特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`缩放特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | -| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `建模意图` 选择 `改底面深度` 或 `缩放特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`缩放特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`缩放特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | -| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁` 或 `缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽和深度。复杂/交叉槽会识别为受限,不暴露伪可改槽参数。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心移动暂不在参数表开放。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | -| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `建模意图` 里选择 `只改凸台`、`拉伸/切除端盖`、`移动凸台`、`缩放特征` 或 `移动特征`。多台阶矩形凸台的顶层规则台阶可以独立修改;中间过渡面会识别为复杂多台阶凸台并禁用伪可改参数。 | 复杂凸台、异形凸台、多台阶凸台整组联动和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | -| 已有圆角 / 倒圆面 / 简单倒角面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,简单等半径圆角链可以改整链半径/弧长,并通过 `建模意图` 选择 `重建圆角` 或 `缩放特征`。`重建圆角` 会先移除旧圆角面或链上圆角面,再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。选中简单等距倒角斜面时可以改 `倒角距离`,当前同样走“移除旧斜面 -> 恢复锐边 -> 重新倒角”。相邻不同半径圆角 Face 会被标成变半径圆角链,计划阶段直接提示暂未实现稳定重建。 | 大链式 blend、复杂角部补面、支撑面不明确、变半径圆角稳定重建、复杂倒角链、不等距已有倒角或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | -| Edge | 直线 Edge 可以尝试改目标长度、起点坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍未作为稳定能力开放;复杂曲线、B-spline、Bezier 和无法确定局部变形区域的 Edge 不会在扫描列表或参数表里显示为可改长度入口;直线 Edge 中心坐标后端保留但参数表暂不开放;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | -| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(缩放特征)` 或 `直径(缩放特征)`;环面可以改 `主半径(缩放特征)`、`主直径(缩放特征)`、`小半径(缩放特征)` 或 `小直径(缩放特征)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | -| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | -| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | - -## 现在已经实现了什么 - -当前已经实现: - -- 启动后默认进入空场景,不自动读取 `assets/models/geom_extract.step`;用户点击“导入几何模型”后才加载模型,避免客户打开软件时被大 STEP 阻塞。 -- 支持打开其他 `.step` / `.stp` 文件。 -- 打开 STEP 时会在 `LoadWorker` 后台线程里完成读取和首屏显示网格生成;失败时当前模型保持不变。大模型不会再把 STEP 读取、B-Rep 网格生成和 VTK polydata 构建直接压在 UI 主线程上,并且默认只先显示面片,几千条 Edge 边线会在切到 Edge 选择或显式请求时再后台生成。 -- 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名。 -- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 -- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 -- 使用 OCCT 读取真实 B-Rep 拓扑。 -- 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 -- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 -- 支持选择模式: - - Part - - Solid - - Face - - Edge - - Feature -- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 -- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 -- 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。 -- SCDM 识别 cache 的本地 Face 映射现在使用轻量几何签名,只读取曲面类型、平面法向/偏移、圆柱轴线/半径等必要信息;不再为每个 Face 调完整 `quick_face_info()`,且签名生成放在后台 worker 内执行,不会在启动 SCDM probe 前卡住 UI。在 `geom_extract.step` 上,这一步从约 83 秒降到约 0.06 秒。 -- 拉伸/切除后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回拉伸/切除前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 -- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 -- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 -- 支持按当前选择模式输入 ID 直接选择零件 / Solid / Face / Edge / 特征。 -- Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如拉伸/切除前侧壁是 558,拉伸/切除后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 -- Face / 特征的 `复制 ID` 也优先复制 `逻辑 Face ID`,属性区会同时显示 `逻辑选择 ID` 和 `当前拓扑 Face ID`;这样编辑后拓扑编号变化时,用户仍能用原来的逻辑 ID 回到同一个面区域。 -- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 -- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 -- `Feature` 模式现在会把点到的Face解释为几何特征候选: - - 平面会识别为可拉伸/切除平面候选。 - - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算壳体局部区域厚度。 - - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。 - - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果共享边连通的同半径圆角 Face 形成 2 到 4 个面的简单小链,会标出链内 Face 并尝试整链半径/弧长重建;变半径或复杂长链会快速阻止。 -- `特征探测级别` 切到 `探测相邻特征` 或 `探测二级特征` 后,关联孔、槽、凸台等可改尺寸可以作为 `关联 Face` 参数行合并到同一张参数表里;`关联探测` 本身属于诊断信息,不占用特征参数表。 -- 当前选中对象表使用四列表格:`尺寸参数`、`当前值`、`建模意图` 和 `目标值`。表格只放可作为建模输入的尺寸,面积、识别摘要、一级关系、关联探测和其它只读诊断放在下方 `诊断信息(高级)` 中;用户改完目标值后统一点击 `参数化建模`,不再保留逐行执行入口。 -- 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 -- 模型层提供统一的一级拓扑事实包 `face_first_level_facts()`:它把当前主体 Face 区域、边界 Edge、边界 Vertex、共享边直接相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Face、孔、槽等编辑计划也会携带这份事实包;`只改当前 Face` 这类局部重建计划会在执行前先检查一级事实图是否 ready,若无法确认当前 Face、边界 Edge/Vertex 和共享边相邻 Face,会直接 blocked 并说明原因。Face 编辑成功后还会重建目标 Face 的一级事实图做结果校验,若目标值虽然达到但一级事实图不再成立,会自动回滚。后续孔、槽、凸台、圆角等候选识别应优先使用这份事实包,再叠加各自的语义判断和置信度。 -- Edge 阶段新增统一的一级拓扑事实包 `edge_first_level_topology()` / `edge_first_level_facts()`:它把当前 Edge、端点 Vertex、共享端点的相邻 Edge、直接包含该 Edge 的相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Edge 长度、起点/中心/终点坐标、圆角、倒角、圆边轴心和椭圆半径等编辑计划会携带这份事实包,后续 Edge 一级策略应优先用它判断“只改当前边、移动端面、相邻圆柱或缩放所属对象”的执行影响。 -- 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式: - - 零件名称 - - Solid 数量 - - Face 数量 - - Edge 数量 - - 面类型 - - 面面积 - - 平面法向 - - 圆柱面半径/直径 - - 边类型 - - 边长度 - - 圆边半径/直径 -- 属性面板现在还会显示更多几何调试信息: - - 零件 / Solid 的包围盒、尺寸、体积、重心、表面积。 - - Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 - - 平面Face的几何法向和按拓扑方向修正后的法向。 - - 平面Face的拉伸/切除向外方向、向内方向和方向判断置信度。 - - 圆柱Face的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。 - - Edge 的参数范围、起点、终点、长度中心、包围盒。 - - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 -- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 -- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 -- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face / 特征点选现在优先使用轻量信息,避免一次点击就扫描同域面、端盖和底面,当前选中对象表会先显示可安全快速得到的参数,点击具体修改或手动扫描时再生成完整编辑计划。 -- 模型边线默认渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔拉伸/切除后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 -- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 -- 支持两点测量: - - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 -- 特征模式会先显示特征来源Face、基础几何参数和当前可用的快速编辑入口;侧壁Face、同域圆柱范围、端面Face、疑似底面 Face、开口端相邻Face和边界Edge等深层信息会在扫描候选、执行编辑计划或历史定位时再计算。 -- 支持导出当前完整模型为 STEP。 -- 支持导出选中的零件为 STEP。 -- 支持导出选中的Solid为 STEP。 -- 支持导出选中的Face为 STEP;如果当前编辑计划、扫描候选或历史定位已经解析出明确特征区域,也可以导出这片相关区域。 -- 支持导出选中特征区域为 STEP。 -- 支持导出选中的Edge为 STEP。 -- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 -- 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 -- 导出完整模型、零件或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 -- 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。 -- 支持扫描可编辑对象: - - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 - - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - - 可拉伸/切除的平面Face。 - - 可调整孔径的圆柱Face候选。 - - 可调整槽/半孔宽度、深度、圆弧长度和轴心坐标的局部圆柱孔/槽候选。 - - 可调整盲孔/盲槽深度的圆柱Face候选。 - - 可调整圆柱凸台直径和高度的凸台候选。 - - 可添加新圆角的直线Edge候选。 - - 可添加倒角的直线Edge候选。 - - 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变。 - - 可调整圆Edge半径/直径的圆形或圆弧Edge候选。 - - 表格会显示当前值、状态、风险、置信度和说明。 - - 盲孔/盲槽深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 Face轴向位置计算。 - - 点击表格行会直接选中对应Face或Edge,方便继续执行拉伸/切除、孔径调整、槽/半孔宽度/深度/圆弧长度调整、凸台高度调整、盲孔/盲槽深度调整、Edge倒圆、Edge倒角或修改Edge长度。 -- 支持列出圆柱面候选特征。 -- 圆柱面候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。 -- 圆柱面候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 -- 圆柱面候选的类型下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免滚动面板时误改筛选条件。 -- 圆柱面候选表格会显示初步分类: - - `hole/groove candidate`:孔/槽一类的凹向圆柱面候选。 - - `round/fillet candidate`:小半径局部圆柱面,可能是圆角/倒圆。 - - `boss/outer-round candidate`:凸柱/外圆角一类的外向圆柱面候选。 - - `cylindrical face`:暂时无法明确分类的圆柱面。 -- 圆柱面候选表格会显示直径、角度跨度、估算高度和置信度。 -- 圆柱面候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。 -- 圆柱面候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。 -- 当前版本支持平面Face拉伸/切除。 -- 平面拉伸/切除会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 -- 拉伸/切除平面会先显示半透明预览体,表示拉伸/切除方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 -- 拉伸/切除平面现在会从选中Face出发,自动查找共面且相接/重叠的相邻Face,并把它们作为同一片平面区域一起拉伸/切除。 -- 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。 -- 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。 -- 拉伸/切除加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 -- Fuse/Cut 完成后会执行更强的拉伸/切除结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 -- 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。 -- 如果拉伸/切除本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 -- 拉伸/切除平面会先生成操作计划:检查距离是否为 0、方向置信度和相对Face尺寸的风险;能拉伸/切除时直接执行,不能拉伸/切除时会提示阻止原因。 -- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域Face,工具范围会优先覆盖整段同域圆柱。 -- 当前版本支持盲孔/盲槽深度调整: - - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 Face 的圆柱面候选开放。 - - 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。 - - 目标深度小于当前估算深度时,会从目标新底面到旧底面方向补料,让孔变浅。 - - 调整盲孔深度会先显示半透明预览体:红色表示加深切削范围,绿色表示变浅补料范围。 - - 孔深调整计划会记录当前深度、目标深度、变化比例、疑似底面、开口端和工具参数。 - - 当前深度会优先使用疑似底面 Face在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一Solid内轴线附近的平面圆盘候选。 -- 当前版本支持槽/半孔候选结构化识别: - - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`。 - - 会估算槽宽、圆弧长度和槽深。 - - 会识别槽圆柱Face和槽边界相邻Face,并在特征模式下一起高亮。 - - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 -- 当前版本支持槽/半孔宽度调整: - - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - - 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 - - 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。 -- 当前版本支持槽/半孔深度调整: - - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - - 输入目标槽深后,会按当前圆弧角度把槽深换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 - - 这仍是 B-Rep 几何近似,不是完整 CAD 槽深参数编辑。 -- 当前版本支持槽/半孔圆弧长度调整: - - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - - 输入目标圆弧长度后,会按当前圆弧角度把圆弧长度换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 - - 这仍是 B-Rep 几何近似,不是完整 CAD 槽参数编辑。 -- 当前版本支持槽/半孔圆弧角度和开口角度调整: - - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - - 输入目标圆弧角度时,程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 - - 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。 - - 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。 -- 当前版本支持长圆槽/槽孔总长度和中心距调整: - - 你可以直接在当前选中对象表里修改 `槽孔总长度` 或 `槽孔中心距`,不需要先填写配对端 Face ID。 - - 点击修改时,程序会自动寻找另一个半圆槽端;识别成功后保持槽宽不变,先补旧长圆槽体,再切出目标总长度。 - - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端 Face ID`,后续槽宽、槽深、圆弧长度、总长度和中心距调整会优先尝试使用这个手动配对端。 -- 当前版本支持壳体厚度调整: - - 对已找到投影重叠相对平面的平面候选开放。 - - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的拉伸/切除距离,保留相对平面不动。 - - 会先显示半透明拉伸/切除预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和拉伸/切除距离。 - - 这是局部 B-Rep 平面拉伸/切除近似,不是 CAD 壳命令或完整壳体参数编辑。 - - 当前选中对象表的 `壳体厚度` 会通过 `建模意图` 选择 `拉伸/切除` 或 `缩放特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。 - - 对简单全平面薄板/盒体,`缩放特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。 - - 对开口薄壁盒、槽腔这类上下文,程序会把 `完整抽壳/开口面编辑` 显示为受限能力;局部壁厚仍可尝试修改,但不会伪装成 CAD 的完整抽壳历史或开口面特征级重建。 -- 当前版本支持已有圆角/倒圆候选结构化识别: - - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - - 会估算已有圆角半径、圆弧角度和圆弧长度。 - - 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。 - - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径/弧长` 或 `修改已有圆角链半径/弧长`,点击后会选中该圆角 Face,并把圆角半径/圆角弧长输入框预填为当前估算值的参考目标值。 - - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - - 当前版本的已有圆角半径修改会先尝试移除圆角Face或简单等半径圆角链,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选和 2 到 4 个共享边连通面的简单等半径链开放,复杂 blend 或更长圆角链会提前阻止或回滚。 - - 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `建模意图` 选择 `重建圆角` 或 `缩放特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。 -- 当前版本支持圆柱孔封堵: - - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 - - 封堵会用略带半径重叠的圆柱补料体执行 Fuse。 - - 绿色半透明预览表示即将补上的材料范围。 - - 当前常用测试模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。 -- 当前版本支持圆柱凸台直径调整: - - 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。 - - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - - 当前选中对象表的 `直径` / `半径` 会通过 `建模意图` 选择 `只改凸台` 或 `缩放特征`;后者用于按目标尺寸比例均匀缩放所属特征或 Solid,不是凸台包络重建,会连带影响其它尺寸。 - - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - - 当前常用测试模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 -- 当前版本支持圆柱凸台高度和轴心坐标调整: - - 只对已识别为完整圆柱凸台、并找到可拉伸/切除端盖 Face 的候选开放。 - - 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖拉伸/切除距离。 - - 输入目标轴心坐标后,程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。 - - 这不是 CAD 历史里的拉伸特征高度参数;端盖识别不稳定或拉伸/切除计划被阻止时会拒绝执行。 -- 当前版本支持直线Edge添加圆角: - - 只对直线Edge开放。 - - 会先检查边长、相邻Face数量和目标半径相对边长的比例。 - - 蓝色半透明预览表示目标Edge和输入半径的大致范围。 - - 真实编辑使用 OCCT `BRepFilletAPI_MakeFillet` 在后台执行。 - - 这一步是“给直线边添加新圆角”,还不是“修改已有圆角半径”。 - - STEP 没有建模历史,某些看起来像直线边的对象仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 -- 当前版本支持直线Edge添加倒角: - - 只对直线Edge开放。 - - 会先检查边长、相邻Face数量和倒角距离相对边长的比例。 - - 当前选中对象表支持 `新增倒角距离` 的对称距离倒角、`新增不等距倒角D1/D2` 的两侧不同距离倒角,以及 `新增距离+角度倒角D/角度`。 - - 橙色半透明预览表示目标Edge和输入倒角距离的大致范围。 - - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 -- 当前版本支持修改Edge长度: - - 选中Edge后,`长度` 会自动填入当前Edge长度。 - - 对圆形/圆弧Edge,当前选中对象表会额外提供圆边半径和圆边直径可编辑行,内部会换算为等比例Edge长度,并通过 `建模意图` 选择自动、相邻圆柱或缩放所属。 - - `长度基准` 行可以选择自动、中心、固定起点、固定终点。 - - `长度` 行的 `建模意图` 可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属`。 - - `只改当前Edge` 会强制使用局部边形变;如果当前Edge不满足简单全平面多面体条件,会直接阻止,不会偷偷改成整体缩放。 - - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面拉伸/切除;对正方体这类模型,会得到更像“变成长方体”的结果。计划阶段会确认直接相邻面和移动端面能形成可验证的垂直关系;如果找不到端面拉伸/切除路径或关系不可验证,会直接阻止。 - - `缩放所属` 会强制对所属特征或 Solid 做轴向/整体缩放,是高风险兜底语义,会影响其它尺寸。 - - 对直线Edge,当前选中对象表会额外提供 `起点`、`中心` 和 `终点`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 - - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 - - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变形,四点非平面面会优先重建成连续曲面,避免从面中间出现对角折线。 - - 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。 - - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 - - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面拉伸/切除操作。 - - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge长度按比例换算成目标圆柱直径再执行局部编辑。 - - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 - - 椭圆 Edge 的 `椭圆主半径` / `椭圆小半径` 是单独的属性行:主半径会沿椭圆主轴方向单轴缩放,小半径会沿小轴方向单轴缩放;另一个半径方向尽量不动。 - - 椭圆 Edge 不再把通用 `长度` 作为默认可改入口;界面只开放语义更清楚的主半径/小半径。B-spline / Bezier 等复杂曲线 Edge 暂不在参数表里显示可改长度,避免把实验性 fallback 误当成稳定 CAD 参数。 - - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值;这条路径保留作内部回归和后备,不作为复杂曲线Edge的稳定界面入口。 - - `中心`、没有明确端面、或非圆边没有更明确局部路径时,后端仍可能进入整体/轴向几何缩放 fallback,但当前参数表会优先隐藏未稳定的复杂曲线长度入口。 - - 该 fallback 会影响所属零件/Solid的其它尺寸,因此必须按高风险处理,并依赖失败回滚/撤销兜底。 - - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 -- 支持零件 / Solid 级平移: - - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 - - 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。 - - 单Solid零件中平移Solid实际等同于移动整个零件 shape,程序会按中风险提示。 - - 平移操作会写入历史记录,并支持撤销/重做。 -- 支持零件 / Solid 级旋转: - - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。 - - 当前版本的旋转中心取目标对象的包围盒中心。 - - 单Solid零件中旋转Solid实际等同于旋转整个零件 shape,程序会按中风险提示。 - - 旋转操作会写入历史记录,并支持撤销/重做。 -- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 -- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 -- 首次加载默认使用中等清晰度显示网格,避免长期停留在粗网格预览;B-Rep 编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。 -- 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。 -- 鼠标悬停高亮做了节流和移动阈值;大模型会直接关闭悬停拾取高亮,避免鼠标路过复杂面片时触发 VTK CellPicker 卡顿。 -- 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。 -- 可编辑对象和圆柱面候选扫描已经在 `StepModel` 层做参数化缓存;同一模型、同一扫描范围重复打开候选列表时会直接复用结果,编辑、撤销/重做或重新加载模型后缓存会随拓扑刷新清空。 -- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 -- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 -- 当前版本支持圆柱孔/圆柱面扩大切削。 -- 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 -- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动孔` 时,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽会走 `移动槽/半孔` 语义,避免整圆柱工具误补材料。把 `建模意图` 设为 `移动特征` 时,会平移所属特征或 Solid。 -- 当前版本支持孔/槽 `直径` / `半径` 的 `缩放特征` 建模意图: - - 这条路线不会重切孔壁,而是按目标直径或半径与当前值的比例均匀缩放所属特征或 Solid。 - - 高度、厚度和同一对象上的其它尺寸会同比例变化。 - - 如果你只想让孔径变大/变小,优先在 `直径` 或 `半径` 行把 `建模意图` 设为 `只改孔/槽壁`;如果你的建模意图是让所属圆柱区域整体缩放,再设为 `缩放特征`。 -- 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 - - 会比较目标直径和圆柱面估算高度;比例异常时会提高风险等级。 - - 候选不是 `hole/groove candidate` 时会标为高风险并要求确认。 - - 圆角/倒圆候选会标为高风险,避免误切圆角。 - - 缩小孔径当前版本只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。 - - 选中Face或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。 - - 实际切削优先使用有限长度 cutter,按选中圆柱面的 V 参数范围加少量余量生成,不再默认贯穿整个零件。 - - 会沿圆柱轴线采样两端材料状态,初步区分通孔/开口端、盲孔和封闭端。 - - 会把圆柱相邻平面按轴线位置分成起点端/终点端,并结合端部采样标记疑似盲孔底面;相邻端面缺失时,会尝试扫描轴线附近的平面圆盘候选作为兜底。 - - 孔径修改计划会记录疑似底面 Face、开口端相邻Face和 cutter 底面保护状态。 - - 对盲孔/封闭端,有限长度 cutter 会使用更小的端部余量,降低切深变长的风险。 -- 支持撤销/重做编辑。 -- 撤销/重做会先确认模型恢复成功,再移动历史栈;如果恢复失败,会尽量回到操作前状态。 -- 支持操作历史面板,显示当前已经成功执行的编辑。 -- 操作历史详情会显示编辑类型、目标对象、参数、执行结果和拓扑数量变化。 -- 操作历史详情会额外显示目标零件自身的 Solid / Face / Edge 数量变化。 -- 如果一次局部编辑导致目标零件 Solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。 -- 操作历史详情会显示编辑前后的几何差异摘要: - - 体积变化。 - - 表面积变化。 - - 包围盒尺寸变化。 - - 包围盒对角线变化。 -- 点击操作历史记录时,会显示三维差异叠加预览: - - 红色半透明表示编辑前模型。 - - 绿色半透明表示编辑后模型。 - - 编辑后模型上会覆盖距离热力图,蓝色接近无变化,黄色/红色表示变化更大。 - - 右下角会显示 `distance` 色带。 - - 可以点击 `清除差异预览` 关闭叠加显示。 -- 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。 -- 鼠标拾取模型时会显示三维拾取点坐标。 -- 编辑历史会记录目标对象和当时的拾取点。 -- 支持把本软件加载后的编辑历史导出为 JSON v2;除摘要和详情外,也会保存 `operation_name`、`target`、`parameters` 和 `result_message`,为后续安全复放做准备。 -- 点击操作历史记录时,程序会尝试重新高亮目标对象,并在详情里保留当时的拾取点坐标;3D 视图里不会再额外显示拾取点小球。 -- 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。 -- 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。 - -当前 `assets/models/geom_extract.step` 的读取结果是: - -```text -part: 2001-LOWER_HOUSING -parts: 1 -solids: 1 -faces: 1800 -edges: 4982 -vertices: 3262 -``` - -所以当前测试文件大概率是单个零件,不是多零件装配。Face、Edge、圆柱面、孔、槽和圆角等,是这个零件里的几何特征;代码也按“将来可能有多个零件/装配”的方式组织。 - -## 还没有实现什么 - -这里分成两类看,不然容易误解为“完全没有推进”。 - -已经有当前版本的初步实现,但还需要继续稳定和完善: - -- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 -- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、多台阶中间过渡面识别和快速阻止、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。 -- 已有圆角/倒圆面半径/弧长:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;2 到 4 个共享边连通面的简单等半径圆角链会作为高风险候选开放整链半径/弧长修改,变半径圆角链和更长复杂链会在识别层标成受限能力并在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 -- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 -- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 -- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 - -仍基本没有实现,或者 STEP 本身不一定能可靠支持: - -- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史;当前只能在本程序里记录并导出后续编辑历史,或做近似的特征语义识别。 -- 所有场景都稳定的复杂特征识别。 - -## 下一步要实现什么 - -当前已经有按阶段跑通的本地一级编辑基线,但这还不等于 CAD 级完成。后续按上面的 `SCDM-first 主路线` 继续推进:`[x]` 项保持回归和真实 STEP 验证,`[~]` 项优先补 SCDM API 细节、守门、失败解释和更多真实模型样例,`[ ]` 项不能提前在 UI 里伪装成稳定能力。新增能力优先看 SCDM 能否返回结构化对象、当前值、可用命令和可校验结果;本地 OCCT 只作为兜底和已验证快路径保留。 - -当前整体验证基线: - -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage face`,覆盖 Face 专项套件和 Face isolated worker。 -- 2026-08-11,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage face`,覆盖 R1 Face 专项套件、Face isolated worker、保持关系隔离契约和 Face 阶段收口口径。 -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖孔/槽一级拓扑、局部重建和 isolated worker。 -- 2026-08-11,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径。 -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。 -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。 -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算和一级验收文档一致性。 -- 2026-08-18,新增 `python scripts\verify_scdm_backend.py`、`python scripts\verify_scdm_status.py`、`python scripts\verify_scdm_probe_pipeline.py`、`python scripts\verify_scdm_edit_runner.py` 和 `python scripts\verify_scdm_result_validator.py`,覆盖 SCDM 后端发现、缓存、禁用开关、/RunScript 命令生成、左侧状态摘要、probe job/script 生成、raw->cache 映射、edit job/script/result/error 执行协议,以及结果文件检查、目标值回测、ID 映射和关系式重写;已接入 `--quick`。 -- 2026-08-18,本机 `D:\softwaresInstallDir\ANSYS Inc\v222\SCDM\SpaceClaim.exe` 已通过真实 `/RunScript` smoke;对 `assets/models/ICEPAK-NATURAL.stp` 的真实 probe 可打开模型并输出 13 个 Body、158 个 Face、396 条 Edge,396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。 - -Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口): - -- 已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑;支持 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 和受限的 `保持关系`。`中心` 移动后端保留,但特征参数表暂不开放。 -- 已验收:Face 的 `一级关系` 定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为 R1 自动传播范围。 -- 已验收:选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域、同域/共面碎片、一级平行/垂直关系、一级同轴圆柱事实、边界 Edge/Vertex、共享边相邻 Face,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示。 -- 可用但受限:带内孔平面、曲面 Solid 上的平面、自由曲面、非矩形面、复杂端盖、接近切穿的内切等场景会按明确守门执行;能稳定改就走隔离/回滚,不能稳定改就提前 blocked 并说明原因。 -- 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、手写孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动;这些不再作为 SCDM-first 近期主线,不算 Face 兜底基线未完成项。 -- `python scripts\verify_first_level_edit_suites.py --stage face` 必须通过,集中运行 R1 Face 专项套件和 Face isolated worker。 -- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、保持关系、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。 -- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度`、`面内宽度`、`偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。 -- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 - -孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口): - -- 已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度` 和 `封堵/删除孔`。孔径按同轴重切孔壁执行,轴心移动会先补旧孔再切新孔,盲孔深度会按加深切削或变浅补料处理,结果必须仍能回读目标孔特征。 -- 已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`、`圆弧角度`、`开口角度`、`轴心`、`总长度` 和 `中心距`。普通半圆槽按局部圆柱槽重切,长圆槽按两端圆弧配对后重建,修改后必须仍能识别为目标槽或半孔。 -- 已验收:孔/槽的 `一级关系` 定义为当前圆柱侧壁或槽端 Face、边界 Edge/Vertex、共享边直接相邻 Face,以及开口面、底面、槽边界面等一跳角色;孔/槽计划会明确二级、三级关系暂不自动传播。 -- 已验收:孔/槽局部布尔和重建会走隔离 worker,修改后会校验 B-Rep、单 Solid、目标几何回读和逻辑 Face ID 保持;失败时回滚,不破坏原模型。 -- 可用但受限:当前只承诺单个稳定孔、单个盲孔、单个半圆槽和可稳定配对的长圆槽;锥孔/沉孔只处理简单解析或局部重切路径,复杂浅锥、螺纹孔、组合孔、底面识别不可靠的盲孔会提前阻止。 -- 可用但受限:复杂槽、交叉槽、多槽端配对不唯一时,只做识别和快速阻止,不在参数表暴露槽宽、槽深、弧长、弧角或轴心这些伪可改入口。 -- 未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动、螺纹孔语义编辑、任意草图槽、跨复杂面槽、手写孔底/槽底带动台阶的二级/三级传播;这些不再作为 SCDM-first 近期主线,不算孔/槽兜底基线未完成项。 -- `python scripts\verify_first_level_edit_suites.py --stage hole-slot` 必须通过,集中运行 R2/R3 孔槽专项套件。 -- `python scripts\verify_hole_slot_edit_suite.py` 必须通过,覆盖孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。 -- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征会暴露侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。 -- `python scripts\verify_hole_slot_isolated_edit.py` 必须通过,确保孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距都能通过隔离执行通道验证。 - -本地 OCCT 兜底能力的当前基线: - -- R2/R3 孔槽已经拆成独立验收口径;后续只在真实 STEP 失败项上补识别、补守门、补局部重建。孔组、阵列孔、多槽组联动是否开放,改由上面的 SCDM capability 路线判断。 -- `python scripts\verify_first_level_edit_suites.py --stage edge` 覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。任意曲线 Edge 通用约束编辑仍未开放。 -- `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体厚度和解析曲面当前可用范围。复杂链式特征、通用抽壳历史恢复和跨特征传播仍不承诺。 -- 这些阶段后续只作为本地兜底能力维护;新增客户入口优先从 SCDM capability 路线开放。 - -## 怎么使用 - -打开程序后,右侧是 3D 模型窗口,左侧是操作面板。 - -3D 窗口基本操作: - -- 鼠标左键拖动:旋转模型。 -- 鼠标滚轮:缩放模型。 -- 鼠标中键或组合拖动:平移模型,具体取决于 VTK 默认交互方式。 -- 只有明确单击模型时才会选择对象;左键拖动旋转后即使释放点落在某个 Face 上,也不会自动改选。 - -左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。 - -左侧 `选择模式` 用来切换鼠标选择模式: - -- `Part`:选择整个零件。 -- `Solid`:选择实体。 -- `Face`:选择面。 -- `Edge`:选择边。 -- `Feature`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 - 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 -- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 -- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 -- `选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 - -左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0`、`实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。 - -普通单零件 STEP 通常会显示成类似: - -```text -零件 1:2001-LOWER_HOUSING 零件 | 实体 1 个 | 面 1800 个 | 边 4982 条 - 实体 0 面 1800 个 | 边 4982 条 -``` - -如果 STEP 是多零件装配,树里可能会先出现 `装配` 节点,下面再挂多个 `零件` 节点;装配可以理解为“多个零件组成的一组结构”。 - -左侧 `按 ID 选择` 可以直接输入编号选择对象: - -- 按 ID 选择会复用上方 `选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`。 -- 输入框右侧会同步显示当前类型,例如 `Face`、`Edge` 或 `Solid`,用于确认这个 ID 会按哪类对象解释。 -- `零件` 的 ID 从 1 开始。 -- `Solid`、`Face`、`Edge`、`特征` 的 ID 从 0 开始;`特征` 使用来源Face的 ID。 -- 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。 - -左侧 `显示` 用来临时缩小三维视图范围: - -- `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。 -- Face 模式下 `只显示选中` 会优先隔离当前命中的 Face,避免普通点选时触发同域面重计算;如果当前选择来自编辑计划、扫描候选或历史定位,并且已经解析出特征区域,则会使用这片已知区域。 -- `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。 -- `显示全部`:恢复显示完整模型。 -- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔拉伸/切除后的分面情况。 -- `导出选中面区域` 会导出当前选中 Face;如果当前选择已经带有明确的特征区域或同域区域信息,则导出这片已知区域。 -- Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 -- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 - -左侧 `测量` 用来做最基础的点到点距离检查: - -- 先在模型中点击一个对象或具体位置,然后点击 `设为 A`。 -- 再点击另一个对象或位置,然后点击 `设为 B`。 -- 如果选择来自鼠标点击,测量会优先使用实际拾取点;如果是从结构树或 ID 选择,程序会使用对象中心、面积中心、长度中心、重心或包围盒中心。 -- 设置 A/B 后会显示两点距离和 `ΔX/ΔY/ΔZ`。 -- 3D 视图里会显示一条青色测量线,方便确认测量方向。 -- `复制测量` 会复制当前测量结果;`清除测量` 会清空 A/B 和测量线。 -- 测量不会修改模型,也不会写入操作历史。 - -左侧 `可编辑对象` 是当前 MVP 的主要入口: - -- 点击 `扫描对象` 会列出当前版本能尝试编辑的对象。 -- 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 -- 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 -- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 -- `拉伸/切除平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `偏移` 行里把 `建模意图` 选为 `拉伸/切除` 来执行。 -- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 -- `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 -- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 -- `封堵圆柱孔` 行表示这个Face是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 -- `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。 -- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 -- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 -- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模`。 -- `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 -- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 -- 点击任意一行会自动选中并高亮对应Face或Edge。 - -选择对象后: - -- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 -- 在 `Face` 模式下,普通点选只做轻量拾取和单个对象高亮,保证旋转、悬停和选中更流畅;同域面、端盖、底面和槽端配对等深层识别会延后到扫描候选、执行编辑计划或历史定位时进行。 -- 如果处于 `特征` 模式,普通点选会先高亮命中的基础对象;扫描候选、执行编辑计划或历史定位时,程序会尽量解析并高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 -- 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 -- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 -- 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 -- 下方 `选中对象信息` 会显示属性。 -- `属性表` 页签会把属性分成身份、拓扑、测量、位置、方向/轴线、参数、特征判断等分组。 -- `原始文本` 页签会显示同一份信息的纯文本版本,也用于显示候选列表、操作历史详情和普通提示。 -- `复制 ID` 会复制当前选中对象,例如 `face 2`。 -- `复制坐标` 会复制当前拾取点坐标。 -- `复制信息` 会复制当前信息面板里的完整文本。 -- 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 -- 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。 -- 如果选中直线Edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `长度` 填成当前Edge长度。 - -圆柱面候选: - -- 点击 `扫描圆柱面` 会刷新候选表格。 -- 圆柱面候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。 -- 可以通过候选表格上方的类型下拉框筛选候选;筛选只过滤已经扫描出的缓存结果,不会重新做几何识别。 -- 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 -- 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 -- 表格里的每一行对应一个圆柱面候选。 -- 点击候选行会自动切到 `特征` 模式,并选中对应Face。 -- `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。 -- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。 -- `diameter` 是圆柱直径。 -- `span` 是圆柱面的角度跨度,接近 `6.283` 表示接近完整圆柱,接近 `3.142` 表示半圆柱。 -- `height` 是根据圆柱面积估算出来的高度。 -- `confidence` 是当前猜测置信度。 -- 这些候选可能是孔、柱、圆角或其他圆柱面,目前还不是稳定的“孔识别”。 - -编辑: - -- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `拉伸/切除平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 -- `偏移`: - - 先选择一个平面 Face。 - - 在当前选中对象表里修改 `偏移`,再用 `建模意图` 选择改法。 - - `偏移` 是沿程序判定的材料外侧法向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标;它不一定等同于 STEP 原始拓扑法向坐标。 - - `拉伸/切除` 会把目标位置换算成这次要移动的距离,然后沿判定的材料外侧法向加料或切削,适合改变厚度、拉出凸起或切入凹槽。 - - `局部重建` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 - - `移动特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。 - - 如果选中Face周围有共面且相接/重叠的碎面,`拉伸/切除` 会自动把这些Face合成同一片拉伸/切除区域,属性表和历史记录会显示 `拉伸/切除共面区域 Face 数`。 - - 当前拉伸/切除方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 -- `面内长度` / `面内宽度`: - - 这两个参数只在稳定矩形凸台/口袋或简单全平面实体上显示;普通曲面、复杂平面和带内孔平面不会把它们伪装成可改参数。 - - 在当前选中对象表里修改目标值,再用 `建模意图` 选择改法。 - - `局部重建` 会在当前矩形平面特征内移动边界并重建周边相邻平面;当前只对稳定矩形特征或简单全平面实体开放。 - - `缩放特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 - - `面内长度` / `面内宽度` 是矩形平面特征在自身平面内两个稳定方向上的驱动尺寸,不是面积,不是任意曲面的通用尺寸,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。面积会在诊断信息中作为结果值展示。 -- `中心`: - - 后端保留了 Face 中心移动能力和回归基线,但模型工程师认为当前作用不大,特征参数表暂时不开放这一行。 -- `孔直径` + `调整圆柱孔径`: - - 先选择一个圆柱面。 - - 输入新的直径。 - - 目标直径大于当前直径时,会使用有限长度 cutter 扩大切削。 - - 目标直径小于当前直径时,会先在原圆柱面范围内补料,再按目标直径重切。 - - 点击 `调整圆柱孔径` 后,会先显示半透明预览;红色是切削范围,绿色是缩小孔径时的补料范围。 - - 程序会先生成切削计划,检查当前直径、目标直径、候选类型、材料投票和风险。 - - 切削计划会显示直径变化量、直径变化比例,以及目标直径相对圆柱面估算高度的比例。 - - 切削计划会显示端部类型、深度估算、起点端状态和终点端状态。 - - 切削计划会显示疑似底面 Face、开口端相邻Face和底面保护说明;这些信息会写入操作历史。 - - 如果目标直径小于等于 0,或几乎等于当前直径,程序会直接阻止。 - - 如果目标直径变化过大,或者目标直径相对圆柱估算高度明显异常,程序会提高风险并要求确认。 - - 缩小孔径当前版本只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。 - - 缩小孔径属于高风险实验功能,因为它依赖“补料 + 重切”的布尔近似,不是 CAD 历史参数修改。 - - 如果当前 Face的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 - - 切削计划会显示 cutter 策略、高度和余量;当前优先使用有限长度 cutter,减少误切其他区域。 - - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 -- `直径` / `半径` 的 `缩放特征`: - - 先选择一个圆柱 Face,然后在当前选中对象表里修改 `直径` 或 `半径`,并把 `建模意图` 设为 `缩放特征`。 - - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。 - - 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。 -- `槽/半孔宽度` + `调整槽/半孔宽度`: - - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 - - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - - 输入目标槽宽后点击 `调整槽/半孔宽度`。 - - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 -- `槽/半孔深度`: - - 先选择一个槽/半孔候选 Face。 - - 在特征参数表里修改 `槽/半孔深度`,然后点击 `参数化建模`。 - - 程序会按当前圆弧角度把目标槽深换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 -- `槽/半孔圆弧长度`: - - 先选择一个槽/半孔候选 Face。 - - 在特征参数表里修改 `槽/半孔圆弧长度`,然后点击 `参数化建模`。 - - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 -- `槽宽` / `槽深` / `弧长` 的 `缩放特征`: - - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改对应属性,并把 `建模意图` 设为 `缩放特征`。 - - 程序会把目标槽宽、槽深或弧长换算成目标圆柱直径,然后按比例整体缩放所属特征或 Solid。 - - 这不是局部重切槽;同一所属对象上的高度、厚度和其它尺寸也会跟随变化。 -- `槽/半孔圆弧角度` / `槽/半孔开口角度`: - - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改角度值,角度输入单位是度。 - - 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角`。 - - 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 -- `轴心` 的 `移动槽/半孔`: - - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改目标坐标,格式是 `X, Y, Z`,并把 `建模意图` 设为 `移动槽/半孔`。 - - 程序会先填充旧的局部扇形槽,再在目标轴心位置按同宽度、同圆弧角度切出新槽。 - - 这一步只移动选中的局部扇形槽面,不等于完整 CAD 草图里的槽整体迁移;长圆槽、链式槽和复杂槽需要确认结果,失败会回滚。 -- `轴心` 的 `移动特征`: - - 同样输入目标 X, Y, Z 坐标。 - - 把 `建模意图` 设为 `移动特征`。 - - 程序会把目标轴心换算成平移向量,并平移所属特征或 Solid;不会填旧槽,也不会切新槽。 - - 这适合“整个对象搬动到新位置”的意图,不适合只想移动槽本身的场景。 -- `槽孔配对端 Face ID` / `槽孔总长度` / `槽孔中心距`: - - 对长圆槽或成对半圆槽,程序会在点击修改时自动寻找另一个槽端,避免选择对象时扫描过重。 - - 如果自动配对失败,可以先在 `槽孔配对端 Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`。 - - 修改 `槽孔总长度` 或 `槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。 -- `壳体厚度` 的 `拉伸/切除`: - - 先选择一个已识别到相对平面的壳体平面 Face。 - - 在当前选中对象表里修改 `壳体厚度`,并把 `建模意图` 设为 `拉伸/切除`。 - - 输入目标厚度后点击 `参数化建模`。 - - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - - 如果相对平面重叠率低、厚度变化过大或拉伸/切除方向不稳定,会提高风险或阻止执行。 - - 这一步仍是局部 B-Rep 平面拉伸/切除近似,不是完整 CAD 壳体特征参数编辑。 -- `壳体厚度` 的 `缩放特征`: - - 先选择一个已识别到相对平面的壳体平面 Face。 - - 在当前选中对象表里修改 `壳体厚度`,并把 `建模意图` 设为 `缩放特征`。 - - 程序会沿壳体厚度方向缩放所属特征或 Solid,让估算厚度接近目标值。 - - 这不是拉伸/切除;同一对象上的孔距、台阶位置和其它厚度方向尺寸也会跟随变化。 -- `凸台直径` + `调整圆柱凸台直径`: - - 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 - - 目标直径大于当前直径时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 -- `直径` / `半径` 的 `缩放特征`: - - 先选择一个圆柱凸台 Face,然后在当前选中对象表里修改这一行。 - - 把 `建模意图` 设为 `缩放特征`。 - - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,不走凸台包络移除/重建路线。 - - 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。 -- `凸台高度`: - - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `拉伸/切除端盖`,然后点击 `参数化建模`。 - - 程序会尝试找到凸台外端盖 Face,并通过拉伸/切除端盖改变高度。 -- `高度` 的 `缩放特征`: - - 先选择一个完整圆柱凸台或普通完整圆柱 Face。 - - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `缩放特征`。 - - 程序会沿圆柱轴向缩放所属特征或 Solid,让圆柱高度接近目标值。 - - 这不是端盖拉伸/切除;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 -- `轴心`: - - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动凸台`,然后点击 `参数化建模`。 - - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 -- `轴心` 的 `移动特征`: - - 同样输入目标 X, Y, Z 坐标。 - - 把 `建模意图` 设为 `移动特征`。 - - 程序会整体平移凸台所属特征或 Solid;不会移除旧凸台,也不会补新凸台。 - - 这适合移动整个所属对象,不适合只改凸台相对基体的位置。 -- `轴心` 的 `移动孔`: - - 先选择一个完整圆柱孔 Face。 - - 在当前选中对象表里修改目标 X, Y, Z 坐标,并把 `建模意图` 设为 `移动孔`。 - - 程序会先填补旧孔,再按同直径在目标轴心位置切出新孔。 -- `轴心` 的 `移动特征`: - - 同样输入目标 X, Y, Z 坐标。 - - 把 `建模意图` 设为 `移动特征`。 - - 程序会整体平移孔所属特征或 Solid;不会填旧孔,也不会切新孔。 - - 这适合移动整个所属对象,不适合只改孔相对基体的位置。 -- `封堵圆柱孔`: - - 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。 - - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 - - 只有接近完整圆柱的 `hole/groove candidate` 会放行;半孔/槽、圆角、凸柱、未明确圆柱面会被阻止。 - - 通过检查后,会显示绿色半透明补料体预览。 - - 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。 - - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。 -- `盲孔/盲槽深度` 的 `改底面深度`: - - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `建模意图` 设为 `改底面深度`。 - - 输入目标深度后点击 `参数化建模`。 - - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 - - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 - - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 - - 当前只支持 `hole/groove candidate`、端部类型为 `blind`、并且已经识别到 `疑似底面 Face` 的对象。 - - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 - - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 - - 操作历史会记录当前深度、目标深度、深度变化比例、底面 Face、开口端 Face和工具参数。 -- `盲孔/盲槽深度` 的 `缩放特征`: - - 先选择一个已经识别出底面的盲孔或盲槽圆柱面;自动底面不准时,可以先填写 `底面 Face`。 - - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `建模意图` 设为 `缩放特征`。 - - 程序会沿孔/槽轴向缩放所属特征或 Solid,让深度接近目标值。 - - 这不是加深切削,也不是变浅补料;同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化。 -- `圆角半径` + `给Edge添加圆角`: - - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。 - - 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。 - - 输入目标圆角半径后点击 `给Edge添加圆角`。 - - 程序会先检查半径是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。 - - 点击后会显示蓝色半透明圆管预览,用于提示目标边和半径范围。 - - 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。 - - 这是“给直线边添加新圆角”,不是修改已有圆角面。 - - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 -- `圆角半径` / `圆角弧长` 的 `重建圆角`: - - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `重建圆角`。 - - 输入目标值后点击 `参数化建模`。 - - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面;简单等半径圆角链会把链上圆角面一起纳入重建计划。 - - 真实编辑会先用 OCCT defeaturing 移除已有圆角面或链上圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 -- `圆角半径` / `圆角弧长` 的 `缩放特征`: - - 先选择一个已有圆角/倒圆候选 Face。 - - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `缩放特征`。 - - 程序会按目标圆角半径或圆弧长度换算出的比例整体缩放所属特征或 Solid,不会移除旧圆角再重建。 - - 这会连带改变同一对象上的其它尺寸。 -- `倒角距离` + `给Edge添加倒角`: - - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。 - - 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。 - - 输入目标倒角距离后点击 `给Edge添加倒角`。 - - 如果需要两侧不同距离,在当前选中对象表里填写 `新增不等距倒角D1/D2`,格式为 `D1, D2`。 - - 如果需要距离+角度倒角,在当前选中对象表里填写 `新增距离+角度倒角D/角度`,格式为 `距离, 角度`,角度单位是度。 - - `倒角参考Face ID` 决定 D1/D2 的方向;默认使用当前Edge的第一个相邻Face,也可以改成另一个相邻Face后点击 `设置`。 - - 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。 - - 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。 - - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - - 当前支持对称距离倒角、两侧不同距离倒角和距离+角度倒角。 - - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 -- `长度` + `建模意图`: - - 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。 - - 选中Edge后,`长度` 会自动填入当前Edge长度。 - - 当前选中对象表中的 `长度基准` 行可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - - `长度` 行的 `建模意图` 下拉框可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱` 或 `缩放所属`。 - - 把 `长度` 改成目标值后点击 `参数化建模`。 - - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击 `参数化建模`。 - - 修改 `中心` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - - 对 `assets/models/cube_10mm.step` 这类全平面多面体,选择 `只改当前Edge` 时会只移动被选Edge的端点并重建周边面。这时正方体不会变成长方体;相邻面会跟着被移动的边自然变形,四点非平面面会优先重建成连续曲面,不再默认拆出面内对角线。 - - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面拉伸/切除,相关平行边会一起变化,结果更像从正方体变成长方体;执行后会反查目标 Edge、相邻侧面和移动端面仍满足一级平面关系。 - - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只改这条边”的局部形变路径。 - - 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。 - - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心”的局部形变路径。 - - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面拉伸/切除后备路径,并计算需要移动的端面Face和拉伸/切除距离。 - - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - - 椭圆边优先通过 `椭圆主半径` / `椭圆小半径` 修改;B-spline / Bezier 等复杂曲线边暂不作为界面里的稳定长度修改入口。 - - `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。 - - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属零件/Solid的其它尺寸,因此会弹出高风险确认。 - - 执行成功后会做后置边长校验:在刷新后的零件/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 - - 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。 - - 真实编辑在后台执行,失败时会回滚到编辑前状态。 - - 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。 -- `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`: - - 输入 X、Y、Z 三个方向的平移量。 - - 选择 `零件` 后点击 `平移选中零件`,会移动整个零件。 - - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。 - - 如果当前零件只有一个Solid,平移Solid实际会移动整个零件 shape,程序会提示中风险。 - - 平移向量为 0 会直接阻止。 - - 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。 - - 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 -- `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`: - - 选择 X、Y 或 Z 旋转轴。 - - 输入旋转角度,单位是度。 - - 选择 `零件` 后点击 `旋转选中零件`,会绕该零件的包围盒中心旋转整个零件。 - - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。 - - 如果当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape,程序会提示中风险。 - - 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。 - - 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 -- `撤销`: - - 撤销上一次成功的编辑。 -- `重做`: - - 重做刚刚撤销的编辑。 -- `操作历史`: - - 显示当前模型已经成功执行的编辑。 - - 点击某条历史记录,会在 `选中对象信息` 中显示该次编辑的目标、参数、执行结果和拓扑数量变化。 - - 历史详情也会显示几何差异摘要,包括体积、表面积、包围盒尺寸和包围盒对角线的前后变化。 - - 点击某条历史记录时,3D 窗口会显示红/绿半透明差异叠加预览:红色表示编辑前,绿色表示编辑后。 - - 编辑后模型会覆盖距离热力图:蓝色接近无变化,黄色/红色表示变化更大。 - - 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。 - - `清除差异预览` 可以关闭红/绿叠加显示。 - - `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。 - - `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘;当前格式是 `step-editor-operation-history-v2`,包含机器可读的动作名、目标和参数。 - - 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。 - - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并在详情里显示当时的拾取位置;3D 视图里不会额外画拾取点小球。 - - 由于布尔编辑后拓扑 ID 可能重新分配,历史里的Face ID只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。 - - 撤销后对应记录会从列表里退回。 - - 重做后对应记录会重新显示。 - -导出: - -- `导出模型`:导出当前完整模型。 -- `导出选中零件`:导出当前选中的零件。 -- `导出选中Solid`:先选中一个Solid,再导出该Solid。 -- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。 -- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中特征,再导出当前特征高亮的Face集合。 -- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 -- `导出参数`:在 `特征参数` 表的 `输入参数` 列勾选需要外部驱动的尺寸行后,点击按钮会在项目根目录写出 `data.json`,并在 `nodes/` 下生成一个 FlowEditor 风格的参数化组件 `main.py`。组件脚本会把勾选的输入参数直接嵌入 `INPUT_PARAMETERS`,通过 `PARAMETERS / NODE_INFO` 暴露给节点设计器,并提供 `execute(inputs, params, context)` 调用入口;组件目录里不会额外生成 `data.json` 或 `step_edit_config.json`。执行后返回 `output_step`,指向修改后的 STEP 文件。 -- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 -- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 -- `修复选中零件/Solid`:优先修复当前选中对象所属Solid;如果当前对象没有Solid,则修复所属零件。Face或Edge被选中时,也会按其所属Solid/零件执行局部修复。 -- 导出按钮会根据当前选中对象自动启用或禁用,避免选中Face时误点 `导出选中Edge` 这类不匹配的操作。 -- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有Solid、零件包含多个Solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。 - -## 目录结构 - -当前整理后的项目结构是: - -```text -pythonocc-step-editor/ - main.py # 程序入口,保持 python main.py 启动 - data.json # 导出参数生成的本地流程参数文件,默认不提交 - nodes/ # 导出参数生成的本地组件目录,组件参数会嵌入 main.py,默认不提交 - tools/ - asitus_probe/ # 可提交的 Analysis Situs probe 源码,构建输出放到 third_party/asitus_probe_tools_build - assets/ - models/ - geom_extract.step # 当前默认测试模型 - cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作 - screenshots/ # 调试截图和问题截图,默认不提交 - local/ # 本机自用说明和教学文档,默认不提交 - scripts/ - GeometryParametric.spec # PyInstaller 文件夹版打包配置 - package_windows.ps1 # 生成 dist/GeometryParametric 和 dist/GeometryParametric_windows_x64.zip - generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 - verify_first_level_acceptance_docs.py # 验证 README 的一级验收口径和阶段验证入口保持一致 - verify_first_level_edit_suites.py # 按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体阶段串起一级编辑回归 - verify_analytic_surface_resize.py # 临时生成圆锥/球/环面 STEP 并验证简单圆锥解析重建和曲面整体缩放 - verify_cone_semi_angle_isolation.py # 验证圆锥半角/参考半径隔离执行和嵌入式锥孔局部重切 - verify_boss_edit_suite.py # 一键运行凸台专项修改验证 - verify_boss_first_level_topology.py # 临时生成圆柱凸台 STEP 并验证凸台一级共边拓扑字段 - verify_boss_resize.py # 临时生成圆柱凸台 STEP 并验证凸台直径/高度/轴心修改 - verify_edge_edit_suite.py # 一键运行 Edge 专项修改验证 - verify_edge_first_level_topology.py # 验证 Edge 一级端点/相邻边/相邻 Face 事实包和编辑计划携带情况 - verify_edge_coordinate_edit.py # 验证直线 Edge 起点/中心/终点坐标局部修改 - verify_edge_target_guards.py # 验证 Edge 目标值不变、0/负数、退化或过大参数会在计划阶段阻止 - verify_edge_intent_specs.py # 验证 Edge 长度建模意图下拉项和底层策略映射一致 - verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 - verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 - verify_edge_round_chamfer.py # 临时生成盒子/已有圆角/圆角链 STEP 并验证 Edge 圆角、倒角、已有圆角修改、简单等半径圆角链/四面连通链重建和变半径链守门 - verify_round_chamfer_edit_suite.py # 一键运行圆角/倒角专项修改验证 - verify_edge_isolated_edit.py # 通过 isolated worker 真实验证 Edge 一级编辑隔离执行 - verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起拉伸/切除 - verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用局部重建的局部变形,但保留向外拉伸和向内切削 - verify_face_edit_suite.py # 一键运行 Face 专项修改验证 - verify_face_extreme_target_guards.py # 验证 Face 面内尺寸、偏移和壳体厚度不能输入过大或过小的极端目标值 - verify_face_freeform_guard.py # 验证 B-spline 等自由曲面 Face 只显示只读限制说明,不暴露伪参数化编辑入口 - verify_face_invalid_target_guards.py # 验证 Face 面内尺寸和壳体厚度不能输入非数字、0 或负数 - verify_large_stepped_cap_push_pull.py # 验证大 STEP 里的同轴阶梯环形端盖拉伸/切除走局部 shell 重建而不是 180s 布尔慢路径 - verify_face_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面 - verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留拉伸/切除;保持关系会因非平面一级邻域阻止 - verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面内尺寸、偏移和拉伸/切除;斜交一级邻域会阻止保持关系 - verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止 - verify_first_level_fact_graph.py # 验证平面、孔、槽会输出统一一级拓扑事实包,且编辑计划会携带这份事实 - verify_feature_recognition_summary.py # 验证平面、孔、槽、凸台候选会输出统一识别摘要、常用优先级、置信度、风险和证据 - verify_face_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何 - verify_face_push_pull_limits.py # 验证 Face 向内切除不会允许切穿实体厚度 - verify_face_ui_isolation_contract.py # 验证 Face UI 入口和 isolated worker 的隔离操作列表一致 - verify_face_rectangular_axes.py # 验证矩形 Face 的面内长度和面内宽度方向不会混淆 - verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 - verify_hole_slot_edit_suite.py # 一键运行孔/槽专项修改验证 - verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 - verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 - verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数 - verify_relation_formula_rules.py # 验证关系式自引用、重复目标和循环依赖会在添加阶段阻止 - verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json - verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成 - verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明 - verify_scdm_probe_pipeline.py # 验证 SCDM probe job/script 生成和 raw 识别结果到产品化 cache 的映射 - verify_scdm_edit_runner.py # 验证 SCDM edit job/script/result/error 执行协议 - verify_scdm_result_validator.py # 验证 SCDM 结果文件检查、目标值回测、全局摘要漂移、ID 映射和关系式重写 - verify_asitus_hole_bridge.py # 验证 Analysis Situs 孔组识别结果会映射回 Python Face 编号和 UI 整孔选择 - verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证 - verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 - verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 - verify_cylindrical_first_level_topology.py # 临时生成孔/槽 STEP 并验证圆柱孔、盲孔、半圆槽、长圆槽的一级共边拓扑字段 - step_editor/ - __init__.py # 包导出 - app.py # 主窗口初始化、UI 搭建和程序启动 - constants.py # OCCT 曲面、曲线和方向类型常量映射 - export.py # 导出、导出质量检查和面区域导出逻辑 - features.py # 平面、圆柱、孔、槽、凸台、圆角等候选识别和编辑计划 - geometry_utils.py # OCC 几何、拓扑、布尔修复、向量和形状工具函数 - info_panel.py # 属性面板、信息树和复制信息逻辑 - model.py # StepModel 核心状态、拓扑索引、基础属性和 mixin 组合 - model_types.py # PartNode、TopologyStats 等模型数据结构 - operations.py # 拉伸/切除、孔径、孔深、圆角、倒角、边长等实际编辑操作 - polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成 - records.py # 操作历史记录数据结构 - step_io.py # STEP/XCAF 读取、产品名解析、STEP 写出和内部 BREP 临时交换 - transforms.py # 零件 / Solid 平移和旋转 - ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具 - widgets.py # 自定义 Qt 小组件 - window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作 - window_core.py # 文件加载、结构树、VTK 视图、选择、拾取、高亮和预览 - window_state.py # 选择状态、按钮状态、撤销/重做和历史定位 - workers.py # 后台编辑和后台扫描 worker - environment.yml # Conda 环境说明,用于安装 pythonocc-core、VTK、PySide6 等依赖 - README.md # 项目说明和使用说明 -``` - -这次整理把根目录收窄到入口、环境配置和说明文档;仓库自带 STEP 测试模型放到 `assets/models/`,问题截图和调试截图放到 `assets/screenshots/`,本机自用的 `教学.txt` 和 `几何参数化测试说明.txt` 放到 `local/`。`environment.yml` 保留在根目录,方便 Conda 用户直接发现并运行 `conda env create -f environment.yml`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。 - -`step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py`、`operations.py`、`export.py`、`transforms.py`、`polydata.py`、`step_io.py` 和 `geometry_utils.py`。 - -`step_editor/app.py` 现在主要保留主窗口初始化、左侧操作面板搭建和程序入口;VTK 视图、选择高亮、编辑动作、扫描、撤销/重做和属性面板逻辑已经拆到 `window_core.py`、`window_actions.py`、`window_state.py`、`info_panel.py` 和 `ui_helpers.py`。 - -其他文件说明: - -- `.gitignore`:Git 忽略规则,避免提交 Python 缓存、临时文件和导出的 STEP 文件。 -- `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。 -- `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略。 -- `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。 -- `local/`:本机自用文档目录,例如 `教学.txt` 和 `几何参数化测试说明.txt`,默认不提交;打包脚本会把 `local/几何参数化测试说明.txt` 复制到 `main.exe` 同级目录。 - -## 当前版本边界 - -这一版重点是验证整体链路,不是最终 CAD 编辑器。 - -模型显示、选择、属性查看、撤销/重做和导出是当前主要能力。局部编辑已经接入,但仍属于受限 B-Rep 能力。复杂 STEP 几何上的布尔操作可能失败;失败时程序会弹窗提示,不会静默覆盖原始文件。 - -## Codex 项目记忆(开发者可忽略) - -这一节只给后续重新打开的 Codex 聊天框使用,普通开发者不需要阅读。Codex 接手时先读这里,再读本 README 的“目录结构”和“当前版本边界”。 - -### 项目目标 - -本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。 - -最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 - -当前开发节奏必须分阶段闭环:先把 Face 修改能力做完整并集中测试,再做孔/槽;孔/槽通过后再做 Edge;Edge 通过后再做凸台、圆角、倒角、壳体和解析曲面。不要每个对象只做一点就切换方向。 - -截至 2026-08-06,一级编辑总验证入口 `scripts/verify_first_level_edit_suites.py` 已在 `pyocc` 环境下按阶段通过:Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面,以及 `--quick` 轻量回归。后续不要把这些阶段当作“完全没做”;应在现有基线上根据真实 STEP 失败项继续补识别、补风险守门、补局部重建和性能稳定性。 - -### 运行方式 - -常用命令: - -```powershell -conda activate pyocc -python main.py -python main.py assets\models\cube_10mm.step -python scripts\generate_cube_step.py python scripts\verify_first_level_edit_suites.py --quick -python scripts\verify_first_level_edit_suites.py -python scripts\verify_face_edit_suite.py -python scripts\verify_isolated_face_edit.py -python scripts\verify_hole_slot_edit_suite.py -python scripts\verify_edge_edit_suite.py -python scripts\verify_edge_isolated_edit.py -python scripts\verify_boss_edit_suite.py -python scripts\verify_round_chamfer_edit_suite.py -python scripts\verify_shell_edit_suite.py -python scripts\verify_analytic_surface_resize.py -python scripts\verify_slot_resize.py -python main.py --smoke-test -``` - -如果 shell 里没有 `conda`,用户机器上之前可用的方式是: - -```powershell -C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py -``` - -在 Windows PowerShell 里用 `conda run` 跑输出较多的验证脚本时,如果遇到 Conda 自身的 `UnicodeEncodeError`,通常不是几何测试失败,而是外层 Conda 打印子进程日志时被系统编码绊住。可以先设置 UTF-8 输出再运行: - -```powershell -$env:PYTHONIOENCODING='utf-8' -$env:PYTHONUTF8='1' -C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python scripts\verify_first_level_edit_suites.py --stage face -``` - -当前 Codex 沙盒环境可能没有 VTK/pythonocc,因此 GUI 和 smoke test 可能报 `ModuleNotFoundError: vtk`。能做的本地验证至少包括: - -```powershell -python -m compileall -q main.py step_editor scripts git diff --check +git status --short +git add README.md step_editor scripts +git status --short +git commit -m "feat: 完善 SCDM-first 参数化编辑交付版" ``` -### 项目结构提示 +## 后续开发备注 -- `main.py`:入口,保持 `python main.py` 可启动。 -- `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`,避免目标文档和实际验证入口脱节。 -- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、关系式规则、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 -- `scripts/verify_scdm_backend.py`:纯 Python 检查 SCDM 后端发现和缓存逻辑,不依赖 OCC;覆盖环境变量、常见安装目录、`local/scdm_backend.json`、禁用开关和 `/RunScript` 命令生成。 -- `scripts/verify_scdm_status.py`:纯 Python 检查 SCDM 运行状态摘要,不依赖 OCC;覆盖未配置、已配置、后台识别中、识别成功、识别失败、cache 失效和缓存来源显示。 -- `scripts/verify_scdm_probe_pipeline.py`:纯 Python 检查 SCDM probe job、RunScript 脚本生成、raw 识别结果到产品化 cache 的映射;覆盖第一批 `hole.diameter`、`hole.position` 和 `face.offset` 能力。 -- `scripts/verify_scdm_edit_runner.py`:纯 Python 检查 SCDM edit job、临时修改脚本、成功 result.step/result.json、失败 error.json 和缺输出回滚判定;不依赖 OCC,也不需要真实 SCDM。 -- `scripts/verify_scdm_result_validator.py`:纯 Python 检查 SCDM 修改结果的输出 STEP 存在性、目标值回测、raw summary 全局规模漂移、旧对象到新对象的唯一匹配、Face ID 映射、关系式 ID 重写和歧义匹配拒绝。 -- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。 -- `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。 -- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 -- `scripts/verify_boss_first_level_topology.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径、高度、轴心和圆 Edge 代理轴心计划都会暴露一级共边拓扑关系,并验证凸台直径执行后会反查目标 Face 和一级关系。 -- `scripts/verify_boss_edit_suite.py`:集中运行当前凸台阶段验证,串起凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理轴心移动和属性表分组。 -- `scripts/verify_round_chamfer_edit_suite.py`:集中运行圆角/倒角阶段验证,串起直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径/弧长重建、简单等半径圆角链半径/弧长重建、四面连通圆角链重建、复杂同半径长链和变半径链守门、已有简单倒角距离重建、结果反查和属性表分组。 -- `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖 Edge 一级拓扑事实包、直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 建模意图 UI 映射、直线 Edge 起点/中心/终点坐标修改、Edge 圆角/倒角/已有圆角和简单等半径圆角链修改、椭圆 Edge 主/小半径修改。 -- `scripts/verify_edge_first_level_topology.py`:加载 `cube_10mm.step`,验证一条正方体 Edge 的一级关系包含当前 Edge、2 个端点 Vertex、4 条共享端点相邻 Edge 和 2 个直接相邻 Face,并确认边长、端点/中心坐标、圆角和倒角计划都会携带这份一级事实包。 -- `scripts/verify_edge_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。 -- `scripts/verify_edge_target_guards.py`:验证 Edge 长度、端点、中心、圆角、倒角、椭圆半径和圆 Edge 轴心这几类参数在目标值等于当前值、0/负数、退化或明显过大时会直接返回 blocked,并给出阻止原因,不进入真实几何计算。 -- `scripts/verify_edge_intent_specs.py`:验证当前选中对象表里 Edge `长度` 行的 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属` 五种建模意图都能正确同步到底层 `strategy_mode`,并确认计划摘要会解释“邻面变斜”“端面跟随/类似长方体”“其它尺寸跟随”等执行影响。 -- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略、目标边长和形态分布都正确:局部变形只应有 1 条目标长度边;移动端面或缩放所属应让同向 4 条边一起变成目标长度;移动端面还会检查相邻侧面和移动端面保持可验证的垂直/平行关系。 -- `scripts/verify_edge_round_chamfer.py`:临时生成盒子、已有圆角盒子、已有倒角盒子和圆角链盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径/弧长修改、简单等半径圆角链半径/弧长修改、四面连通圆角链修改、已有简单倒角距离修改,以及复杂同半径长链和变半径圆角链会在识别/计划阶段阻止。 -- `scripts/verify_edge_isolated_edit.py`:通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证 Edge 长度、直线 Edge 起点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、直线 Edge 新增圆角、三类倒角、已有圆角半径、简单等半径圆角链半径和已有简单倒角距离修改可以在隔离执行通道里完成,输出 STEP 能重新加载并保持目标几何语义。 -- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面内长度/面内宽度、矩形 Face 两个面内方向区分、中心、偏移、共面碎面一起拉伸/切除/切削、向内切除切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、B-spline 自由曲面只读守卫、大 STEP 同轴阶梯环形端盖局部 shell 拉伸/切除、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、壳体厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面内尺寸/偏移/壳体厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。面积相关后端回归仍保留作结果校验,不作为特征参数表里的可编辑行。 -- `scripts/verify_face_edit_integrity_guards.py`:验证 Face 编辑结果不能丢 Solid、不能新增无效 B-Rep、不能只达到目标数值却破坏一级拓扑;若目标检查、一级拓扑计数或一级事实图校验失败,模型层会恢复编辑前快照。 -- `scripts/verify_face_first_level_topology.py`:加载 `cube_10mm.step`,验证正方体上表面 Face 的一级关系只包含当前 Face、4 条边界 Edge、4 个边界 Vertex 和 4 个共享边相邻侧面;局部移动上表面后,目标 Face 的边界 Vertex 与共享边 Edge 会到达新高度,4 个一级侧面会跟随重建,二级底面保持不动。 -- `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 拉伸/切除,并确认拉伸/切除后原逻辑 Face ID 会定位到新生成的整片顶面。 -- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `局部重建` 的面内长度/面内宽度、中心和偏移计划;同时确认 `拉伸/切除` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 -- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的中心和偏移计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加,`保持关系` 会因一级相邻含非平面/曲面而在 UI 和计划阶段阻止。 +这一节给后续开发交接使用,普通试用人员可以跳过。 -- `scripts/verify_face_freeform_guard.py`:临时生成 B-spline 自由曲面 STEP,验证它会被识别为只读受限 Face,识别风险为 blocked;属性表只显示限制说明和只读诊断,不暴露面积、中心、偏移、半径、半角等伪参数化编辑入口。 -- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面内尺寸和壳体厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数;历史面积路径只作为后端守门回归保留。 -- `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_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,并给出“不需要修改”的说明。 -- `scripts/verify_first_level_fact_graph.py`:验证正方体平面 Face、拆分共面 Face、空心圆柱端盖、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、同域/共面碎片、一级同轴圆柱关系、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 拉伸/切除/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。 -- `scripts/verify_feature_recognition_summary.py`:验证平面 Face、带内孔平面 Face、通孔圆柱面、槽/半孔和凸台候选都会输出统一的 `识别摘要`、候选类型、常用优先级、置信度、风险、证据项、`当前可改` 和 `当前受限修改`;全局可编辑对象扫描也会按 Face、孔、槽、凸台、圆角/倒角、壳体、Edge 的常用顺序排序。 -- `scripts/verify_face_property_readback.py`:验证 Face 的面内尺寸、中心和偏移编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `偏移` 的 `局部重建`、`移动特征` 和 `拉伸/切除` 三种语义。面积读回只作为诊断结果验证。 -- `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内切除时会估算当前面背后的材料厚度;浅范围切除允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked;向外一次性拉伸/切除较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。 -- `scripts/verify_face_ui_isolation_contract.py`:验证 Face、孔槽和 Edge 阶段使用的隔离操作集合、`WindowActionMixin._isolation_for_plan()` 的 UI 隔离列表,以及 `isolated_edit_worker` 真正支持的 worker 操作完全一致;脚本还会反向追踪属性表暴露的 Face 修改动作、孔槽入口和 Edge 入口是否最终进入隔离 worker,防止后续新增参数行时忘记接入后台隔离执行,导致 UI 卡死或主进程被危险 OCCT 计算拖崩。没有 pyocc 时可以先运行 `python scripts\verify_face_ui_isolation_contract.py --static-only` 做 AST 级契约检查。 -- `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `面内长度` 和 `面内宽度` 两个方向上可以分别做 `局部重建` 与 `缩放特征`,另一个方向不会被误改,属性表也会读回新值。 -- `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖拉伸/切除并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`缩放特征` 的轴向缩放会按轴向跨度校验结果。 -- `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面浅范围向内收缩也走 `boundary-shell-rebuild`,深向内切除会快速阻止且说明材料厚度或二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 -- `scripts/verify_hole_slot_edit_suite.py`:集中运行当前孔/槽阶段验证,串起孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker 执行、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。后续孔/槽能力新增或重构时优先跑这个套件,而不是只跑单个孔径或槽宽脚本。 -- `scripts/verify_icepak_cylindrical_region_selection.py`:加载本地 `assets/models/ICEPAK-NATURAL.stp` 时,验证 Face87/94 这类被 STEP 拆成两片的同域圆柱孔会按整孔高亮、按整孔生成孔径计划,Face85/Face87 可以进入 Ctrl 多选孔参数面板,并能通过隔离 worker 完成 `0.5 -> 0.3` 的孔径缩小;模型文件不存在时自动跳过。 -- `scripts/verify_asitus_hole_bridge.py`:验证 Analysis Situs `recognize_holes` 输出的 1-based AAG Face 组会先映射成 Python/Qt 里的 Face 编号,再作为整孔区域参与高亮、参数表和一级关系计划;本地 C++ CLI 不存在时只验证解析和映射逻辑。 -- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 -- `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。 -- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划不会退回通用慢路径,避免按钮点击阶段长时间卡住主界面。 -- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示面内长度、面内宽度、中心和偏移,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 编辑;同时验证面积不会作为特征参数表驱动变量出现,目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的额外 `移动量` 行。 -- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表;同时守住普通 OCCT 操作历史里的 backend / execution / recognition 来源记录。 -- `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表;`关联探测` 本身保留为诊断信息,不再混入参数表。同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。 -- `scripts/verify_shell_edit_suite.py`:集中运行壳体厚度阶段验证,串起壳体厚度局部拉伸/切除、缩放特征、Face 一级事实图、结果校验和属性表分组。 -- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。脚本还会生成开口薄壁盒,验证完整抽壳/开口面编辑会被列为受限能力,而局部壳体厚度仍保留可用计划。 -- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 - -- `scripts/verify_cylindrical_first_level_topology.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,验证孔/槽类圆柱特征的修改计划会暴露一级共边拓扑关系,包括圆柱侧壁、边界 Edge、边界 Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。 -- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。 -- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 -- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。 -- `step_editor/window_actions.py`:导出按钮、编辑动作、后台任务、扫描、操作历史记录生成。 -- `step_editor/window_state.py`:选择状态、按钮启用/禁用、撤销/重做和历史定位。 -- `step_editor/model.py`:`StepModel` 核心状态和 mixin 组合。 -- `step_editor/features.py`:可编辑对象和圆柱面/孔/槽/凸台/圆角等识别计划。 -- `step_editor/operations.py`:拉伸/切除、孔径、孔深、圆角、倒角、壳体、边长等真实几何编辑。 -- `step_editor/export.py`:STEP 导出和质量检查。 -- `step_editor/polydata.py`:OCCT shape/face/edge 到 VTK polydata。 -- `step_editor/widgets.py`:Qt 自定义小控件,例如不误吃滚轮的下拉框。 - -### 左侧面板工作流 - -左侧面板前半段按用户指定顺序排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量。后半段继续放可编辑对象、圆柱面候选、操作历史和对象信息。 - -下拉框关闭时不能吃掉滚轮,鼠标停在下拉框上滚动应继续滚动左侧面板;只有打开下拉列表后,滚轮才用于滚动选项。 - -控件悬停提示必须面向普通用户说明“这个控件是干什么的、什么时候可用、失败后会怎样”,不要只写内部名词。 - -### 当前关键能力 - -- STEP 显示:默认使用中等清晰度网格,避免粗网格长期显示导致画质发糊;复杂模型旋转时保持边线和静态画质一致,并可隐藏同域内部边。 -- 模型展示区域右上角会显示最近渲染窗口的 `FPS`,用于观察旋转、缩放和复杂模型查看时的实际渲染帧率;静止后会用一次性 idle 刷新归零,不会持续空转重绘。 -- 模型展示区域左下角会显示 CAD 风格的 view cube + 彩色 `XYZ` 方向标;主视图前景层会在 STEP 文件真实全局原点 `(0,0,0)` 显示一组 `XYZ` 坐标基准,带原点球、粗箭头、负方向短轴和 3D 标签,且不参与拾取或初始相机适配,避免打开模型时被坐标轴拉远缩小。 -- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;左键拖动旋转不会触发选择;悬停和普通点选只做轻量拾取,不触发完整特征识别。 -- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 -- 编辑:偏移、面内长度/面内宽度、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、简单等半径圆角链半径/圆弧长度、已有等距倒角距离、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积作为诊断结果只读展示。 -- 编辑语义:Face 的偏移、面内长度/面内宽度和中心通过 `建模意图` 选择“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积不作为特征参数表里的驱动变量。圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、壳体、圆角和 Edge 长度也会通过 `建模意图` 或计划策略说明本次修改会局部重建、拉伸/切除、移动还是整体缩放。 -- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 -- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 - -### 编辑语义 - -所有几何参数修改都必须先明确语义、建模意图、执行影响和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `建模意图` 提供显式改法选择;Face 当前把 `偏移`、`面内长度`、`面内宽度` 和 `中心` 显示为单行属性,并通过 `建模意图` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积只作为诊断结果展示。孔/槽直径和半径也通过 `建模意图` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `建模意图` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `建模意图` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `建模意图` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `建模意图` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `建模意图` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长和简单等半径圆角链半径/弧长也通过 `建模意图` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的建模意图,后续还要继续把更多多语义场景做成用户可选策略。 - -必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。 - -### 修改Edge长度 - -这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。 - -当前支持的长度修改策略: - -1. `auto`:自动策略。按局部边形变、端面拉伸/切除、相邻圆柱直径、几何缩放的顺序寻找可用路径。 -2. `local-edge-only-deform`:只改当前Edge。直线Edge位于简单全平面多面体时,只移动被选Edge的端点并重建所属Solid/特征。对立方体测试模型,默认固定起点移动终点;如果 `长度基准` 选择 `中心`,才会两端各移动一半以保持 Edge 中心不动。相邻面会自然变形,四点非平面面会优先重建成连续曲面,而不是把整个正方体缩放成长方体,也不会默认拆出面内对角线。 -3. `move-edge-end-plane-by-push-pull`:移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面拉伸/切除;端面和相关边跟随移动。计划阶段会确认当前 Edge 的直接相邻面和移动端面都是可验证平面,执行后会反查侧面与端面仍保持垂直、侧面之间的平行/垂直关系没有丢失;对正方体会更像变成长方体。 -4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 -5. `scale-owning-shape-from-edge`:缩放所属对象。当前界面主要对直线Edge和圆Edge开放该长度语义;圆边会优先用相邻圆柱或径向缩放处理。椭圆边改用主半径/小半径入口,B-spline / Bezier 等复杂曲线边暂不作为稳定长度入口,避免高风险 fallback 误导用户。 - -直线Edge起点/终点/中心坐标编辑是另一类显式语义:`local-edge-endpoint-deform` 和 `local-edge-center-deform` 会在简单全平面多面体上移动端点或整条Edge,并重建周边平面。 - -确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。 - -最近一次基线验证:在 `assets/models/cube_10mm.step` 上选第一条直线Edge,将 10mm 改为 15mm,计划策略为 `local-edge-only-deform`,内存执行成功。执行后边长分布为 `10:9, 11.1803:2, 15:1`,Face 数保持 `6 -> 6`,Edge 数保持 `12 -> 12`,说明没有整体拉成长方体,也没有再把非平面四边面拆成两张三角半面。 - -移动端面基线验证:在同一个立方体模型上将第一条 10mm 直线 Edge 改为 15mm,并强制选择 `move-edge-end-plane-by-push-pull`、`keep-start`,内存执行成功。执行后 Face 数仍为 6、Edge 数仍为 12,边长分布为 `10:8, 15:4`,并通过 `Planar relation check: ok` 反查侧面、端面和目标 Edge 的一级平面关系,说明相关端面和同向边一起移动,结果更接近长方体语义。 - -Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。 - -Face 一级关系基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,`face_first_level_topology()` 会返回 `topology_relation_depth=1`、4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并明确二级/三级关系暂不递归传播。将上表面中心沿 Z 方向从 `10` 移到 `12` 后,4 个一级侧面中心 Z 从 `5` 变为 `6`,二级底面中心 Z 仍为 `0`,说明当前局部 Face 编辑只处理一级共享边拓扑。 - -Face 偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `偏移` 正向或负向改变 `1mm`。`局部重建` 会走 `local-face-plane-offset-deform`,移动当前 Face 顶点并重建相邻面;`移动特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`拉伸/切除` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削;`保持关系` 会走受限的 `push-pull-planar-face-keep-relations`,要求一级相邻平面关系在执行前可验证、执行后可反查。四条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“偏移”操作。 - -Face 面积后端基线验证:面积是结果变量,不再显示为特征参数表里的驱动行;后端仍保留 `local-face-area-only-deform` 等历史回归,用来确认旧路径不会破坏 B-Rep、逻辑 ID 和结果校验。 - -Face 局部面内长度/面内宽度基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面内长度` 或 `面内宽度` 的建模意图设为 `局部重建` 并从 `10` 改为 `15`,计划策略分别为 `local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后能检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 - -Face 整体面内长度/面内宽度基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面内长度` 或 `面内宽度` 的建模意图设为 `缩放特征` 并从 `10` 改为 `15`,计划策略分别为 `axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变化,同时避免通用仿射把平面盒子变成样条面。 - -Face 面积结果校验:表格不再要求用户输入目标面积;面积用于诊断信息、历史差异和后端结果校验,避免把结果变量伪装成直接可控的设计参数。 - -Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将中心按 `(2, 0, 3)` 偏移。`局部重建` 会走 `local-face-only-deform`,当前 Face 被移到目标中心并重建相邻面,Face 自身尺寸保持约 10mm x 10mm;`移动特征` 会走 `translate-part`,整个所属特征平移,所有面尺寸保持 10mm x 10mm。 - -孔/盲孔局部重建基线验证:`scripts/verify_hole_resize.py` 会临时生成一个通孔板件和一个盲孔块体。当前验证通过六条孔修改路线:通孔孔径 `6 -> 8`、通孔孔径 `6 -> 4`、通孔轴心偏移 `2mm`、完整通孔封堵、盲孔深度 `6 -> 8`、盲孔深度 `6 -> 4`。封堵会检查补料后仍是单 Solid、孔候选消失,并且体积接近完整板件;孔径缩小、孔轴心移动、封堵和盲孔变浅都会先补旧孔/旧底部再重切或补料;这些孔内补料 Fuse 必须关闭 OCCT glue,否则可能只生成一个插在孔里的独立圆柱 Solid,而不是和主体真正合并。 - -圆柱凸台局部重建基线验证:`scripts/verify_boss_resize.py` 会临时生成一个底板加圆柱凸台的 STEP。当前验证通过四条凸台修改路线:凸台直径 `6 -> 8`、凸台直径 `6 -> 4`、凸台高度 `5 -> 7`、凸台轴心偏移 `2mm`。直径缩小和轴心移动会先移除旧凸台包络,再按目标直径或目标轴心补新凸台;凸台补料/重建 Fuse 和旧包络 Cut 都必须显式关闭 OCCT glue,否则旧凸台可能没切掉,或补出的凸台变成独立 Solid。验证脚本会断言修改后仍是单 Solid,并且直径/轴心修改不会顺手改变凸台高度。 - -槽/半孔局部重建基线验证:`scripts/verify_slot_resize.py` 会临时生成一个 30 x 20 x 10 的块体并在上表面切出半径 3 的半圆槽,还会生成一个 40 x 24 x 6 的长圆槽板件。当前验证通过八条局部槽修改路线:槽宽 `6 -> 8`、槽深 `3 -> 4`、弧长 `9.42478 -> 12`、弧角 `180° -> 126.051°`、半圆槽轴心偏移 `1mm`、长圆槽整槽轴心偏移 `1mm`、槽孔总长度 `16 -> 20`、两端中心距 `10 -> 14`。槽宽/槽深/弧长会保持当前圆弧角度并换算目标圆柱直径;弧角会先补旧扇形槽,再按目标角度重切;半圆槽轴心会先填旧槽再按同尺寸切新槽,并丢弃极小布尔毛刺;长圆槽轴心会填旧胶囊槽再整体切到新位置;槽孔总长度和中心距会保持槽宽不变并调整两端中心距。槽扇形和长圆槽胶囊工具体必须使用真实圆弧边拉伸,不能用多边形近似,否则结果会变成小平面,后续识别不到目标圆柱槽面。 - -Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子、已有圆角盒子、已有倒角盒子和圆角链盒子。当前验证通过十二条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`、已有圆角弧长 `2.35619`、已有等距倒角距离 `1.5 -> 2.0`、简单等半径圆角链半径 `1 -> 1.5`、简单等半径圆角链弧长 `2.35619`、四面连通同半径圆角链半径 `1 -> 1.5`、五面同半径复杂长链识别/计划阶段阻止、变半径圆角链识别/计划阶段阻止。已有简单圆角和简单等半径圆角链修改走 defeature + refillet;已有简单倒角修改走 defeature + rechamfer;圆角弧长会按当前圆弧角换算成目标半径;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离;复杂同半径长链、变半径圆角链、大链式 blend 和复杂角部补面暂不尝试重建,避免进入高风险 blend 计算。 - -Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过真实 isolated worker 请求覆盖 Edge 长度、端点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、Edge 圆角、三类倒角、已有圆角半径、简单等半径圆角链半径和已有简单倒角距离修改。这个脚本的目标不是替代内存执行套件,而是确认 UI 交给 worker 的操作名和参数能在子进程里独立完成,避免危险 OCCT 倒圆、倒角或局部重建卡死主程序。 - -壳体厚度基线验证:`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。 - -椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine` 或 `ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。 - -内部后备基线验证:历史套件仍保留椭圆Edge通用长度缩放和圆边变换后 B-spline 长度缩放的几何回归,用来约束后端 fallback 不退化;但这不代表界面把椭圆长度或 B-spline 长度作为当前稳定参数开放,用户入口仍以椭圆主/小半径和明确的直线/圆Edge语义为准。 - -后置校验基线:修改Edge长度完成后,结果消息会包含 `match`、`nearest_edge`、`nearest_length`、`target_error`、`relative_error`、`endpoint_error` 和校验范围;局部边形变、端面拉伸/切除和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length` 和 `target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 - -### 当前边界 - -- STEP 通常不包含可稳定恢复的 CAD 建模历史;不要承诺“任意场景都能像原 CAD 参数一样修改”。 -- 复杂槽、复杂凸台、复杂圆角链、带内孔Face、曲面混合拓扑和大装配仍可能失败。 -- 布尔和 ShapeFix 可能耗时或失败,编辑动作应继续走后台 worker、预览和失败回滚。 -- 操作历史是本软件加载后的编辑留档,不是 STEP 原始建模历史。 - -### 提交与忽略 - -- `assets/models/*.step` 中用于测试的模型可以提交。 -- `assets/screenshots/` 默认忽略。 -- 运行导出的 `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json` 默认不提交。 -- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。 -- 工作区可能有用户改动,不能随意 `git checkout --`、`git reset --hard` 或回滚不相关文件。 - -### 2026-08-18 geom_extract.step 性能记录 - -- `ICEPAK-NATURAL.stp` 速度快不是因为有原 CAD 建模历史;它仍是 STEP/B-Rep 结果几何。`geom_extract.step` 慢的主要原因是模型规模更大,约 1800 个 Face、近 5000 条 Edge,且 Face594 是带多个内孔的大平面端盖,一级边界和相邻面更多。 -- 大模型点选阶段必须保持轻量:当前 Face / Feature 选择只生成快速一级边界摘要,完整一级关系事实图放到编辑计划或后台任务中计算,避免点一个 Face 就同步深扫全模型。圆柱面 quick 选择只合并直接相接的同圆柱碎面,不触发 Analysis Situs 或内部孔组识别图;超过 1000 个 Face 的模型默认不在 UI 选择路径同步构建内部全模型识别图。即使用户把“特征探测级别”切到相邻/二级,大模型点击选择也会先按“只识别当前特征”处理,深层识别必须走显式扫描或后台任务。 -- Face594 `偏移 57.5 -> 70` 的基线:`face_info` 从约 10.5s 降到约 0.9s,`push_pull_plan` 从约 9.5s 降到约 1.5s,内存 `push_pull_face` 约 3.5s-5.5s;即使 SCDM cache 识别出 `face.offset`,这类大模型多内孔平面位置调整也会优先使用本软件已验证的本地 OCCT 一级边界重建,避免退回 100s 级别的外部后端/导出/启动/回读慢路径。2026-08-18 复测:Face594 核心 UI job 约 9.1s,其中快照/统计约 2.0s、几何计算约 3.7s、校验约 2.2s、面显示约 1.2s;独立后台几何进程单次约 12.6s,但旋转查看更不容易被 Python/OCCT worker 抢占。 -- 大模型编辑后的逻辑 Face ID 保持必须走结果消息快路径:如果子进程返回 `nearest_face` / `verified_face` 且该 Face 通过目标位置回测,直接把旧逻辑 ID 绑定到这个结果 Face;不要再同步扫描全模型寻找同平面候选。2026-08-18 发现 Face594 isolated UI job 曾有约 75s 花在结果 Face 映射兜底扫描,修复后同一路径总耗时约 11.8s,`result_face_mapping` 约 0.013s。 -- 大模型显示阶段:先后台读取主面片,默认跳过全量 Edge overlay;超过 1000 个 Face 或 2500 条 Edge 的模型首屏和编辑后显示会使用交互网格预算,`geom_extract.step` 主显示三角面从约 11 万降到约 4.7 万,旋转和点选优先流畅,局部几何校验仍使用 OCCT B-Rep 而不是显示网格。编辑完成后的操作历史定位优先读取结果消息中的 `verified_face` / `nearest_face`,fallback 只做轻量平面位置查询,不再在 UI 主线程逐个 Face 调完整 `face_info()`;Face594 定位从 120s 级超时降到 0s 级。普通导入大模型后会延后 SCDM/Analysis Situs 全模型预热,优先保证旋转和点选;SCDM 修改结果回读校验仍会强制刷新 cache。旋转相机时仍会临时隐藏已有边线 overlay,停止后恢复,保证画质不降的同时减少交互卡顿。 -- 大模型交互阶段不再把所有 UI 辅助工作绑到点选上:关系式补全只在公式输入框获得焦点或正在输入时刷新,避免每次点 Face 都重建数千个 Face/Edge 候选;左键按下会记录按下位置的对象,背景开始拖动/旋转后即使松手落在模型上也不会误选;旋转期间会临时隐藏选择高亮和拾取点覆盖层,松手后恢复,减少透明 overlay 对帧率的影响。 - -### 2026-08-03 工程师返还代码合并记录 - -- 已合并特征参数策略:Feature 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。 -- 已合并矩形平面轮廓识别:规则四直边平面可以识别为矩形平面、矩形棱柱、矩形口袋或矩形凸台候选,并估算长度、宽度、中心和高度/深度;规则矩形凸台/口袋已开放长度、宽度、中心和高度/深度修改,多台阶矩形凸台的顶层规则台阶也可按独立矩形凸台编辑,中间过渡面会识别为复杂多台阶凸台并快速阻止。长宽和中心移动会局部重建矩形包络,当前不在一次编辑中交换长宽方向,也不把中心沿高度/深度方向移动。 -- 已合并关联特征扫描:选中一个 Face 时,可在有限共享边拓扑范围内关联附近孔、槽、凸台、圆角、圆锥/球/环面等候选特征,并把关联特征的可改尺寸显示到当前特征参数表中。 -- 已合并显示质量修复:精细网格只重点细化解析曲面;法线平滑不再合并不同 B-Rep Face 的边界点,减少平面被相邻曲面污染后发黑的问题。 -- 新增验证脚本:`scripts/verify_feature_parameter_policy.py`、`scripts/verify_prismatic_feature.py`、`scripts/verify_associated_features.py`;其中 `verify_prismatic_feature.py` 会同时覆盖矩形凸台/口袋长度/宽度、中心、高度/深度、多台阶矩形凸台顶层台阶编辑和中间过渡面快速阻止。 +- 当前主线是 `SCDM-first 主路线`。继续开发时先读这条路线,不要回到“自己手写 Face 一级、孔槽、Edge、凸台、圆角、壳体、二级三级传播”的旧路线。 +- 新增客户入口必须先有 SCDM raw 证据、capability key、中文参数、SCDM edit job/脚本、postCheck 校验、失败解释和回归脚本。 +- 本地 OCCT 和 Analysis Situs 只作为兜底、对照和历史回归基线。 +- 大模型交互稳定性优先:导入、旋转、边线补充、SCDM probe、参数表刷新和 actor 替换不能在 UI 线程互相抢。 +- `geom_extract.step` 的 Face1722 是重要误识别样例:SCDM raw 标为 `round`,本地兜底不能在 cache 未就绪时把它当普通圆柱孔开放。 +- 当前需要继续补的主线问题:SCDM 模型树用于 Pattern/Component/Solid 定位、阵列局部间距真实 STEP 回测、槽/凸台/圆角/壳体真实 SCDM 样例、关系式优先级/更细错误定位、复杂 ID 续接歧义提示、偶发 VTK 旋转闪退复现。 diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index 0a076fa..3f99e8c 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -295,6 +295,53 @@ def _verify_slot_summary(root: Path) -> None: _assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}") +def _verify_split_cylinder_slot_and_hole_guard() -> None: + geom_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" + if geom_path.exists(): + geom_model = StepModel.load(geom_path) + for slot_face_id in (1360, 1722): + slot_info = geom_model.feature_info(slot_face_id) + _assert( + "槽" in str(slot_info.get("feature_type") or "") + and "圆柱孔候选" not in str(slot_info.get("feature_type") or ""), + f"geom_extract Face{slot_face_id} should be treated as a slot/groove, not a cylindrical hole: {slot_info}", + ) + _assert( + slot_info.get("slot_status") in {"candidate", "blocked"}, + f"geom_extract Face{slot_face_id} should keep slot classification fields: {slot_info}", + ) + _assert( + "封堵圆柱孔" not in str(slot_info.get("feature_edit_actions") or ""), + f"geom_extract Face{slot_face_id} should not expose cylindrical-hole suppress wording: {slot_info}", + ) + _assert( + int(slot_info.get("recognition_user_priority", 99)) == 30, + f"geom_extract Face{slot_face_id} should use slot priority: {slot_info}", + ) + through_hole_info = geom_model.feature_info(1591) + _assert( + through_hole_info.get("feature_type") == "圆柱孔候选", + f"geom_extract Face1591 should remain a cylindrical hole guard case: {through_hole_info}", + ) + _assert( + int(through_hole_info.get("recognition_user_priority", 99)) == 20, + f"geom_extract Face1591 should keep hole priority: {through_hole_info}", + ) + + icepak_path = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp" + if icepak_path.exists(): + icepak_model = StepModel.load(icepak_path) + hole_info = icepak_model.feature_info(87) + _assert( + hole_info.get("feature_type") == "圆柱孔候选", + f"ICEPAK Face87 is a split through-hole and should remain a hole: {hole_info}", + ) + _assert( + int(hole_info.get("recognition_user_priority", 99)) == 20, + f"ICEPAK Face87 should keep hole priority: {hole_info}", + ) + + def _verify_boss_summary(root: Path) -> None: path = root / "boss.step" _write_boss_model(path) @@ -574,6 +621,7 @@ def main() -> int: _verify_quick_cylinder_recognition(root) _verify_hole_summary(root) _verify_slot_summary(root) + _verify_split_cylinder_slot_and_hole_guard() _verify_boss_summary(root) _verify_asitus_slot_boss_fillet_hints(root) _verify_torus_summary(root) diff --git a/scripts/verify_first_level_acceptance_docs.py b/scripts/verify_first_level_acceptance_docs.py index 3252d52..66db2fd 100644 --- a/scripts/verify_first_level_acceptance_docs.py +++ b/scripts/verify_first_level_acceptance_docs.py @@ -57,6 +57,9 @@ def _verify_readme_mentions(readme: str) -> None: "[scdm_probe_job.json -> /RunScript 扫描 STEP]", "[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", "[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]", + "[公式输入 -> 支持 Face85.直径 = Face87.半径", + "[批量联动 -> 多条公式先求值成一组目标参数", + "[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接", "[Face 偏移 -> face.offset / OffsetFaces]", "[槽宽 -> slot.width / OffsetFaces", "[槽深 -> slot.depth / Move 或 OffsetFaces", @@ -96,7 +99,7 @@ def _verify_readme_mentions(readme: str) -> None: def _verify_roadmap_scope(readme: str) -> None: start_marker = "SCDM-first 主路线" - end_marker = "└── 7. 交付与兜底边界" + end_marker = "└── 8. 交付与兜底边界" start = readme.find(start_marker) end = readme.find(end_marker) _assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary") @@ -110,14 +113,19 @@ def _verify_roadmap_scope(readme: str) -> None: "│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]", "│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", "├── 2. 能力字典、参数表和用户入口", - "├── 3. SCDM 参数化建模执行", - "├── 4. 结果校验、回滚和 ID 续接", - "├── 5. 当前已开放或正在开放的 SCDM 能力", + "├── 3. 关系式与参数联动", + "[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]", + "[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]", + "[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]", + "├── 4. SCDM 参数化建模执行", + "├── 5. 结果校验、回滚和 ID 续接", + "├── 6. 当前已开放或正在开放的 SCDM 能力", "[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]", - "[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心]", - "├── 6. 下一批只按 SCDM 能力适配", - "[阵列实例位置 -> pattern.instance_position]", - "[壳体厚度 -> shell.thickness,薄壁候选配对已进 cache", + "[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项]", + "[阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补]", + "[壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补]", + "├── 7. 下一批只按 SCDM 能力适配", + "[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]", ) for fragment in required_active_fragments: _assert(fragment in active_scope, f"README active roadmap missing: {fragment}") diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 7b69e03..f95d024 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -37,6 +37,8 @@ if str(PROJECT_ROOT) not in sys.path: from step_editor.widgets import NoWheelComboBox from step_editor.relation_formulas import ObjectParameterRef, parse_relation_formula +from step_editor.scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION +from step_editor.scdm_schema import file_fingerprint from step_editor.window_actions import WindowActionMixin from step_editor.window_core import WindowCoreMixin from step_editor.window_state import ( @@ -103,6 +105,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): "feature.delete_round_or_chamfer", "pattern.spacing", "pattern.segment_spacing", + "pattern.instance_position", + "shell.thickness", } layout = QVBoxLayout(self) @@ -142,6 +146,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed) layout.addWidget(self.relation_formula_input) self.add_relation_formula_button = QPushButton() + self.import_relation_formula_button = QPushButton() + self.export_relation_formula_button = QPushButton() + self.toggle_relation_formula_button = QPushButton() self.remove_relation_formula_button = QPushButton() self.relation_formula_list = QListWidget() self.face_width_input = QLineEdit() @@ -1043,6 +1050,142 @@ def _assert_relation_formula_input_clickable_after_existing_formula() -> None: _assert(probe.add_relation_formula_button.isEnabled(), "clickable input should allow adding another formula") +def _assert_relation_formula_can_toggle_without_deleting() -> None: + probe = _PropertyTableProbe() + probe._refresh_property_editor() + probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2") + probe.add_relation_formula() + for _index in range(12): + QApplication.processEvents() + if not getattr(probe, "relation_formula_replay_active", False): + break + _assert(len(probe.relation_formula_items) == 1, f"formula should exist before toggling: {probe.relation_formula_items}") + _assert(probe.relation_formula_list.count() == 1, "formula list should show the formula before toggling") + probe.relation_formula_list.setCurrentRow(0) + probe._update_relation_formula_buttons() + _assert(probe.toggle_relation_formula_button.isEnabled(), "selected formula should enable the toggle button") + _assert(probe.toggle_relation_formula_button.text() == "停用公式", f"enabled formula should offer disable action: {probe.toggle_relation_formula_button.text()}") + + restore_calls = {"count": 0} + + def _restore_base() -> bool: + restore_calls["count"] += 1 + return True + + probe.relation_formula_base_snapshot = {"baseline": True} + probe._restore_relation_formula_base_snapshot = _restore_base + probe.toggle_selected_relation_formula() + _assert(restore_calls["count"] == 1, "disabling the last active formula should restore the formula base snapshot") + _assert(probe.relation_formula_items[0].get("enabled") is False, f"formula should be disabled, not deleted: {probe.relation_formula_items}") + _assert("停用" in probe.relation_formula_list.item(0).text(), f"disabled formula should be visibly marked: {probe.relation_formula_list.item(0).text()}") + _assert(not getattr(probe, "relation_formula_replay_active", False), "disabling all formulas should not leave replay active") + probe.relation_formula_list.setCurrentRow(0) + probe._update_relation_formula_buttons() + _assert(probe.toggle_relation_formula_button.text() == "启用公式", f"disabled formula should offer enable action: {probe.toggle_relation_formula_button.text()}") + + probe.toggle_selected_relation_formula() + for _index in range(12): + QApplication.processEvents() + if not getattr(probe, "relation_formula_replay_active", False): + break + _assert(probe.relation_formula_items[0].get("enabled") is True, f"formula should be re-enabled: {probe.relation_formula_items}") + _assert(str(probe.relation_formula_items[0].get("status")) in {"applied", "ready"}, f"enabled formula should return to a computable state: {probe.relation_formula_items}") + probe.relation_formula_list.setCurrentRow(0) + probe._update_relation_formula_buttons() + _assert(probe.toggle_relation_formula_button.text() == "停用公式", "re-enabled formula should offer disable action again") + + +def _assert_relation_formula_import_export() -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + probe = _PropertyTableProbe() + probe.relation_formula_items = [ + { + "id": 7, + "text": "Face87.直径 = Face85.半径", + "enabled": True, + "status": "applied", + "message": "已修改模型。", + } + ] + export_path = root / "relation_formulas.json" + _assert(probe.export_relation_formulas(export_path), "relation formula export should succeed") + payload = json.loads(export_path.read_text(encoding="utf-8")) + _assert(payload.get("schema") == "python-occt.relation-formulas.v1", f"export schema missing: {payload}") + formulas = payload.get("formulas") + _assert(isinstance(formulas, list) and len(formulas) == 1, f"export should contain one formula: {payload}") + _assert( + formulas[0].get("text") == "Face87.直径 = Face85.半径" and formulas[0].get("enabled") is True, + f"exported formula row is wrong: {formulas}", + ) + + import_path = root / "import_formulas.json" + import_path.write_text( + json.dumps( + { + "formulas": [ + {"text": "Face87.直径 = 10mm", "enabled": True}, + {"text": "Face85.直径 = Face87.半径", "enabled": False}, + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + reapply_calls: list[dict[str, object]] = [] + probe._start_relation_formula_reapply = lambda **kwargs: reapply_calls.append(dict(kwargs)) + _assert(probe.import_relation_formulas(import_path), "relation formula import should succeed") + _assert(len(probe.relation_formula_items) == 2, f"import should replace the formula set: {probe.relation_formula_items}") + _assert(probe.relation_formula_items[0].get("text") == "Face87.直径 = 10mm", f"import should normalize first formula: {probe.relation_formula_items}") + _assert(probe.relation_formula_items[1].get("enabled") is False, f"import should preserve disabled state: {probe.relation_formula_items}") + _assert(reapply_calls and reapply_calls[-1].get("reason") == "import", f"enabled imported formulas should trigger reapply: {reapply_calls}") + + previous_items = [dict(item) for item in probe.relation_formula_items] + invalid_path = root / "invalid_formulas.json" + invalid_path.write_text( + json.dumps({"formulas": ["Face1.直径 = 1", "Face1.直径 = 2"]}, ensure_ascii=False), + encoding="utf-8", + ) + _assert(not probe.import_relation_formulas(invalid_path), "invalid formula groups should be rejected") + _assert(probe.relation_formula_items == previous_items, "failed import should not alter the current formula set") + + +def _assert_scdm_first_holds_ambiguous_large_cylinders() -> None: + probe = _PropertyTableProbe() + probe.selected_kind = "feature" + probe.selected_face_id = 1722 + probe.scdm_feature_cache = None + probe.scdm_feature_cache_state = "deferred" + probe._large_model_interaction_mode = lambda: True + + def _cylinder_action_info() -> dict[str, object]: + return { + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "feature_type": "圆柱孔候选", + "diameter": 2.6, + "radius": 1.3, + "axis": (0.0, 1.0, 0.0), + "axis_point": (-83.0, -31.0, -116.35), + "part_id": 1, + "solid_id": 0, + } + + probe.current_info_values = _cylinder_action_info() + probe._selected_action_info = _cylinder_action_info + probe._refresh_property_editor() + labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + _assert(not labels, f"SCDM-first large-model cylinder should not expose local hole parameters while cache is deferred: {labels}") + _assert( + "不开放本地兜底孔/槽参数" in str(getattr(probe, "scdm_selection_status_message", "")), + f"SCDM-first hold should explain why local cylinder specs are hidden: {getattr(probe, 'scdm_selection_status_message', '')}", + ) + + def _assert_relation_formula_input_is_selection_independent() -> None: probe = _RelationFormulaEventProbe() probe.relation_formula_input.installEventFilter(probe) @@ -1577,6 +1720,191 @@ def _assert_scdm_selection_diagnostics() -> None: _assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}") +def _assert_scdm_parameter_table_shows_only_enabled_specs() -> None: + probe = _PropertyTableProbe() + probe.selected_kind = "face" + probe.selected_face_id = 31 + probe.scdm_feature_cache_state = "ready" + probe.scdm_feature_cache = { + "objects": [ + { + "objectId": "slot:31", + "objectType": "slot", + "geometrySignature": {"faceIds": [31], "objectType": "slot"}, + "capabilities": [ + { + "key": "slot.width", + "displayName": "槽宽", + "currentValue": 2.0, + "valueKind": "positive", + "defaultIntent": "改槽宽", + "backendOperation": "change_slot_width", + "postCheck": "target_slot_width", + }, + { + "key": "slot.depth", + "displayName": "槽深", + "currentValue": 1.5, + "valueKind": "positive", + "defaultIntent": "改槽深", + "backendOperation": "change_slot_depth", + "postCheck": "target_slot_depth", + }, + ], + } + ] + } + + def labels() -> list[str]: + return [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + + probe.scdm_edit_runner_ready = set() + probe._refresh_property_editor() + disabled_labels = labels() + _assert("槽宽" not in disabled_labels and "槽深" not in disabled_labels, f"disabled SCDM specs should stay out of the parameter table: {disabled_labels}") + _assert( + "当前能力未开放" in str(probe.current_info_values.get("scdm_selection_status") or ""), + f"disabled SCDM specs should remain visible as a diagnostic reason: {probe.current_info_values}", + ) + + probe.scdm_edit_runner_ready = {"slot.width", "slot.depth"} + probe._refresh_property_editor() + enabled_labels = labels() + _assert(enabled_labels == ["槽宽", "槽深"], f"enabled SCDM specs should replace local fallback rows: {enabled_labels}") + _assert( + all(bool(spec.get("enabled")) for spec in probe.property_editor_specs), + f"property table should only hold executable SCDM rows: {probe.property_editor_specs}", + ) + + +def _assert_ambiguous_slot_empty_state_explains_missing_params() -> None: + probe = _PropertyTableProbe() + probe.selected_kind = "feature" + probe.selected_face_id = 1362 + probe.scdm_feature_cache_state = "ready" + probe.scdm_feature_cache = {"objects": []} + slot_candidate_info = { + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "feature_type": "圆柱孔/槽候选", + "recognition_confidence": "medium", + "recognition_score": 62, + "diameter": 2.6, + "radius": 1.3, + "angular_span": 3.141592653589793, + "part_id": 1, + "solid_id": 0, + } + probe.current_info_values = dict(slot_candidate_info) + probe._selected_action_info = lambda: dict(slot_candidate_info) + probe._refresh_property_editor() + labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + _assert("槽宽" not in labels and "槽深" not in labels, f"ambiguous slot candidates should not expose fake slot params: {labels}") + _assert("可修改参数" in labels, f"ambiguous slot should keep an empty-state row: {labels}") + status = str(probe.current_info_values.get("scdm_selection_status") or "") + _assert( + "圆柱孔/槽候选" in status and "证据完整" in status, + f"ambiguous slot empty state should explain the missing editable evidence: {probe.current_info_values}", + ) + + +def _assert_scdm_round_delete_conflict_filtered_for_blind_pocket() -> None: + def round_delete_cache(face_id: int) -> dict[str, object]: + return { + "objects": [ + { + "objectId": f"round:{face_id}", + "objectType": "round", + "geometrySignature": {"faceIds": [face_id], "surfaceType": "cylinder", "radius": 1.3}, + "capabilities": [ + { + "key": "feature.delete_round_or_chamfer", + "displayName": "删除圆角/倒角", + "currentValue": 1, + "valueKind": "command", + "editable": True, + "defaultIntent": "删除圆角/倒角并补面", + "backendOperation": "delete_round_or_chamfer", + "postCheck": "target_feature_removed", + } + ], + } + ] + } + + probe = _PropertyTableProbe() + probe.selected_kind = "feature" + probe.selected_face_id = 1360 + probe.scdm_feature_cache_state = "ready" + probe.scdm_edit_runner_ready = {"feature.delete_round_or_chamfer"} + probe.scdm_feature_cache = round_delete_cache(1360) + pocket_info = { + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "feature_type": "圆柱孔/槽候选", + "blind_split_cylindrical_pocket": True, + "diameter": 2.6, + "radius": 1.3, + "part_id": 1, + "solid_id": 0, + } + probe.current_info_values = dict(pocket_info) + probe._selected_action_info = lambda: dict(pocket_info) + probe._refresh_property_editor() + labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + _assert("删除圆角/倒角" not in labels, f"blind pocket should not expose round/chamfer delete: {labels}") + _assert(not labels, f"blind pocket SCDM conflict should not fall back to misleading local rows: {labels}") + _assert( + probe.current_info_values.get("scdm_selection_capability_count") == 0, + f"filtered SCDM conflict should not remain in diagnostic capability count: {probe.current_info_values}", + ) + _assert( + probe.current_info_values.get("scdm_selection_blocked_count") == 0, + f"filtered SCDM conflict should not remain in blocked diagnostics: {probe.current_info_values}", + ) + _assert( + "已隐藏该冲突操作" in str(probe.current_info_values.get("scdm_selection_status") or ""), + f"filtered SCDM conflict should be explained in diagnostics: {probe.current_info_values}", + ) + + round_probe = _PropertyTableProbe() + round_probe.selected_kind = "feature" + round_probe.selected_face_id = 1722 + round_probe.scdm_feature_cache_state = "ready" + round_probe.scdm_edit_runner_ready = {"feature.delete_round_or_chamfer"} + round_probe.scdm_feature_cache = round_delete_cache(1722) + round_info = { + "surface": "cylinder", + "feature_guess": "round/fillet candidate", + "feature_type": "圆角/倒圆候选", + "existing_fillet_status": "candidate", + "radius": 1.3, + "part_id": 1, + "solid_id": 0, + } + round_probe.current_info_values = dict(round_info) + round_probe._selected_action_info = lambda: dict(round_info) + round_probe._refresh_property_editor() + round_labels = [ + round_probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(round_probe.property_table.rowCount()) + if round_probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + _assert("删除圆角/倒角" in round_labels, f"real round candidates should keep SCDM delete capability: {round_labels}") + + def _assert_solid_selection_does_not_expand_face_scdm_specs() -> None: probe = _PropertyTableProbe() probe.scdm_feature_cache_state = "ready" @@ -1799,21 +2127,35 @@ def _assert_large_model_preload_stays_lightweight() -> None: "STEP load should restore a matching SCDM disk cache before launching a new probe", ) _assert( - "_defer_large_model_recognition_preloads" in loaded_body, - "large model loads should defer full external recognition preloads by default", + "_defer_post_import_recognition_preloads" in loaded_body + and "_start_asitus_hole_recognition_preload" not in loaded_body, + "ordinary STEP import should display the model first and defer external recognition until selection", ) _assert( - "_start_scdm_probe_preload(force=pending_scdm_reload)" in loaded_body, + "_start_scdm_probe_preload(force=True)" in loaded_body, "SCDM edit-result reloads should still be able to force cache refresh for validation", ) + restore_cache_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_restore_scdm_feature_cache_from_disk") + _assert( + "scdm_raw_features.json" not in restore_cache_body + and "attach_local_face_ids_to_scdm_cache" not in restore_cache_body + and "_scdm_local_face_signatures()" not in restore_cache_body, + "STEP import should not rebuild SCDM Face mappings from raw cache during the first display path", + ) preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload") _assert( "_current_scdm_feature_cache_matches_loaded_step()" in preload_body, "SCDM preload should skip relaunching SpaceClaim when a current cache is already installed", ) _assert( - "local_face_signatures = self._scdm_local_face_signatures()" not in preload_body, - "SCDM preload must not build all local Face signatures on the UI thread", + "model=model" not in preload_body + and 'getattr(model, "scdm_local_face_signatures"' not in preload_body + and "getattr(model, 'scdm_local_face_signatures'" not in preload_body, + "SCDM preload worker must not keep or inspect the current UI StepModel across threads", + ) + _assert( + "face_signatures=tuple(local_face_signatures)" in preload_body, + "SCDM preload should pass a copied local Face signature snapshot into the worker", ) _assert( "force: bool = False" in preload_body @@ -1822,8 +2164,8 @@ def _assert_large_model_preload_stays_lightweight() -> None: "large-model SCDM preload should be deferred unless a validation path forces it", ) _assert( - 'builder = getattr(model, "scdm_local_face_signatures", None)' in preload_body, - "SCDM preload should build local Face signatures inside the background worker", + 'builder = getattr(model, "scdm_local_face_signatures", None)' not in preload_body, + "SCDM preload should not build local Face signatures from the current UI model inside the background worker", ) status_body = _function_text(PROJECT_ROOT / "step_editor/scdm_status.py", "summarize_scdm_runtime") _assert('state == "deferred"' in status_body, "SCDM status should explain deferred large-model recognition") @@ -1888,6 +2230,15 @@ def _assert_large_model_selection_stays_lightweight() -> None: "large_model_hover_disabled" in hover_body, "large-model hover picking should be suppressed before the VTK picker runs", ) + select_feature_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "select_feature") + select_face_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "select_face") + scdm_on_demand_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_maybe_start_scdm_probe_for_selection") + _assert( + "_maybe_start_scdm_probe_for_selection" in select_feature_body + and "_maybe_start_scdm_probe_for_selection" in select_face_body + and "_start_scdm_probe_preload(force=True)" in scdm_on_demand_body, + "large-model object selection should trigger on-demand SCDM probing instead of relying on ambiguous local cylinder fallback", + ) action_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_isolation_for_plan") _assert( "_prefer_isolated_process_for_large_interactive_edit" in action_body @@ -1907,6 +2258,101 @@ def _assert_large_model_selection_stays_lightweight() -> None: ) +def _assert_vtk_interaction_stability_guards() -> None: + copy_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_copy_polydata_for_ui_thread") + _assert( + "DeepCopy" in copy_body, + "worker-built VTK polydata should be deep-copied before the UI renderer owns it", + ) + render_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_render_window_safely") + _assert( + "_is_ui_thread" in render_body and "scene_rebuild_in_progress" in render_body, + "render requests should stay on the UI thread and skip non-forced renders during scene rebuilds", + ) + window_core_text = (PROJECT_ROOT / "step_editor/window_core.py").read_text(encoding="utf-8") + window_state_text = (PROJECT_ROOT / "step_editor/window_state.py").read_text(encoding="utf-8") + direct_core_renders = window_core_text.count("render_window.Render()") - render_body.count("render_window.Render()") + _assert( + direct_core_renders == 0 and "self.render_window.Render()" not in window_state_text, + "window code should route all VTK render requests through _render_window_safely()", + ) + pick_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_pick_actor_cell") + _assert( + "scene_rebuild_in_progress" in pick_body and "PickFromListOff" in pick_body, + "VTK picking should be disabled while actors/polydata are being replaced", + ) + hover_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_update_hover_target") + _assert( + "scene_rebuild_in_progress" in hover_body, + "hover picking should be suppressed during scene rebuilds", + ) + finish_scdm_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_finish_scdm_probe_preload") + _assert( + "installed = self._install_scdm_feature_cache" in finish_scdm_body + and "cache_ready=False" in finish_scdm_body, + "SCDM probe finish should not report success when cache installation is rejected", + ) + deferred_edge_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_finish_deferred_edge_display") + _assert( + "_defer_scene_actor_update_if_interacting" in deferred_edge_body + and "_apply_deferred_edge_display_result" in deferred_edge_body, + "deferred edge actor installation should wait until camera interaction is idle", + ) + scene_delay_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_scene_actor_update_should_wait_for_camera") + _assert( + "camera_interaction_active" in scene_delay_body + and "pointer_button_down" in scene_delay_body + and "last_camera_interaction_ended_at" in scene_delay_body, + "scene actor updates should be delayed during and immediately after camera interaction", + ) + finish_edit_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_finish_scdm_edit_action") + _assert( + "_load_step_sync" in finish_edit_body + and "background=True" not in finish_edit_body, + "SCDM edit-result reload should avoid the background LoadWorker path that can hand worker-built VTK objects to the renderer", + ) + load_sync_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_sync") + _assert( + "_reject_scdm_result_after_reload" in load_sync_body + and "result-load-failed" in load_sync_body, + "SCDM edit-result reload should catch scene-apply failures and roll back instead of leaking exceptions through Qt", + ) + load_apply_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result") + _assert( + "pending_scdm_reload" in load_apply_body + and "if not pending_scdm_reload:\n self._clear_history()" in load_apply_body, + "SCDM edit-result reload should not run the ordinary import path that clears edit history while validation is pending", + ) + + +def _assert_scdm_cache_revision_guard() -> None: + with tempfile.TemporaryDirectory(prefix="step_editor_cache_guard_") as temp: + step_path = Path(temp) / "guard.step" + step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8") + fingerprint = file_fingerprint(step_path) + + probe = _MouseSelectionProbe() + probe.step_path = step_path + probe.model = SimpleNamespace() + stale = { + "modelFingerprint": fingerprint, + "mapperRevision": max(0, SCDM_FEATURE_CACHE_REVISION - 1), + "objects": [{"objectId": "cylindrical_face_group:stale", "capabilities": [{"key": "hole.diameter"}]}], + } + installed = probe._install_scdm_feature_cache(stale) + _assert(installed is False, "stale SCDM mapper cache must not be installed") + _assert(probe.scdm_feature_cache is None and probe.scdm_feature_cache_state == "stale", "stale SCDM cache should clear UI cache state") + + current = { + "modelFingerprint": fingerprint, + "mapperRevision": SCDM_FEATURE_CACHE_REVISION, + "objects": [{"objectId": "face:0", "capabilities": [{"key": "face.offset"}]}], + } + installed = probe._install_scdm_feature_cache(current) + _assert(installed is True, "current SCDM mapper cache should install") + _assert(probe._current_scdm_feature_cache_matches_loaded_step() is True, "current cache should match the loaded STEP") + + def _assert_large_planar_offset_prefers_local_backend() -> None: probe = _PropertyTableProbe() probe.model = _LargeDisplayModel() @@ -2043,6 +2489,8 @@ def main() -> int: _assert("## 已能识别" in progress_detail, "software progress dialog text should list recognized capabilities") _assert("## 暂不能修改" in progress_detail, "software progress dialog text should list unsupported edits") _assert("## 暂不能稳定识别" in progress_detail, "software progress dialog text should list unsupported recognition") + _assert("证据完整的简单槽/长圆槽" in progress_detail, f"software progress should not overpromise ambiguous slot edits: {progress_detail}") + _assert("不等于已经可改" in progress_detail, f"software progress should distinguish recognition from editability: {progress_detail}") _assert("孔组" in progress_detail and "一级关系" in progress_detail, "software progress dialog text should include recognition scope") _assert("B-Rep 校验" in progress_detail and "原 CAD 历史树" in progress_detail, "software progress dialog text should explain limits") _assert("SCDM-first 路线状态" not in progress_detail, "software progress dialog text should hide roadmap internals") @@ -2080,11 +2528,17 @@ def main() -> int: _assert_relation_radius_formula_proxy() _assert_relation_formula_input_remains_editable_while_replaying() _assert_relation_formula_input_clickable_after_existing_formula() + _assert_relation_formula_can_toggle_without_deleting() + _assert_relation_formula_import_export() _assert_relation_formula_input_is_selection_independent() _assert_relation_formula_input_recovers_after_loading() _assert_relation_formula_ids_follow_model_remap() _assert_mouse_selection_guards() _assert_scdm_selection_diagnostics() + _assert_scdm_parameter_table_shows_only_enabled_specs() + _assert_ambiguous_slot_empty_state_explains_missing_params() + _assert_scdm_round_delete_conflict_filtered_for_blind_pocket() + _assert_scdm_first_holds_ambiguous_large_cylinders() _assert_solid_selection_does_not_expand_face_scdm_specs() _assert_operation_record_backend_sources() _assert_scdm_auto_prompt() @@ -2092,6 +2546,8 @@ def main() -> int: _assert_worker_ui_callbacks_guarded() _assert_large_model_preload_stays_lightweight() _assert_large_model_selection_stays_lightweight() + _assert_vtk_interaction_stability_guards() + _assert_scdm_cache_revision_guard() _assert_large_planar_offset_prefers_local_backend() _assert_background_load_uses_worker() _assert_quick_blind_depth_spec() diff --git a/scripts/verify_relation_formula_rules.py b/scripts/verify_relation_formula_rules.py index 01e7a1b..24fda92 100644 --- a/scripts/verify_relation_formula_rules.py +++ b/scripts/verify_relation_formula_rules.py @@ -10,6 +10,8 @@ if str(PROJECT_ROOT) not in sys.path: from step_editor.relation_formulas import ( # noqa: E402 RelationFormulaError, + Vector3, + evaluate_relation_formula, parse_relation_formula, validate_relation_formula_graph, ) @@ -34,10 +36,33 @@ def _assert_fails(texts: list[str], expected_fragment: str) -> None: raise AssertionError(f"expected relation formulas to fail: {texts!r}") +def _assert_value(text: str, expected: object, values: dict[str, object] | None = None) -> None: + formula = parse_relation_formula(text) + values = dict(values or {}) + value = evaluate_relation_formula(formula, lambda ref: values[ref.token]) + if isinstance(value, Vector3): + actual = tuple(value.values) + expected_tuple = tuple(expected) # type: ignore[arg-type] + if len(actual) != len(expected_tuple) or any(abs(float(left) - float(right)) > 1.0e-12 for left, right in zip(actual, expected_tuple)): + raise AssertionError(f"expected {expected_tuple!r}, got {actual!r} for {text!r}") + return + if abs(float(value) - float(expected)) > 1.0e-12: + raise AssertionError(f"expected {expected!r}, got {value!r} for {text!r}") + + def main() -> int: _assert_ok(["Face87.直径 = Face87.半径 + 0.1"]) _assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"]) _assert_ok(["Face85.直径 = Face87.半径", "Face11.直径 = Face85.半径"]) + _assert_ok(["Face87.直径 = 10mm"]) + _assert_ok(["Face87.直径 = 1 cm + 2mm"]) + _assert_value("Face87.直径 = 1cm + 2毫米", 12.0) + _assert_value("Face87.直径 = .5m / 10", 50.0) + _assert_value( + "Face87.位置 = Face85.位置 + (0mm, 1cm, -0.002m)", + (1.0, 12.0, 1.0), + {"Face85.位置": (1.0, 2.0, 3.0)}, + ) _assert_fails(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身") _assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数") diff --git a/scripts/verify_scdm_edit_runner.py b/scripts/verify_scdm_edit_runner.py index 3cc9939..219010c 100644 --- a/scripts/verify_scdm_edit_runner.py +++ b/scripts/verify_scdm_edit_runner.py @@ -175,10 +175,15 @@ def main() -> int: "change_boss_diameter", "change_round_radius", "change_chamfer_distance", + "change_shell_thickness", "change_pattern_spacing", "change_pattern_segment_spacing", + "move_pattern_instance", "segment_before", "segment_split", + "segment_single_left", + "segment_single_right", + "wallFaceLocators", "pull_face_offset", "fill_feature", "delete_round_or_chamfer", @@ -207,6 +212,11 @@ def main() -> int: ): _assert(token in script, f"generated edit script missing {token}") _assert("Pull.Execute" not in script, "generated edit script should use documented OffsetFaces instead of Pull.Execute") + _assert("_delete_selection(selection, fill_errors)" in script, "generated edit script should fall back from Fill to Delete") + _assert( + "if fill is None:\n raise Exception('capability_not_implemented: Fill command not available')" not in script, + "Delete-capable SCDM environments must not fail merely because Fill is missing", + ) generated = generate_scdm_edit_script(job_path) _assert("JOB_PATH =" in generated and job_path.name in generated, "generated edit script should embed the job path") @@ -253,6 +263,19 @@ def main() -> int: _assert(slot_width_job.get("target", {}).get("backendOperation") == "change_slot_width", f"slot.width should route to change_slot_width: {slot_width_job}") _assert(slot_width_job.get("target", {}).get("value") == 2.5, f"slot.width target should be numeric: {slot_width_job}") _assert(slot_width_job.get("object", {}).get("geometrySignature", {}).get("width") == 2.0, f"slot width signature should be preserved: {slot_width_job}") + slot_width_noop = prepare_scdm_edit_job( + step_path, + output_dir=root / "slot-width-noop", + backend=backend, + capability_key="slot.width", + target_value="2.0", + object_id="slot:30-31-32", + object_signature=slot_signature, + ) + _assert( + slot_width_noop.get("ok") is False and slot_width_noop.get("reason") == "target-already-current", + f"slot.width no-op should be blocked before launching SCDM: {slot_width_noop}", + ) slot_depth_prepared = prepare_scdm_edit_job( step_path, output_dir=root / "slot-depth-prepared", @@ -270,6 +293,22 @@ def main() -> int: _assert(slot_depth_signature.get("depth") == 1.5, f"slot depth signature should be preserved: {slot_depth_job}") _assert(slot_depth_signature.get("depthFaceLocators"), f"slot depth bottom face locator should be preserved: {slot_depth_job}") _assert(slot_depth_signature.get("depthAxis") == [0.0, 0.0, -1.0], f"slot depth axis should be preserved: {slot_depth_job}") + slot_depth_missing_locator_signature = dict(slot_signature) + slot_depth_missing_locator_signature.pop("depthFaceLocators", None) + slot_depth_missing_locator = prepare_scdm_edit_job( + step_path, + output_dir=root / "slot-depth-missing-locator", + backend=backend, + capability_key="slot.depth", + target_value="2.0", + object_id="slot:30-31-32", + object_signature=slot_depth_missing_locator_signature, + ) + _assert( + slot_depth_missing_locator.get("ok") is False + and slot_depth_missing_locator.get("reason") == "object-signature-missing-depth-face-locator", + f"slot.depth should be blocked before SCDM when bottom face locator is missing: {slot_depth_missing_locator}", + ) boss_signature = { "objectType": "cylindrical_boss", @@ -316,6 +355,22 @@ def main() -> int: boss_height_signature = boss_height_job.get("object", {}).get("geometrySignature", {}) _assert(boss_height_signature.get("height") == 4.0, f"boss height signature should be preserved: {boss_height_job}") _assert(boss_height_signature.get("heightFaceLocators"), f"boss height top face locator should be preserved: {boss_height_job}") + boss_height_missing_locator_signature = dict(boss_signature) + boss_height_missing_locator_signature.pop("heightFaceLocators", None) + boss_height_missing_locator = prepare_scdm_edit_job( + step_path, + output_dir=root / "boss-height-missing-locator", + backend=backend, + capability_key="boss.height", + target_value="5.5", + object_id="boss:50-51-52", + object_signature=boss_height_missing_locator_signature, + ) + _assert( + boss_height_missing_locator.get("ok") is False + and boss_height_missing_locator.get("reason") == "object-signature-missing-height-face-locator", + f"boss.height should be blocked before SCDM when top face locator is missing: {boss_height_missing_locator}", + ) boss_diameter_prepared = prepare_scdm_edit_job( step_path, output_dir=root / "boss-diameter-prepared", @@ -332,6 +387,48 @@ def main() -> int: boss_diameter_signature = boss_diameter_job.get("object", {}).get("geometrySignature", {}) _assert(boss_diameter_signature.get("diameter") == 3.0, f"boss diameter signature should be preserved: {boss_diameter_job}") _assert(boss_diameter_signature.get("diameterFaceLocators"), f"boss diameter side face locator should be preserved: {boss_diameter_job}") + boss_diameter_missing_locator_signature = dict(boss_signature) + boss_diameter_missing_locator_signature.pop("diameterFaceLocators", None) + boss_diameter_missing_locator = prepare_scdm_edit_job( + step_path, + output_dir=root / "boss-diameter-missing-locator", + backend=backend, + capability_key="boss.diameter", + target_value="4.5", + object_id="boss:50-51-52", + object_signature=boss_diameter_missing_locator_signature, + ) + _assert( + boss_diameter_missing_locator.get("ok") is False + and boss_diameter_missing_locator.get("reason") == "object-signature-missing-diameter-face-locator", + f"boss.diameter should be blocked before SCDM when side face locator is missing: {boss_diameter_missing_locator}", + ) + hole_position_noop = prepare_scdm_edit_job( + step_path, + output_dir=root / "hole-position-noop", + backend=backend, + capability_key="hole.position", + target_value=[0.5, 1.0, 9.5], + object_id="hole:85-94", + object_signature=signature, + ) + _assert( + hole_position_noop.get("ok") is False and hole_position_noop.get("reason") == "target-already-current", + f"hole.position no-op should be blocked before launching SCDM: {hole_position_noop}", + ) + boss_height_zero = prepare_scdm_edit_job( + step_path, + output_dir=root / "boss-height-zero", + backend=backend, + capability_key="boss.height", + target_value="0", + object_id="boss:50-51-52", + object_signature=boss_signature, + ) + _assert( + boss_height_zero.get("ok") is False and boss_height_zero.get("reason") == "target-value-illegal", + f"positive dimensions should reject zero before launching SCDM: {boss_height_zero}", + ) round_signature = { "objectType": "round", @@ -404,6 +501,55 @@ def main() -> int: _assert(chamfer_job_signature.get("isEqualDistanceChamfer") is True, f"chamfer.distance should preserve equal-distance evidence: {chamfer_distance_job}") _assert(chamfer_job_signature.get("distance") == 0.8, f"chamfer.distance should preserve current distance: {chamfer_distance_job}") + shell_signature = { + "objectType": "thin_wall", + "faceIds": [101, 102], + "bodyIndex": 0, + "faceOrdinals": [101, 102], + "globalFaceOrdinals": [201, 202], + "thickness": 1.2, + "thicknessAxis": [0.0, 0.0, 1.0], + "wallFaceCenters": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.2]], + "wallFaceLocators": [ + {"bodyIndex": 0, "faceOrdinal": 101, "globalFaceOrdinal": 201}, + {"bodyIndex": 0, "faceOrdinal": 102, "globalFaceOrdinal": 202}, + ], + } + shell_thickness_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "shell-thickness-prepared", + backend=backend, + capability_key="shell.thickness", + target_value="1.6", + object_id="thin_wall:101-102", + object_signature=shell_signature, + ) + _assert(shell_thickness_prepared.get("ok") is True, f"shell.thickness should be productized and prepare an edit job: {shell_thickness_prepared}") + shell_thickness_job = read_json(shell_thickness_prepared["job_path"]) + _assert(shell_thickness_job.get("target", {}).get("backendOperation") == "change_shell_thickness", f"shell.thickness should route to change_shell_thickness: {shell_thickness_job}") + _assert(shell_thickness_job.get("target", {}).get("value") == 1.6, f"shell.thickness target should be numeric: {shell_thickness_job}") + shell_job_signature = shell_thickness_job.get("object", {}).get("geometrySignature", {}) + _assert(shell_job_signature.get("thickness") == 1.2, f"shell thickness signature should be preserved: {shell_thickness_job}") + _assert(shell_job_signature.get("wallFaceLocators"), f"shell wall face locators should be preserved: {shell_thickness_job}") + shell_missing_locator_signature = dict(shell_signature) + shell_missing_locator_signature.pop("wallFaceLocators", None) + shell_missing_locator_signature.pop("faceOrdinals", None) + shell_missing_locator_signature.pop("globalFaceOrdinals", None) + shell_missing_locator = prepare_scdm_edit_job( + step_path, + output_dir=root / "shell-thickness-missing-locator", + backend=backend, + capability_key="shell.thickness", + target_value="1.6", + object_id="thin_wall:101-102", + object_signature=shell_missing_locator_signature, + ) + _assert( + shell_missing_locator.get("ok") is False + and shell_missing_locator.get("reason") == "object-signature-missing-wall-face-locator", + f"shell.thickness should be blocked before SCDM when both wall locators are missing: {shell_missing_locator}", + ) + pattern_signature = { "objectType": "linear_pattern", "faceIds": [85, 94, 87, 96, 89, 98], @@ -514,6 +660,73 @@ def main() -> int: and read_json(pattern_segment_split_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "split", f"pattern.segment_spacing should preserve split/keep-center semantics: {pattern_segment_split_prepared}", ) + pattern_segment_single_left_signature = dict(pattern_segment_signature) + pattern_segment_single_left_signature["movingSide"] = "single_left" + pattern_segment_single_left_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "pattern-segment-spacing-single-left-prepared", + backend=backend, + capability_key="pattern.segment_spacing", + target_value="6.5", + object_id="pattern:holes-a-b-c", + object_signature=pattern_segment_single_left_signature, + ) + _assert( + pattern_segment_single_left_prepared.get("ok") is True + and read_json(pattern_segment_single_left_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_left", + f"pattern.segment_spacing should preserve move-only-left-instance semantics: {pattern_segment_single_left_prepared}", + ) + pattern_segment_single_right_signature = dict(pattern_segment_signature) + pattern_segment_single_right_signature["movingSide"] = "single_right" + pattern_segment_single_right_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "pattern-segment-spacing-single-right-prepared", + backend=backend, + capability_key="pattern.segment_spacing", + target_value="6.5", + object_id="pattern:holes-a-b-c", + object_signature=pattern_segment_single_right_signature, + ) + _assert( + pattern_segment_single_right_prepared.get("ok") is True + and read_json(pattern_segment_single_right_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_right", + f"pattern.segment_spacing should preserve move-only-right-instance semantics: {pattern_segment_single_right_prepared}", + ) + pattern_instance_signature = dict(pattern_signature["patternInstances"][1]) + pattern_instance_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "pattern-instance-position-prepared", + backend=backend, + capability_key="pattern.instance_position", + target_value=[6.0, 0.0, 0.0], + object_id="pattern:holes-a-b-c:instance-b", + object_signature=pattern_instance_signature, + ) + _assert(pattern_instance_prepared.get("ok") is True, f"pattern.instance_position should prepare an edit job when instance locators exist: {pattern_instance_prepared}") + pattern_instance_job = read_json(pattern_instance_prepared["job_path"]) + _assert(pattern_instance_job.get("target", {}).get("backendOperation") == "move_pattern_instance", f"pattern.instance_position should route to move_pattern_instance: {pattern_instance_job}") + _assert(pattern_instance_job.get("target", {}).get("value") == [6.0, 0.0, 0.0], f"pattern.instance_position target should be vector3: {pattern_instance_job}") + pattern_instance_missing_locator = dict(pattern_instance_signature) + pattern_instance_missing_locator.pop("scdmFaceLocators", None) + pattern_instance_missing_locator.pop("faceOrdinals", None) + pattern_instance_missing_locator.pop("globalFaceOrdinals", None) + pattern_instance_missing_locator.pop("bodyLocators", None) + pattern_instance_missing_locator.pop("componentLocators", None) + pattern_instance_missing_locator.pop("bodyIndex", None) + blocked_pattern_instance = prepare_scdm_edit_job( + step_path, + output_dir=root / "pattern-instance-position-missing-locator", + backend=backend, + capability_key="pattern.instance_position", + target_value=[6.0, 0.0, 0.0], + object_id="pattern:holes-a-b-c:instance-b", + object_signature=pattern_instance_missing_locator, + ) + _assert( + blocked_pattern_instance.get("ok") is False + and blocked_pattern_instance.get("reason") == "object-signature-missing-pattern-instance-locator", + f"pattern.instance_position should be blocked before SCDM when instance locator is missing: {blocked_pattern_instance}", + ) body_pattern_signature = { "objectType": "linear_pattern", @@ -658,19 +871,6 @@ def main() -> int: f"pattern segment spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_segment_prepared}", ) - planned = prepare_scdm_edit_job( - step_path, - output_dir=root / "unsupported", - backend=backend, - capability_key="pattern.instance_position", - target_value="1", - object_signature=signature, - ) - _assert( - planned.get("ok") is False and planned.get("reason") == "capability-not-productized", - f"planned capability should fail early with a roadmap reason: {planned}", - ) - unsupported = prepare_scdm_edit_job( step_path, output_dir=root / "unsupported-unknown", @@ -823,6 +1023,19 @@ def main() -> int: _assert(chamfer_distance_success.get("ok") is True, f"fake chamfer.distance edit should succeed: {chamfer_distance_success}") _assert(chamfer_distance_success.get("backend_operation") == "change_chamfer_distance", f"chamfer.distance should report change_chamfer_distance: {chamfer_distance_success}") + shell_thickness_success = run_scdm_edit_job( + step_path, + output_dir=root / "shell-thickness-success", + backend=backend, + capability_key="shell.thickness", + target_value=1.6, + object_id="thin_wall:101-102", + object_signature=shell_signature, + runner=_successful_runner, + ) + _assert(shell_thickness_success.get("ok") is True, f"fake shell.thickness edit should succeed: {shell_thickness_success}") + _assert(shell_thickness_success.get("backend_operation") == "change_shell_thickness", f"shell.thickness should report change_shell_thickness: {shell_thickness_success}") + pattern_spacing_success = run_scdm_edit_job( step_path, output_dir=root / "pattern-spacing-success", @@ -850,6 +1063,18 @@ def main() -> int: pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing", f"pattern.segment_spacing should report change_pattern_segment_spacing: {pattern_segment_success}", ) + pattern_instance_success = run_scdm_edit_job( + step_path, + output_dir=root / "pattern-instance-position-success", + backend=backend, + capability_key="pattern.instance_position", + target_value=[6.0, 0.0, 0.0], + object_id="pattern:holes-a-b-c:instance-b", + object_signature=pattern_instance_signature, + runner=_successful_runner, + ) + _assert(pattern_instance_success.get("ok") is True, f"fake pattern.instance_position edit should succeed: {pattern_instance_success}") + _assert(pattern_instance_success.get("backend_operation") == "move_pattern_instance", f"pattern.instance_position should report move_pattern_instance: {pattern_instance_success}") missing_output = run_scdm_edit_job( step_path, diff --git a/scripts/verify_scdm_probe_pipeline.py b/scripts/verify_scdm_probe_pipeline.py index 6a5a121..2b572eb 100644 --- a/scripts/verify_scdm_probe_pipeline.py +++ b/scripts/verify_scdm_probe_pipeline.py @@ -326,6 +326,42 @@ def main() -> int: ], "rawLimitations": [], }, + { + "backendId": "body:0/face:1722", + "objectType": "round", + "geometry": { + "surfaceType": "cylinder", + "radius": 0.0013, + "diameter": 0.0026, + "center": [-0.083, -0.031, -0.11635], + "axis": [0.0, 1.0, 0.0], + "roundInfo": {"radius": 0.0, "available": True, "type": "RoundInfoResult"}, + }, + "topologyHint": {"faceIds": [1722], "bodyIndex": 0, "faceOrdinal": 1722, "globalFaceOrdinal": 1722}, + "backendCommandCandidates": [ + {"operation": "change_round_radius", "enabled": True}, + {"operation": "delete_round_or_chamfer", "enabled": True}, + ], + "rawLimitations": [], + }, + { + "backendId": "body:0/face:1723", + "objectType": "round", + "geometry": { + "surfaceType": "cylinder", + "radius": 0.0013, + "diameter": 0.0026, + "center": [-0.083, -0.031, -0.11635], + "axis": [0.0, 1.0, 0.0], + "roundInfo": {"radius": 0.0, "available": True, "type": "RoundInfoResult"}, + }, + "topologyHint": {"faceIds": [1723], "bodyIndex": 0, "faceOrdinal": 1723, "globalFaceOrdinal": 1723}, + "backendCommandCandidates": [ + {"operation": "change_round_radius", "enabled": True}, + {"operation": "delete_round_or_chamfer", "enabled": True}, + ], + "rawLimitations": [], + }, { "backendId": "slot:1", "objectType": "slot", @@ -549,6 +585,36 @@ def main() -> int: _assert(isinstance(round_object, dict), f"round object should be normalized: {cache}") round_signature = round_object.get("geometrySignature") _assert(isinstance(round_signature, dict) and round_signature.get("isConstantRound") is True, f"round constant evidence should be preserved: {round_signature}") + round_split_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(1722,), execution_ready=True) + round_split_keys = {str(spec.get("scdm_capability_key")) for spec in round_split_specs} + round_split_group = next( + ( + item + for item in objects + if isinstance(item, dict) + and item.get("objectType") == "cylindrical_face_group" + and {1722, 1723} <= set(item.get("geometrySignature", {}).get("faceIds", [])) + ), + None, + ) + _assert( + round_split_group is None, + f"SCDM round cylinder fragments must not be derived into a cylindrical hole group: {round_split_group}", + ) + round_split_radius_spec = next((spec for spec in round_split_specs if spec.get("scdm_capability_key") == "round.radius"), None) + round_split_radius_value = float(str(round_split_radius_spec.get("current_raw"))) if round_split_radius_spec else None + _assert( + round_split_radius_value is not None and abs(round_split_radius_value - 0.0013) < 1.0e-9, + f"SCDM roundInfo radius=0 should fall back to geometric radius: {round_split_specs}", + ) + _assert( + "hole.diameter" not in round_split_keys and "hole.position" not in round_split_keys, + f"SCDM round cylinder faces must not be derived into editable hole groups: {round_split_specs}", + ) + _assert( + "feature.delete_round_or_chamfer" in round_split_keys, + f"SCDM round cylinder faces should keep their own round/delete capabilities: {round_split_specs}", + ) round_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(60,), execution_ready={"round.radius", "feature.delete_round_or_chamfer"}) _assert( {str(spec.get("scdm_capability_key")) for spec in round_specs} == {"round.radius", "feature.delete_round_or_chamfer"}, @@ -812,6 +878,56 @@ def main() -> int: missing_command_specs = property_specs_from_scdm_cache(map_scdm_raw_features(missing_command_raw), selected_face_ids=(12,), execution_ready=True) _assert(missing_command_specs and missing_command_specs[0].get("enabled") is False, f"missing SCDM command should disable capability: {missing_command_specs}") _assert("OffsetFaces" in str(missing_command_specs[0].get("disabled_tip") or ""), f"disabled reason should name the missing SCDM command: {missing_command_specs}") + delete_only_raw = { + "schemaVersion": 1, + "backend": {"name": "SCDM", "version": "v222"}, + "model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"}, + "diagnostics": {"availableCommands": [{"name": "Delete", "available": True}, {"name": "Fill", "available": False}]}, + "objects": [ + { + "backendId": "round:delete-only", + "objectType": "round", + "geometry": {"roundInfo": {"radius": 1.0, "isConstant": True, "isRound": True}}, + "topologyHint": {"faceIds": [48], "bodyIndex": 0, "faceOrdinal": 48, "globalFaceOrdinal": 48}, + "backendCommandCandidates": [{"operation": "delete_round_or_chamfer", "enabled": True}], + "rawLimitations": [], + } + ], + } + delete_only_specs = property_specs_from_scdm_cache( + map_scdm_raw_features(delete_only_raw), + selected_face_ids=(48,), + execution_ready={"feature.delete_round_or_chamfer"}, + ) + delete_only_spec = next((spec for spec in delete_only_specs if spec.get("scdm_capability_key") == "feature.delete_round_or_chamfer"), None) + _assert(delete_only_spec and delete_only_spec.get("enabled") is True, f"Delete-only SCDM should enable round/chamfer removal: {delete_only_specs}") + shell_without_locator_raw = { + "schemaVersion": 1, + "backend": {"name": "SCDM", "version": "v222"}, + "model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"}, + "diagnostics": {"availableCommands": [{"name": "Move", "available": True}]}, + "objects": [ + { + "backendId": "shell:local-face-ids-only", + "objectType": "shell", + "geometry": {"thickness": 1.2, "thicknessAxis": [0.0, 0.0, 1.0]}, + "topologyHint": {"faceIds": [149, 150]}, + "backendCommandCandidates": [{"operation": "change_shell_thickness", "enabled": True}], + "rawLimitations": [], + } + ], + } + shell_without_locator_specs = property_specs_from_scdm_cache( + map_scdm_raw_features(shell_without_locator_raw), + selected_face_ids=(149,), + execution_ready={"shell.thickness"}, + ) + shell_without_locator_spec = next((spec for spec in shell_without_locator_specs if spec.get("scdm_capability_key") == "shell.thickness"), None) + _assert( + shell_without_locator_spec and shell_without_locator_spec.get("enabled") is False, + f"shell.thickness should not execute when only local Face IDs are known: {shell_without_locator_specs}", + ) + _assert("两侧墙面" in str(shell_without_locator_spec.get("disabled_tip") or ""), f"shell.thickness disabled reason should explain missing wall locators: {shell_without_locator_specs}") missing_current_raw = { "schemaVersion": 1, "backend": {"name": "SCDM", "version": "v222"}, @@ -1052,10 +1168,10 @@ def main() -> int: f"SCDM component occurrence diagnostics should be preserved: {cache}", ) geometry_hints = diagnostics.get("geometry_candidate_hints") - _assert(isinstance(geometry_hints, list) and geometry_hints, f"SCDM structural candidate hints should be produced: {cache}") + _assert(isinstance(geometry_hints, list), f"SCDM structural candidate hints should be preserved as a list: {cache}") hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)} _assert( - {"pattern.instance_position", "shell.thickness"} <= hint_keys and "slot.depth" not in hint_keys and "pattern.spacing" not in hint_keys, + "pattern.instance_position" not in hint_keys and "shell.thickness" not in hint_keys and "slot.depth" not in hint_keys and "pattern.spacing" not in hint_keys, f"S7 geometry hints should cover planned feature families: {hint_keys}", ) derived_candidates = diagnostics.get("derived_feature_candidates") @@ -1077,6 +1193,11 @@ def main() -> int: _assert(thin_wall_signature.get("thickness") == 1.2, f"thin-wall thickness should be preserved: {thin_wall_signature}") _assert(thin_wall_signature.get("thicknessAxis") == [0.0, 0.0, 1.0], f"thin-wall axis should be preserved: {thin_wall_signature}") _assert(len(thin_wall_signature.get("wallFaceLocators") or []) == 2, f"thin-wall face locators should be preserved: {thin_wall_signature}") + _assert({"shell.thickness"} <= _capability_keys(cache, "derived:thin_wall"), f"derived thin wall should expose productized shell thickness: {cache}") + thin_wall_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(101,), execution_ready={"shell.thickness"}) + thin_wall_thickness_spec = next((spec for spec in thin_wall_specs if spec.get("scdm_capability_key") == "shell.thickness"), None) + _assert(thin_wall_thickness_spec and thin_wall_thickness_spec.get("enabled") is True, f"thin-wall thickness should enable when both wall faces are locatable: {thin_wall_specs}") + _assert(thin_wall_thickness_spec.get("scope_text") == "固定一侧,移动另一侧", f"thin-wall thickness intent should be explicit: {thin_wall_specs}") body_pattern_candidate = next( ( item @@ -1097,7 +1218,7 @@ def main() -> int: body_instances = body_pattern_signature.get("patternInstances") or [] _assert(len(body_instances) == 3 and all(item.get("instanceKind") == "body" for item in body_instances), f"body pattern instances should stay body-level: {body_pattern_signature}") _assert(all(item.get("componentLocators") for item in body_instances if isinstance(item, dict)), f"body pattern instances should preserve component occurrence locators: {body_pattern_signature}") - body_pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(301,), execution_ready={"pattern.spacing", "pattern.segment_spacing"}) + body_pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(301,), execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position"}) body_pattern_spacing = [ spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.spacing" ] @@ -1111,7 +1232,7 @@ def main() -> int: ] _assert(len(body_segment_specs) == 2, f"three body pattern members should expose two local spacing segments: {body_pattern_specs}") _assert( - "零件" in str(body_segment_specs[0].get("label") or "") + "Solid" in str(body_segment_specs[0].get("label") or "") and body_segment_specs[0].get("enabled") is True and body_segment_specs[0].get("scdm_backend_operation") == "change_pattern_segment_spacing", f"local body pattern spacing should be executable and name the adjacent members: {body_segment_specs}", @@ -1119,15 +1240,16 @@ def main() -> int: body_segment_modes = body_segment_specs[0].get("scope_modes") _assert( isinstance(body_segment_modes, dict) - and {"fix_left_move_right", "fix_right_move_left", "split_keep_center", "move_single_right"} <= set(body_segment_modes), + and {"fix_left_move_right", "fix_right_move_left", "split_keep_center", "move_single_left", "move_single_right"} <= set(body_segment_modes), f"local pattern spacing should expose several explicit modeling intents: {body_segment_specs}", ) _assert( body_segment_modes["fix_left_move_right"].get("enabled") is True and body_segment_modes["fix_right_move_left"].get("enabled") is True and body_segment_modes["split_keep_center"].get("enabled") is True - and body_segment_modes["move_single_right"].get("enabled") is False, - f"implemented and blocked local spacing intents should be explicit: {body_segment_modes}", + and body_segment_modes["move_single_left"].get("enabled") is True + and body_segment_modes["move_single_right"].get("enabled") is True, + f"implemented local spacing intents should be explicit: {body_segment_modes}", ) for mode in body_segment_modes.values(): if not isinstance(mode, dict): @@ -1135,6 +1257,11 @@ def main() -> int: label = str(mode.get("label") or "").strip() tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or mode.get("range_hint") or "").strip() _assert(label and tip.startswith(label), f"modeling-intent tooltip should start with its intent label: {body_segment_modes}") + _assert( + "不用于保持整列等距" in str(body_segment_modes["move_single_right"].get("range_hint") or "") + and "不用于保持整列等距" in str(body_segment_modes["move_single_left"].get("range_hint") or ""), + f"single-instance local spacing intents should warn that they break equal spacing: {body_segment_modes}", + ) _assert( "固定前项" in str(body_segment_specs[0].get("scope_text") or "") and "移动后侧" in str(body_segment_specs[0].get("scope_text") or ""), @@ -1147,7 +1274,44 @@ def main() -> int: and body_segment_signature.get("movingSide") == "after", f"local segment specs should carry segment execution metadata: {body_segment_specs}", ) - pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(201,), execution_ready={"pattern.spacing", "pattern.segment_spacing"}) + body_instance_position = next((spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position"), None) + _assert( + body_instance_position + and body_instance_position.get("enabled") is True + and body_instance_position.get("value_type") == "vector3" + and body_instance_position.get("scdm_backend_operation") == "move_pattern_instance", + f"body pattern member should expose a single-instance position edit: {body_pattern_specs}", + ) + _assert( + "只移动该实例" in str(body_instance_position.get("scope_text") or "") + and "不自动保持整体阵列等距" in str(body_instance_position.get("range_hint") or ""), + f"pattern instance position should explain that it is not a spacing edit: {body_instance_position}", + ) + solid_body_pattern_specs = property_specs_from_scdm_cache( + cache, + selected_solid_ids=(21,), + execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position", "face.offset"}, + ) + solid_pattern_keys = [str(spec.get("scdm_capability_key") or "") for spec in solid_body_pattern_specs] + _assert( + "pattern.spacing" in solid_pattern_keys + and "pattern.segment_spacing" in solid_pattern_keys + and "pattern.instance_position" in solid_pattern_keys, + f"selecting a Solid member should expose body-level pattern capabilities: {solid_body_pattern_specs}", + ) + _assert( + "face.offset" not in solid_pattern_keys, + f"selecting a Solid should not expand child Face offsets from the SCDM cache: {solid_body_pattern_specs}", + ) + solid_instance_specs = [ + spec for spec in solid_body_pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position" + ] + _assert( + len(solid_instance_specs) == 1 + and str(solid_instance_specs[0].get("label") or "").startswith("Solid21"), + f"Solid selection should expose only the selected member position row: {solid_body_pattern_specs}", + ) + pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(201,), execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position"}) pattern_spacing_spec = next((spec for spec in pattern_specs if spec.get("scdm_capability_key") == "pattern.spacing"), None) _assert(pattern_spacing_spec and pattern_spacing_spec.get("enabled") is True, f"pattern.spacing should enable for selected pattern member: {pattern_specs}") _assert(pattern_spacing_spec.get("value_type") == "positive", f"pattern.spacing should use positive numeric input: {pattern_specs}") @@ -1159,16 +1323,20 @@ def main() -> int: str(face_segment_specs[0].get("label") or "").startswith("Face"), f"face pattern local spacing should name the adjacent Face members instead of raw ordinal numbers: {face_segment_specs}", ) + face_instance_position = next((spec for spec in pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position"), None) + _assert( + face_instance_position + and face_instance_position.get("enabled") is True + and str(face_instance_position.get("label") or "").startswith("Face"), + f"face pattern member should expose its own position edit with visible Face ID: {pattern_specs}", + ) not_productized = diagnostics.get("discovered_not_productized") _assert(isinstance(not_productized, list) and not_productized, f"non-productized candidates should stay diagnostic only: {cache}") planned = diagnostics.get("planned_not_productized") _assert(isinstance(planned, list), f"planned diagnostics should be present: {cache}") planned_keys = {str(item.get("capabilityKey")) for item in planned if isinstance(item, dict)} - expected_planned = { - "pattern.instance_position", - "shell.thickness", - } - _assert(expected_planned <= planned_keys, f"S7 planned capabilities should be diagnosed but not productized: {planned_keys}") + _assert("pattern.instance_position" not in planned_keys, f"pattern.instance_position should now be productized with member-level specs: {planned_keys}") + _assert("shell.thickness" not in planned_keys, f"shell.thickness should now be productized with strict geometry gates: {planned_keys}") raw_file = root / "raw.json" cache_file = root / "cache.json" diff --git a/scripts/verify_scdm_result_validator.py b/scripts/verify_scdm_result_validator.py index 00c0138..2cbe352 100644 --- a/scripts/verify_scdm_result_validator.py +++ b/scripts/verify_scdm_result_validator.py @@ -543,6 +543,80 @@ def main() -> int: pattern_segment_mismatch.get("ok") is False and pattern_segment_mismatch.get("reason") == "target-mismatch", f"pattern.segment_spacing mismatch should fail from the applied edit result: {pattern_segment_mismatch}", ) + single_left_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index] + single_left_signature["movingSide"] = "single_left" + single_left_ok = validate_scdm_edit_result( + { + "ok": True, + "output_step": str(output_step), + "applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_left"}, + }, + before_signature=single_left_signature, + before_cache=before_pattern_segment, + after_cache=after_pattern_segment, + capability_key="pattern.segment_spacing", + expected_target=6.5, + edited_object_id="pattern:holes", + ) + _assert( + single_left_ok.get("ok") is True + and single_left_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_left", + f"pattern.segment_spacing should validate move-only-left-instance mode: {single_left_ok}", + ) + single_right_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index] + single_right_signature["movingSide"] = "single_right" + single_right_ok = validate_scdm_edit_result( + { + "ok": True, + "output_step": str(output_step), + "applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_right"}, + }, + before_signature=single_right_signature, + before_cache=before_pattern_segment, + after_cache=after_pattern_segment, + capability_key="pattern.segment_spacing", + expected_target=6.5, + edited_object_id="pattern:holes", + ) + _assert( + single_right_ok.get("ok") is True + and single_right_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_right", + f"pattern.segment_spacing should validate move-only-right-instance mode: {single_right_ok}", + ) + wrong_mode = validate_scdm_edit_result( + { + "ok": True, + "output_step": str(output_step), + "applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_after"}, + }, + before_signature=single_right_signature, + before_cache=before_pattern_segment, + after_cache=after_pattern_segment, + capability_key="pattern.segment_spacing", + expected_target=6.5, + edited_object_id="pattern:holes", + ) + _assert( + wrong_mode.get("ok") is False and wrong_mode.get("reason") == "pattern-segment-spacing-mode-mismatch", + f"pattern.segment_spacing should fail when SCDM reports a different modeling intent: {wrong_mode}", + ) + missing_mode = validate_scdm_edit_result( + { + "ok": True, + "output_step": str(output_step), + "applied": {"segmentSpacing": 6.5, "segmentIndex": 1}, + }, + before_signature=single_right_signature, + before_cache=before_pattern_segment, + after_cache=after_pattern_segment, + capability_key="pattern.segment_spacing", + expected_target=6.5, + edited_object_id="pattern:holes", + ) + _assert( + missing_mode.get("ok") is False and missing_mode.get("reason") == "pattern-segment-spacing-mode-missing", + f"pattern.segment_spacing should require SCDM to report a known modeling intent: {missing_mode}", + ) shell_thickness_ok = check_scdm_target( { "objectType": "thin_wall", @@ -574,6 +648,31 @@ def main() -> int: ) summary_ok = check_scdm_summary_delta(summary_before, summary_after_ok, capability_key="hole.diameter") _assert(summary_ok.get("ok") is True, f"small summary changes should pass: {summary_ok}") + summary_after_repartition = _cache_with_summary( + {"bodyCount": 9, "objectCount": 548, "faceCount": 157, "edgeCount": 390}, + _hole("hole:90", 90, diameter=0.9, center=(0.5, 1.0, 9.5)), + ) + summary_repartition = check_scdm_summary_delta(summary_before, summary_after_repartition, capability_key="hole.diameter") + _assert( + summary_repartition.get("ok") is None and summary_repartition.get("reason") == "body-count-repartitioned", + f"SCDM body repartition should be a warning, not a hard failure: {summary_repartition}", + ) + repartition_ok = validate_scdm_edit_result( + {"ok": True, "output_step": str(output_step)}, + before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index] + before_cache=summary_before, + after_cache=summary_after_repartition, + capability_key="hole.diameter", + expected_target=0.9, + edited_object_id="hole:85", + brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"}, + ) + _assert( + repartition_ok.get("ok") is True + and repartition_ok.get("summaryCheck", {}).get("reason") == "body-count-repartitioned" + and repartition_ok.get("validationWarnings"), + f"target-verified SCDM hole diameter edit should survive body repartition warnings: {repartition_ok}", + ) summary_after_bad = _cache_with_summary( {"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45}, _hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)), diff --git a/scripts/verify_scdm_status.py b/scripts/verify_scdm_status.py index 2d3a448..840a3cd 100644 --- a/scripts/verify_scdm_status.py +++ b/scripts/verify_scdm_status.py @@ -113,9 +113,7 @@ def main() -> int: "geometrySignature": {"spacing": 5.0, "instanceCount": 3}, } ], - "planned_not_productized": [ - {"capabilityKey": "shell.thickness", "objectType": "shell"}, - ], + "planned_not_productized": [], "discovered_not_productized": [ {"objectType": "mystery_feature"}, {"objectType": "mystery_feature"}, @@ -140,14 +138,16 @@ def main() -> int: "feature.delete_round_or_chamfer", "pattern.spacing", "pattern.segment_spacing", + "pattern.instance_position", + "shell.thickness", }, ) summary = progress.get("summary") _assert(isinstance(summary, dict), f"capability progress should include summary: {progress}") - _assert(summary.get("productized") == 15, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, Move-based S7 entries and round/chamfer delete: {summary}") - _assert(summary.get("runnerReady") == 15, f"runner-ready capability count should honor UI gate: {summary}") + _assert(summary.get("productized") == 17, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, instance position, shell thickness, Move-based S7 entries and round/chamfer delete: {summary}") + _assert(summary.get("runnerReady") == 17, f"runner-ready capability count should honor UI gate: {summary}") _assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}") - _assert(summary.get("plannedDetected") == 1, f"planned S7 detections should be counted: {summary}") + _assert(summary.get("plannedDetected") == 0, f"planned S7 detections should be counted: {summary}") _assert(summary.get("discoveredNotProductized") == 2, f"unknown discoveries should be counted: {summary}") _assert(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}") _assert( @@ -169,8 +169,9 @@ def main() -> int: _assert("倒角距离:已开放待识别" in productized_lines, f"productized chamfer.distance should be visible: {productized_lines}") _assert("阵列间距:已开放待识别" in productized_lines, f"productized pattern.spacing should be visible: {productized_lines}") _assert("局部间距:已开放待识别" in productized_lines, f"productized pattern.segment_spacing should be visible: {productized_lines}") - _assert("壳体厚度:已识别待验证" in planned_lines, f"planned shell.thickness candidate should be visible: {planned_lines}") - _assert("阵列实例位置:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_lines}") + _assert("阵列实例位置:已开放待识别" in productized_lines, f"productized pattern.instance_position should be visible: {productized_lines}") + _assert("壳体厚度:已开放待识别" in productized_lines, f"productized shell.thickness should be visible: {productized_lines}") + _assert("阵列实例位置" not in planned_lines, f"pattern.instance_position should no longer be a planned-only line: {planned_lines}") _assert("Face 邻接 2 组" in evidence_lines and "圆边 4 条" in evidence_lines, f"probe evidence lines should be readable: {evidence_lines}") _assert("对象分布" in evidence_lines and "命令候选分布" in evidence_lines, f"probe inventory lines should be readable: {evidence_lines}") _assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}") diff --git a/step_editor/app.py b/step_editor/app.py index 3944acb..55be5ff 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -228,6 +228,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {} self.edge_overlay_polydata_cache: dict[int, object] = {} self.overlay_cache_limit = 160 + self.scene_rebuild_in_progress = False self.show_internal_edges_checkbox: QCheckBox | None = None self.scene_isolated = False self.undo_stack: list[dict[int, object]] = [] @@ -290,6 +291,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf "feature.delete_round_or_chamfer", "pattern.spacing", "pattern.segment_spacing", + "pattern.instance_position", + "shell.thickness", } self.load_in_progress = False self.load_thread: QThread | None = None @@ -1409,7 +1412,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf relation_layout.addWidget(self.relation_formula_list) relation_button_row = QHBoxLayout() relation_button_row.setContentsMargins(0, 0, 0, 0) + self.import_relation_formula_button = QPushButton("导入公式") + self.import_relation_formula_button.setObjectName("relationFormulaImportButton") + self.import_relation_formula_button.clicked.connect(self.import_relation_formulas) + relation_button_row.addWidget(self.import_relation_formula_button) + self.export_relation_formula_button = QPushButton("导出公式") + self.export_relation_formula_button.setObjectName("relationFormulaExportButton") + self.export_relation_formula_button.clicked.connect(self.export_relation_formulas) + relation_button_row.addWidget(self.export_relation_formula_button) relation_button_row.addStretch(1) + self.toggle_relation_formula_button = QPushButton("停用公式") + self.toggle_relation_formula_button.setObjectName("relationFormulaToggleButton") + self.toggle_relation_formula_button.clicked.connect(self.toggle_selected_relation_formula) + relation_button_row.addWidget(self.toggle_relation_formula_button) self.remove_relation_formula_button = QPushButton("删除公式") self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton") self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula) diff --git a/step_editor/model.py b/step_editor/model.py index 2e62d73..8a455e9 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -479,6 +479,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "same_domain_v_range", "same_domain_height_estimate", "same_domain_range_source", + "selected_angular_span", ): if key in hint: info[key] = hint[key] @@ -656,7 +657,33 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if angular_span is None: angular_span = _float_or_none(info.get("angular_span")) or 0.0 is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92 + selected_span = ( + _float_or_none(info.get("selected_angular_span")) + or _float_or_none(info.get("slot_angular_span")) + or _float_or_none(info.get("angular_span")) + or angular_span + ) + try: + same_domain_count = int(info.get("same_domain_face_count", 1) or 1) + except (TypeError, ValueError): + same_domain_count = 1 + is_blind_split_groove = ( + is_full + and same_domain_count > 1 + and 1e-6 < selected_span < math.tau * 0.92 + and str(info.get("cylinder_end_type") or "") == "blind" + and guess == "hole/groove candidate" + ) if guess == "hole/groove candidate": + if is_blind_split_groove: + return { + "feature_type": "圆柱孔/槽候选", + "feature_edit_actions": ( + "调整圆柱孔径或槽/凹槽宽度、深度;" + "具体按孔还是按槽会在完整识别时根据周围同类特征确认。" + ), + "blind_split_cylindrical_pocket": True, + } if not is_full: return { "feature_type": "槽/半孔候选", @@ -861,6 +888,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "same_domain_face_count": len(side_face_ids), "feature_highlight_face_ids": tuple(side_face_ids), "angular_span": combined_span, + "selected_angular_span": selected_span, "same_domain_angular_span": combined_span, "same_domain_note": same_domain_note, "is_full_cylinder": combined_span >= math.tau * 0.92, @@ -2813,6 +2841,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd bottom_face_ids = end_faces["bottom_face_ids"] opening_face_ids = end_faces["opening_face_ids"] guess = str(info.get("feature_guess", "cylindrical face")) + selected_angular_span = _float_or_none(info.get("angular_span")) or combined_angular_span has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"]) support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids)) material_toward = str(info.get("material_toward_axis", "") or "") @@ -2832,13 +2861,37 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "cocylindrical region has material inside its axis and explicit planar caps at both ends" ) domain_info["feature_guess"] = info["feature_guess"] - slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info) + is_blind_split_cylindrical_pocket = ( + guess == "hole/groove candidate" + and len(side_face_ids) > 1 + and combined_angular_span >= math.tau * 0.92 + and 1e-6 < selected_angular_span < math.tau * 0.92 + and str(info.get("cylinder_end_type") or "") == "blind" + and bool(bottom_face_ids) + and bool(opening_face_ids) + ) + slot_domain_info = dict(domain_info) + if is_blind_split_cylindrical_pocket: + slot_domain_info["angular_span"] = selected_angular_span + slot_domain_info["is_full_cylinder"] = False + slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, slot_domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info) + try: + slot_pair_candidate_count = int(slot_info.get("slot_pair_candidate_count", 0) or 0) + except (TypeError, ValueError): + slot_pair_candidate_count = 0 + is_blind_split_cylindrical_groove = ( + is_blind_split_cylindrical_pocket + and slot_pair_candidate_count >= 10 + ) guess = str(info.get("feature_guess", "cylindrical face")) - angular_span = combined_angular_span + angular_span = selected_angular_span if is_blind_split_cylindrical_groove else combined_angular_span if guess == "hole/groove candidate": - if angular_span < math.tau * 0.92: + if is_blind_split_cylindrical_groove: + feature_type = "盲槽/圆柱凹槽候选" + edit_actions = "调整槽/凹槽宽度;调整槽/凹槽深度;调整圆弧长度;调整圆弧角度" + elif angular_span < math.tau * 0.92: feature_type = "槽/半孔候选" edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度" else: @@ -2848,7 +2901,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd edit_actions += ";调整盲孔/盲槽深度" else: edit_actions += ";孔深调整需要明确盲孔底面" - if angular_span >= math.tau * 0.92: + if angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove: edit_actions += ";封堵圆柱孔" elif guess == "round/fillet candidate": fillet_status = str(fillet_info.get("existing_fillet_status") or "") @@ -2868,7 +2921,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd else: feature_type = "未明确圆柱特征" edit_actions = "可尝试调整圆柱孔径,但风险较高。" - if str(slot_info.get("slot_status") or "") == "blocked": + if str(slot_info.get("slot_status") or "") == "blocked" and ( + is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92 + ): feature_type = "复杂槽/多槽组候选(暂不支持修改)" edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。" @@ -2900,7 +2955,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "same_domain_height_estimate": axis_range["span"], "same_domain_angular_span": combined_angular_span, "angular_span": combined_angular_span, - "is_full_cylinder": combined_angular_span >= math.tau * 0.92, + "is_full_cylinder": combined_angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove, + "blind_split_cylindrical_groove": is_blind_split_cylindrical_groove, "same_domain_range_source": axis_range["range_source"], "same_domain_face_ids": tuple(side_face_ids), "same_domain_face_count": len(side_face_ids), @@ -2921,7 +2977,15 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd } ) result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result)) - if angular_span >= math.tau * 0.92: + if is_blind_split_cylindrical_groove: + result["angular_span"] = selected_angular_span + result.setdefault("slot_kind", "partial-cylindrical-groove") + result.setdefault("slot_status", "candidate") + result.setdefault( + "slot_note", + "同域碎面合并后接近完整圆柱,但当前对象只有一个开口和一个底面,按盲槽/圆柱凹槽处理。", + ) + elif angular_span >= math.tau * 0.92: result.update( { "slot_kind": "", @@ -2938,7 +3002,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd } ) scoped_readiness_info = dict(result) - scoped_readiness_info["angular_span"] = combined_angular_span + scoped_readiness_info["angular_span"] = angular_span scoped_readiness_info["height_estimate"] = axis_range["span"] scoped_readiness_info["feature_guess"] = guess if guess == "hole/groove candidate": @@ -2966,7 +3030,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", } ) - if str(slot_info.get("slot_status") or "") == "blocked": + if str(slot_info.get("slot_status") or "") == "blocked" and ( + is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92 + ): blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。") result.update( { diff --git a/step_editor/recognition_priority.py b/step_editor/recognition_priority.py index de37e4b..c80a36d 100644 --- a/step_editor/recognition_priority.py +++ b/step_editor/recognition_priority.py @@ -165,7 +165,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int: not _is_effectively_full_cylinder(info) and angular_span is not None and angular_span < math.tau * 0.92 - ) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or "槽/半孔候选" in feature_type: + ) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or any( + token in feature_type for token in ("槽/半孔候选", "盲槽", "凹槽") + ): return 30 return 20 if feature_guess == "boss/outer-round candidate": diff --git a/step_editor/relation_formulas.py b/step_editor/relation_formulas.py index e05fabf..9aea471 100644 --- a/step_editor/relation_formulas.py +++ b/step_editor/relation_formulas.py @@ -10,6 +10,18 @@ from typing import Callable, Iterable RELATION_REF_PATTERN = re.compile( r"\b(?PFace|Edge)(?P\d+)\.(?P[A-Za-z0-9_\u4e00-\u9fff]+)\b" ) +RELATION_UNIT_LITERAL_PATTERN = re.compile( + r"(?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?Pmm|毫米|cm|厘米|m|米)\b", + re.IGNORECASE, +) +RELATION_UNIT_MULTIPLIERS = { + "mm": 1.0, + "毫米": 1.0, + "cm": 10.0, + "厘米": 10.0, + "m": 1000.0, + "米": 1000.0, +} class RelationFormulaError(ValueError): @@ -112,7 +124,10 @@ def parse_relation_formula(text: str) -> RelationFormula: references.append(_ref_from_match(match)) return f"__ref{len(references) - 1}" + # 用户输入的 Face85.直径 不能直接丢给 eval;先替换成内部占位符, + # 后面只允许这些占位符和白名单 AST 节点参与计算。 safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression) + safe_expression = _replace_unit_literals(safe_expression) try: tree = ast.parse(safe_expression, mode="eval") except SyntaxError as exc: @@ -136,6 +151,8 @@ def evaluate_relation_formula( namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref)) code = compile(formula.safe_expression, "", "eval") try: + # 这里仍然使用 Python 表达式能力,但 builtins 为空,AST 也已校验过。 + # 关系式只承担参数求值,不允许调用函数、访问属性或执行任意代码。 value = eval(code, {"__builtins__": {}}, namespace) except ZeroDivisionError as exc: raise RelationFormulaError("关系式中出现除以 0。") from exc @@ -158,6 +175,8 @@ def validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None reference_tokens = [ref.token for ref in formula.references] if target_token in reference_tokens: raise RelationFormulaError(f"关系式不能引用自身:{target_token}。") + # 只把“由其它公式控制的参数”纳入依赖图;普通测量值由模型/cache 提供, + # 不参与循环依赖判断。 graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens] visit_state: dict[str, str] = {} @@ -213,6 +232,18 @@ def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef: ) +def _replace_unit_literals(expression: str) -> str: + def replace(match: re.Match[str]) -> str: + number_text = str(match.group("number")) + unit = str(match.group("unit")) + multiplier = RELATION_UNIT_MULTIPLIERS.get(unit) or RELATION_UNIT_MULTIPLIERS.get(unit.lower()) + if multiplier is None: + raise RelationFormulaError(f"不支持的单位:{unit}") + return f"({number_text}*{multiplier:.12g})" + + return RELATION_UNIT_LITERAL_PATTERN.sub(replace, expression) + + def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None: allowed = ( ast.Expression, diff --git a/step_editor/scdm_capabilities.py b/step_editor/scdm_capabilities.py index f0ca96a..1ef1d4c 100644 --- a/step_editor/scdm_capabilities.py +++ b/step_editor/scdm_capabilities.py @@ -36,6 +36,8 @@ class ScdmCapabilityDefinition: } +# 产品能力字典:SCDM raw 对象只有进入这里,才会被翻译成客户可见的中文参数。 +# 新能力要同时补 backend_operation、post_check 和验证脚本,避免只显示不能执行的参数。 CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { "hole.diameter": ScdmCapabilityDefinition( key="hole.diameter", @@ -226,9 +228,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { default_intent="移动阵列实例", backend_operation="move_pattern_instance", post_check="target_pattern_instance_center", - productized=False, + required_backend_command_groups=(("Move",),), roadmap_stage="S7.5", - block_reason="阵列实例位置属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "shell.thickness": ScdmCapabilityDefinition( key="shell.thickness", @@ -236,12 +237,11 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { object_types=("shell", "thin_wall"), value_kind="number", current_fields=("geometry.thickness",), - default_intent="修改壳体厚度", + default_intent="固定一侧,移动另一侧", backend_operation="change_shell_thickness", post_check="target_shell_thickness", - productized=False, + required_backend_command_groups=(("Move",),), roadmap_stage="S7.5", - block_reason="壳体厚度属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), } diff --git a/step_editor/scdm_edit_runner.py b/step_editor/scdm_edit_runner.py index 316c1dd..c0b9267 100644 --- a/step_editor/scdm_edit_runner.py +++ b/step_editor/scdm_edit_runner.py @@ -50,6 +50,8 @@ def prepare_scdm_edit_job( "roadmap_stage": definition.roadmap_stage, } converted_target = _target_value_for_job(target_value, value_kind=definition.value_kind) + # 先在本软件侧做快速守门,避免为了明显非法或证据不足的输入启动 SCDM, + # 否则用户会等很久才得到一个本可提前说明的失败。 preflight = _preflight_scdm_edit_job( definition.key, converted_target, @@ -74,8 +76,11 @@ def prepare_scdm_edit_job( script_path = work_dir / "scdm_edit.py" operation = backend_operation or definition.backend_operation + # 旧 result.step/error.json 不能留着复用;否则 SCDM 启动失败时可能被误判为成功。 _clear_stale_outputs((output_path, result_path, error_path)) + # 这是本软件和 SCDM 的后台协议。UI 不直接调用 SCDM API, + # 而是写 job JSON + 临时脚本,再用 SpaceClaim.exe /RunScript 执行。 job = { "schemaVersion": SCDM_EDIT_SCHEMA_VERSION, "adapter": SCDM_EDIT_ADAPTER, @@ -210,6 +215,8 @@ def run_prepared_scdm_edit_job( command = scdm_run_script_command(backend.path, script_path) run = runner or subprocess.run try: + # SpaceClaim 是外部 GUI/几何进程,必须独立启动并加超时; + # Python/Qt 主进程只等待结果文件,避免 SCDM 卡死时拖垮界面。 completed = run( command, cwd=str(script_path.parent), @@ -286,9 +293,17 @@ def _preflight_scdm_edit_job( target_value: object, object_signature: Mapping[str, object], ) -> dict[str, object]: + basic = _preflight_basic_target(capability_key, target_value, object_signature) + if basic.get("ok") is False: + return basic + required = _preflight_required_geometry(capability_key, object_signature) + if required.get("ok") is False: + return required if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}: return {"ok": True, "reason": "ok"} if _is_body_pattern_signature(object_signature): + # 实体/组件阵列必须有 occurrence locator。只有 bodyIndex 时移动的可能是共享定义, + # 不是用户眼里那个独立阵列成员。 if not _body_pattern_has_component_locators(object_signature): return { "ok": False, @@ -345,6 +360,203 @@ def _preflight_scdm_edit_job( } +def _preflight_basic_target( + capability_key: str, + target_value: object, + object_signature: Mapping[str, object], +) -> dict[str, object]: + if capability_key in _positive_number_capabilities(): + target = _float_or_none(target_value) + if target is not None and target <= 0: + return { + "ok": False, + "reason": "target-value-illegal", + "message": "目标值必须大于 0。", + } + current_number = _current_number_for_capability(capability_key, object_signature) + target_number = _float_or_none(target_value) + if current_number is not None and target_number is not None: + tolerance = max(abs(current_number), abs(target_number), 1.0) * 1.0e-9 + if abs(current_number - target_number) <= tolerance: + return { + "ok": False, + "reason": "target-already-current", + "message": "目标值与当前值相同,不需要修改。", + "current": current_number, + "target": target_number, + } + current_vector = _current_vector_for_capability(capability_key, object_signature) + target_vector = _parse_vector3(target_value) + if len(current_vector) == 3 and len(target_vector) == 3: + error = sum((current_vector[index] - target_vector[index]) ** 2 for index in range(3)) ** 0.5 + scale = max(max(abs(item) for item in current_vector + target_vector), 1.0) + if error <= scale * 1.0e-9: + return { + "ok": False, + "reason": "target-already-current", + "message": "目标位置与当前位置相同,不需要修改。", + "current": current_vector, + "target": target_vector, + } + return {"ok": True, "reason": "ok"} + + +def _preflight_required_geometry(capability_key: str, signature: Mapping[str, object]) -> dict[str, object]: + if capability_key == "slot.depth": + if _positive_number(signature.get("depth")) is None: + return _missing_signature("object-signature-missing-depth", "SCDM 已识别槽深,但缓存里没有当前槽深,不能稳定执行。") + if not _has_vector3(signature.get("depthAxis") or signature.get("depthDirection")): + return _missing_signature("object-signature-missing-depth-axis", "SCDM 已识别槽深,但缓存里没有槽深方向,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("depthFaceLocators", "bottomFaceLocators"), ("depthFaceOrdinals", "bottomFaceOrdinals"), ("globalDepthFaceOrdinals", "globalBottomFaceOrdinals")): + return _missing_signature("object-signature-missing-depth-face-locator", "SCDM 已识别槽深,但缓存里没有可推动的槽底面定位,不能稳定执行。") + elif capability_key == "boss.height": + if _positive_number(signature.get("height")) is None: + return _missing_signature("object-signature-missing-height", "SCDM 已识别凸台高度,但缓存里没有当前高度,不能稳定执行。") + if not _has_vector3(signature.get("axis")): + return _missing_signature("object-signature-missing-axis", "SCDM 已识别凸台高度,但缓存里没有高度方向,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("heightFaceLocators", "topFaceLocators"), ("heightFaceOrdinals", "topFaceOrdinals"), ("globalHeightFaceOrdinals", "globalTopFaceOrdinals")): + return _missing_signature("object-signature-missing-height-face-locator", "SCDM 已识别凸台高度,但缓存里没有可推动的顶面定位,不能稳定执行。") + elif capability_key == "boss.diameter": + if _positive_number(signature.get("diameter"), _twice(signature.get("radius"))) is None: + return _missing_signature("object-signature-missing-diameter", "SCDM 已识别凸台直径,但缓存里没有当前直径,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("diameterFaceLocators", "sideFaceLocators"), ("diameterFaceOrdinals", "sideFaceOrdinals"), ("globalDiameterFaceOrdinals", "globalSideFaceOrdinals")): + return _missing_signature("object-signature-missing-diameter-face-locator", "SCDM 已识别凸台直径,但缓存里没有可偏移的侧壁定位,不能稳定执行。") + elif capability_key == "round.radius": + if signature.get("isConstantRound") is not True: + return _missing_signature("object-signature-missing-constant-round-evidence", "SCDM 没有返回等半径圆角证据,不能稳定修改圆角半径。") + if _positive_number(signature.get("radius")) is None: + return _missing_signature("object-signature-missing-round-radius", "SCDM 已识别圆角,但缓存里没有当前半径,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True): + return _missing_signature("object-signature-missing-round-face-locator", "SCDM 已识别圆角,但缓存里没有可定位的圆角面,不能稳定执行。") + elif capability_key == "chamfer.distance": + if signature.get("isEqualDistanceChamfer") is not True: + return _missing_signature("object-signature-missing-equal-distance-chamfer-evidence", "SCDM 没有返回等距倒角证据,不能稳定修改倒角距离。") + if _positive_number(signature.get("distance"), signature.get("offset")) is None: + return _missing_signature("object-signature-missing-chamfer-distance", "SCDM 已识别倒角,但缓存里没有当前距离,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True): + return _missing_signature("object-signature-missing-chamfer-face-locator", "SCDM 已识别倒角,但缓存里没有可定位的倒角面,不能稳定执行。") + elif capability_key == "shell.thickness": + if _positive_number(signature.get("thickness")) is None: + return _missing_signature("object-signature-missing-shell-thickness", "SCDM 已识别壳体厚度,但缓存里没有当前厚度,不能稳定执行。") + if not _has_vector3(signature.get("thicknessAxis") or signature.get("axis")): + return _missing_signature("object-signature-missing-shell-thickness-axis", "SCDM 已识别壳体厚度,但缓存里没有厚度方向,不能稳定执行。") + if not _has_face_locator_evidence(signature, ("wallFaceLocators", "scdmFaceLocators"), ("wallFaceOrdinals", "faceOrdinals"), ("globalWallFaceOrdinals", "globalFaceOrdinals"), minimum=2): + return _missing_signature("object-signature-missing-wall-face-locator", "SCDM 已识别壳体厚度,但缓存里没有两侧墙面定位,不能稳定执行。") + elif capability_key == "pattern.instance_position": + if not _has_vector3(signature.get("center") or signature.get("instanceCenter")): + return _missing_signature("object-signature-missing-pattern-instance-center", "SCDM 已识别阵列实例,但缓存里没有当前实例位置,不能稳定执行。") + if not _has_pattern_instance_locator(signature): + return _missing_signature("object-signature-missing-pattern-instance-locator", "SCDM 已识别阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定执行。") + return {"ok": True, "reason": "ok"} + + +def _missing_signature(reason: str, message: str) -> dict[str, object]: + return {"ok": False, "reason": reason, "message": message} + + +def _has_vector3(value: object) -> bool: + return len(_parse_vector3(value)) == 3 + + +def _has_face_locator_evidence( + signature: Mapping[str, object], + locator_keys: tuple[str, ...], + ordinal_keys: tuple[str, ...], + global_ordinal_keys: tuple[str, ...], + *, + minimum: int = 1, + allow_scalar_face: bool = False, +) -> bool: + count = 0 + for key in locator_keys: + value = signature.get(key) + if isinstance(value, (list, tuple)): + count += sum(1 for item in value if isinstance(item, Mapping)) + for key in ordinal_keys + global_ordinal_keys: + value = signature.get(key) + if isinstance(value, (list, tuple)): + count += sum(1 for item in value if _int_or_none_value(item) is not None) + if count >= minimum: + return True + if allow_scalar_face and minimum <= 1: + return any(_int_or_none_value(signature.get(key)) is not None for key in ("faceOrdinal", "globalFaceOrdinal")) + return False + + +def _has_pattern_instance_locator(signature: Mapping[str, object]) -> bool: + if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"): + return True + if _has_face_locator_evidence(signature, (), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True): + return True + instance_kind = str(signature.get("instanceKind") or "").strip().lower() + return instance_kind in {"body", "part", "component"} and _int_or_none_value(signature.get("bodyIndex")) is not None + + +def _positive_number_capabilities() -> set[str]: + return { + "hole.diameter", + "slot.width", + "slot.depth", + "boss.height", + "boss.diameter", + "round.radius", + "chamfer.distance", + "pattern.spacing", + "pattern.segment_spacing", + "shell.thickness", + } + + +def _current_number_for_capability(capability_key: str, signature: Mapping[str, object]) -> float | None: + if capability_key == "hole.diameter": + return _first_number(signature.get("diameter"), _twice(signature.get("radius"))) + if capability_key == "face.offset": + return _first_number(signature.get("planeOffset"), signature.get("offset"), 0.0) + if capability_key == "slot.width": + return _first_number(signature.get("width"), signature.get("diameter"), _twice(signature.get("radius"))) + if capability_key == "slot.depth": + return _first_number(signature.get("depth")) + if capability_key == "boss.height": + return _first_number(signature.get("height")) + if capability_key == "boss.diameter": + return _first_number(signature.get("diameter"), _twice(signature.get("radius"))) + if capability_key == "round.radius": + return _first_number(signature.get("radius")) + if capability_key == "chamfer.distance": + return _first_number(signature.get("distance"), signature.get("offset")) + if capability_key == "pattern.spacing": + return _first_number(signature.get("spacing"), signature.get("pitch")) + if capability_key == "pattern.segment_spacing": + return _first_number(signature.get("segmentSpacing")) + if capability_key == "shell.thickness": + return _first_number(signature.get("thickness")) + return None + + +def _current_vector_for_capability(capability_key: str, signature: Mapping[str, object]) -> list[float]: + if capability_key in {"hole.position", "slot.position", "boss.position", "pattern.instance_position"}: + return _parse_vector3(signature.get("center") or signature.get("axisCenter") or signature.get("instanceCenter")) + return [] + + +def _first_number(*values: object) -> float | None: + for value in values: + number = _float_or_none(value) + if number is not None: + return number + return None + + +def _positive_number(*values: object) -> float | None: + number = _first_number(*values) + return number if number is not None and number > 0 else None + + +def _twice(value: object) -> float | None: + number = _float_or_none(value) + return None if number is None else number * 2.0 + + def _float_or_none(value: object) -> float | None: try: return float(str(value).strip()) @@ -935,6 +1147,18 @@ def _locate_depth_faces(signature): raise Exception('object_signature_missing_depth_face_locator') +def _locate_wall_faces(signature): + faces = _locate_named_faces( + signature, + ('wallFaceLocators', 'scdmFaceLocators'), + ('wallFaceOrdinals', 'faceOrdinals'), + ('globalWallFaceOrdinals', 'globalFaceOrdinals'), + ) + if len(faces) >= 2: + return faces + raise Exception('object_signature_missing_wall_face_locator') + + def _locate_pattern_instance_faces(instance, fallback_body_index): signature = dict(instance) if signature.get('bodyIndex') is None and fallback_body_index is not None: @@ -1246,41 +1470,53 @@ def _translate_selection(selection, delta): raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or [])) +def _delete_selection(selection, fill_errors=None): + delete = _command_type('Delete') + if delete is None: + errors = list(fill_errors or []) + errors.append('Delete command not available') + raise Exception('capability_not_implemented: Fill/Delete command not available; ' + '; '.join(errors)) + variants = ((selection,), (selection, None)) + fallback = _call_variants('Delete.Execute', delete.Execute, variants) + if fallback.get('ok'): + return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': list(fill_errors or [])} + errors = list(fill_errors or []) + errors.extend(fallback.get('errors') or []) + raise Exception('capability_not_implemented: Delete.Execute failed; ' + '; '.join(errors)) + + def _fill_selection(selection): fill = _command_type('Fill') - if fill is None: - raise Exception('capability_not_implemented: Fill command not available') - options = _new_options('FillOptions') - for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)): - try: - if options is not None and hasattr(options, attr): - setattr(options, attr, value) - except Exception: - pass - secondary = _empty_selection() - mode = _fill_mode_three_d() - variants = [] - if mode is not None: - variants.append((selection, secondary, options, mode, None)) - variants.append((selection, secondary, options, mode)) - variants.append((selection, None, options, mode, None)) - variants.append((selection, None, options, mode)) - variants.append((selection, secondary, options, None, None)) - variants.append((selection, secondary, options, None)) - variants.append((selection, options, None)) - variants.append((selection, options)) - variants.append((selection, None)) - variants.append((selection,)) - result = _call_variants('Fill.Execute', fill.Execute, variants) - if result.get('ok'): - return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')} - delete = _command_type('Delete') - if delete is not None: - fallback = _call_variants('Delete.Execute', delete.Execute, ((selection,),)) - if fallback.get('ok'): - return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': result.get('errors') or []} - result['errors'].extend(fallback.get('errors') or []) - raise Exception('capability_not_implemented: Fill.Execute failed; ' + '; '.join(result.get('errors') or [])) + fill_errors = [] + if fill is not None: + options = _new_options('FillOptions') + for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)): + try: + if options is not None and hasattr(options, attr): + setattr(options, attr, value) + except Exception: + pass + secondary = _empty_selection() + mode = _fill_mode_three_d() + variants = [] + if mode is not None: + variants.append((selection, secondary, options, mode, None)) + variants.append((selection, secondary, options, mode)) + variants.append((selection, None, options, mode, None)) + variants.append((selection, None, options, mode)) + variants.append((selection, secondary, options, None, None)) + variants.append((selection, secondary, options, None)) + variants.append((selection, options, None)) + variants.append((selection, options)) + variants.append((selection, None)) + variants.append((selection,)) + result = _call_variants('Fill.Execute', fill.Execute, variants) + if result.get('ok'): + return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')} + fill_errors.extend(result.get('errors') or []) + else: + fill_errors.append('Fill command not available') + return _delete_selection(selection, fill_errors) def _float_value(value): @@ -1794,6 +2030,18 @@ def change_pattern_segment_spacing(job): for item in located[segment_index + 1:] ) spacing_mode = 'segment_split' + elif moving_side in ('single_left', 'only_left'): + delta = [axis_unit[index] * -delta_distance for index in range(3)] + moves = [ + {'items': located[segment_index]['items'], 'kind': located[segment_index]['kind'], 'delta': delta, 'itemCount': len(located[segment_index]['items'])} + ] + spacing_mode = 'segment_single_left' + elif moving_side in ('single_right', 'only_right'): + delta = [axis_unit[index] * delta_distance for index in range(3)] + moves = [ + {'items': located[segment_index + 1]['items'], 'kind': located[segment_index + 1]['kind'], 'delta': delta, 'itemCount': len(located[segment_index + 1]['items'])} + ] + spacing_mode = 'segment_single_right' else: raise Exception('capability_not_implemented: unsupported pattern segment spacing motion semantics: ' + moving_side) if not moves: @@ -1833,6 +2081,37 @@ def change_pattern_segment_spacing(job): } +def move_pattern_instance(job): + target = _vector3(job['target']['value']) + signature = job['object'].get('geometrySignature') or {} + center = signature.get('center') or signature.get('instanceCenter') or [] + if not isinstance(center, list) or len(center) != 3: + raise Exception('object_signature_missing_pattern_instance_center') + delta = [float(target[index]) - float(center[index]) for index in range(3)] + if _vector_length(delta) <= 1e-12: + return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'} + body_index = _int_or_none(signature.get('bodyIndex')) + try: + located = _locate_pattern_instance_items(signature, body_index) + items = located.get('items') or [] + if not items: + raise Exception('object_signature_missing_pattern_instance_locator') + if located.get('kind') == 'component': + if len(items) != 1: + raise Exception('component_pattern_instance_not_unique') + applied = _translate_component_occurrence(items[0], delta) + else: + applied = _translate_selection(_selection(items), delta) + applied['targetCenter'] = target + applied['previousCenter'] = center + applied['delta'] = delta + applied['targetKind'] = located.get('kind') or 'face' + applied['itemCount'] = len(items) + return applied + except Exception as exc: + raise Exception('capability_not_implemented: pattern.instance_position adapter failed; ' + str(exc)) + + def change_boss_height(job): target = _float_value(job['target']['value']) if target <= 0: @@ -2038,6 +2317,71 @@ def change_chamfer_distance(job): raise Exception('capability_not_implemented: chamfer.distance adapter failed; ' + str(exc)) +def change_shell_thickness(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: shell thickness must be positive') + signature = job['object'].get('geometrySignature') or {} + current = None + try: + current = float(signature.get('thickness')) + except Exception: + current = None + if current is None or current <= 0: + raise Exception('object_signature_missing_shell_thickness') + axis = signature.get('thicknessAxis') or signature.get('axis') or [] + if not isinstance(axis, list) or len(axis) != 3: + raise Exception('object_signature_missing_shell_thickness_axis') + axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])]) + if axis_length <= 1e-12: + raise Exception('object_signature_missing_shell_thickness_axis') + delta_distance = target - current + if abs(delta_distance) <= 1e-12: + return {'command': 'noop', 'targetThickness': target, 'reason': 'target already reached'} + faces = _locate_wall_faces(signature) + centers = signature.get('wallFaceCenters') or [] + sign = 1.0 + if isinstance(centers, list) and len(centers) >= 2: + left = centers[0] + right = centers[1] + if isinstance(left, list) and len(left) == 3 and isinstance(right, list) and len(right) == 3: + projection_delta = sum((float(right[index]) - float(left[index])) * float(axis[index]) for index in range(3)) + if projection_delta < 0: + sign = -1.0 + moving_side = str(signature.get('movingSide') or 'after').strip().lower() + axis_unit = [float(axis[index]) / axis_length for index in range(3)] + if moving_side in ('before', 'left'): + moving_faces = [faces[0]] + delta = [axis_unit[index] * -sign * delta_distance for index in range(3)] + elif moving_side in ('split', 'both', 'center'): + left_delta = [axis_unit[index] * -sign * delta_distance * 0.5 for index in range(3)] + right_delta = [axis_unit[index] * sign * delta_distance * 0.5 for index in range(3)] + left_applied = _translate_selection(_selection([faces[0]]), left_delta) + right_applied = _translate_selection(_selection([faces[-1]]), right_delta) + return { + 'command': 'Move.Translate shell wall pair', + 'targetThickness': target, + 'previousThickness': current, + 'thicknessDelta': delta_distance, + 'movingSide': moving_side, + 'wallFaceCount': len(faces), + 'moves': [left_applied, right_applied], + } + else: + moving_faces = [faces[-1]] + delta = [axis_unit[index] * sign * delta_distance for index in range(3)] + try: + applied = _translate_selection(_selection(moving_faces), delta) + applied['targetThickness'] = target + applied['previousThickness'] = current + applied['thicknessDelta'] = delta_distance + applied['movingSide'] = moving_side + applied['wallFaceCount'] = len(faces) + return applied + except Exception as exc: + raise Exception('capability_not_implemented: shell.thickness adapter failed; ' + str(exc)) + + def pull_face_offset(job): target = _float_value(job['target']['value']) signature = job['object'].get('geometrySignature') or {} @@ -2097,6 +2441,8 @@ def _apply_edit(job): return change_pattern_spacing(job) if operation == 'change_pattern_segment_spacing': return change_pattern_segment_spacing(job) + if operation == 'move_pattern_instance': + return move_pattern_instance(job) if operation == 'change_boss_height': return change_boss_height(job) if operation == 'change_boss_diameter': @@ -2105,6 +2451,8 @@ def _apply_edit(job): return change_round_radius(job) if operation == 'change_chamfer_distance': return change_chamfer_distance(job) + if operation == 'change_shell_thickness': + return change_shell_thickness(job) if operation == 'pull_face_offset': return pull_face_offset(job) if operation == 'fill_feature': diff --git a/step_editor/scdm_feature_mapper.py b/step_editor/scdm_feature_mapper.py index 182544d..f575b89 100644 --- a/step_editor/scdm_feature_mapper.py +++ b/step_editor/scdm_feature_mapper.py @@ -8,7 +8,7 @@ from .scdm_capabilities import capability_definition, planned_capability_keys, p from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json -SCDM_FEATURE_CACHE_REVISION = 4 +SCDM_FEATURE_CACHE_REVISION = 8 def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]: @@ -400,6 +400,8 @@ def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str, for raw_object in raw_objects: if not isinstance(raw_object, Mapping): continue + if not _can_derive_cylindrical_hole_group(raw_object): + continue geometry = _mapping(raw_object.get("geometry")) if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "cylinder": continue @@ -453,6 +455,56 @@ def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str, return result +def _can_derive_cylindrical_hole_group(raw_object: Mapping[str, object]) -> bool: + object_type = str(raw_object.get("objectType") or "").strip().lower() + if object_type in { + "round", + "fillet", + "chamfer", + "slot", + "obround_slot", + "rectangular_slot", + "boss", + "cylindrical_boss", + "rectangular_boss", + "shell", + "thin_wall", + }: + return False + if object_type not in {"", "face", "planar_face", "hole", "cylindrical_hole"}: + return False + geometry = _mapping(raw_object.get("geometry")) + if _mapping(geometry.get("roundInfo")) or _mapping(geometry.get("chamferInfo")) or _mapping(geometry.get("slotInfo")): + return object_type in {"hole", "cylindrical_hole"} + commands = tuple(_raw_command_tokens(raw_object.get("backendCommandCandidates"))) + if any(token in command for command in commands for token in ("round", "fillet", "chamfer", "slot", "boss")): + return object_type in {"hole", "cylindrical_hole"} + return True + + +def _raw_command_tokens(value: object) -> list[str]: + if not isinstance(value, list): + return [] + tokens: list[str] = [] + for item in value: + if not isinstance(item, Mapping): + continue + if item.get("enabled") is False: + continue + tokens.append( + " ".join( + str(part or "") + for part in ( + item.get("key"), + item.get("operation"), + item.get("command"), + item.get("type"), + ) + ).lower() + ) + return tokens + + def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]: return [ *_derived_linear_pattern_objects(source_objects), @@ -952,7 +1004,7 @@ def _derived_thin_wall_objects(source_objects: list[object]) -> list[dict[str, o {"operation": "change_shell_thickness", "enabled": True, "parameterFields": {"thickness": round(float(candidate["thickness"]), 6)}}, ], "rawLimitations": [ - "Derived from paired planar SCDM faces; shell.thickness remains non-productized until real SCDM edit samples pass.", + "Derived from paired planar SCDM faces; shell.thickness is productized only when both wall faces and thickness axis are locatable.", ], } ) @@ -1153,6 +1205,8 @@ def attach_local_face_ids_to_scdm_cache( item = dict(raw_object) signature = dict(item.get("geometrySignature") if isinstance(item.get("geometrySignature"), Mapping) else {}) if not _int_list(signature.get("faceIds")): + # SCDM 和本软件的 Face 编号不是同一套 ID。优先用 ordinal locator 精确映射; + # locator 不完整时才退到几何签名打分,避免修改后 ID 变化把公式续接错。 ordinal_face_ids = _face_ids_from_signature_ordinals(signature, local_signatures) if ordinal_face_ids: signature["faceIds"] = ordinal_face_ids @@ -1195,6 +1249,8 @@ def attach_local_face_ids_to_scdm_cache( support_face_ids = _pattern_support_face_ids(signature, local_signatures) if support_face_ids: signature["supportFaceIds"] = support_face_ids + # 阵列间距不能只看实例中心距,还要知道这些实例是否仍落在支撑面上。 + # supportPatternFit 会给 UI 和 preflight 提供最大安全间距。 support_fit = _pattern_support_fit(signature, local_signatures, support_face_ids, unit_scale or 1.0) if support_fit: signature["supportPatternFit"] = support_fit @@ -1420,6 +1476,8 @@ def _pattern_support_face_ids( if distance > tolerance: continue area = _number(local.get("area")) or _box_area_estimate(face_box) + # 支撑面候选要求和阵列包围盒投影重叠,并且沿面法向几乎贴合; + # 面积越大、贴合越近,越像承载该阵列的底面。 candidates.append((overlap * max(area, 1.0) - distance, int(face_id))) candidates.sort(reverse=True) return [face_id for _score, face_id in candidates[:4]] @@ -1469,6 +1527,8 @@ def _pattern_support_fit( max_spacing_local = (2.0 * min(left_capacity, right_capacity)) / (len(center_projections) - 1) if max_spacing_local <= 0: continue + # 这里按“保持整列中心不变”等距重排”估算最大间距。 + # 局部段间距会在 property spec 里按具体移动语义再计算范围。 support_candidates.append( { "faceId": int(face_id), @@ -1742,20 +1802,58 @@ def _current_value(fields: tuple[str, ...], raw_object: Mapping[str, object]) -> return 1 if field == "geometry.radius*2": radius = _number(geometry.get("radius")) - if radius is not None: + if radius is not None and radius > 0: return radius * 2.0 continue if field.startswith("geometry."): value = _path_value(geometry, field.split(".", 1)[1]) if value is not None: + if _invalid_dimension_current_value(field, value): + continue return value return None +def _invalid_dimension_current_value(field: str, value: object) -> bool: + if not field.startswith("geometry."): + return False + path = field.split(".", 1)[1] + if path in {"offset", "planeOffset"} or path.endswith(".offset"): + return False + dimension_suffixes = ( + "radius", + "diameter", + "width", + "depth", + "height", + "distance", + "thickness", + "spacing", + "pitch", + ) + if not path.endswith(dimension_suffixes): + return False + number = _number(value) + return number is not None and number <= 0 + + def _missing_geometry_reason(key: str, raw_object: Mapping[str, object]) -> str: - if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing"}: + if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing", "shell.thickness"}: return "" signature = geometry_signature(raw_object) + if key == "shell.thickness": + thickness = _rounded_number(signature.get("thickness")) + if thickness is None or thickness <= 0: + return "SCDM 已识别薄壁候选,但没有返回可用于编辑的当前壳体厚度。" + axis = _rounded_vector(signature.get("thicknessAxis") or signature.get("axis")) + if len(axis) != 3: + return "SCDM 已识别薄壁厚度,但没有返回稳定的厚度方向,暂不能修改。" + wall_locators = _face_locators(signature.get("wallFaceLocators") or signature.get("scdmFaceLocators")) + wall_ordinals = _int_list(signature.get("wallFaceOrdinals") or signature.get("faceOrdinals")) + wall_global_ordinals = _int_list(signature.get("globalWallFaceOrdinals") or signature.get("globalFaceOrdinals")) + if len(wall_locators) < 2 and len(wall_ordinals) < 2 and len(wall_global_ordinals) < 2: + return "SCDM 已识别薄壁厚度,但没有返回两侧墙面的定位信息,暂不能修改。" + return "" if key == "pattern.spacing": spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch"))) if spacing is None or spacing <= 0: diff --git a/step_editor/scdm_property_specs.py b/step_editor/scdm_property_specs.py index b02c7c5..31d330a 100644 --- a/step_editor/scdm_property_specs.py +++ b/step_editor/scdm_property_specs.py @@ -8,11 +8,13 @@ def property_specs_from_scdm_cache( *, selected_face_ids: Iterable[int] = (), selected_edge_ids: Iterable[int] = (), + selected_solid_ids: Iterable[int] = (), execution_ready: bool | Iterable[str] = False, ) -> list[dict[str, object]]: face_ids = {int(item) for item in selected_face_ids} edge_ids = {int(item) for item in selected_edge_ids} - if not face_ids and not edge_ids: + solid_ids = {int(item) for item in selected_solid_ids} + if not face_ids and not edge_ids and not solid_ids: return [] objects = cache.get("objects") if not isinstance(objects, list): @@ -20,30 +22,56 @@ def property_specs_from_scdm_cache( specs: list[dict[str, object]] = [] for item in objects: - if not isinstance(item, Mapping) or not _object_matches(item, face_ids=face_ids, edge_ids=edge_ids): + if not isinstance(item, Mapping) or not _object_matches( + item, + face_ids=face_ids, + edge_ids=edge_ids, + solid_ids=solid_ids, + ): continue capabilities = item.get("capabilities") if not isinstance(capabilities, list): continue for capability in capabilities: if isinstance(capability, Mapping): - if str(capability.get("key") or "") == "pattern.segment_spacing": + if str(capability.get("key") or "") in {"pattern.segment_spacing", "pattern.instance_position"}: continue spec = _capability_spec(item, capability, execution_ready=execution_ready) if spec is not None: specs.append(spec) + specs.extend( + _pattern_instance_position_specs( + item, + selected_face_ids=face_ids, + selected_solid_ids=solid_ids, + execution_ready=execution_ready, + ) + ) specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready)) return specs -def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edge_ids: set[int]) -> bool: +def _object_matches( + raw_object: Mapping[str, object], + *, + face_ids: set[int], + edge_ids: set[int], + solid_ids: set[int], +) -> bool: signature = raw_object.get("geometrySignature") if not isinstance(signature, Mapping): return False object_faces = set(_int_values(signature.get("faceIds"))) object_faces.update(_int_values(signature.get("supportFaceIds"))) object_edges = set(_int_values(signature.get("edgeIds"))) - return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids)) + if (face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids): + return True + if not solid_ids: + return False + object_type = str(raw_object.get("objectType") or "").strip().lower() + if object_type not in {"pattern", "linear_pattern"}: + return False + return bool(_pattern_local_solid_ids(signature) & solid_ids) def _capability_spec( @@ -197,6 +225,8 @@ def _pattern_segment_spacing_specs( can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip()) specs: list[dict[str, object]] = [] for segment_index in range(len(instances) - 1): + # UI 上展示的是相邻实例之间的“段间距”,不是整列统一 spacing。 + # 每一段都带自己的移动语义和安全范围,避免“第 1-2 间距”改成整列平移。 left = instances[segment_index] right = instances[segment_index + 1] left_label = _segment_instance_label(left, segment_index + 1) @@ -268,6 +298,138 @@ def _pattern_segment_spacing_specs( return specs +def _pattern_instance_position_specs( + raw_object: Mapping[str, object], + *, + selected_face_ids: set[int], + selected_solid_ids: set[int], + execution_ready: bool | Iterable[str], +) -> list[dict[str, object]]: + if str(raw_object.get("objectType") or "").strip().lower() not in {"pattern", "linear_pattern"}: + return [] + signature = raw_object.get("geometrySignature") + if not isinstance(signature, Mapping): + return [] + unit_scale = _unit_scale(signature) + can_execute = bool(_capability_execution_ready("pattern.instance_position", execution_ready) and not str(raw_object.get("blockReason") or "").strip()) + specs: list[dict[str, object]] = [] + instances = _pattern_instances_in_original_order(signature) + for ordinal, instance in enumerate(instances, start=1): + if selected_face_ids and not (set(_int_values(instance.get("faceIds"))) & selected_face_ids): + continue + if selected_solid_ids and not (_instance_local_solid_ids(instance) & selected_solid_ids): + continue + center = _float_values(instance.get("center") or instance.get("instanceCenter")) + if len(center) != 3: + continue + label = _segment_instance_label({"source": instance}, ordinal) + current_display = [value / unit_scale for value in center] if unit_scale > 0 else list(center) + instance_signature = _pattern_instance_signature(signature, instance, unit_scale=unit_scale, label=label) + locatable = _pattern_instance_has_locator(instance_signature) + enabled = bool(can_execute and locatable) + disabled_tip = "" + if not can_execute: + disabled_tip = "SCDM 已识别该阵列实例,但当前修改执行器尚未开放。" + elif not locatable: + disabled_tip = "SCDM 已识别该阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定移动。" + range_hint = f"移动阵列实例:只平移 {label},不自动保持整体阵列等距;需要保持间距时请使用“阵列间距”或“局部间距”。" + specs.append( + { + "key": f"scdm:pattern.instance_position:{ordinal - 1}", + "label": f"{label}位置", + "current_raw": current_display, + "scdm_current_raw": center, + "scdm_unit_scale": unit_scale, + "current_text": _format_value(current_display, value_type="vector3"), + "target_text": _format_value(current_display, value_type="vector3"), + "editable": True, + "enabled": enabled, + "status_text": "可修改" if enabled else "暂未接入", + "scope_text": "只移动该实例", + "action": "apply_scdm_property_edit", + "value_type": "vector3", + "enabled_tip": range_hint if enabled else "", + "disabled_tip": disabled_tip, + "range_hint": range_hint, + "min_value": None, + "min_exclusive": False, + "max_value": None, + "scdm_object_id": raw_object.get("objectId"), + "scdm_source_backend_id": raw_object.get("sourceBackendId"), + "scdm_capability_key": "pattern.instance_position", + "scdm_backend_operation": "move_pattern_instance", + "scdm_post_check": "target_pattern_instance_center", + "scdm_geometry_signature": instance_signature, + } + ) + return specs + + +def _pattern_instances_in_original_order(signature: Mapping[str, object]) -> list[Mapping[str, object]]: + value = signature.get("patternInstances") + if not isinstance(value, (list, tuple)): + return [] + return [item for item in value if isinstance(item, Mapping)] + + +def _pattern_instance_signature( + signature: Mapping[str, object], + instance: Mapping[str, object], + *, + unit_scale: float, + label: str, +) -> dict[str, object]: + result = dict(instance) + result["objectType"] = "pattern_instance" + result["displayLabel"] = label + result["patternObjectType"] = signature.get("objectType") + result["patternKind"] = signature.get("patternKind") + result["instanceKind"] = instance.get("instanceKind") or signature.get("instanceKind") + result["axis"] = signature.get("axis") + result["localUnitScale"] = unit_scale + center = _float_values(instance.get("center") or instance.get("instanceCenter")) + if len(center) == 3: + result["center"] = center + result["instanceCenter"] = center + return result + + +def _pattern_instance_has_locator(signature: Mapping[str, object]) -> bool: + if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"): + return True + if _int_values(signature.get("faceOrdinals")) or _int_values(signature.get("globalFaceOrdinals")): + return True + if _int_or_none(signature.get("faceOrdinal")) is not None or _int_or_none(signature.get("globalFaceOrdinal")) is not None: + return True + instance_kind = str(signature.get("instanceKind") or "").strip().lower() + return instance_kind in {"body", "part", "component"} and _int_or_none(signature.get("bodyIndex")) is not None + + +def _pattern_local_solid_ids(signature: Mapping[str, object]) -> set[int]: + ids = set(_int_values(signature.get("localSolidIds"))) + local_solid = _int_or_none(signature.get("localSolidId")) + if local_solid is not None: + ids.add(local_solid) + ids.update(_int_values(signature.get("bodyIndices"))) + body_index = _int_or_none(signature.get("bodyIndex")) + if body_index is not None: + ids.add(body_index) + for instance in _pattern_instances_in_original_order(signature): + ids.update(_instance_local_solid_ids(instance)) + return ids + + +def _instance_local_solid_ids(instance: Mapping[str, object]) -> set[int]: + ids = set(_int_values(instance.get("localSolidIds"))) + local_solid = _int_or_none(instance.get("localSolidId")) + if local_solid is not None: + ids.add(local_solid) + body_index = _int_or_none(instance.get("bodyIndex")) + if body_index is not None: + ids.add(body_index) + return ids + + def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]: value = signature.get("patternInstances") if not isinstance(value, (list, tuple)): @@ -287,6 +449,7 @@ def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float] "projection": _point_projection(center, axis), } ) + # 用阵列轴投影排序,比原始 cache 顺序更接近用户看到的左到右/前到后顺序。 result.sort(key=lambda item: float(item["projection"])) return result @@ -314,7 +477,7 @@ def _segment_max_spacing_display( last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection backward_capacity = first_projection_display - projection_min - (member_span * 0.5) forward_capacity = projection_max - (member_span * 0.5) - last_projection_display - if moving_side in {"before", "left"}: + if moving_side in {"before", "left", "single_left", "only_left"}: extra = backward_capacity elif moving_side in {"split", "both", "center"}: extra = 2.0 * min(backward_capacity, forward_capacity) @@ -337,6 +500,8 @@ def _segment_scope_modes( can_execute: bool, ) -> dict[str, dict[str, object]]: modes: dict[str, dict[str, object]] = {} + # 同一个“间距”参数有多种建模意图:固定哪一侧、是否保持中心。 + # 这些模式会直接进入 scdm_edit_job,不能只作为 UI 文案存在。 for key, label, moving_side, semantics, description in ( ( "fix_left_move_right", @@ -391,25 +556,58 @@ def _segment_scope_modes( "max_value": max_display, "scdm_geometry_signature": mode_signature, } - modes["move_single_right"] = { - "label": "只移动后项(未开放)", - "enabled": False, - "disabled_tip": ( - f"只移动后项(未开放):只移动 {right_label} 会同时改变它和右侧下一个成员的间距,容易破坏阵列规律;" - "需要交互确认后再开放。" + for key, label, moving_side, semantics, moved_label, neighbor_warning in ( + ( + "move_single_left", + "只移动前项", + "single_left", + "move_only_left_instance", + left_label, + "会改变它与左侧相邻成员的距离", ), - "range_hint": f"只移动后项(未开放):该策略暂不执行。对象段:{segment_label}。", - "scdm_geometry_signature": _segment_signature( + ( + "move_single_right", + "只移动后项", + "single_right", + "move_only_right_instance", + right_label, + "会改变它与右侧相邻成员的距离", + ), + ): + max_display = _segment_max_spacing_display( + signature, + instances, + segment_index, + current_display, + unit_scale, + moving_side=moving_side, + ) + mode_signature = _segment_signature( signature, segment_index, current_backend, unit_scale, left_label=left_label, right_label=right_label, - moving_side="single_right", - motion_semantics="move_only_right_instance_blocked", - ), - } + moving_side=moving_side, + motion_semantics=semantics, + max_display=max_display, + ) + range_hint = ( + f"{label}:只平移 {moved_label},把 {left_label}-{right_label} 这段调到目标间距;" + f"{neighbor_warning},不用于保持整列等距。对象段:{segment_label}。" + ) + if max_display is not None and max_display > 0: + range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}。" + modes[key] = { + "label": label, + "enabled": can_execute, + "enabled_tip": range_hint if can_execute else "", + "disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。", + "range_hint": range_hint, + "max_value": max_display, + "scdm_geometry_signature": mode_signature, + } return modes @@ -464,7 +662,7 @@ def _segment_instance_reference(item: Mapping[str, object], *, label: str = "") def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str: source = item.get("source") if not isinstance(source, Mapping): - return f"成员{ordinal}" + return f"阵列成员{ordinal}" instance_kind = str(source.get("instanceKind") or "").strip().lower() if instance_kind in {"body", "part", "component"}: local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")]))) @@ -478,7 +676,7 @@ def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str: return component_label body_index = _int_or_none(source.get("bodyIndex")) if body_index is not None: - return f"零件{body_index}" + return f"Solid{body_index}" face_ids = sorted(set(_int_values(source.get("faceIds")))) if face_ids: return f"Face{face_ids[0]}{'组' if len(face_ids) > 1 else ''}" @@ -487,11 +685,11 @@ def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str: return component_label body_index = _int_or_none(source.get("bodyIndex")) if body_index is not None: - return f"零件{body_index}" + return f"Solid{body_index}" source_id = str(source.get("sourceObjectId") or "").strip() if source_id: return source_id - return f"成员{ordinal}" + return f"阵列成员{ordinal}" def _component_locator_label(value: object, *, include_index: bool = True) -> str: diff --git a/step_editor/scdm_result_validator.py b/step_editor/scdm_result_validator.py index 5e77771..7ea3728 100644 --- a/step_editor/scdm_result_validator.py +++ b/step_editor/scdm_result_validator.py @@ -51,10 +51,13 @@ def validate_scdm_edit_result( summary_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "SCDM summary check needs the old and new caches."} topology_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Object drift check needs the old and new SCDM caches."} + context_failure: dict[str, object] | None = None if before_cache and after_cache: + # B-Rep 有效只说明 STEP 能读回来;还要比较 SCDM cache,防止一次局部编辑 + # 顺手让其它可识别对象消失、漂移或变得歧义。 summary_check = check_scdm_summary_delta(before_cache, after_cache, capability_key=capability_key) if summary_check.get("ok") is False: - return { + context_failure = { "ok": False, "reason": str(summary_check.get("reason") or "summary-drift"), "message": str(summary_check.get("message") or "SCDM result changed the model summary too much."), @@ -70,14 +73,15 @@ def validate_scdm_edit_result( capability_key=capability_key, ) if topology_check.get("ok") is not True: - return { - "ok": False, - "reason": str(topology_check.get("reason") or "unexpected-object-drift"), - "message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."), - "topologyCheck": topology_check, - "brep": brep, - "editResult": dict(edit_result), - } + if context_failure is None: + context_failure = { + "ok": False, + "reason": str(topology_check.get("reason") or "unexpected-object-drift"), + "message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."), + "topologyCheck": topology_check, + "brep": brep, + "editResult": dict(edit_result), + } if _is_removal_capability(capability_key) and (not before_signature or not after_cache): return { @@ -91,7 +95,12 @@ def validate_scdm_edit_result( } if capability_key == "pattern.segment_spacing": - check = _check_pattern_segment_spacing_edit_result(edit_result, expected_target, tolerance=tolerance) + check = _check_pattern_segment_spacing_edit_result( + edit_result, + expected_target, + before_signature=before_signature, + tolerance=tolerance, + ) if check.get("ok") is not True: return { "ok": False, @@ -103,6 +112,15 @@ def validate_scdm_edit_result( "brep": brep, "editResult": dict(edit_result), } + warning = _context_guard_warning_if_target_verified( + context_failure, + capability_key=capability_key, + target_check=check, + summary_check=summary_check, + brep=brep, + ) + if context_failure is not None and warning is None: + return context_failure return { "ok": True, "reason": "ok", @@ -115,11 +133,14 @@ def validate_scdm_edit_result( "topologyCheck": topology_check, "brep": brep, "editResult": dict(edit_result), + "validationWarnings": [warning] if warning else [], } matched: dict[str, object] | None = None removal_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."} if before_signature and after_cache: + # 修改后的拓扑 ID 可能变化,不能按旧 Face ID 验证;这里用几何签名在新 cache + # 里找唯一对象,再用它做目标值回测和关系式 ID 续接。 match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key) status = str(match.get("status") or "") if _is_removal_capability(capability_key): @@ -176,6 +197,16 @@ def validate_scdm_edit_result( else: check = {"ok": None, "reason": "not-run", "message": "Target check needs a matched object and an expected target."} + warning = _context_guard_warning_if_target_verified( + context_failure, + capability_key=capability_key, + target_check=check, + summary_check=summary_check, + brep=brep, + ) + if context_failure is not None and warning is None: + return context_failure + return { "ok": True, "reason": "ok", @@ -188,6 +219,62 @@ def validate_scdm_edit_result( "topologyCheck": topology_check, "brep": brep, "editResult": dict(edit_result), + "validationWarnings": [warning] if warning else [], + } + + +def _context_guard_warning_if_target_verified( + context_failure: Mapping[str, object] | None, + *, + capability_key: str, + target_check: Mapping[str, object], + summary_check: Mapping[str, object], + brep: Mapping[str, object], +) -> dict[str, object] | None: + # SCDM 重新导出 STEP 时有时会重新划分 body count,但目标对象和 B-Rep 都正确。 + # 对已能目标回测的几何能力,将这种情况降级为警告,避免把成功修改误回滚。 + if brep.get("ok") is not True: + return None + if target_check.get("ok") is not True: + return None + if str(summary_check.get("reason") or "") != "body-count-repartitioned": + return None + if not _is_target_verified_scdm_geometry_capability(capability_key): + return None + if not isinstance(context_failure, Mapping): + return { + "reason": "body-count-repartitioned", + "message": str(summary_check.get("message") or "SCDM changed body count while re-exporting the STEP result."), + "summaryCheck": dict(summary_check), + } + warning = { + "reason": str(context_failure.get("reason") or "context-guard-warning"), + "message": str(context_failure.get("message") or "SCDM cache context changed after edit."), + "summaryCheck": dict(summary_check), + } + topology_check = context_failure.get("topologyCheck") + if isinstance(topology_check, Mapping): + warning["topologyCheck"] = dict(topology_check) + return warning + + +def _is_target_verified_scdm_geometry_capability(capability_key: str) -> bool: + return capability_key in { + "face.offset", + "hole.diameter", + "hole.position", + "slot.width", + "slot.depth", + "slot.position", + "boss.height", + "boss.diameter", + "boss.position", + "round.radius", + "chamfer.distance", + "pattern.spacing", + "pattern.segment_spacing", + "pattern.instance_position", + "shell.thickness", } @@ -453,6 +540,7 @@ def _check_pattern_segment_spacing_edit_result( edit_result: Mapping[str, object], expected_target: object, *, + before_signature: Mapping[str, object] | None, tolerance: float, ) -> dict[str, object]: applied = edit_result.get("applied") @@ -469,11 +557,53 @@ def _check_pattern_segment_spacing_edit_result( expected = _number(expected_target) check = _number_check(actual, expected, "pattern.segment_spacing", tolerance) if check.get("ok") is True: + spacing_mode = str(applied.get("spacingMode") or "").strip() + known_modes = { + "segment_after", + "segment_before", + "segment_split", + "segment_single_left", + "segment_single_right", + } + if spacing_mode not in known_modes: + return { + "ok": False, + "reason": "pattern-segment-spacing-mode-missing", + "message": "SCDM edit result did not report a known local spacing mode.", + "spacingMode": spacing_mode, + "knownModes": sorted(known_modes), + } + expected_modes = _expected_pattern_segment_spacing_modes(before_signature) + if expected_modes and spacing_mode not in expected_modes: + return { + "ok": False, + "reason": "pattern-segment-spacing-mode-mismatch", + "message": f"SCDM edit used {spacing_mode}, but the selected modeling intent expected one of {sorted(expected_modes)}.", + "spacingMode": spacing_mode, + "expectedModes": sorted(expected_modes), + } check["segmentIndex"] = applied.get("segmentIndex") - check["spacingMode"] = applied.get("spacingMode") + check["spacingMode"] = spacing_mode return check +def _expected_pattern_segment_spacing_modes(signature: Mapping[str, object] | None) -> set[str]: + if not isinstance(signature, Mapping): + return set() + moving_side = str(signature.get("movingSide") or "").strip().lower() + if moving_side in {"after", "right"}: + return {"segment_after"} + if moving_side in {"before", "left"}: + return {"segment_before"} + if moving_side in {"split", "both", "center"}: + return {"segment_split"} + if moving_side in {"single_left", "only_left"}: + return {"segment_single_left"} + if moving_side in {"single_right", "only_right"}: + return {"segment_single_right"} + return set() + + def check_scdm_summary_delta( before_cache: Mapping[str, object], after_cache: Mapping[str, object], @@ -493,11 +623,14 @@ def check_scdm_summary_delta( body_after = _int_or_none(after_summary.get("bodyCount")) if body_before is not None and body_after is not None and body_before != body_after: return { - "ok": False, - "reason": "summary-drift", - "message": f"SCDM result changed body count unexpectedly: {body_before} -> {body_after}.", + "ok": None, + "reason": "body-count-repartitioned", + "message": f"SCDM result changed body count during STEP re-export: {body_before} -> {body_after}.", "before": dict(before_summary), "after": dict(after_summary), + "metric": "bodyCount", + "beforeValue": body_before, + "afterValue": body_after, } for key, label in (("faceCount", "Face"), ("edgeCount", "Edge"), ("objectCount", "对象")): diff --git a/step_editor/scdm_status.py b/step_editor/scdm_status.py index e46637d..0312c00 100644 --- a/step_editor/scdm_status.py +++ b/step_editor/scdm_status.py @@ -52,11 +52,11 @@ def summarize_scdm_runtime( reason = message or "未拿到 SCDM 识别结果" detail = f"识别未启用:{reason};当前使用本软件已有能力。" elif state == "deferred": - detail = message or "大模型已延后 SCDM 全量识别,优先保证查看、旋转和点选流畅。" + detail = message or "已延后 SCDM 全量识别,优先保证导入显示、旋转和点选流畅。" elif state == "stale": - detail = message or "缓存已失效,导入、编辑、撤销或重做后会后台重新识别。" + detail = message or "缓存已失效,用户选择对象后会按需重新识别。" else: - detail = "导入 STEP 后会尝试启动 SCDM 识别;找不到时使用 OCCT/Analysis Situs 兜底。" + detail = "导入 STEP 后先显示模型;用户选择对象后再按需启动 SCDM 识别。" tooltip_lines = [headline, detail] if path: @@ -83,6 +83,8 @@ def summarize_scdm_capability_progress( execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False, ) -> dict[str, object]: ready_keys = _execution_ready_keys(execution_ready) + # 这里把“识别到”“计划中”“几何 hint”和“可执行”分开统计。 + # 客户界面只展示能稳定解释的进度,不能把 SCDM/raw hint 直接包装成可改参数。 detection_counts = _cache_capability_counts(feature_cache) blocked_counts = _cache_blocked_capability_counts(feature_cache) planned_counts = _planned_capability_counts(feature_cache) diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 480b071..e0274d8 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -133,13 +133,14 @@ class WindowCoreMixin: self.vtk_widget.Start() except Exception: pass - try: - self.render_window.Render() - except Exception: - pass + self._render_window_safely(force=True) def _is_ui_thread(self) -> bool: - return QThread.currentThread() == self.thread() + try: + owner_thread = self.thread() + except AttributeError: + return True + return QThread.currentThread() == owner_thread def _invoke_on_ui_thread(self, callback) -> None: if self._is_ui_thread(): @@ -148,6 +149,21 @@ class WindowCoreMixin: if hasattr(self, "ui_task_requested"): self.ui_task_requested.emit(callback) + def _render_window_safely(self, *, force: bool = False) -> None: + render_window = getattr(self, "render_window", None) + if render_window is None: + return + if not self._is_ui_thread(): + # VTK/Qt 渲染窗口只能在 UI 线程触碰;后台 worker 请求渲染时转成 queued UI 任务。 + self._invoke_on_ui_thread(lambda force=force: self._render_window_safely(force=force)) + return + if bool(getattr(self, "scene_rebuild_in_progress", False)) and not force: + return + try: + render_window.Render() + except Exception: + pass + def _reroute_to_ui_thread(self, callback) -> bool: if self._is_ui_thread(): return False @@ -193,6 +209,8 @@ class WindowCoreMixin: editor = getattr(self, "relation_formula_input", None) popup = self._relation_formula_popup_widget() if editor is not None and hasattr(editor, "backspace"): + # QCompleter 的 popup 会抢走 Backspace/Tab 焦点;手动把按键转回输入框, + # 否则用户补全后继续编辑时会出现“失焦、删不动”的体验。 if popup is not None and hasattr(popup, "hide"): popup.hide() if hasattr(editor, "setFocus"): @@ -483,9 +501,7 @@ class WindowCoreMixin: return self._fps_idle_rendering_zero = True try: - render_window.Render() - except Exception: - pass + self._render_window_safely(force=True) finally: self._fps_idle_rendering_zero = False @@ -1005,10 +1021,7 @@ class WindowCoreMixin: self.renderer.ResetCameraClippingRange() except Exception: pass - try: - self.render_window.Render() - except Exception: - pass + self._render_window_safely() def _hover_suppressed_after_camera(self) -> bool: ended_at = getattr(self, "last_camera_interaction_ended_at", None) @@ -1180,6 +1193,7 @@ class WindowCoreMixin: show_internal_edges: bool, status_prefix: str, defer_edges: bool = True, + reset_camera: bool = True, ) -> bool: self.statusBar().showMessage(f"{status_prefix} {new_path.name}...") QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) @@ -1198,7 +1212,24 @@ class WindowCoreMixin: return False finally: QApplication.restoreOverrideCursor() - self._apply_loaded_model_result(result, reset_camera=True) + try: + self._apply_loaded_model_result(result, reset_camera=reset_camera) + except Exception as exc: + pending = getattr(self, "pending_scdm_edit_reload", None) + if isinstance(pending, dict) and hasattr(self, "_reject_scdm_result_after_reload"): + self.pending_scdm_edit_reload = None + self._reject_scdm_result_after_reload( + pending, + { + "ok": False, + "reason": "result-load-failed", + "message": f"SCDM 修改结果 STEP 已生成,但加载到当前场景失败:{exc}", + }, + ) + else: + QMessageBox.critical(self, "Load failed", str(exc)) + self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + return False 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}") @@ -1224,7 +1255,10 @@ class WindowCoreMixin: if polydata is None: return None copied = vtk.vtkPolyData() - copied.ShallowCopy(polydata) + try: + copied.DeepCopy(polydata) + except Exception: + copied.ShallowCopy(polydata) return copied def _detach_worker_polydata_result(self, result: dict[str, object]) -> dict[str, object]: @@ -1239,10 +1273,12 @@ class WindowCoreMixin: apply_started = time.perf_counter() new_path = Path(result["path"]) stats = result["stats"] + pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict) self.model = result["model"] self.step_path = new_path self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。") - self._clear_history() + if not pending_scdm_reload: + self._clear_history() if hasattr(self, "measure_text"): self.clear_measurement() path_text = str(self.step_path) @@ -1250,7 +1286,10 @@ class WindowCoreMixin: self.path_label.setToolTip(path_text) self.path_label.setCursorPosition(0) self._populate_part_tree() - self._reset_selection() + if pending_scdm_reload: + self._reset_selection(clear_highlight=True, clear_info=False) + else: + self._reset_selection() large_interaction_model = self._large_model_interaction_mode(stats=stats) self.hide_edges_during_camera_interaction = large_interaction_model self.hide_overlays_during_camera_interaction = large_interaction_model @@ -1307,14 +1346,11 @@ class WindowCoreMixin: self.statusBar().showMessage( f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。" ) - pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict) scdm_cache_restored = False if pending_scdm_reload else self._restore_scdm_feature_cache_from_disk() - if large_interaction_model and not pending_scdm_reload and not scdm_cache_restored: - self._defer_large_model_recognition_preloads() - else: - QTimer.singleShot(160, self._start_asitus_hole_recognition_preload) - if pending_scdm_reload or not scdm_cache_restored: - QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload)) + if pending_scdm_reload: + QTimer.singleShot(120, lambda: self._start_scdm_probe_preload(force=True)) + elif not scdm_cache_restored: + self._defer_post_import_recognition_preloads() def _large_model_interaction_mode(self, stats: object | None = None) -> bool: if stats is not None: @@ -1343,6 +1379,36 @@ class WindowCoreMixin: if hasattr(self, "_update_current_capability_panel"): self._update_current_capability_panel() + def _defer_post_import_recognition_preloads(self) -> None: + self.scdm_feature_cache_state = "deferred" + self.scdm_feature_cache_message = ( + "模型已先完成显示;SCDM 可修改参数将在用户选择对象后按需识别,避免导入阶段与首次渲染抢占资源。" + ) + if self.model is not None: + try: + self.model.fail_asitus_hole_region_load("导入后已延后 Analysis Situs 全量孔组识别。") + except Exception: + pass + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + + def _maybe_start_scdm_probe_for_selection(self) -> None: + if self.model is None or self.step_path is None: + return + if str(getattr(self, "scdm_feature_cache_state", "") or "") != "deferred": + return + thread = getattr(self, "scdm_thread", None) + if thread is not None and thread.isRunning(): + return + self.scdm_feature_cache_message = ( + "已选择对象,正在按需调用 SCDM 识别可修改参数;" + "识别完成前暂不把圆柱类本地兜底判断当作已确认孔/槽能力。" + ) + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + self.statusBar().showMessage("正在按需识别 SCDM 可修改参数,可继续查看模型。") + QTimer.singleShot(0, lambda: self._start_scdm_probe_preload(force=True)) + def _invalidate_scdm_feature_cache(self, message: str = "") -> None: self.scdm_feature_cache = None self.scdm_feature_cache_state = "stale" @@ -1356,7 +1422,30 @@ class WindowCoreMixin: if hasattr(self, "_update_current_capability_panel"): self._update_current_capability_panel() - def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> None: + def _scdm_feature_cache_valid_for_loaded_step(self, cache: object) -> bool: + if self.step_path is None or not isinstance(cache, dict): + return False + try: + fingerprint = file_fingerprint(Path(self.step_path)) + except OSError: + return False + revision = _safe_int_or_none(cache.get("mapperRevision")) or 0 + return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION + + def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> bool: + if not self._scdm_feature_cache_valid_for_loaded_step(cache): + self.scdm_feature_cache = None + self.scdm_feature_cache_state = "stale" + self.scdm_feature_cache_message = "本地 SCDM cache 与当前模型或当前 mapper 版本不匹配,已丢弃。" + self.scdm_feature_cache_path = "" + if self.model is not None: + try: + self.model.scdm_feature_cache = None + except Exception: + pass + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + return False self.scdm_feature_cache = dict(cache) self.scdm_feature_cache_state = "ready" self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。" @@ -1370,6 +1459,7 @@ class WindowCoreMixin: self._update_current_capability_panel() if hasattr(self, "_refresh_property_editor"): self._refresh_property_editor() + return True def _restore_scdm_feature_cache_from_disk(self) -> bool: if self.model is None or self.step_path is None: @@ -1382,7 +1472,6 @@ class WindowCoreMixin: project_root = Path(__file__).resolve().parent.parent work_dir = default_scdm_work_dir(step_path, project_root=project_root, fingerprint=fingerprint) cache_path = work_dir / "scdm_feature_cache.json" - raw_path = work_dir / "scdm_raw_features.json" cache: dict[str, object] | None = None if cache_path.is_file(): @@ -1391,43 +1480,25 @@ class WindowCoreMixin: revision = _safe_int_or_none(candidate.get("mapperRevision")) or 0 if str(candidate.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION: cache = candidate - except Exception: - cache = None - - if cache is None and raw_path.is_file(): - try: - raw = read_json(raw_path) - raw_model = raw.get("model") - raw_fingerprint = str(raw_model.get("fingerprint") or "") if isinstance(raw_model, dict) else "" - if raw_fingerprint == fingerprint: - cache = attach_local_face_ids_to_scdm_cache( - map_scdm_raw_features(raw), - self._scdm_local_face_signatures(), - ) - write_json(cache_path, cache) + elif str(candidate.get("modelFingerprint") or "") == fingerprint: + self.scdm_feature_cache_message = "本地 SCDM cache 版本过旧,将在选择对象后按需重新识别。" except Exception: cache = None if cache is None: return False - self._install_scdm_feature_cache( + installed = self._install_scdm_feature_cache( cache, cache_path=str(cache_path), message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。", ) + if not installed: + return False self.statusBar().showMessage("已恢复本地 SCDM 识别缓存,参数表可直接使用。") return True def _current_scdm_feature_cache_matches_loaded_step(self) -> bool: - if self.step_path is None or not isinstance(getattr(self, "scdm_feature_cache", None), dict): - return False - cache = getattr(self, "scdm_feature_cache", None) - try: - fingerprint = file_fingerprint(Path(self.step_path)) - except OSError: - return False - revision = _safe_int_or_none(cache.get("mapperRevision")) or 0 - return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION + return self._scdm_feature_cache_valid_for_loaded_step(getattr(self, "scdm_feature_cache", None)) def _start_scdm_probe_preload(self, *, force: bool = False) -> None: if self.model is None or self.step_path is None: @@ -1441,11 +1512,14 @@ class WindowCoreMixin: QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force)) return step_path = Path(self.step_path) + try: + local_face_signatures = [dict(item) for item in self._scdm_local_face_signatures()] + except Exception: + local_face_signatures = [] context = { "path": step_path, "model_id": id(self.model), } - model = self.model self.pending_scdm_context = dict(context) self.scdm_feature_cache_state = "running" self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。" @@ -1453,7 +1527,7 @@ class WindowCoreMixin: if hasattr(self, "_update_current_capability_panel"): self._update_current_capability_panel() - def action(path=step_path, model=model): + def action(path=step_path, face_signatures=tuple(local_face_signatures)): probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0) if not isinstance(probe, dict) or probe.get("ok") is not True: return { @@ -1465,13 +1539,6 @@ class WindowCoreMixin: raw = probe.get("raw") if not isinstance(raw, dict): return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe} - face_signatures = [] - builder = getattr(model, "scdm_local_face_signatures", None) - if callable(builder): - try: - face_signatures = [dict(item) for item in builder() if isinstance(item, dict)] - except Exception: - face_signatures = [] cache = attach_local_face_ids_to_scdm_cache( map_scdm_raw_features(raw), face_signatures, @@ -1553,11 +1620,16 @@ class WindowCoreMixin: return if isinstance(result.get("backend"), dict): self.scdm_backend_status = dict(result["backend"]) - self._install_scdm_feature_cache( + installed = self._install_scdm_feature_cache( dict(cache), cache_path=str(result.get("cache_path") or ""), message="SCDM 可修改参数识别完成。", ) + if not installed: + self.statusBar().showMessage("SCDM cache 与当前模型不匹配,已丢弃并保持参数表不可用。") + if hasattr(self, "_finish_pending_scdm_edit_reload"): + self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 与当前模型不匹配") + return objects = cache.get("objects") count = len(objects) if isinstance(objects, list) else 0 self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。") @@ -1754,6 +1826,7 @@ class WindowCoreMixin: self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}") def _end_load_task(self) -> None: + self.scene_rebuild_in_progress = False self.load_in_progress = False self.pending_load_path = None self._update_action_states() @@ -2136,7 +2209,7 @@ class WindowCoreMixin: QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。") return self._fit_camera_to_bounds(bounds) - self.render_window.Render() + self._render_window_safely() self.statusBar().showMessage("已对准选中对象") def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None: @@ -2275,7 +2348,7 @@ class WindowCoreMixin: self.renderer.AddActor(self.edge_actor) self.edge_overlay_polydata_cache.clear() if render: - self.render_window.Render() + self._render_window_safely() @Slot() def _rebuild_deferred_edge_display(self) -> None: @@ -2327,13 +2400,52 @@ class WindowCoreMixin: if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path): return edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata")) - self._install_edge_polydata(edge_polydata, render=True) - self.large_model_edge_overlay_skipped = False - elapsed = float(result.get("elapsed") or 0.0) - self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s") + deferred_result = dict(result) + deferred_result["edge_polydata"] = edge_polydata + if self._defer_scene_actor_update_if_interacting( + lambda deferred_result=deferred_result: self._apply_deferred_edge_display_result(deferred_result) + ): + return + self._apply_deferred_edge_display_result(deferred_result) finally: self._request_thread_quit(self.load_refine_thread) + def _apply_deferred_edge_display_result(self, result: dict[str, object]) -> None: + if not isinstance(result, dict): + return + if self.model is None or id(self.model) != result.get("model_id"): + return + if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path): + return + if self._defer_scene_actor_update_if_interacting( + lambda result=result: self._apply_deferred_edge_display_result(result) + ): + return + self._install_edge_polydata(result.get("edge_polydata"), render=True) + self.large_model_edge_overlay_skipped = False + elapsed = float(result.get("elapsed") or 0.0) + self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s") + + def _defer_scene_actor_update_if_interacting(self, callback, *, delay_ms: int = 140) -> bool: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(callback) + return True + if not self._scene_actor_update_should_wait_for_camera(): + return False + QTimer.singleShot(max(int(delay_ms), 1), callback) + return True + + def _scene_actor_update_should_wait_for_camera(self) -> bool: + if bool(getattr(self, "camera_interaction_active", False)): + return True + if bool(getattr(self, "pointer_button_down", False)): + return True + ended_at = getattr(self, "last_camera_interaction_ended_at", None) + if ended_at is None: + return False + elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0 + return elapsed_ms < 120.0 + @Slot(str) def _fail_deferred_edge_display(self, message: str) -> None: if self._reroute_to_ui_thread(lambda message=message: self._fail_deferred_edge_display(message)): @@ -2433,6 +2545,7 @@ class WindowCoreMixin: return polydata def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None: + self.scene_rebuild_in_progress = True self._clear_overlay_polydata_cache() self.renderer.RemoveAllViewProps() self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) @@ -2486,7 +2599,8 @@ class WindowCoreMixin: self.renderer.ResetCamera() self._ensure_step_coordinate_axes() self.renderer.ResetCameraClippingRange() - self.render_window.Render() + self.scene_rebuild_in_progress = False + self._render_window_safely(force=True) def _on_mode_changed(self, mode: str) -> None: self._clear_hover(render=True) @@ -2620,6 +2734,7 @@ class WindowCoreMixin: getattr(self, "load_in_progress", False) or getattr(self, "operation_in_progress", False) or getattr(self, "scan_in_progress", False) + or getattr(self, "scene_rebuild_in_progress", False) or getattr(self, "model", None) is None ): return None @@ -2659,14 +2774,14 @@ class WindowCoreMixin: if press_signature is None or release_signature != press_signature: self._cancel_hover_tracking(render=False) if hasattr(self, "render_window"): - self.render_window.Render() + self._render_window_safely() if self.selected_kind is not None: self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择") return if target is None: self._cancel_hover_tracking(render=False) if hasattr(self, "render_window"): - self.render_window.Render() + self._render_window_safely() if self.selected_kind is not None: self.statusBar().showMessage("未命中对象,已保持当前选择") else: @@ -2815,6 +2930,7 @@ class WindowCoreMixin: self.operation_in_progress or self.scan_in_progress or self.load_in_progress + or getattr(self, "scene_rebuild_in_progress", False) or self.model is None or self.model_actor is None or self._hover_suppressed_after_camera() @@ -2828,6 +2944,7 @@ class WindowCoreMixin: self.operation_in_progress or self.scan_in_progress or self.load_in_progress + or getattr(self, "scene_rebuild_in_progress", False) or self.model is None or self.model_actor is None ): @@ -2843,9 +2960,11 @@ class WindowCoreMixin: if ( getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False) + or getattr(self, "scene_rebuild_in_progress", False) or getattr(self, "large_model_hover_disabled", False) or self._hover_suppressed_after_camera() ): + # 鼠标拖动旋转和场景重建期间不做 hover/pick,避免“只是看模型却自动选面”。 return position = (int(x), int(y)) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) @@ -2869,6 +2988,7 @@ class WindowCoreMixin: self.operation_in_progress or self.scan_in_progress or self.load_in_progress + or getattr(self, "scene_rebuild_in_progress", False) or self.model is None or self.model_actor is None or self.pending_hover_position is None @@ -2918,19 +3038,27 @@ class WindowCoreMixin: return None def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None: - if actor is None: + if actor is None or bool(getattr(self, "scene_rebuild_in_progress", False)): return None - self.picker.InitializePickList() - self.picker.PickFromListOn() - self.picker.AddPickList(actor) - picked = self.picker.Pick(int(x), int(y), 0, self.renderer) - self.picker.PickFromListOff() - if not picked or self.picker.GetCellId() < 0: + try: + self.picker.InitializePickList() + self.picker.PickFromListOn() + self.picker.AddPickList(actor) + # 只拾取指定 actor,避免坐标轴、高亮 overlay 或边线 actor 抢到 Face/Edge 选择。 + picked = self.picker.Pick(int(x), int(y), 0, self.renderer) + self.picker.PickFromListOff() + if not picked or self.picker.GetCellId() < 0: + return None + return { + "cell_id": int(self.picker.GetCellId()), + "pick_position": _vector_tuple(self.picker.GetPickPosition()), + } + except Exception: + try: + self.picker.PickFromListOff() + except Exception: + pass return None - return { - "cell_id": int(self.picker.GetCellId()), - "pick_position": _vector_tuple(self.picker.GetPickPosition()), - } def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None: hit = self._pick_actor_cell(self.model_actor, x, y) @@ -3263,6 +3391,7 @@ class WindowCoreMixin: self._sync_id_picker("Part", part_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() + self._maybe_start_scdm_probe_for_selection() kind_label = _part_tree_kind_label(str(info.get("kind", "part"))) self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position)) @@ -3288,6 +3417,7 @@ class WindowCoreMixin: self._sync_id_picker("Solid", solid_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() + self._maybe_start_scdm_probe_for_selection() self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position)) def select_face( @@ -3328,6 +3458,7 @@ class WindowCoreMixin: self._sync_id_picker("Feature" if feature_mode else "Face", logical_id) self.set_info(self._with_pick_info(input_info, pick_position)) self._update_action_states() + self._maybe_start_scdm_probe_for_selection() message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}" self.statusBar().showMessage(self._selection_status(message, pick_position)) @@ -3383,6 +3514,7 @@ class WindowCoreMixin: if hasattr(self, "_feature_display_label"): feature_type = self._feature_display_label(feature_type) self._update_action_states() + self._maybe_start_scdm_probe_for_selection() self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position)) def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: @@ -3559,6 +3691,7 @@ class WindowCoreMixin: self._sync_id_picker("Edge", edge_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() + self._maybe_start_scdm_probe_for_selection() self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position)) def _highlight_faces(self, face_ids=None, part_ids=None) -> None: @@ -3590,7 +3723,7 @@ class WindowCoreMixin: self.highlight_actor = actor self.highlight_signature = signature self.renderer.AddActor(actor) - self.render_window.Render() + self._render_window_safely() def _highlight_edge(self, edge_id: int) -> None: if self.model is None: @@ -3614,7 +3747,7 @@ class WindowCoreMixin: self.edge_highlight_actor = actor self.highlight_signature = signature self.renderer.AddActor(actor) - self.render_window.Render() + self._render_window_safely() def _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None: if self.model is None or target is None: @@ -3633,7 +3766,7 @@ class WindowCoreMixin: self._clear_hover(render=False) if target is None: - self.render_window.Render() + self._render_window_safely() return kind = str(target["kind"]) @@ -3652,7 +3785,7 @@ class WindowCoreMixin: face_ids = self._selection_same_domain_face_ids(target_id) or [target_id] self._highlight_hover_faces(face_ids=face_ids) self.hover_signature = signature - self.render_window.Render() + self._render_window_safely() def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None: if self.model is None: @@ -3729,7 +3862,7 @@ class WindowCoreMixin: removed = True self.hover_signature = None if render and removed and hasattr(self, "render_window"): - self.render_window.Render() + self._render_window_safely() def _clear_highlight(self) -> None: if self.highlight_actor is not None: @@ -3754,7 +3887,7 @@ class WindowCoreMixin: self.edit_preview_actors = [] self.edit_preview_actor = None if render and hasattr(self, "render_window"): - self.render_window.Render() + self._render_window_safely() def _add_edit_preview_actor( self, @@ -3806,7 +3939,7 @@ class WindowCoreMixin: ) self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_shell_thickness_preview( self, @@ -3840,7 +3973,7 @@ class WindowCoreMixin: ) self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34, position=movement) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None: if self.model is None: @@ -3857,7 +3990,7 @@ class WindowCoreMixin: opacity = 0.28 if role == "fill" else 0.32 self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None: if self.model is None: @@ -3874,7 +4007,7 @@ class WindowCoreMixin: opacity = 0.3 if role == "fill" else 0.34 self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None: if self.model is None: @@ -3887,7 +4020,7 @@ class WindowCoreMixin: return self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_cylinder_suppress_preview(self, face_id: int) -> None: if self.model is None: @@ -3901,7 +4034,7 @@ class WindowCoreMixin: for preview in previews: self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_cylinder_depth_preview( self, @@ -3927,7 +4060,7 @@ class WindowCoreMixin: opacity = 0.3 if role == "fill" else 0.34 self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None: if self.model is None: @@ -3941,7 +4074,7 @@ class WindowCoreMixin: for preview in previews: self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None: if self.model is None: @@ -3982,7 +4115,7 @@ class WindowCoreMixin: else: self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _show_edge_tube_preview( self, @@ -4018,7 +4151,7 @@ class WindowCoreMixin: tube.Update() self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34) self._start_edit_preview_pulse() - self.render_window.Render() + self._render_window_safely() def _start_edit_preview_pulse(self) -> None: if self.edit_preview_timer is None: @@ -4036,7 +4169,7 @@ class WindowCoreMixin: opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0) for actor in self.edit_preview_actors: actor.GetProperty().SetOpacity(opacity) - self.render_window.Render() + self._render_window_safely() def clear_diff_preview(self, render: bool = True) -> None: if not hasattr(self, "renderer"): @@ -4046,7 +4179,7 @@ class WindowCoreMixin: self.renderer.RemoveViewProp(actor) self.diff_actors.clear() if render and hasattr(self, "render_window"): - self.render_window.Render() + self._render_window_safely() self.statusBar().showMessage("已清除差异预览") if hasattr(self, "mode_combo"): self._update_action_states() @@ -4070,7 +4203,7 @@ class WindowCoreMixin: self.diff_actors.append(scalar_bar) for actor in self.diff_actors: self.renderer.AddViewProp(actor) - self.render_window.Render() + self._render_window_safely() self._update_action_states() if heatmap_stats: record.diff_stats = heatmap_stats diff --git a/step_editor/window_state.py b/step_editor/window_state.py index af6e25c..e6caa68 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +import json import math from pathlib import Path import re @@ -482,7 +483,7 @@ class WindowStateMixin: self.renderer.RemoveActor(self.pick_marker_actor) self.pick_marker_actor = None if getattr(self, "render_window", None) is not None: - self.render_window.Render() + self._render_window_safely() def _with_pick_info( self, @@ -1911,7 +1912,7 @@ class WindowStateMixin: headline = "当前支持:常见 Face、孔、槽、凸台、圆角/倒角、壳体、Edge" detail = "" tooltip = ( - "能改:Face 偏移,孔径/位置,槽位置,凸台位置/尺寸,部分槽宽/槽深,简单圆角/倒角,Edge 长度等。\n" + "能改:Face 偏移,孔径/位置,证据完整的简单槽/长圆槽,凸台位置/尺寸,简单圆角/倒角,Edge 长度等。\n" "能识别:基础拓扑、曲面类型、一级关系、孔组、槽、凸台、圆角/倒角候选。\n" "暂不能:原 CAD 历史树、复杂装配约束、二级/三级拓扑自动传播。" ) @@ -2064,7 +2065,7 @@ class WindowStateMixin: "## 已能修改", "- **Face**:偏移/推拉,部分稳定矩形平面的长度、宽度,局部壳体厚度。", "- **孔**:直径、半径、位置、盲孔深度、封堵;拆成多片的圆柱孔会按同一孔组处理。", - "- **槽**:简单槽/长圆槽的位置、槽宽、槽深、弧长、总长、中心距。", + "- **槽**:证据完整的简单槽/长圆槽可改位置、槽宽、槽深、弧长、总长、中心距;中置信度“圆柱孔/槽候选”只表示已识别到槽可能性,不等于已经可改。", "- **凸台**:圆柱凸台位置、直径、高度;部分矩形凸台/口袋的长度、宽度、高度或深度。", "- **圆角/倒角**:简单已有圆角半径、已有等距倒角,直线 Edge 新增圆角/倒角。", "- **Edge**:直线 Edge 长度/端点,圆或椭圆 Edge 的半径类参数。", @@ -2081,6 +2082,7 @@ class WindowStateMixin: "- 任意复杂自由曲面的直接变形。", "- 二级/三级拓扑自动传播。", "- 阵列间距联动、跨特征强约束、复杂公式组求解。", + "- 只有槽候选、但缺少槽宽/槽深/槽底面/方向/成对端面等证据的复杂槽或多槽组。", "- 复杂圆角链/倒角链批量重建。", "- 无法通过 B-Rep 校验或目标回测的修改会被阻止并回滚。", "", @@ -2119,6 +2121,21 @@ class WindowStateMixin: if selection_status: info["scdm_selection_status"] = selection_status scdm_specs = self._scdm_property_specs_for_selection() if hasattr(self, "_scdm_property_specs_for_selection") else [] + filter_message = "" + if scdm_specs and hasattr(self, "_filter_scdm_specs_for_local_recognition"): + try: + action_info = self._selected_action_info() if hasattr(self, "_selected_action_info") else info + except Exception: + action_info = info + try: + scdm_specs, filter_message = self._filter_scdm_specs_for_local_recognition( + list(scdm_specs), + dict(action_info or {}), + ) + except Exception: + filter_message = "" + if filter_message and not selection_status: + info["scdm_selection_status"] = filter_message enabled_labels = [ str(spec.get("label") or spec.get("scdm_capability_key") or "").strip() for spec in scdm_specs @@ -3081,15 +3098,47 @@ class WindowStateMixin: "请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。", ) remove_button = getattr(self, "remove_relation_formula_button", None) + toggle_button = getattr(self, "toggle_relation_formula_button", None) formula_list = getattr(self, "relation_formula_list", None) + selected_ids = self._selected_relation_formula_ids() + selected_items = [ + dict(item) + for item in (getattr(self, "relation_formula_items", []) or []) + if int(item.get("id", -1)) in selected_ids + ] + selected_has_enabled = any(bool(item.get("enabled", True)) for item in selected_items) + if isinstance(toggle_button, QPushButton): + toggle_button.setText("停用公式" if selected_has_enabled else "启用公式") + self._set_control_state( + toggle_button, + bool(can_modify_formula_set and selected_items), + "临时停用或重新启用选中的关系式,并按当前启用公式重新计算模型。", + "请先在已有关系式列表里选择一条公式;公式计算中暂不能切换。", + ) if isinstance(remove_button, QPushButton): - has_selected = bool(formula_list is not None and formula_list.selectedItems()) self._set_control_state( remove_button, - bool(can_modify_formula_set and has_selected), + bool(can_modify_formula_set and selected_items), "删除选中的关系式,并按剩余公式重新计算模型。", "请先在已有关系式列表里选择一条公式;公式计算中暂不能删除。", ) + import_button = getattr(self, "import_relation_formula_button", None) + if isinstance(import_button, QPushButton): + self._set_control_state( + import_button, + bool(can_modify_formula_set), + "从 JSON 导入一组关系式,替换当前公式组,并按启用公式重新计算模型。", + "请先导入模型;公式计算中暂不能导入。", + ) + export_button = getattr(self, "export_relation_formula_button", None) + if isinstance(export_button, QPushButton): + has_formulas = bool(getattr(self, "relation_formula_items", []) or []) + self._set_control_state( + export_button, + bool(has_formulas), + "把当前关系式导出为 JSON,便于复用或交给外部流程。", + "当前还没有可导出的关系式。", + ) def add_relation_formula(self) -> None: if self.model is None: @@ -3143,11 +3192,7 @@ class WindowStateMixin: return if self._edit_busy("请等待当前几何计算完成后再删除公式。"): return - ids = { - int(item.data(Qt.ItemDataRole.UserRole)) - for item in formula_list.selectedItems() - if item.data(Qt.ItemDataRole.UserRole) is not None - } + ids = self._selected_relation_formula_ids() if not ids: return self.relation_formula_items = [ @@ -3157,15 +3202,218 @@ class WindowStateMixin: ] self._refresh_relation_formula_list() self._update_property_apply_state() - if self.relation_formula_items: + if self._enabled_relation_formula_items(): self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,正在按剩余公式更新模型...") self._start_relation_formula_reapply(reason="remove") + elif self.relation_formula_items: + restored = self._restore_relation_formula_base_snapshot() + message = "当前没有启用公式,模型已恢复到公式基准状态。" if restored else "当前没有启用公式。" + self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}") + self._refresh_relation_formula_list() + self._update_property_apply_state() else: restored = self._restore_relation_formula_base_snapshot() self._clear_relation_formula_runtime_state(clear_items=False) message = "模型已恢复到添加公式前状态。" if restored else "已删除关系式。" self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}") + def toggle_selected_relation_formula(self) -> None: + formula_list = getattr(self, "relation_formula_list", None) + if formula_list is None: + return + if self._edit_busy("请等待当前几何计算完成后再切换公式。"): + return + ids = self._selected_relation_formula_ids() + if not ids: + return + selected_items = [ + dict(item) + for item in (getattr(self, "relation_formula_items", []) or []) + if int(item.get("id", -1)) in ids + ] + enable_selected = not any(bool(item.get("enabled", True)) for item in selected_items) + changed: list[dict[str, object]] = [] + for raw_item in getattr(self, "relation_formula_items", []) or []: + item = dict(raw_item) + if int(item.get("id", -1)) in ids: + item["enabled"] = enable_selected + if enable_selected: + item["status"] = "ready" + item["message"] = "已启用,等待重新计算。" + else: + item["status"] = "disabled" + item["message"] = "已停用,不参与计算。" + changed.append(item) + self.relation_formula_items = changed + self._refresh_relation_formula_list() + self._update_property_apply_state() + action = "启用" if enable_selected else "停用" + if self._enabled_relation_formula_items(): + self.statusBar().showMessage(f"已{action} {len(ids)} 条关系式,正在按启用公式更新模型...") + self._start_relation_formula_reapply(reason="toggle") + else: + restored = self._restore_relation_formula_base_snapshot() + message = "模型已恢复到公式基准状态。" if restored else "当前没有启用公式。" + self.statusBar().showMessage(f"已{action} {len(ids)} 条关系式,{message}") + self._refresh_relation_formula_list() + self._update_property_apply_state() + + def export_relation_formulas(self, path: str | Path | bool | None = None) -> bool: + interactive = path is None or isinstance(path, bool) + if isinstance(path, bool): + path = None + formulas = list(getattr(self, "relation_formula_items", []) or []) + if not formulas: + self.statusBar().showMessage("当前没有可导出的关系式。") + if interactive: + QMessageBox.information(self, "导出公式", "当前没有可导出的关系式。") + return False + if path is None: + start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent + selected, _ = QFileDialog.getSaveFileName( + self, + "导出关系式", + str(start_dir / "relation_formulas.json"), + "JSON (*.json);;所有文件 (*.*)", + ) + if not selected: + return False + path = selected + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema": "python-occt.relation-formulas.v1", + "exportedAt": datetime.now().isoformat(timespec="seconds"), + "sourceStep": str(getattr(self, "step_path", "") or ""), + "formulas": self._relation_formula_export_rows(), + } + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + self.statusBar().showMessage(f"已导出 {len(payload['formulas'])} 条关系式:{output_path}") + return True + + def import_relation_formulas(self, path: str | Path | bool | None = None) -> bool: + interactive = path is None or isinstance(path, bool) + if isinstance(path, bool): + path = None + if self.model is None: + self.statusBar().showMessage("请先导入模型后再导入关系式。") + if interactive: + QMessageBox.information(self, "导入公式", "请先导入模型后再导入关系式。") + return False + if self._edit_busy("请等待当前几何计算完成后再导入公式。"): + return False + if path is None: + start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent + selected, _ = QFileDialog.getOpenFileName( + self, + "导入关系式", + str(start_dir), + "JSON (*.json);;所有文件 (*.*)", + ) + if not selected: + return False + path = selected + input_path = Path(path) + try: + payload = json.loads(input_path.read_text(encoding="utf-8")) + imported_items = self._relation_formula_items_from_import_payload(payload) + except Exception as exc: + message = f"导入公式失败:{exc}" + self.statusBar().showMessage(message) + if interactive: + QMessageBox.information(self, "导入公式失败", message) + return False + self._ensure_relation_formula_base_snapshot() + self.relation_formula_items = imported_items + next_ids = [int(item.get("id", 0) or 0) for item in imported_items] + self.relation_formula_next_id = (max(next_ids) + 1) if next_ids else 1 + self._refresh_relation_formula_list() + self._update_property_apply_state() + enabled_count = len(self._enabled_relation_formula_items()) + if enabled_count: + self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式,正在按 {enabled_count} 条启用公式更新模型...") + self._start_relation_formula_reapply(reason="import") + else: + self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式;当前没有启用公式。") + return True + + def _relation_formula_export_rows(self) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for index, raw_item in enumerate(getattr(self, "relation_formula_items", []) or [], start=1): + item = dict(raw_item) + rows.append( + { + "index": index, + "text": str(item.get("text") or "").strip(), + "enabled": bool(item.get("enabled", True)), + "status": str(item.get("status") or "").strip(), + "message": str(item.get("message") or "").strip(), + } + ) + return rows + + def _relation_formula_items_from_import_payload(self, payload: object) -> list[dict[str, object]]: + raw_items = payload.get("formulas") if isinstance(payload, dict) else payload + if not isinstance(raw_items, list): + raise RelationFormulaError("JSON 必须是公式数组,或包含 formulas 数组。") + formulas = [] + items: list[dict[str, object]] = [] + for index, raw_item in enumerate(raw_items, start=1): + if isinstance(raw_item, str): + text = raw_item + enabled = True + elif isinstance(raw_item, dict): + text = str(raw_item.get("text") or raw_item.get("formula") or "").strip() + enabled = bool(raw_item.get("enabled", True)) + else: + raise RelationFormulaError(f"第 {index} 条公式格式不正确。") + if not text: + raise RelationFormulaError(f"第 {index} 条公式为空。") + try: + formula = parse_relation_formula(text) + except RelationFormulaError as exc: + raise RelationFormulaError(f"第 {index} 条公式无效:{exc}") from exc + formulas.append(formula) + items.append( + { + "id": index, + "text": formula.text, + "enabled": enabled, + "status": "ready" if enabled else "disabled", + "message": "导入后等待计算。" if enabled else "已停用,不参与计算。", + "signatures": {}, + } + ) + if not items: + raise RelationFormulaError("JSON 里没有关系式。") + try: + validate_relation_formula_graph(formulas) + except RelationFormulaError as exc: + raise RelationFormulaError(f"导入的公式组无效:{exc}") from exc + return items + + def _selected_relation_formula_ids(self) -> set[int]: + formula_list = getattr(self, "relation_formula_list", None) + if formula_list is None: + return set() + ids: set[int] = set() + for item in formula_list.selectedItems(): + value = item.data(Qt.ItemDataRole.UserRole) + if value is None: + continue + try: + ids.add(int(value)) + except (TypeError, ValueError): + continue + return ids + + def _enabled_relation_formula_items(self) -> list[dict[str, object]]: + return [ + dict(item) + for item in (getattr(self, "relation_formula_items", []) or []) + if bool(item.get("enabled", True)) + ] + def _clear_relation_formula_runtime_state(self, *, clear_items: bool) -> None: if clear_items: self.relation_formula_items = [] @@ -3264,6 +3512,10 @@ class WindowStateMixin: label = "添加" elif reason == "dependency": label = "依赖参数变化" + elif reason == "toggle": + label = "切换" + elif reason == "import": + label = "导入" else: label = "删除" self.statusBar().showMessage(f"{label}公式后正在重新计算:共 {self.relation_formula_replay_total} 条。") @@ -3424,7 +3676,10 @@ class WindowStateMixin: message = str(item.get("message") or "").strip() text = str(item.get("text") or "") suffix = "" - if status == "invalid": + enabled = bool(item.get("enabled", True)) + if not enabled: + suffix = f" [停用:{message or '不参与计算'}]" + elif status == "invalid": suffix = f" [失效:{message or '需要重新选择'}]" elif status == "pending": suffix = f" [待选择:{message or '目标不在当前参数表'}]" @@ -3435,7 +3690,9 @@ class WindowStateMixin: row_item = QListWidgetItem(f"{text}{suffix}") row_item.setData(Qt.ItemDataRole.UserRole, int(item.get("id", -1))) row_item.setToolTip(message or text) - if status == "invalid": + if not enabled: + row_item.setForeground(QColor("#6b7280")) + elif status == "invalid": row_item.setForeground(QColor("#b91c1c")) elif status == "pending": row_item.setForeground(QColor("#92400e")) @@ -4527,6 +4784,7 @@ class WindowStateMixin: if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes": return self._multi_hole_property_specs(action_info) scdm_specs = self._scdm_property_specs_for_selection() + scdm_specs, scdm_filter_message = self._filter_scdm_specs_for_local_recognition(scdm_specs, action_info) if self._prefer_local_face_offset_over_scdm(scdm_specs, action_info): scdm_specs = [ spec @@ -4538,9 +4796,17 @@ class WindowStateMixin: "避免 SCDM 长时间直接建模计算。" ) else: - self.scdm_selection_status_message = self._scdm_selection_status_message(scdm_specs) + self.scdm_selection_status_message = scdm_filter_message or self._scdm_selection_status_message(scdm_specs) if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs): return [spec for spec in scdm_specs if bool(spec.get("enabled"))] + if scdm_filter_message: + return [] + if self._should_hold_local_cylindrical_specs_for_scdm(action_info): + self.scdm_selection_status_message = ( + "SCDM 正在按需识别当前大模型对象;圆柱、槽、圆角这类容易误分的对象," + "识别完成前不开放本地兜底孔/槽参数。" + ) + return [] editable_specs, _used_keys = self._editable_property_specs(action_info) if self.selected_kind == "feature": return self._feature_context_property_specs(editable_specs, action_info) @@ -4554,6 +4820,46 @@ class WindowStateMixin: and _property_parameter_is_visible(spec) ] + def _filter_scdm_specs_for_local_recognition( + self, + scdm_specs: list[dict[str, object]], + action_info: dict[str, object], + ) -> tuple[list[dict[str, object]], str]: + if not scdm_specs or self.selected_kind not in {"feature", "face"}: + return scdm_specs, "" + conflicting_keys = {"round.radius", "chamfer.distance", "feature.delete_round_or_chamfer"} + if not any(str(spec.get("scdm_capability_key") or "") in conflicting_keys for spec in scdm_specs): + return scdm_specs, "" + + feature_guess = str(action_info.get("feature_guess") or "").strip() + feature_type = str(action_info.get("feature_type") or "").strip() + if feature_guess in {"round/fillet candidate", "chamfer candidate"} or any( + token in feature_type for token in ("圆角", "倒圆", "倒角") + ): + return scdm_specs, "" + + local_slot_or_pocket = bool( + action_info.get("blind_split_cylindrical_pocket") + or action_info.get("blind_split_cylindrical_groove") + or "复杂槽" in feature_type + or ("槽" in feature_type and "圆柱孔" not in feature_type) + ) + if not local_slot_or_pocket: + return scdm_specs, "" + + filtered = [ + spec + for spec in scdm_specs + if str(spec.get("scdm_capability_key") or "") not in conflicting_keys + ] + if len(filtered) == len(scdm_specs): + return scdm_specs, "" + return ( + filtered, + "SCDM 返回了圆角/倒角删除能力,但当前对象本地识别为盲孔/槽凹坑或复杂槽;" + "为避免把槽误删成圆角,已隐藏该冲突操作。", + ) + def _prefer_local_face_offset_over_scdm( self, scdm_specs: list[dict[str, object]], @@ -4578,6 +4884,35 @@ class WindowStateMixin: ) or 0 return inner_wires > 0 or boundary_edges >= 16 + def _should_hold_local_cylindrical_specs_for_scdm(self, action_info: dict[str, object]) -> bool: + if self.selected_kind not in {"feature", "face"} or self.model is None: + return False + if not bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): + return False + state = str(getattr(self, "scdm_feature_cache_state", "") or "") + if state not in {"deferred", "running", "empty", "stale"}: + return False + surface = str(action_info.get("surface") or "").strip().lower() + if surface == "cylinder": + return True + feature_guess = str(action_info.get("feature_guess") or "").strip().lower() + feature_type = str(action_info.get("feature_type") or "").strip() + cylindrical_markers = ( + "cylindrical_feature_side_face_count", + "diameter", + "radius", + "axis", + "axis_point", + "slot_chord_width_estimate", + "slot_sagitta_depth_estimate", + "existing_fillet_radius_estimate", + ) + if any(action_info.get(key) is not None and str(action_info.get(key)) != "" for key in cylindrical_markers): + return True + return any(token in feature_guess for token in ("hole", "groove", "round", "fillet", "boss")) or any( + token in feature_type for token in ("孔", "槽", "圆角", "倒圆", "凸台", "外圆") + ) + def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str: if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}: return "" @@ -4617,16 +4952,20 @@ class WindowStateMixin: return [] face_ids: set[int] = set() edge_ids: set[int] = set() + solid_ids: set[int] = set() if self.selected_kind in {"feature", "face"} and self.selected_face_id is not None: face_ids.update(self._scdm_selection_face_match_ids(int(self.selected_face_id))) if self.selected_kind == "edge" and self.selected_edge_id is not None: edge_ids.add(int(self.selected_edge_id)) - if not face_ids and not edge_ids: + if self.selected_kind == "solid" and self.selected_solid_id is not None: + solid_ids.add(int(self.selected_solid_id)) + if not face_ids and not edge_ids and not solid_ids: return [] return property_specs_from_scdm_cache( cache, selected_face_ids=face_ids, selected_edge_ids=edge_ids, + selected_solid_ids=solid_ids, execution_ready=getattr(self, "scdm_edit_runner_ready", False), ) @@ -4917,7 +5256,8 @@ class WindowStateMixin: ) -> list[dict[str, object]]: def no_editable_feature_dimensions_spec() -> dict[str, object]: reason = str( - action_info.get("freeform_face_blockers") + self._no_editable_feature_dimension_reason(action_info) + or action_info.get("freeform_face_blockers") or action_info.get("recognition_blockers") or action_info.get("local_face_deform_blocker") or action_info.get("feature_edit_actions") @@ -4925,6 +5265,9 @@ class WindowStateMixin: ).strip() if not reason: reason = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。" + existing_message = str(getattr(self, "scdm_selection_status_message", "") or "").strip() + if not existing_message or existing_message.startswith("SCDM 未在当前对象上识别到"): + self.scdm_selection_status_message = reason return { "key": "no_editable_feature_dimensions", "label": "可修改参数", @@ -4968,6 +5311,51 @@ class WindowStateMixin: rows = root_dimensions + related_rows return rows or [no_editable_feature_dimensions_spec()] + def _no_editable_feature_dimension_reason(self, action_info: dict[str, object]) -> str: + feature_type = str(action_info.get("feature_type") or "").strip() + feature_guess = str(action_info.get("feature_guess") or "").strip() + surface = str(action_info.get("surface") or "").strip() + slot_status = str(action_info.get("slot_status") or "").strip() + slot_blockers = str(action_info.get("slot_blockers") or action_info.get("recognition_blockers") or "").strip() + slotish = bool( + "槽" in feature_type + or bool(action_info.get("blind_split_cylindrical_groove")) + or bool(action_info.get("blind_split_cylindrical_pocket")) + or ( + surface == "cylinder" + and feature_guess == "hole/groove candidate" + and not _is_effectively_full_cylinder(action_info) + ) + ) + if not slotish: + return "" + if slot_status == "blocked": + return slot_blockers or "当前槽属于复杂槽/多槽组,暂时不能稳定修改。" + if slot_status != "candidate": + state = str(getattr(self, "scdm_feature_cache_state", "") or "").strip() + if state in {"deferred", "running", "empty", "stale"}: + return ( + "当前对象只识别为圆柱孔/槽候选,还没有确认成可编辑的简单槽/长圆槽;" + "需要等 SCDM 识别或本地计划拿到稳定的槽宽、槽深、槽底面/方向或成对端面证据后,才会显示槽参数。" + ) + return ( + "当前对象只识别为圆柱孔/槽候选,SCDM/cache 没有返回可执行的槽宽、槽深或槽位置能力;" + "软件进度里的槽编辑只针对证据完整的简单槽/长圆槽。" + ) + missing: list[str] = [] + if _float_or_none(action_info.get("slot_chord_width_estimate")) is None: + missing.append("槽宽") + if _float_or_none(action_info.get("slot_sagitta_depth_estimate")) is None: + missing.append("槽深") + if not _int_values(action_info.get("feature_bottom_face_ids")) and not _int_values(action_info.get("feature_slot_face_ids")): + missing.append("槽底面/槽组") + if missing: + return ( + f"当前槽候选缺少可稳定执行的{'、'.join(missing)}证据;" + "为避免把复杂槽误改坏,暂不在参数表开放槽参数。" + ) + return "" + def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: items = self._ordered_info_items(info) if not self._is_feature_like_info(info): @@ -8673,6 +9061,8 @@ class WindowStateMixin: return item = queue.pop(0) self.property_batch_queue = queue + # 每改完一个参数都会重新加载/识别模型,原来的表格行可能已经失效; + # 所以下一个批量项必须用 key/能力信息重新定位当前行。 row = self._property_batch_row_for_item(item) if row is None: self._clear_property_batch_state() @@ -8895,6 +9285,8 @@ class WindowStateMixin: thread = QThread(self) def action() -> dict[str, object]: + # Worker 线程只做外部 SCDM 调用和文件等待,不碰 Qt 控件和当前 StepModel。 + # 完成后的模型替换、弹窗、cache 刷新都在 queued slot 里回 UI 线程处理。 return run_scdm_edit_job( source_step, capability_key=capability_key, @@ -9018,7 +9410,26 @@ class WindowStateMixin: } self._end_edit_task(clear_preview=True) self.statusBar().showMessage("SCDM 修改完成,正在重新读取结果模型...") - self.load_step(output_step, background=True) + # 先把 result.step 加载到当前模型,再进入 _finish_pending_scdm_edit_reload。 + # 只有加载后的 OCCT B-Rep 和新 SCDM cache 都通过校验,才真正记录历史。 + loaded = False + if hasattr(self, "_load_step_sync"): + loaded = bool( + self._load_step_sync( + output_step, + deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35), + show_internal_edges=bool(self._show_same_domain_internal_edges()) if hasattr(self, "_show_same_domain_internal_edges") else False, + status_prefix="读取 SCDM 修改结果", + defer_edges=True, + reset_camera=False, + ) + ) + else: + self.load_step(output_step, background=False) + loaded = True + if not loaded and isinstance(getattr(self, "pending_scdm_edit_reload", None), dict): + self.pending_scdm_edit_reload = None + self._after_property_edit_finished(success=False) @Slot(str) def _fail_scdm_edit_action(self, message: str) -> None: @@ -9052,6 +9463,7 @@ class WindowStateMixin: return if isinstance(before_cache, dict) and isinstance(after_cache, dict): signature = pending.get("object_signature") + # 结果模型已经显示出来了,但还没承诺成功;验证失败会立即恢复修改前快照。 validation = validate_scdm_edit_result( pending.get("edit_result") if isinstance(pending.get("edit_result"), dict) else {}, before_signature=signature if isinstance(signature, dict) else None,