feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距

接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
This commit is contained in:
2026-08-19 18:02:47 +08:00
parent a3eb7e2476
commit 4e7877e05c
21 changed files with 5646 additions and 491 deletions
+197 -327
View File
@@ -10,96 +10,104 @@
- 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step` - 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
- 后续过渡到 `SCDM-first` 后端:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚结果映射。 - 当前统一走 `SCDM-first` 主线:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚结果映射、参数导出和关系式管理
- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点 - 当前开发顺序按一条路线闭环:SCDM 识别与缓存 -> 能力字典和参数表 -> 参数化建模任务 -> 结果校验和 ID 续接 -> 按 Face、孔、槽、凸台、圆角/倒角、阵列、壳体逐项适配;本地 OCCT / Analysis Situs 只作为兜底和对照
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
## 软件整体架构 ## 软件整体架构
当前项目大致是这样分层的 当前项目大致是这样分层的。行数按当前 Git 跟踪的可读源码/脚本统计,排除 README、STEP 模型、图标、`.git``local``third_party` 和临时文件。
```text ```text
python-occt python-occt(源码/脚本 84,740 行)
├── main.py ├── main.py7 行)
│ └── 程序入口,支持普通启动、烟测、隔离子进程 worker 入口 │ └── 程序入口,支持普通启动、烟测、隔离子进程 worker 入口
├── step_editor/ ├── step_editor/62,303 行)
│ ├── app.py │ ├── app.py1,976 行)
│ │ └── 主窗口初始化、左侧操作面板、按钮和布局 │ │ └── 主窗口初始化、左侧操作面板、按钮和布局
│ │ │ │
│ ├── window_core.py │ ├── window_core.py4,171 行)
│ │ └── STEP 加载、VTK 视图、模型显示、拾取、高亮、坐标轴、FPS、显示刷新 │ │ └── STEP 加载、VTK 视图、模型显示、拾取、高亮、坐标轴、FPS、显示刷新
│ │ │ │
│ ├── window_actions.py │ ├── window_actions.py9,677 行)
│ │ └── 导出、参数化建模动作、后台任务、隔离子进程、操作历史 │ │ └── 导出、参数化建模动作、后台任务、隔离子进程、操作历史
│ │ │ │
│ ├── window_state.py │ ├── window_state.py10,169 行)
│ │ └── 选择状态、参数表状态、按钮启用/禁用、撤销/重做、历史定位 │ │ └── 选择状态、参数表状态、按钮启用/禁用、撤销/重做、历史定位
│ │ │ │
│ ├── model.py │ ├── model.py5,713 行)
│ │ └── StepModel 核心对象,保存 shape、Face、Edge、Solid、拓扑缓存和 mixin 组合 │ │ └── StepModel 核心对象,保存 shape、Face、Edge、Solid、拓扑缓存和 mixin 组合
│ │ │ │
│ ├── features.py │ ├── features.py3,591 行)
│ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面 │ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面
│ │ │ │
│ ├── recognition_graph.py │ ├── recognition_graph.py539 行)
│ │ └── 内部 Face 几何图和通孔拆面识别缓存,记录平面/圆柱、邻接、共面、平行、垂直、同轴等基础关系 │ │ └── 内部 Face 几何图和通孔拆面识别缓存,记录平面/圆柱、邻接、共面、平行、垂直、同轴等基础关系
│ │ │ │
│ ├── asitus_bridge.py │ ├── asitus_bridge.py308 行)
│ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号 │ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号
│ │ │ │
│ ├── scdm_backend.py │ ├── scdm_backend.py623 行)
│ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测 │ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测
│ │ │ │
│ ├── scdm_probe.py │ ├── scdm_probe.py680 行)
│ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定 │ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定
│ │ │ │
│ ├── scdm_edit_runner.py │ ├── scdm_edit_runner.py1,145 行)
│ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json │ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json
│ │ │ │
│ ├── scdm_result_validator.py │ ├── scdm_result_validator.py770 行)
│ │ └── SCDM 结果校验和 ID 续接工具,负责输出文件检查、目标值回测、旧对象到新对象的唯一匹配和关系式 ID 重写 │ │ └── SCDM 结果校验和 ID 续接工具,负责输出文件检查、目标值回测、旧对象到新对象的唯一匹配和关系式 ID 重写
│ │ │ │
│ ├── scdm_schema.py / scdm_capabilities.py / scdm_feature_mapper.py / scdm_property_specs.py │ ├── scdm_schema.py / scdm_capabilities.py / scdm_feature_mapper.py / scdm_property_specs.py1,821 行)
│ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI │ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI
│ │ │ │
│ ├── operations.py │ ├── operations.py14,604 行)
│ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角 │ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角
│ │ │ │
│ ├── geometry_utils.py │ ├── geometry_utils.py1,427 行)
│ │ └── 通用 OCCT 几何工具、布尔结果处理、B-Rep 校验、拓扑辅助 │ │ └── 通用 OCCT 几何工具、布尔结果处理、B-Rep 校验、拓扑辅助
│ │ │ │
│ ├── polydata.py │ ├── polydata.py425 行)
│ │ └── 把 OCCT Shape / Face / Edge 转成 VTK polydata,用于显示和拾取 │ │ └── 把 OCCT Shape / Face / Edge 转成 VTK polydata,用于显示和拾取
│ │ │ │
│ ├── step_io.py │ ├── step_io.py160 行)
│ │ └── STEP/XCAF 读取、STEP 导出、内部 BREP 临时交换 │ │ └── STEP/XCAF 读取、STEP 导出、内部 BREP 临时交换
│ │ │ │
│ ├── export.py │ ├── export.py179 行)
│ │ └── 导出完整模型、零件、Solid、Face、Edge,以及质量检查 │ │ └── 导出完整模型、零件、Solid、Face、Edge,以及质量检查
│ │ │ │
│ ├── isolated_edit_worker.py │ ├── isolated_edit_worker.py238 行)
│ │ └── 高风险几何编辑的隔离子进程入口 │ │ └── 高风险几何编辑的隔离子进程入口
│ │ │ │
│ ├── workers.py │ ├── workers.py51 行)
│ │ └── Qt 后台任务 worker,避免主界面被计算堵死 │ │ └── Qt 后台任务 worker,避免主界面被计算堵死
│ │ │ │
│ ├── ui_helpers.py / widgets.py / info_panel.py │ ├── ui_helpers.py / widgets.py / info_panel.py1,682 行)
│ │ └── UI 辅助函数、自定义控件、属性信息显示 │ │ └── UI 辅助函数、自定义控件、属性信息显示
│ │ │ │
│ └── recognition_priority.py │ └── recognition_priority.py238 行)
│ └── 特征识别优先级,让常用、稳定、语义清楚的特征排前面 │ └── 特征识别优先级,让常用、稳定、语义清楚的特征排前面
├── scripts/ ├── scripts/21,988 行)
│ └── 各类专项验证脚本和一级编辑总回归入口 │ └── 各类专项验证脚本和一级编辑总回归入口
├── tools/asitus_probe/386 行)
│ └── Analysis Situs probe 的 C++ 源码和 CMake 配置
├── environment.yml / .gitignore56 行)
│ └── Conda 环境和 Git 忽略规则
└── assets/models/ └── assets/models/
└── 仓库自带测试 STEP 模型 └── 仓库自带测试 STEP 模型
``` ```
## SCDM 后端过渡路线 ## SCDM-first 主路线
本阶段目标:把 SpaceClaim/SCDM 接成优先几何后端。SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责显示、选择、能力映射、任务生成、异步执行、结果校验、回滚、ID 续接和参数导出。SCDM 不接管本软件 UI,也不把 SCDM 原始界面文字直接展示给客户 本阶段开始,README 里过去分散写的 SCDM 后端、开源辅助识别、参数化编辑和关系式内容统一收敛成一条 SCDM-first 路线,不再并行推进多套方案
统一主线是:SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责显示、选择、能力映射、参数表、关系式、任务生成、异步执行、结果校验、回滚、ID 续接和参数导出。Analysis Situs 和本地 OCCT 识别不再作为主路线和 SCDM 抢“谁说了算”,只作为兜底、对照验证和轻量几何关系辅助。SCDM 不接管本软件 UI,也不把 SCDM 原始界面文字直接展示给客户。
交付判断不是“调用了 SCDM”,而是下面这条闭环能稳定跑通: 交付判断不是“调用了 SCDM”,而是下面这条闭环能稳定跑通:
@@ -107,103 +115,153 @@ python-occt
导入 STEP 导入 STEP
-> 自动找到可用的 SpaceClaim.exe,并缓存路径、来源、版本和验证结果 -> 自动找到可用的 SpaceClaim.exe,并缓存路径、来源、版本和验证结果
-> 本软件显示模型并建立当前 Face / Edge / Solid 基础索引 -> 本软件显示模型并建立当前 Face / Edge / Solid 基础索引
-> 生成 scdm_probe_job.json,说明要让 SCDM 扫描哪个 STEP、输出到哪里、使用哪个适配器 -> 本软件把“请识别这个 STEP”的任务写给 SCDM,并用 /RunScript 启动 SpaceClaim
-> SCDM probe 打开 STEP读取结构化几何对象、可执行命令候选、失败/限制原因 -> SCDM 在后台打开 STEP识别可编辑对象、参数、命令候选和限制原因,并把结果写回给本软件
-> 写出 scdm_raw_features.json,保留 SCDM 原始结构化结果,不依赖 UI 显示名称 -> 本软件把 SCDM 结果翻译成能力字典和中文参数,并缓存起来,后续点击对象直接查缓存
-> 本软件把 raw 结果映射到能力字典,生成 scdm_feature_cache.json -> 用户点击对象时,参数表只显示已经产品化、可执行、可校验的参数
-> 用户点击对象时,参数表只显示 cache 中已产品化、可执行、可校验的参数 -> 用户修改目标值后,本软件把“改哪个对象、改成多少、失败怎么回滚”的修改任务写给 SCDM
-> 用户修改目标值后,生成 scdm_edit_job.json 和对应 SCDM 修改脚本 -> SpaceClaim.exe /RunScript 在后台执行修改,成功写出新的 STEP,失败写出错误原因
-> SpaceClaim.exe /RunScript 在后台隔离执行,成功输出 result.step,失败输出 error.json -> 本软件用 OCCT 重新读取新的 STEP,做 B-Rep 校验、目标值回测和非预期变形检查
-> 本软件用 OCCT 重新读 result.step,做 B-Rep 校验、目标值回测和非预期变形检查 -> 校验通过后替换当前模型,重新让 SCDM 识别并刷新缓存
-> 校验通过后替换当前模型,重新跑 probe 刷新 scdm_feature_cache.json
-> 用新缓存更新 Face / Edge / 特征 ID 映射和关系式引用 -> 用新缓存更新 Face / Edge / 特征 ID 映射和关系式引用
``` ```
核心实现拆分如下: ### 本软件与 SCDM 如何交互
本软件不是把 SCDM 界面嵌进来,也不是让客户写脚本。客户看到的仍然是本软件:导入 STEP、点击 Face/Feature、修改“偏移、孔径、位置、阵列间距”等中文参数,然后点击“参数化建模”。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、要识别或修改哪个对象、目标值是多少、结果写到哪里。
```text ```text
SCDM-first 实施路线 识别阶段:
├── S0. 后端发现与缓存 本软件写识别任务 JSON + 识别脚本 scdm_probe.py
│ ├── [x] 新建 step_editor/scdm_backend.py -> 用 SpaceClaim.exe /RunScript=scdm_probe.py 启动 SCDM
│ ├── [x] 读取 local/scdm_backend.json,缓存可用时直接复用 SpaceClaim.exe -> SCDM 打开 STEP,并把“识别到什么、哪些参数可改、有哪些限制”写回 JSON
│ ├── [x] 缓存失效时自动发现:Windows 注册表 -> 环境变量 -> ANSYS 常见安装目录 -> PATH -> 本软件读取结果,翻译成中文能力和参数表,并缓存起来
│ ├── [x] 自动发现失败时,后端返回需要用户配置;导入模型后的 SCDM probe 会自动弹出路径选择框,用户选择 SpaceClaim.exe 后写回缓存
│ ├── [x] 找到后可运行最小 /RunScript 烟测,确认能启动、能写报告、许可证可用 修改阶段:
│ ├── [x] 写回 local/scdm_backend.jsonpath、source、version、verifiedAt、runScriptOk、licenseOk 本软件写修改任务 JSON + 修改脚本 scdm_edit.py
│ └── [x] 验收:scripts/verify_scdm_backend.py 覆盖缓存、发现、禁用开关和 /RunScript 命令;SCDM 不可用时主程序仍可走 OCCT 降级模式 -> 用 SpaceClaim.exe /RunScript=scdm_edit.py 启动 SCDM
-> SCDM 按任务修改模型,并输出新的 STEP 或失败原因
-> 本软件读取新 STEP,校验通过才替换当前模型;失败就回滚
```
这些中转文件都放在项目的 `local/scdm/` 工作目录里,只用于本软件和 SCDM 后台沟通、调试和失败回放;清掉后可以重新生成,不提交到 Git。以 `ICEPAK-NATURAL.stp` 为例,当前本机路径类似:
```text
local/scdm/ICEPAK-NATURAL_19ff9d1ede49/
├── scdm_probe.py # 本软件生成,喂给 SCDM 做识别
├── scdm_probe_job.json # 本软件生成,告诉识别脚本要打开哪个 STEP、结果写哪
├── 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 的后台通信。
这里说的“SCDM 能做”,不是指 SCDM 界面里人工能点的所有按钮,而是指本软件能通过脚本拿到下面四类证据:
1. SCDM 返回了结构化对象或可定位几何,比如 Face、Hole、Slot、Boss、Round、Chamfer、Pattern、Shell。
2. SCDM 返回或脚本环境确认了可用命令,比如 `OffsetFaces``Move``Fill``Delete``Chamfer``ConstantRound`
3. 当前对象有可用的当前值或定位信息,比如直径、位置、半径、Face locator、实例中心。
4. 修改后能用新 STEP、新 cache、目标值回测或“目标特征消失”证明结果是对的。
不满足这四点的内容,只能进入诊断、日志或“待适配能力”,不能直接展示成客户可修改参数。
SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM。本软件不再把手写一级、二级、三级传播当成新增能力主线,只负责把 SCDM 能力适配成可见参数、可执行任务和可校验结果;SCDM 做不到或当前脚本拿不到证据的能力,先不开放。
状态标记:
- `[x]` 已适配:已有能力字典、参数表/命令入口、edit job、runner 或 UI gate、结果校验和回归测试。
- `[~]` 部分适配:已有识别、cache、脚本入口或假 runner,但真实 STEP 样例、命令细节或结果守门还要继续补。
- `[ ]` 待适配:SCDM 可能能做,但本软件还没有完成结构化识别、命令脚本和结果校验闭环。
核心路线只保留下面这一棵树。前半段是 SCDM 后端闭环,后半段是按客户高频和 SCDM 可验证能力开放参数:
```text
SCDM-first 主路线
├── 0. 后端发现与可用性
│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]
│ ├── [x] [发现顺序 -> 缓存、注册表、环境变量、ANSYS 常见目录、PATH]
│ ├── [x] [找不到 -> 导入模型后弹出路径选择,用户选中后写回缓存]
│ └── [x] [SCDM 不可用 -> 软件仍可查看、导出并使用本地兜底能力]
├── S1. Probe 任务与原始识别输出 ├── 1. SCDM 识别与缓存
│ ├── [x] 新建 step_editor/scdm_probe.py,负责生成 scdm_probe_job.json 和临时 SCDM probe 脚本 │ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]
│ ├── [x] Probe 输入:STEP 路径、单位、模型指纹、输出目录、SCDM 版本、扫描范围 │ ├── [x] [scdm_raw_features.json -> 保存 SCDM 结构化对象、几何属性、命令候选和限制原因]
│ ├── [~] Probe 动作:当前脚本已防御式遍历 Body / Face / EdgeEdge raw 已输出长度、起终点、圆弧半径和相邻 Face 摘要,并会先尝试 `StandardHoles.Find` / `GetHoleFaces`,失败或为空再回退到圆柱碎片聚合;圆柱面会尝试 `RoundInfo.Create` 生成圆角诊断摘要,Chamfer / Fill 的深度 API 识别仍待继续补 │ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]
│ ├── [x] Probe 输出:scdm_raw_features.json,包含 geometry、topologyHint、backendCommandCandidates、rawLimitations、availableCommands、Face 邻接、Edge 几何摘要和 featureInventory │ ├── [x] [cache 命中 -> 点击对象只查缓存,不重复启动 SCDM]
│ ├── [x] 不依赖 SCDM UI 文案;只记录 API 类型、几何属性、命令对象、参数字段和返回状态 │ ├── [~] [probe 深度 -> Face / Edge / Body 已稳定遍历,Hole / Round API 真实样例继续补]
│ └── [~] 验收:scripts/verify_scdm_probe_pipeline.py 已覆盖 job/script/raw->cache 假数据闭环、`StandardHoles.Find`、`RoundInfo` 脚本入口和 availableCommands 映射;本机 SCDM v222 已验证 ICEPAK 可打开并遍历 13 Body / 158 Face / 396 Edge,且 396 条 Edge 均可回传长度、起终点和相邻 Face 数,脚本环境可见 `StandardHoles` / `OffsetFaces` / `Move` / `Fill` / `Delete` / `Chamfer` / `ConstantRound` / `RoundInfo`;该样例的标准孔和圆角 API 识别结果均为 0,仍需继续拿更多样例适配 │ └── [x] [不能产品化的 raw 结果 -> 只进诊断,不进客户参数表]
├── S2. 统一 Schema 与能力字典 ├── 2. 能力字典、参数表和用户入口
│ ├── [x] 新建 step_editor/scdm_schema.py,定义 raw feature、normalized feature、capability、edit job 的字段 │ ├── [x] [capability key -> 中文名、单位、输入类型、建模意图、后端命令和 postCheck]
│ ├── [x] 新建 step_editor/scdm_capabilities.py,定义产品能力 key、中文名、单位、输入类型、默认建模意图、SCDM 命令、所需后端命令组和校验器 │ ├── [x] [参数表 -> 只显示已产品化、可执行、可校验的参数]
│ ├── [x] 第一批能力:hole.diameter、hole.position、face.offset、feature.fill │ ├── [x] [不可改 -> UI 阶段直接说明是未实现、命令不可用、识别不完整还是风险过高]
│ ├── [x] S7 后续能力先以 productized=false 进入能力字典和诊断缓存,不直接显示成客户可执行参数 │ ├── [x] [参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]
│ ├── [x] 未进入能力字典的 SCDM 结果只进入诊断和日志,状态为 discovered_not_productized │ ├── [x] [导出参数 -> 勾选输入参数后生成组件目录 main.py 和参数 schema]
── [x] 每个能力都必须声明 postCheck,例如目标孔径、目标轴心、目标偏移或目标特征消失 ── [~] [关系式 -> 作为参数表上游输入,计算后回填目标值,已阻止自引用/重复目标/循环依赖]
│ └── [x] 验收:假 raw JSON 经过映射后,只产出已定义能力;未定义能力不会出现在产品化 cache
├── S3. SCDM 缓存生成与失效策略 ├── 3. SCDM 参数化建模执行
│ ├── [x] 新建 step_editor/scdm_feature_mapper.py,把 scdm_raw_features.json 映射为 scdm_feature_cache.json │ ├── [x] [scdm_edit_job.json -> 记录模型、对象签名、能力、目标值、输出路径和超时]
│ ├── [x] cache 记录:modelFingerprint、backendVersion、objects、capabilities、geometrySignature、blockReason │ ├── [x] [/RunScript 后台执行 -> 成功写 result.step/result.json,失败写 error.json]
│ ├── [x] SCDM raw 没有 Python Face ID 时,用本软件当前 StepModel 的轻量几何签名补回 faceIds,支持米/mm 单位比例匹配 │ ├── [x] [face.offset / hole.diameter / hole.position -> ICEPAK 真实 SCDM 回测通过]
│ ├── [x] cache 会额外保留 geometry_candidate_hints:当 SCDM 只给出平面/圆柱面/圆边/直边这些底层证据,而没有明确 slot/boss/round 对象时,先把 S7 候选记为“几何证据待分类”,不进入客户参数表 │ ├── [~] [slot.width / slot.depth / slot.position / boss.diameter / boss.height / boss.position -> runner、UI gate 和目标校验已接,真实样例待补]
── [x] 导入模型、重新加载、SCDM 参数化修改成功、撤销和重做后都会标记 cache 失效并后台刷新 ── [~] [round.radius / chamfer.distance / feature.fill / feature.delete_round_or_chamfer -> 脚本和假 runner 已接,真实样例待补]
│ ├── [x] cache 可用时点击对象只查窗口/model 上的 scdm_feature_cache,不重新启动 SCDM
│ ├── [x] cache 正在生成时,UI 状态栏显示“正在后台识别 SCDM 可修改参数”,不阻塞旋转查看模型
│ └── [~] 验收:raw->cache 映射已由 scripts/verify_scdm_probe_pipeline.py 覆盖;window_core.py 已接后台预热,真实 SCDM 运行耗时和失败提示待样例机验证
├── S4. 参数表接入 ├── 4. 结果校验、回滚和 ID 续接
│ ├── [x] 修改 window_state.py 的参数表数据源:存在 SCDM cache 时优先转成参数表 spec,现有 OCCT/Analysis Situs 能力作为兜底;对大模型多内孔平面位置调整这类本地已验证快路径,参数表会优先走 OCCT 专用一级边界重建,避免 SCDM 直接建模长时间计算 │ ├── [x] [OCCT 回读 result.step -> 检查输出文件、B-Rep 和基础模型规模]
│ ├── [x] 选中对象后,用当前 Face / Edge 索引、逻辑 ID、整孔 Face 组和 SCDM->Python faceIds 映射匹配 cache 中的 geometrySignature │ ├── [~] [目标回测 -> 已覆盖数值目标、位置目标、目标特征消失和未编辑对象漂移]
│ ├── [~] 匹配成功且 capability 已接入执行器时展示能力字典里的中文参数、当前值、建模意图、目标值和输入参数勾选框;参数表只展示 enabled 能力,未开放能力只进状态提示/诊断;当前按能力开放 `face.offset`、`hole.diameter`、`hole.position`、`slot.position`、`boss.position` │ ├── [~] [ID 续接 -> 简单唯一匹配已接,歧义/拆分/合并样例继续补]
│ ├── [x] 匹配失败或能力未开放时给出短原因:后台识别中、SCDM 未识别、能力未产品化、脚本命令不可用、后端失败;当前状态栏和下方诊断信息都会显示 SCDM 后端状态、当前选择状态、可执行能力和未开放原因 │ ├── [~] [关系式刷新 -> 修改后按新 cache 重写 Face / Edge / 特征 ID]
── [x] 保留现有关系式、参数导出和统一“参数化建模”按钮,不新增一套 SCDM 专用 UI;数值型 SCDM 能力改目标值后执行,命令型能力在选中该行后由同一个按钮执行 ── [x] [失败 -> 回滚原模型并给出明确原因]
│ └── [~] 验收:scripts/verify_scdm_probe_pipeline.py 已验证 Face / Hole 参数 spec 转换、能力级启用开关和圆柱碎片组到本地 Face ID 的回填;scripts/verify_property_card_editor_ui.py 已覆盖 SCDM 状态摘要和选择诊断同步;真实 UI 长流程回归待继续
├── S5. 修改任务执行闭环 ├── 5. 当前已开放或正在开放的 SCDM 能力
│ ├── [x] 新建 step_editor/scdm_edit_runner.py,生成 scdm_edit_job.json 和临时 SCDM 修改脚本 │ ├── [x] [Face 偏移 -> face.offset / OffsetFaces]
│ ├── [x] edit job 记录:模型路径、对象签名、capabilityKey、目标值、输出 STEP、超时、回滚文件 │ ├── [x] [孔直径 -> hole.diameter / StandardHoles 或同轴 OffsetFaces]
│ ├── [~] 第一批执行:face.offset、孔径、孔位置已在本机 SCDM v222 + ICEPAK 样例真机验证;S7.2 的槽位置和 S7.3 的凸台位置已接入 `Move` 脚本、edit job、cache 映射和 UI gate,真实槽/凸台样例回测待补;命令型能力的统一按钮承载已补,填孔/删除小特征已补 `Fill.Execute` / `Delete.Execute` 脚本适配但仍待真实 STEP 验证后再开放 │ ├── [x] [孔位置 -> hole.position / Move]
│ ├── [~] SpaceClaim.exe /RunScript 已接入 UI 后台任务,成功后加载 result.step,重新触发 SCDM probe,并在 cache 刷新后再通知关系式/批量执行继续;校验通过后会写入撤销历史,撤销/重做会触发 SCDM cache 失效和后台刷新 │ ├── [x] [填孔/删除小特征 -> feature.fill / Fill 或 Delete]
│ ├── [x] 成功写 result.step / result.json;失败写 error.json,包含 SCDM 命令、对象、参数和失败原因 │ ├── [~] [槽宽 -> slot.width / OffsetFaces,真实 STEP 回测待补]
── [~] 验收:scripts/verify_scdm_edit_runner.py 已覆盖 job/script/result/error、缺失输出、空 STEP、`Fill.Execute` / `Delete.Execute` 脚本路径和假 runner 闭环;真实 SCDM 已验证 `OffsetFaces.Execute`、`Move.Translate`、`DocumentSave.Execute`UI 撤销历史和撤销/重做 cache 失效已接入,真实长流程回归待继续 ── [~] [槽深 -> slot.depth / Move 或 OffsetFaces,只开放 SCDM 返回槽深、槽底面和深度方向证据的槽]
│ ├── [x] [槽位置 -> slot.position / Move]
│ ├── [~] [凸台直径 -> boss.diameter / OffsetFaces,真实 STEP 回测待补]
│ ├── [~] [凸台高度 -> boss.height / Move 或 OffsetFaces,真实 STEP 回测待补]
│ ├── [x] [凸台位置 -> boss.position / Move]
│ ├── [~] [圆角半径 -> round.radius / ConstantRound,只开放 SCDM 明确返回等半径证据的圆角]
│ ├── [~] [倒角距离 -> 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 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心]
├── S6. 结果校验、ID 续接和关系式刷新 ├── 6. 下一批只按 SCDM 能力适配
│ ├── [~] 用 OCCT 读取 result.step,校验 B-Rep 有效性、Solid 数量、目标参数、关键特征是否仍存在;当前 UI 已拒绝缺失/空输出 STEP,并在重新 probe 后调用 OCCT B-Rep 检查、目标值回测、未编辑对象漂移检查和 raw summary 全局规模漂移检查,圆角链/孔面数量等更专门几何质量规则待继续 │ ├── [ ] [阵列实例位置 -> pattern.instance_position]
── [x] 修改前后保存 geometrySignature,用新 cache 做旧对象到新对象的唯一匹配 ── [~] [壳体厚度 -> shell.thickness,薄壁候选配对已进 cache,真实命令和 STEP 回测待补]
│ ├── [~] 唯一匹配成功时更新 Face / Edge / 特征 ID,并同步更新关系式文本;SCDM 修改后的 cache 刷新阶段已接入关系式 ID 重写,歧义场景仍需更多真实样例回归
│ ├── [x] 找不到或匹配多个时,返回 none / multiple 状态,供 UI 将关系式标记为失效并提示用户重新选择对象
│ ├── [~] 对 SCDM 返回成功但几何变形的结果必须回滚;当前已能在 cache 层拦截其它已识别对象丢失、漂移、歧义匹配,以及 Face/Edge/Object 总量异常大幅变化,并由 UI 恢复编辑前快照;圆角/孔面数量等更专门质量规则待补
│ └── [~] 验收:scripts/verify_scdm_result_validator.py 已覆盖输出文件、目标值、raw summary 全局规模漂移、未编辑对象漂移、ID 映射、关系式重写和歧义匹配;ICEPAK 真实 SCDM 已回读验证 face.offset、孔径和孔位置输出 STEP
── S7. 能力扩展顺序 ── 7. 交付与兜底边界
├── [~] 第二批:slot.position 已接入能力字典、参数表、`move_slot` 执行脚本和 fake runner 回归,真实槽 STEP 回测待补;slot.width、slot.depth 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补 ├── [x] [软件进度 -> 只展示能识别、不能识别、能修改、不能修改]
├── [~] 第三批:boss.position 已接入能力字典、参数表、`move_boss` 执行脚本和 fake runner 回归,真实凸台 STEP 回测待补;boss.height、boss.diameter 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补 ├── [x] [日志 -> 记录本次使用 SCDM / OCCT / Analysis Situs 哪个后端]
├── [~] 第四批:round.radius、chamfer.distance、feature.delete_round_or_chamfer 已进入能力字典和 planned_not_productized 诊断;probe 已补 `RoundInfo` 摘要入口,真实 SCDM 命令和样例回测待补 ├── [x] [Analysis Situs -> 只做辅助定位、兜底识别和开源对照]
├── [~] 第五批:pattern.spacing、pattern.instance_position、shell.thickness 已进入能力字典和 planned_not_productized 诊断;重复圆柱孔中心已能派生 linear_pattern 候选并写入 derived_feature_candidates,真实 SCDM 命令和样例回测待补 ├── [x] [本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]
├── [x] 内部能力进度报告:`scdm_status.summarize_scdm_capability_progress` 会统计能力字典、执行器开放名单、当前 cache 已识别能力、后端命令阻止项、planned_not_productized、discovered_not_productized、geometry_candidate_hints、derived_feature_candidates,以及 SCDM probe 的对象/曲面/命令候选分布,供开发诊断和内部日志解释当前进度;客户 UI 只展示能识别/不能识别、能修改/不能修改 ├── [ ] [商业 SCDM -> 不打包进本软件,只检测客户本机安装和许可证]
└── [~] 每新增一个 capability,都必须同时补 probe 映射、edit runner、postCheck、UI 假 cache 测试和真实 STEP 样例;当前已补能力字典、probe 映射、Face 邻接、Edge raw 几何摘要、featureInventory、geometry_candidate_hints、假 cache 测试和能力进度报告,slot.position、boss.position 已补 edit runner 协议,其它 S7 能力的 edit runner 与真实 STEP 样例待补 └── [ ] [SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]
└── S8. 打包与交付策略
├── [ ] 不把商业 SCDM 打包进本软件,只检测客户本机安装和许可证
├── [~] 打包产物带默认发现逻辑、自动路径选择和 SCDM 不可用说明;当前“软件进度”已改为紧凑按钮,点击后弹出 Markdown 风格能力清单,按“已能修改、已能识别、暂不能修改、暂不能稳定识别”组织;SCDM 找不到时再自动弹出 SpaceClaim.exe 路径选择
├── [~] SCDM-only 能力在后端命令不可用时禁用并显示原因;当前已根据 availableCommands 阻止缺少 `OffsetFaces` / `Move` / `Fill` / `Delete` 等命令的能力,隐藏策略待打包态细化
├── [x] 日志保留本次使用的后端:SCDM / OCCT / Analysis Situs,方便给客户解释结果来源;当前 SCDM probe/edit job、result.json、SCDM 操作历史、普通 OCCT 操作历史和左侧状态摘要都会记录后端来源,普通 OCCT 历史会额外写 execution=Qt worker/isolated subprocess,以及 recognition=internal StepModel 或 Analysis Situs + internal StepModel
└── [ ] 验收:无 SCDM 机器可启动和查看 STEP;有 SCDM 机器可完成第一批 SCDM 修改闭环
``` ```
短期开发只做第一批能力,不追求把 SCDM 识别到的所有候选都立即开放。`scdm_raw_features.json` 保留 SCDM 发现的全部结构化候选,供后续扩展;`scdm_feature_cache.json` 只保存已经映射到能力字典、能执行、能校验、能解释失败原因的产品化能力。`geometry_candidate_hints` 只表示“有几何证据值得继续分类”,不能当作可编辑参数展示给客户。`软件进度` 弹窗只展示客户关心的能力边界:哪些能识别、哪些不能稳定识别、哪些能修改、哪些暂不能修改;cache 数量、probe 证据、Runner 开放数等后台细节留在日志和开发诊断里。这样既能利用 SCDM 的专业识别和直接建模能力,也不会把未验证的内部候选暴露给客户。 短期开发只做第一批能力,不追求把 SCDM 识别到的所有候选都立即开放。`scdm_raw_features.json` 保留 SCDM 发现的全部结构化候选,供后续扩展;`scdm_feature_cache.json` 只保存已经映射到能力字典、能执行、能校验、能解释失败原因的产品化能力。`geometry_candidate_hints` 只表示“有几何证据值得继续分类”,不能当作可编辑参数展示给客户。`软件进度` 弹窗只展示客户关心的能力边界:哪些能识别、哪些不能稳定识别、哪些能修改、哪些暂不能修改;cache 数量、probe 证据、Runner 开放数等后台细节留在日志和开发诊断里。这样既能利用 SCDM 的专业识别和直接建模能力,也不会把未验证的内部候选暴露给客户。
## Analysis Situs 接入状态 ### Analysis Situs 辅助定位(非主线后端)
一句话状态:还没有全量接完;当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”这一步,能把 Analysis Situs 识别出的拆面圆柱孔组用于整孔高亮、参数表和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py``external_*` 关系证据;但还没有把它的完整 AAG/特征分析能力接成通用识别引擎 一句话状态:Analysis Situs 不再作为当前主识别路线继续接入;它保留为 SCDM-first 路线里的辅助定位、兜底识别和开源对照验证。当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”,能把拆面圆柱孔组用于整孔高亮、参数表兜底和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py``external_*` 关系证据。
当前已完成: 当前已完成:
@@ -225,19 +283,18 @@ SCDM-first 实施路线
powershell -ExecutionPolicy Bypass -File .\scripts\build_asitus_probe.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\build_asitus_probe.ps1
``` ```
当前未完成 当前不再作为主线推进的内容
- `[ ]` 还没有把 Analysis Situs 的完整 AAG 关系图作为主识别数据源;现在已经消费孔组、AAG 邻接/角度摘要和基础几何关系摘要,并用它们增强孔、槽、凸台、圆角候选的置信度和排序,但编辑计划的主判断仍以本项目现有 `StepModel` / `recognition_graph.py` 为准 - `[ ]` 不再把 Analysis Situs 的完整 AAG 关系图接成主识别数据源;主识别和主修改优先走 SCDMAnalysis Situs 只增强孔、槽、凸台、圆角候选的置信度和排序
- `[ ]` 还没有用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;槽、凸台和圆角当前只是辅助识别提示,不等同于外部特征引擎直接给出的可编辑特征 - `[ ]` 不再优先用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;这些高频能力按 SCDM capability 字典逐项产品化
- `[ ]` 还没有让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;目前它们只作为外部证据增强识别图和置信度,真正能不能改仍由现有 `StepModel``recognition_graph.py`、编辑计划和结果守门共同决定。 - `[ ]` 不再让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;它们只作为外部证据,真正能不能改由 SCDM cache、能力字典、编辑计划和结果守门共同决定。
- `[ ]` 还没有把 Analysis Situs CLI 编译产物纳入打包流程;本地没有 `recognize_holes.exe` 时,程序只走内部识别,不会报错退出。 - `[ ]` 还没有把 Analysis Situs CLI 编译产物纳入打包流程;本地没有 `recognize_holes.exe` 时,程序只走内部识别,不会报错退出。
- `[ ]` 还没有做 pybind11 直连;当前 Python 调外部 CLI,优点是隔离稳定,缺点是启动和 JSON 交换有额外开销 - `[ ]` 暂不做 pybind11 直连;当前 Python 调外部 CLI 的方式更适合作为兜底工具,避免把开源 C++ 分析库直接塞进主 GUI 进程
下一步接入顺序 后续只在两种情况下继续动 Analysis Situs
1. `tools/asitus_probe` 的构建步骤写入 Windows 打包流程,让交付包能自带可用的 `recognize_holes.exe` 或明确跳过外部识别 1. SCDM 不可用,需要无商业后端兜底识别孔组或轻量关系
2. 继续扩展倒角、壳体、阵列孔等候选的辅助识别提示,仍然只参与“识别和排序”,不直接绕过一级编辑守门 2. 需要开源结果和 SCDM probe 结果做对照,帮助判断 SCDM 识别是否漏掉明显几何关系
3. 最后再评估是否需要 pybind11 直连;在稳定性没证明之前,CLI 隔离比直接把 C++ 库塞进主进程更安全。
## 怎么运行 ## 怎么运行
@@ -294,7 +351,7 @@ python main.py assets\models\cube_10mm.step
python scripts\generate_cube_step.py python scripts\generate_cube_step.py
``` ```
10. 验证几何修改基线。当前推进和测试优先级按用户常用操作排序:先 Face 面编辑,再孔、槽、凸台、圆角/倒角、壳体和 Edge,最后再扩二级/三级关系。完整一级编辑回归可以用一个入口串起来 10. 验证几何修改基线。当前主线按 SCDM capability 适配推进;下面这个入口仍保留历史 OCCT/一级编辑回归,用来证明本地兜底能力、UI 和文档守门没有退化
```powershell ```powershell
python scripts\verify_first_level_edit_suites.py python scripts\verify_first_level_edit_suites.py
@@ -391,194 +448,6 @@ git diff --check
需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。
## 参数化编辑路线图
排序原则:`用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚`。这张路线图是后续实现顺序和验收拆分的主线;UI 可以只显示短版进度,但开发和测试按下面这棵树推进。
状态标记:
- `[x]` 已实现:已经接入 UI/后端/回归测试,适用于当前明确支持的模型范围。
- `[~]` 部分实现/进行中:已有识别、计划或部分模型编辑能力,但真实 STEP 上还需要继续补守门、补失败解释或补局部重建。
- `[ ]` 未实现:作为后续目标保留,当前不应在 UI 里伪装成可稳定修改。
验收口径:
- `[x]` 不是“所有 CAD 形态都能改”,而是“当前声明支持的模型范围已经有 UI/后端/回归测试闭环”。
- R1 Face 是当前主线的第一阶段收口对象;孔、槽、凸台、圆角、壳体和 Edge 已有各自一级基线,但仍按模型工程师给出的真实 STEP 失败项继续补边界。
- R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里,不能拿它反向判定 R1 Face 没完成。
```text
STEP/B-Rep 参数化编辑主线
├── 0. 先让用户知道“能不能改”
│ ├── [x] [选中对象 -> 识别建模形式]
│ │ └── Face / 孔 / 槽 / 凸台 / Edge / 圆角 / 倒角候选都要先映射成用户能理解的 CAD 入口。
│ ├── [x] [识别结果 -> 显示可修改项]
│ │ └── 特征参数表只放当前真正可执行的 [参数 -> 操作],面积这类结果变量放诊断信息。
│ ├── [x] [不能修改 -> 立即说明原因]
│ │ └── 所有常见特征入口的 blocked plan 会在执行前统一归类成非法输入 / 暂未实现 / 风险过高 / 识别不足,避免等几十秒后才失败。
│ ├── [x] [高风险修改 -> 后台隔离执行]
│ │ └── OCCT 布尔、圆角、局部重建等危险计算走子进程,失败回滚,主界面和原模型保持稳定。
│ └── [x] [路线图 -> 验收脚本守门]
│ └── quick 回归覆盖属性表、一级事实、关联探测、各阶段入口、README 口径一致性,并守住 0~7 不混入二级/三级传播任务。
├── 1. 平面 Face,第一条主线
│ ├── [x] [偏移 -> 推拉平面 / 拉伸切除]
│ │ └── 当前 Face 移动或加料/切削,边、点和共享边一级相邻面跟随重建,复杂端盖走隔离和回滚。
│ ├── [x] [偏移 + 保持关系 -> 受限拉伸/切除]
│ │ └── 偏移行新增“保持关系”建模意图:执行前要求一级相邻 Face 都能验证为平面平行/垂直关系,执行后反查关系;非平面、斜交或复杂多边界慢计划会提前阻止并有回归守门。
│ ├── [x] [面内长度 -> 改长度]
│ │ └── 近矩形平面 Face 沿长度方向变化,一级相邻面跟随,面积不作为驱动参数。
│ ├── [x] [面内宽度 -> 改宽度]
│ │ └── 近矩形平面 Face 沿宽度方向变化,一级相邻面跟随。
│ ├── [~] [中心位置 -> 后端保留,界面暂不开放]
│ │ └── 中心移动已有后端验证,但模型工程师判断当前客户价值不高,特征参数表暂不显示。
│ ├── [x] [壳体厚度 -> 改薄壁厚度]
│ │ └── 平面相对面可做局部拉伸/切除或厚度方向缩放,并做 Face 结果校验。
│ └── [x] [复杂多边界端盖 -> 边界侧壁重建]
│ └── 大 STEP 多内孔平面端盖可走 boundary-shell-rebuild,浅范围内外移动保持内孔线圈和单 Solid;接近切穿或需要孔底/槽底更深关系判断时快速阻止。
├── 2. 孔,第二条主线
│ ├── [x] [直径/半径 -> 改孔径]
│ │ └── 通孔/盲孔优先同轴重切孔壁,结果必须仍能识别目标圆柱孔。
│ ├── [x] [深度 -> 改盲孔深度]
│ │ └── 加深切削、变浅补料,目标底面和盲孔语义必须能回读。
│ ├── [x] [中心位置 -> 移动孔]
│ │ └── 先补旧孔再切新孔,移动的是整个孔特征,不是一片圆柱面。
│ ├── [x] [孔本体 -> 封堵/删除孔]
│ │ └── 孔壁消失,孔口补面有效,结果保持单 Solid。
│ ├── [x] [锥孔/沉孔 -> 局部重切或提前阻止]
│ │ └── 简单圆锥解析重建,嵌入式锥孔/沉孔优先局部重切;复杂浅锥、螺纹孔和组合孔提前解释为受限能力。
│ ├── [x] [孔编辑范围 -> 单个稳定孔特征]
│ │ └── 单孔、锥孔、沉孔的一级关系修改已经作为孔阶段基线。
│ ├── [~] [Ctrl 多选完整孔 -> 批量孔径 / 位置偏移]
│ │ └── 第一版支持多个完整圆柱孔统一改孔径或按同一偏移移动;等距、阵列、同尺寸联动关系式仍放到第 8 阶段。
│ └── [~] [关系式 -> FaceID.参数 = 表达式]
│ └── 第一版先把关系式作为“目标值生成器”:用户输入 `Face87.直径 = Face85.直径` 或 `Face87.位置 = Face85.位置 + (0, 0, -3.5)`,程序先计算并回填当前参数表目标值,再复用现有一级编辑执行;跨对象批量队列、等距/阵列/同尺寸联动和持久约束求解后续再扩展。
├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征
│ ├── [x] [槽宽 -> 改槽宽]
│ │ └── 两侧壁和圆弧端同步更新,结果仍能识别为槽/半孔。
│ ├── [x] [槽深 -> 改盲槽深度]
│ │ └── 槽底移动,槽口边界有效,不把母体切坏。
│ ├── [x] [矩形槽/口袋长宽/深度 -> 局部重建矩形切除]
│ │ └── 规则矩形槽/口袋可改长宽和深度;中心移动后端保留但参数表暂不开放。
│ ├── [x] [弧长/弧角/开口角 -> 改圆弧槽段]
│ │ └── 保持圆柱槽语义,重切后能回读目标弧长或角度。
│ ├── [x] [总长/中心距 -> 改长圆槽长度]
│ │ └── 自动或手动配对两个圆弧端,槽宽保持不变。
│ ├── [x] [中心位置 -> 移动槽]
│ │ └── 半圆槽和长圆槽都按特征整体补旧槽、切新槽。
│ ├── [x] [复杂/交叉槽 -> 识别并快速阻止]
│ │ └── 多个同半径槽端但没有稳定共享侧壁配对时,会标成复杂槽/多槽组,不在扫描列表和参数表里暴露槽宽、槽深、弧长、弧角或轴心入口。
│ └── [ ] [复杂草图槽/跨复杂面槽 -> 稳定修改]
│ └── 非圆柱槽、跨复杂面的槽暂不承诺稳定编辑;多槽组关联放到第 8 阶段。
├── 4. 凸台 / Boss,从切除特征扩展到加料特征
│ ├── [x] [直径/半径 -> 改圆柱凸台直径]
│ │ └── 移除旧凸台包络后重建,底部融合必须有效。
│ ├── [x] [高度 -> 改凸台高度]
│ │ └── 顶面移动,侧壁延伸或裁剪,目标高度可回读。
│ ├── [x] [中心位置 -> 移动圆柱凸台]
│ │ └── 旧凸台先移除,新凸台按目标轴心补回,不只移动侧壁。
│ ├── [x] [矩形凸台/口袋高度/深度 -> 端面推拉]
│ │ └── 规则矩形凸台顶面和规则矩形口袋底面可按高度/深度推拉,修改后会回读矩形拉伸尺寸。
│ ├── [x] [矩形凸台/口袋长度/宽度 -> 局部重建矩形包络]
│ │ └── 规则矩形凸台/口袋可先移除或补回旧包络,再按目标长宽重建;当前不在一次编辑中交换长宽方向。
│ ├── [~] [矩形凸台/口袋中心位置 -> 后端保留,界面暂不开放]
│ │ └── 中心移动后端仍有计划和回归覆盖,但客户参数表暂时只开放长宽、高度/深度这类更常用尺寸。
│ ├── [x] [多台阶矩形凸台顶层规则台阶 -> 独立编辑]
│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽和高度。
│ └── [x] [复杂多台阶凸台整体 -> 识别并快速阻止]
│ └── 多台阶中间过渡面会标记为受限,扫描列表、参数表和计划阶段都不暴露伪可改入口;整组联动、异形凸台和复杂融合边界仍未作为稳定能力。
├── 5. 圆角 / 倒角,从主形体扩展到边修饰
│ ├── [x] [Edge -> 添加圆角]
│ │ └── 简单直线 Edge 可调用 OCCT 倒圆,失败时回滚。
│ ├── [x] [Edge -> 添加倒角]
│ │ └── 支持对称倒角、不等距倒角和距离+角度倒角的简单边场景。
│ ├── [x] [距离 -> 改已有简单等距倒角]
│ │ └── 选中简单倒角斜面时按短边和支撑面估算倒角距离,先移除旧倒角面,再在恢复出的锐边上重新倒角。
│ ├── [x] [半径 -> 改已有简单圆角]
│ │ └── 当前走 defeature + refillet,目标半径和结果邻域必须反查通过。
│ ├── [x] [弧长 -> 调整已有圆角]
│ │ └── 按当前圆弧角把目标弧长换算成目标半径,再走 defeature + refillet,结果弧长必须反查通过。
│ ├── [x] [简单等半径圆角链 -> 整链半径/弧长修改]
│ │ └── 2 到 4 个共享边连通的同半径圆角 Face 可一起 defeature,再对恢复出的多条锐边重新倒圆;弧长会按当前圆弧角换算成目标半径,结果至少反查到同数量目标半径/弧长圆角面。
│ ├── [x] [变半径 / 复杂圆角链 -> 识别并快速阻止]
│ │ └── 相邻不同半径圆角 Face、角部 blend 或超过 4 个面的同半径长链会阻止,不暴露为单个可改圆角参数。
│ └── [ ] [复杂圆角链 / 变半径圆角 -> 通用稳定重建]
│ └── 大链式 blend、变半径圆角和复杂角部补面同步重建仍未完成,暂不承诺修改。
├── 6. Edge 一级编辑,补齐底层直接改边能力
│ ├── [x] [边长 -> 改直线 Edge 长度]
│ │ └── 支持只改当前边、移动端面、相邻圆柱和缩放所属对象等显式建模意图。
│ ├── [x] [起点/终点 -> 移动直线 Edge]
│ │ └── 简单全平面模型可移动端点并重建相邻面;中心移动后端保留但参数表暂不开放。
│ ├── [x] [圆形 Edge 半径/直径 -> 代理到相邻圆柱]
│ │ └── 圆边优先复用孔/槽/凸台直径或轴心修改,而不是裸改曲线。
│ ├── [x] [椭圆 Edge 主/小半径 -> 单轴缩放]
│ │ └── 简单椭圆边可沿主轴或小轴做局部仿射,并校验采样半径。
│ ├── [x] [移动端面 -> 简单盒体保持垂直边长语义]
│ │ └── 直线 Edge 可把长度变化转成端面推拉,端面和同向边跟随,计划和结果都会反查端面/侧面垂直关系。
│ ├── [x] [固定起点/终点/中心 -> Edge 长度锚点]
│ │ └── 修改直线 Edge 长度时可选择固定起点、固定终点或固定中心,结果会按锚点反查。
│ ├── [x] [平行/垂直 -> 端面推拉一级关系守门]
│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行前给出平面约束摘要,执行后反查垂直/平行关系。
│ ├── [x] [平行/垂直 -> Face/Edge 一级事实识别]
│ │ └── Face 和 Edge 一级事实图会记录直接邻域内的平行、垂直、斜交关系数量和摘要;Face 事实图还会明确同域/共面碎片和一级同轴圆柱关系,供 UI 说明、计划守门和后续约束求解复用。
│ ├── [~] [平行/垂直 -> 通用可选约束]
│ │ └── Face 偏移可走受限拉伸/切除,Face 面内长度/宽度的“保持关系”会转成所属对象单向缩放,Edge 长度可走端面推拉;这些路径都会要求一级平面关系可验证并做结果反查。跨多面、跨特征的通用约束求解仍需继续泛化。
│ ├── [x] [复杂曲线 Edge -> 可编辑入口守门]
│ │ └── B-spline / Bezier / 其它复杂曲线不再在扫描列表或参数表里伪装成稳定“改长度”入口。
│ └── [ ] [任意曲线 Edge -> 通用约束编辑]
│ └── B-spline 边、复杂曲线边和多约束边不作为当前稳定目标。
├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力
│ ├── [x] [壳体厚度 -> 局部拉伸/切除或缩放]
│ │ └── 简单薄板和可识别相对平面已纳入 Face/壳体套件。
│ ├── [x] [圆锥参考半径/半角 -> 简单圆锥解析重建]
│ │ └── 嵌入式锥孔优先局部重切,复杂拔模面提前阻止。
│ ├── [x] [球面半径 -> 缩放特征]
│ │ └── 修改后仍需识别为目标球面。
│ ├── [x] [环面主/小半径 -> 缩放特征]
│ │ └── 修改后仍需识别为目标环面。
│ └── [x] [完整抽壳/开口面编辑 -> 先识别开口薄壁上下文并明确限制]
│ └── 开口薄壁盒/槽腔类区域会标出“完整抽壳/开口面编辑”受限;可编辑对象扫描会优先露出真实薄壁的壳体厚度入口,当前仍只开放局部壳体厚度、平面推拉或整体缩放,不做通用抽壳历史恢复。
└── 8. 二级 / 三级关系,从“局部能改”走向“关系能保持”
├── [ ] [Face 二级传播 -> 孔底/槽底/台阶联动]
│ └── 当前只自动处理一级共享边邻域,二级/三级传播放到本阶段。
├── [x] [一级影响范围 -> 明确显示]
│ └── 当前 Face/孔/槽/Edge 已能显示或记录一级边界、顶点和直接相邻 Face 事实。
├── [~] [关联探测 -> 找相邻特征]
│ └── 已能在有限共享边拓扑范围内找关联孔、槽、凸台等,并合并部分关联尺寸到参数表。
├── [~] [共面/同域碎片 -> 同步处理]
│ └── 同域合并已用于计划、扫描和历史定位;作为用户可选“共面同步修改”仍需完善。
├── [~] [同轴圆柱 -> 保持同轴]
│ └── 孔、槽、凸台和圆柱端盖已在部分路径保持同轴;跨特征同轴约束还不是通用能力。
├── [ ] [平行/垂直平面 -> 保持方向关系]
│ └── 用户选择保持平行/垂直时,相关面方向需要作为约束参与重建。
├── [ ] [相切关系 -> 保持连续]
│ └── 圆角、圆柱过渡和相切支撑面的连续性需要在传播计划里显式表达。
├── [ ] [重复特征组 -> 同组联动]
│ └── 孔组、阵列孔、多槽组和多个同尺寸凸台要能选择只改当前一个,或改同组全部。
└── [ ] [传播冲突 -> 用户选择范围]
└── 当二级/三级关系互相冲突时,列出冲突原因,允许只执行一级或扩展传播,不强行硬改。
```
## 关系式实现流程
当前关系式先按“可见公式 + 目标值生成器”推进,不把第一版伪装成完整 CAD 约束求解器。
1. `[~]` 公式输入与显示:左侧 `特征参数` 下方增加 `关系式` 区域,只提供 `添加公式` 和已有公式列表。公式面向用户显示为 `Face87.直径 = Face85.直径``Face87.位置 = Face85.位置 + (0, 0, -3.5)` 这类通用 `对象ID.参数` 形式,不使用“孔1/孔2”这类临时命名。
2. `[~]` 公式补全:输入 `Face87.` 后,按当前选中对象、当前多选对象和可见参数表提供参数候选;第一版优先覆盖 `直径``半径``位置``位置X/Y/Z``偏移``面内长度``面内宽度``长度` 等已经能稳定显示或计算的参数。
3. `[~]` 公式计算:点击 `参数化建模` 时,先计算启用的关系式,把结果回填到当前可见参数表目标值;随后继续走现有单参数或多参数批量建模执行链路。第一版不直接跨多个未显示对象开新编辑任务。
4. `[~]` 简单 Face ID 追踪:公式保存时记录引用 Face 的逻辑 ID 和轻量几何签名;编辑成功后,如果旧 `Face87` 在新模型中能唯一映射到新 `Face90`,就自动把公式显示更新为 `Face90.参数`。如果找不到、找到多个、Face 被拆分/合并或参数不再存在,则把公式标为失效,提示用户重新选择。
5. `[ ]` 跨对象批量执行:后续把多个公式目标拆成执行队列,例如先算 `Face87.直径``Face92.直径`,再按对象逐个切换选择并执行已有建模动作;失败时停在明确对象和原因。
6. `[ ]` 循环依赖和冲突处理:后续检查 `A = B``B = A`、同一目标被多个公式覆盖、同一参数既手工改又被公式驱动等冲突,给出可读提示。
7. `[ ]` 持久化:后续把公式、对象签名、目标参数、建模意图和失效状态保存到项目配置或组件配置里,重新打开模型后能尝试恢复。
8. `[ ]` 复杂拓扑追踪:后续再处理 `原 Face -> 多个新 Face``多个原 Face -> 一个新 Face`、特征删除、阵列孔重编号、装配 occurrence 等情况。
## 几何修改语义原则 ## 几何修改语义原则
所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。
@@ -637,7 +506,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 能导出选中的零件。 - 能导出选中的零件。
- 能撤销/重做编辑。 - 能撤销/重做编辑。
- 先实现少量可控编辑操作,用于验证后续特征编辑路线。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。
- 当前开发顺序固定按上面的“参数化编辑路线图”推进:先把交互规则和 Face 打稳,再进入孔、槽、凸台、圆角/倒角、Edge、壳体/解析曲面,最后扩展二级/三级关系;每一类都要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散 - 当前开发顺序固定按上面的 `SCDM-first 主路线` 推进:先打通 SCDM 识别、能力字典、参数表、执行、校验和 ID 续接闭环,再按 SCDM 已能结构化表达的 Face、孔、槽、凸台、圆角/倒角、阵列、壳体能力逐项适配。历史 OCCT/一级编辑套件继续作为兜底回归,不再作为新增能力主线
## 能改哪些,一分钟版 ## 能改哪些,一分钟版
@@ -684,11 +553,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。
- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 - 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。
- 支持选择模式: - 支持选择模式:
- 零件 - Part
- Solid - Solid
- Face - Face
- Edge - Edge
- 特征 - Feature
- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。
- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 - 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。
- 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。 - 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。
@@ -701,7 +570,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- Face / 特征的 `复制 ID` 也优先复制 `逻辑 Face ID`,属性区会同时显示 `逻辑选择 ID``当前拓扑 Face ID`;这样编辑后拓扑编号变化时,用户仍能用原来的逻辑 ID 回到同一个面区域。 - Face / 特征的 `复制 ID` 也优先复制 `逻辑 Face ID`,属性区会同时显示 `逻辑选择 ID``当前拓扑 Face ID`;这样编辑后拓扑编号变化时,用户仍能用原来的逻辑 ID 回到同一个面区域。
- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
- `特征` 模式现在会把点到的Face解释为几何特征候选: - `Feature` 模式现在会把点到的Face解释为几何特征候选:
- 平面会识别为可拉伸/切除平面候选。 - 平面会识别为可拉伸/切除平面候选。
- 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算壳体局部区域厚度。 - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算壳体局部区域厚度。
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
@@ -994,7 +863,7 @@ vertices: 3262
## 下一步要实现什么 ## 下一步要实现什么
当前已经有按阶段跑通的一级编辑基线,但这还不等于 CAD 级完成。后续按上面的“参数化编辑路线图”继续推进:`[x]` 项保持回归和真实 STEP 验证,`[~]` 项优先补守门、失败解释、补更多模型样例,`[ ]` 项不能提前在 UI 里伪装成稳定能力。每一类先用小模型和默认大模型保持回归通过,再拿模型工程师的真实 STEP 失败项补识别、补守门、补局部重建策略 当前已经有按阶段跑通的本地一级编辑基线,但这还不等于 CAD 级完成。后续按上面的 `SCDM-first 主路线` 继续推进:`[x]` 项保持回归和真实 STEP 验证,`[~]` 项优先补 SCDM API 细节、守门、失败解释和更多真实模型样例,`[ ]` 项不能提前在 UI 里伪装成稳定能力。新增能力优先看 SCDM 能否返回结构化对象、当前值、可用命令和可校验结果;本地 OCCT 只作为兜底和已验证快路径保留
当前整体验证基线: 当前整体验证基线:
@@ -1008,19 +877,19 @@ vertices: 3262
- 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,新增 `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 条 Edge396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。 - 2026-08-18,本机 `D:\softwaresInstallDir\ANSYS Inc\v222\SCDM\SpaceClaim.exe` 已通过真实 `/RunScript` smoke;对 `assets/models/ICEPAK-NATURAL.stp` 的真实 probe 可打开模型并输出 13 个 Body、158 个 Face、396 条 Edge396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。
Face 阶段的当前验收口径(R1 已收口): Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口):
- 已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度``面内宽度`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑;支持 `局部重建``拉伸/切除``移动特征``缩放特征` 和受限的 `保持关系``中心` 移动后端保留,但特征参数表暂不开放。 - 已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度``面内宽度`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑;支持 `局部重建``拉伸/切除``移动特征``缩放特征` 和受限的 `保持关系``中心` 移动后端保留,但特征参数表暂不开放。
- 已验收:Face 的 `一级关系` 定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为 R1 自动传播范围。 - 已验收:Face 的 `一级关系` 定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为 R1 自动传播范围。
- 已验收:选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域、同域/共面碎片、一级平行/垂直关系、一级同轴圆柱事实、边界 Edge/Vertex、共享边相邻 Face,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示。 - 已验收:选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域、同域/共面碎片、一级平行/垂直关系、一级同轴圆柱事实、边界 Edge/Vertex、共享边相邻 Face,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示。
- 可用但受限:带内孔平面、曲面 Solid 上的平面、自由曲面、非矩形面、复杂端盖、接近切穿的内切等场景会按明确守门执行;能稳定改就走隔离/回滚,不能稳定改就提前 blocked 并说明原因。 - 可用但受限:带内孔平面、曲面 Solid 上的平面、自由曲面、非矩形面、复杂端盖、接近切穿的内切等场景会按明确守门执行;能稳定改就走隔离/回滚,不能稳定改就提前 blocked 并说明原因。
- 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动;这些属于 R8,不算 R1 未完成项。 - 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、手写孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动;这些不再作为 SCDM-first 近期主线,不算 Face 兜底基线未完成项。
- `python scripts\verify_first_level_edit_suites.py --stage face` 必须通过,集中运行 R1 Face 专项套件和 Face isolated worker。 - `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_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、保持关系、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。
- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度``面内宽度``偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度``面内宽度``偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。
- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 - `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。
孔/槽阶段的当前验收口径(R2/R3 已收口): 孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口):
- 已验收:圆柱孔/盲孔的 `直径``半径``轴心``盲孔深度``封堵/删除孔`。孔径按同轴重切孔壁执行,轴心移动会先补旧孔再切新孔,盲孔深度会按加深切削或变浅补料处理,结果必须仍能回读目标孔特征。 - 已验收:圆柱孔/盲孔的 `直径``半径``轴心``盲孔深度``封堵/删除孔`。孔径按同轴重切孔壁执行,轴心移动会先补旧孔再切新孔,盲孔深度会按加深切削或变浅补料处理,结果必须仍能回读目标孔特征。
- 已验收:槽/半孔/长圆槽的 `槽宽``槽深``圆弧长度``圆弧角度``开口角度``轴心``总长度``中心距`。普通半圆槽按局部圆柱槽重切,长圆槽按两端圆弧配对后重建,修改后必须仍能识别为目标槽或半孔。 - 已验收:槽/半孔/长圆槽的 `槽宽``槽深``圆弧长度``圆弧角度``开口角度``轴心``总长度``中心距`。普通半圆槽按局部圆柱槽重切,长圆槽按两端圆弧配对后重建,修改后必须仍能识别为目标槽或半孔。
@@ -1028,18 +897,18 @@ Face 阶段的当前验收口径(R1 已收口):
- 已验收:孔/槽局部布尔和重建会走隔离 worker,修改后会校验 B-Rep、单 Solid、目标几何回读和逻辑 Face ID 保持;失败时回滚,不破坏原模型。 - 已验收:孔/槽局部布尔和重建会走隔离 worker,修改后会校验 B-Rep、单 Solid、目标几何回读和逻辑 Face ID 保持;失败时回滚,不破坏原模型。
- 可用但受限:当前只承诺单个稳定孔、单个盲孔、单个半圆槽和可稳定配对的长圆槽;锥孔/沉孔只处理简单解析或局部重切路径,复杂浅锥、螺纹孔、组合孔、底面识别不可靠的盲孔会提前阻止。 - 可用但受限:当前只承诺单个稳定孔、单个盲孔、单个半圆槽和可稳定配对的长圆槽;锥孔/沉孔只处理简单解析或局部重切路径,复杂浅锥、螺纹孔、组合孔、底面识别不可靠的盲孔会提前阻止。
- 可用但受限:复杂槽、交叉槽、多槽端配对不唯一时,只做识别和快速阻止,不在参数表暴露槽宽、槽深、弧长、弧角或轴心这些伪可改入口。 - 可用但受限:复杂槽、交叉槽、多槽端配对不唯一时,只做识别和快速阻止,不在参数表暴露槽宽、槽深、弧长、弧角或轴心这些伪可改入口。
- 未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动、螺纹孔语义编辑、任意草图槽、跨复杂面槽、孔底/槽底带动台阶的二级/三级传播;这些属于 R8 或后续阶段,不算 R2/R3 未完成项。 - 未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动、螺纹孔语义编辑、任意草图槽、跨复杂面槽、手写孔底/槽底带动台阶的二级/三级传播;这些不再作为 SCDM-first 近期主线,不算孔/槽兜底基线未完成项。
- `python scripts\verify_first_level_edit_suites.py --stage hole-slot` 必须通过,集中运行 R2/R3 孔槽专项套件。 - `python scripts\verify_first_level_edit_suites.py --stage hole-slot` 必须通过,集中运行 R2/R3 孔槽专项套件。
- `python scripts\verify_hole_slot_edit_suite.py` 必须通过,覆盖孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。 - `python scripts\verify_hole_slot_edit_suite.py` 必须通过,覆盖孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。
- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征会暴露侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。 - `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征会暴露侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。
- `python scripts\verify_hole_slot_isolated_edit.py` 必须通过,确保孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距都能通过隔离执行通道验证。 - `python scripts\verify_hole_slot_isolated_edit.py` 必须通过,确保孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距都能通过隔离执行通道验证。
0~7 其它阶段的当前基线: 本地 OCCT 兜底能力的当前基线:
- R2/R3 孔槽已经拆成独立验收口径;后续只在真实 STEP 失败项上补识别、补守门、补局部重建,不把孔组、阵列孔、多槽组联动提前混进 0~7 - 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 edge` 覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。任意曲线 Edge 通用约束编辑仍未开放。
- `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体厚度和解析曲面当前可用范围。复杂链式特征、通用抽壳历史恢复和跨特征传播仍不承诺。 - `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体厚度和解析曲面当前可用范围。复杂链式特征、通用抽壳历史恢复和跨特征传播仍不承诺。
- R2/R3 孔槽收口后,下一步优先把 Edge、凸台、圆角/倒角、壳体和解析曲面也按“已验收 / 可用但受限 / 未实现”继续收口 - 这些阶段后续只作为本地兜底能力维护;新增客户入口优先从 SCDM capability 路线开放
## 怎么使用 ## 怎么使用
@@ -1056,11 +925,11 @@ Face 阶段的当前验收口径(R1 已收口):
左侧 `选择模式` 用来切换鼠标选择模式: 左侧 `选择模式` 用来切换鼠标选择模式:
- `零件`:选择整个零件。 - `Part`:选择整个零件。
- `Solid`:选择实体。 - `Solid`:选择实体。
- `Face`:选择面。 - `Face`:选择面。
- `Edge`:选择边。 - `Edge`:选择边。
- `特征`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 - `Feature`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。
圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。
- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。
- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 - 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。
@@ -1204,7 +1073,7 @@ Face 阶段的当前验收口径(R1 已收口):
- 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。 - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。
- 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。 - 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。
- `槽/半孔宽度` + `调整槽/半孔宽度` - `槽/半孔宽度` + `调整槽/半孔宽度`
- 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate` - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`
- 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。
- 输入目标槽宽后点击 `调整槽/半孔宽度` - 输入目标槽宽后点击 `调整槽/半孔宽度`
- 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
@@ -1325,7 +1194,7 @@ Face 阶段的当前验收口径(R1 已收口):
- 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 这是“给直线边添加新圆角”,不是修改已有圆角面。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。
- `圆角半径` / `圆角弧长``重建圆角` - `圆角半径` / `圆角弧长``重建圆角`
- 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 在当前选中对象表里修改 `圆角半径``圆角弧长`,并把 `建模意图` 设为 `重建圆角` - 在当前选中对象表里修改 `圆角半径``圆角弧长`,并把 `建模意图` 设为 `重建圆角`
- 输入目标值后点击 `参数化建模` - 输入目标值后点击 `参数化建模`
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
@@ -1414,7 +1283,7 @@ Face 阶段的当前验收口径(R1 已收口):
- `导出选中零件`:导出当前选中的零件。 - `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。
- `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。 - `导出选中特征区域`:先切换到 `Feature` 选择模式并选中特征,再导出当前特征高亮的Face集合。
- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `导出选中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 文件。 - `导出参数`:在 `特征参数` 表的 `输入参数` 列勾选需要外部驱动的尺寸行后,点击按钮会在项目根目录写出 `data.json`,并在 `nodes/` 下生成一个 FlowEditor 风格的参数化组件 `main.py`。组件脚本会把勾选的输入参数直接嵌入 `INPUT_PARAMETERS`,通过 `PARAMETERS / NODE_INFO` 暴露给节点设计器,并提供 `execute(inputs, params, context)` 调用入口;组件目录里不会额外生成 `data.json``step_edit_config.json`。执行后返回 `output_step`,指向修改后的 STEP 文件。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
@@ -1483,6 +1352,7 @@ pythonocc-step-editor/
verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数 verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数
verify_relation_formula_rules.py # 验证关系式自引用、重复目标和循环依赖会在添加阶段阻止
verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json
verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成 verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成
verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明 verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明
@@ -1605,7 +1475,7 @@ git diff --check
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step` - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`
- `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。 - `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_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_backend.py`:纯 Python 检查 SCDM 后端发现和缓存逻辑,不依赖 OCC;覆盖环境变量、常见安装目录、`local/scdm_backend.json`、禁用开关和 `/RunScript` 命令生成。
- `scripts/verify_scdm_status.py`:纯 Python 检查 SCDM 运行状态摘要,不依赖 OCC;覆盖未配置、已配置、后台识别中、识别成功、识别失败、cache 失效和缓存来源显示。 - `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_probe_pipeline.py`:纯 Python 检查 SCDM probe job、RunScript 脚本生成、raw 识别结果到产品化 cache 的映射;覆盖第一批 `hole.diameter``hole.position``face.offset` 能力。
+51 -34
View File
@@ -47,27 +47,30 @@ def _verify_readme_mentions(readme: str) -> None:
"当前整体验证基线", "当前整体验证基线",
"不等于 CAD 级完成", "不等于 CAD 级完成",
"Face 阶段的当前验收口径", "Face 阶段的当前验收口径",
"参数化编辑路线", "SCDM-first 主路线",
"用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚", "核心路线只保留下面这一棵树",
"`[x]` 已实现", "SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM",
"`[~]` 部分实现/进行中", "本软件不再把手写一级、二级、三级传播当成新增能力主线",
"`[ ]` 未实现", "`[x]` 已适配",
"`[x]` 不是“所有 CAD 形态都能改”", "`[~]` 部分适配",
"R1 Face 是当前主线的第一阶段收口对象", "`[ ]` 待适配",
"R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里", "[scdm_probe_job.json -> /RunScript 扫描 STEP]",
"STEP/B-Rep 参数化编辑主线", "[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[不能修改 -> 立即说明原因]", "[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[一级影响范围 -> 明确显示]", "[Face 偏移 -> face.offset / OffsetFaces]",
"Face 阶段的当前验收口径(R1 已收口)", "[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces",
"[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]",
"Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口)",
"已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`", "已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`",
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建", "未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
"孔/槽阶段的当前验收口径(R2/R3 已收口)", "孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口)",
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`", "已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
"已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`", "已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`",
"未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动", "未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动",
"2026-08-11,在 `pyocc` 环境下已通过 `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 保持和孔槽阶段收口口径", "覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径",
"0~7 其它阶段的当前基线", "本地 OCCT 兜底能力的当前基线",
"verify_first_level_edit_suites.py --quick", "verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face", "verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot", "verify_first_level_edit_suites.py --stage hole-slot",
@@ -92,42 +95,56 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None: def _verify_roadmap_scope(readme: str) -> None:
start_marker = "STEP/B-Rep 参数化编辑主线" start_marker = "SCDM-first 主路线"
end_marker = "└── 8. 二级 / 三级关系" end_marker = "└── 7. 交付与兜底边界"
start = readme.find(start_marker) start = readme.find(start_marker)
end = readme.find(end_marker) end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README roadmap should contain a 0~7 active scope before stage 8") _assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary")
active_scope = readme[start:end] active_scope = readme[start:end]
deferred_scope = readme[end:] deferred_scope = readme[end:]
required_active_fragments = ( required_active_fragments = (
"├── 0. 先让用户知道“能不能改”", "├── 0. 后端发现与可用性",
"│ ├── [x] [不能修改 -> 立即说明原因]", "│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]",
"│ └── [x] [路线图 -> 验收脚本守门]", "├── 1. SCDM 识别与缓存",
"├── 1. 平面 Face,第一条主线", "│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]",
"├── 2. 孔,第二条主线", "│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征", "├── 2. 能力字典、参数表和用户入口",
"├── 4. 凸台 / Boss,从切除特征扩展到加料特征", "├── 3. SCDM 参数化建模执行",
"├── 5. 圆角 / 倒角,从主形体扩展到边修饰", "├── 4. 结果校验、回滚和 ID 续接",
"├── 6. Edge 一级编辑,补齐底层直接改边能力", "├── 5. 当前已开放或正在开放的 SCDM 能力",
"├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力", "[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心]",
"├── 6. 下一批只按 SCDM 能力适配",
"[阵列实例位置 -> pattern.instance_position]",
"[壳体厚度 -> shell.thickness,薄壁候选配对已进 cache",
) )
for fragment in required_active_fragments: for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}") _assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
forbidden_active_fragments = ( forbidden_active_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"└── 8. 二级 / 三级关系",
"[Face 二级传播", "[Face 二级传播",
"[孔组 ->",
"多槽组 ->", "多槽组 ->",
"二级传播 ->",
"三级传播 ->",
"二级 / 三级关系",
) )
for fragment in forbidden_active_fragments: for fragment in forbidden_active_fragments:
_assert(fragment not in active_scope, f"README 0~7 roadmap should defer this to stage 8: {fragment}") _assert(fragment not in active_scope, f"README SCDM-first roadmap should not keep old route scope: {fragment}")
_assert("[Face 二级传播 -> 孔底/槽底/台阶联动]" in deferred_scope, "README should keep Face deeper propagation in stage 8") _assert("[Analysis Situs -> 只做辅助定位、兜底识别和开源对照]" in deferred_scope, "README should keep Analysis Situs as auxiliary boundary")
_assert("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard") _assert("[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]" in deferred_scope, "README should keep OCCT as fallback boundary")
_assert("[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]" in deferred_scope, "README should defer non-SCDM expansion")
forbidden_global_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
)
for fragment in forbidden_global_fragments:
_assert(fragment not in readme, f"README should keep only one SCDM-first route, found old title: {fragment}")
def main() -> int: def main() -> int:
@@ -70,6 +70,7 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Smoke test", ("main.py", "--smoke-test")), ("Smoke test", ("main.py", "--smoke-test")),
("Property editor specs", ("verify_property_editor_specs.py",)), ("Property editor specs", ("verify_property_editor_specs.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)), ("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Relation formula rules", ("verify_relation_formula_rules.py",)),
("Parametric component export", ("verify_parametric_component_export.py",)), ("Parametric component export", ("verify_parametric_component_export.py",)),
("SCDM backend discovery", ("verify_scdm_backend.py",)), ("SCDM backend discovery", ("verify_scdm_backend.py",)),
("SCDM runtime status", ("verify_scdm_status.py",)), ("SCDM runtime status", ("verify_scdm_status.py",)),
+135 -1
View File
@@ -49,6 +49,7 @@ from step_editor.window_state import (
RELATION_FORMULA_OBJECT_COMPLETION_LIMIT, RELATION_FORMULA_OBJECT_COMPLETION_LIMIT,
WindowStateMixin, WindowStateMixin,
) )
from step_editor.ui_helpers import _selection_mode_label, _selection_mode_value
class _StatusBar: class _StatusBar:
@@ -86,7 +87,23 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.scdm_feature_cache = None self.scdm_feature_cache = None
self.scdm_feature_cache_state = "empty" self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = "" self.scdm_feature_cache_message = ""
self.scdm_edit_runner_ready = {"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"} self.scdm_edit_runner_ready = {
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
}
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.object_edit_box = self self.object_edit_box = self
@@ -1560,6 +1577,65 @@ def _assert_scdm_selection_diagnostics() -> None:
_assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}") _assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}")
def _assert_solid_selection_does_not_expand_face_scdm_specs() -> None:
probe = _PropertyTableProbe()
probe.scdm_feature_cache_state = "ready"
probe.scdm_edit_runner_ready = {"face.offset"}
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"faceIds": [0], "surfaceType": "plane", "planeOffset": 0.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 0.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
},
{
"objectId": "face:1",
"objectType": "face",
"geometrySignature": {"faceIds": [1], "surfaceType": "plane", "planeOffset": -5.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": -5.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
},
]
}
probe.selected_kind = "face"
probe.selected_face_id = 0
face_specs = probe._scdm_property_specs_for_selection()
_assert(
len([item for item in face_specs if item.get("scdm_capability_key") == "face.offset"]) == 1,
f"Face selection should show the selected Face SCDM offset only: {face_specs}",
)
probe.selected_kind = "solid"
probe.selected_face_id = None
probe.selected_solid_id = 0
probe.model = SimpleNamespace(face_solid_ids=[0, 0])
solid_specs = probe._scdm_property_specs_for_selection()
_assert(
solid_specs == [],
f"Solid selection should not expand every child Face SCDM offset into the parameter table: {solid_specs}",
)
def _assert_operation_record_backend_sources() -> None: def _assert_operation_record_backend_sources() -> None:
class _OperationRecordProbe(WindowActionMixin): class _OperationRecordProbe(WindowActionMixin):
pass pass
@@ -1718,6 +1794,10 @@ def _assert_large_model_preload_stays_lightweight() -> None:
_assert("scdm_local_face_signatures" in window_body, "window SCDM preload should use the lightweight model signature API") _assert("scdm_local_face_signatures" in window_body, "window SCDM preload should use the lightweight model signature API")
_assert("quick_face_info" not in window_body, "window SCDM preload must not call quick_face_info for every Face") _assert("quick_face_info" not in window_body, "window SCDM preload must not call quick_face_info for every Face")
loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result") loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert(
"_restore_scdm_feature_cache_from_disk()" in loaded_body,
"STEP load should restore a matching SCDM disk cache before launching a new probe",
)
_assert( _assert(
"_defer_large_model_recognition_preloads" in loaded_body, "_defer_large_model_recognition_preloads" in loaded_body,
"large model loads should defer full external recognition preloads by default", "large model loads should defer full external recognition preloads by default",
@@ -1727,6 +1807,10 @@ def _assert_large_model_preload_stays_lightweight() -> None:
"SCDM edit-result reloads should still be able to force cache refresh for validation", "SCDM edit-result reloads should still be able to force cache refresh for validation",
) )
preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload") 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( _assert(
"local_face_signatures = self._scdm_local_face_signatures()" not in preload_body, "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", "SCDM preload must not build all local Face signatures on the UI thread",
@@ -1866,6 +1950,53 @@ def _assert_large_planar_offset_prefers_local_backend() -> None:
_assert("SCDM" in str(probe.scdm_selection_status_message), "backend preference should explain that SCDM was bypassed") _assert("SCDM" in str(probe.scdm_selection_status_message), "backend preference should explain that SCDM was bypassed")
def _assert_scdm_target_values_use_backend_units() -> None:
probe = _PropertyTableProbe()
offset = probe._scdm_property_target_value(
{"scdm_capability_key": "face.offset", "value_type": "number", "scdm_unit_scale": 0.001},
"2.5",
)
_assert(abs(float(offset) - 0.0025) <= 1.0e-12, f"SCDM numeric targets should be converted back to backend units: {offset}")
position = probe._scdm_property_target_value(
{"scdm_capability_key": "hole.position", "value_type": "vector3", "scdm_unit_scale": 0.001},
"(1, 2, 3)",
)
_assert(position == [0.001, 0.002, 0.003], f"SCDM vector targets should be converted back to backend units: {position}")
spacing_spec = {"label": "阵列间距", "value_type": "positive", "max_value": 1.3571428571428572}
_assert(
not probe._property_target_validation_error(spacing_spec, "1.1"),
"pattern spacing targets within the support face range should be accepted",
)
spacing_error = probe._property_target_validation_error(spacing_spec, "2")
_assert(
"阵列间距" in spacing_error and "1.35714" in spacing_error,
f"pattern spacing beyond the support face range should be blocked in the UI: {spacing_error}",
)
def _assert_selection_mode_labels_are_english() -> None:
expected = {
"Feature": "Feature",
"Face": "Face",
"Edge": "Edge",
"Solid": "Solid",
"Part": "Part",
}
for value, label in expected.items():
_assert(_selection_mode_label(value) == label, f"selection mode {value} should display as English: {_selection_mode_label(value)}")
_assert(_selection_mode_value(label) == value, f"selection mode label {label} should resolve to {value}")
for legacy, value in {
"智能特征": "Feature",
"特征": "Feature",
"": "Face",
"": "Edge",
"实体": "Solid",
"零件": "Part",
"装配零件": "Part",
}.items():
_assert(_selection_mode_value(legacy) == value, f"legacy selection label {legacy} should resolve to {value}")
def _assert_background_load_uses_worker() -> None: def _assert_background_load_uses_worker() -> None:
body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_background_or_sync") body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_background_or_sync")
_assert("LoadWorker(action)" in body, "background STEP load should run through LoadWorker") _assert("LoadWorker(action)" in body, "background STEP load should run through LoadWorker")
@@ -1891,6 +2022,8 @@ def main() -> int:
not top_level_label_probe.shown_labels, not top_level_label_probe.shown_labels,
f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}", f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}",
) )
_assert_scdm_target_values_use_backend_units()
_assert_selection_mode_labels_are_english()
_assert_property_table_editor(probe) _assert_property_table_editor(probe)
_assert(probe.current_capability_button.text() == "软件进度", "software progress should be a compact button") _assert(probe.current_capability_button.text() == "软件进度", "software progress should be a compact button")
_assert("当前支持" in probe.current_capability_button.toolTip(), "software progress button tooltip did not show supported areas") _assert("当前支持" in probe.current_capability_button.toolTip(), "software progress button tooltip did not show supported areas")
@@ -1952,6 +2085,7 @@ def main() -> int:
_assert_relation_formula_ids_follow_model_remap() _assert_relation_formula_ids_follow_model_remap()
_assert_mouse_selection_guards() _assert_mouse_selection_guards()
_assert_scdm_selection_diagnostics() _assert_scdm_selection_diagnostics()
_assert_solid_selection_does_not_expand_face_scdm_specs()
_assert_operation_record_backend_sources() _assert_operation_record_backend_sources()
_assert_scdm_auto_prompt() _assert_scdm_auto_prompt()
_assert_property_ui_reroute_guards() _assert_property_ui_reroute_guards()
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.relation_formulas import ( # noqa: E402
RelationFormulaError,
parse_relation_formula,
validate_relation_formula_graph,
)
def _parse_many(texts: list[str]):
return [parse_relation_formula(text) for text in texts]
def _assert_ok(texts: list[str]) -> None:
validate_relation_formula_graph(_parse_many(texts))
def _assert_fails(texts: list[str], expected_fragment: str) -> None:
try:
validate_relation_formula_graph(_parse_many(texts))
except RelationFormulaError as exc:
message = str(exc)
if expected_fragment not in message:
raise AssertionError(f"expected {expected_fragment!r} in error message, got {message!r}") from exc
return
raise AssertionError(f"expected relation formulas to fail: {texts!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_fails(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身")
_assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数")
_assert_fails(["Face85.直径 = Face87.直径", "Face87.直径 = Face85.直径"], "循环依赖")
_assert_fails(
[
"Face1.位置 = Face2.位置",
"Face2.位置 = Face3.位置",
"Face3.位置 = Face1.位置",
],
"循环依赖",
)
print("relation formula rules ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+533 -1
View File
@@ -168,19 +168,40 @@ def main() -> int:
"change_hole_diameter", "change_hole_diameter",
"move_hole_axis", "move_hole_axis",
"move_slot", "move_slot",
"change_slot_width",
"change_slot_depth",
"move_boss", "move_boss",
"change_boss_height",
"change_boss_diameter",
"change_round_radius",
"change_chamfer_distance",
"change_pattern_spacing",
"change_pattern_segment_spacing",
"segment_before",
"segment_split",
"pull_face_offset", "pull_face_offset",
"fill_feature", "fill_feature",
"delete_round_or_chamfer",
"StandardHoles.ModifyHoleRadius", "StandardHoles.ModifyHoleRadius",
"ConstantRound.ModifyRadius",
"Chamfer.ModifyDistance",
"OffsetFaces.Execute", "OffsetFaces.Execute",
"Move.Translate", "Move.Translate",
"MoveOptions", "MoveOptions",
"ConstantRoundOptions",
"ChamferOptions",
"OffsetFaceOptions", "OffsetFaceOptions",
"FillOptions", "FillOptions",
"FillMode", "FillMode",
"Delete.Execute", "Delete.Execute",
"DocumentSave", "DocumentSave",
"scdmFaceLocators", "scdmFaceLocators",
"heightFaceLocators",
"depthFaceLocators",
"diameterFaceLocators",
"patternInstances",
"bodyLocators",
"instanceKind",
"result.json", "result.json",
"error.json", "error.json",
): ):
@@ -197,6 +218,10 @@ def main() -> int:
"faceOrdinals": [30, 31, 32], "faceOrdinals": [30, 31, 32],
"globalFaceOrdinal": 40, "globalFaceOrdinal": 40,
"globalFaceOrdinals": [40, 41, 42], "globalFaceOrdinals": [40, 41, 42],
"width": 2.0,
"depth": 1.5,
"depthAxis": [0.0, 0.0, -1.0],
"depthFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 42}],
"center": [1.0, 2.0, 3.0], "center": [1.0, 2.0, 3.0],
"axis": [1.0, 0.0, 0.0], "axis": [1.0, 0.0, 0.0],
} }
@@ -214,6 +239,37 @@ def main() -> int:
_assert(slot_job.get("target", {}).get("backendOperation") == "move_slot", f"slot.position should route to move_slot: {slot_job}") _assert(slot_job.get("target", {}).get("backendOperation") == "move_slot", f"slot.position should route to move_slot: {slot_job}")
_assert(slot_job.get("target", {}).get("value") == [1.0, 2.0, 5.0], f"slot.position target should be vector3: {slot_job}") _assert(slot_job.get("target", {}).get("value") == [1.0, 2.0, 5.0], f"slot.position target should be vector3: {slot_job}")
_assert(slot_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [30, 31, 32], f"slot faces should be preserved: {slot_job}") _assert(slot_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [30, 31, 32], f"slot faces should be preserved: {slot_job}")
slot_width_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-width-prepared",
backend=backend,
capability_key="slot.width",
target_value="2.5",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_width_prepared.get("ok") is True, f"slot.width should be productized and prepare an edit job: {slot_width_prepared}")
slot_width_job = read_json(slot_width_prepared["job_path"])
_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_depth_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-prepared",
backend=backend,
capability_key="slot.depth",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_depth_prepared.get("ok") is True, f"slot.depth should be productized and prepare an edit job: {slot_depth_prepared}")
slot_depth_job = read_json(slot_depth_prepared["job_path"])
_assert(slot_depth_job.get("target", {}).get("backendOperation") == "change_slot_depth", f"slot.depth should route to change_slot_depth: {slot_depth_job}")
_assert(slot_depth_job.get("target", {}).get("value") == 2.0, f"slot.depth target should be numeric: {slot_depth_job}")
slot_depth_signature = slot_depth_job.get("object", {}).get("geometrySignature", {})
_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}")
boss_signature = { boss_signature = {
"objectType": "cylindrical_boss", "objectType": "cylindrical_boss",
@@ -223,6 +279,10 @@ def main() -> int:
"faceOrdinals": [50, 51, 52], "faceOrdinals": [50, 51, 52],
"globalFaceOrdinal": 70, "globalFaceOrdinal": 70,
"globalFaceOrdinals": [70, 71, 72], "globalFaceOrdinals": [70, 71, 72],
"height": 4.0,
"diameter": 3.0,
"heightFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 52, "globalFaceOrdinal": 72}],
"diameterFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 50, "globalFaceOrdinal": 70}],
"center": [0.0, 0.0, 2.0], "center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0], "axis": [0.0, 0.0, 1.0],
} }
@@ -240,12 +300,369 @@ def main() -> int:
_assert(boss_job.get("target", {}).get("backendOperation") == "move_boss", f"boss.position should route to move_boss: {boss_job}") _assert(boss_job.get("target", {}).get("backendOperation") == "move_boss", f"boss.position should route to move_boss: {boss_job}")
_assert(boss_job.get("target", {}).get("value") == [2.0, 0.0, 2.0], f"boss.position target should be vector3: {boss_job}") _assert(boss_job.get("target", {}).get("value") == [2.0, 0.0, 2.0], f"boss.position target should be vector3: {boss_job}")
_assert(boss_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [50, 51, 52], f"boss faces should be preserved: {boss_job}") _assert(boss_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [50, 51, 52], f"boss faces should be preserved: {boss_job}")
boss_height_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-prepared",
backend=backend,
capability_key="boss.height",
target_value="5.5",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_height_prepared.get("ok") is True, f"boss.height should be productized and prepare an edit job: {boss_height_prepared}")
boss_height_job = read_json(boss_height_prepared["job_path"])
_assert(boss_height_job.get("target", {}).get("backendOperation") == "change_boss_height", f"boss.height should route to change_boss_height: {boss_height_job}")
_assert(boss_height_job.get("target", {}).get("value") == 5.5, f"boss.height target should be numeric: {boss_height_job}")
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_diameter_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-prepared",
backend=backend,
capability_key="boss.diameter",
target_value="4.5",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_diameter_prepared.get("ok") is True, f"boss.diameter should be productized and prepare an edit job: {boss_diameter_prepared}")
boss_diameter_job = read_json(boss_diameter_prepared["job_path"])
_assert(boss_diameter_job.get("target", {}).get("backendOperation") == "change_boss_diameter", f"boss.diameter should route to change_boss_diameter: {boss_diameter_job}")
_assert(boss_diameter_job.get("target", {}).get("value") == 4.5, f"boss.diameter target should be numeric: {boss_diameter_job}")
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}")
round_signature = {
"objectType": "round",
"faceIds": [60],
"bodyIndex": 0,
"faceOrdinal": 60,
"globalFaceOrdinal": 80,
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.5,
"roundType": "ConstantRound",
"isConstantRound": True,
}
round_radius_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "round-radius-prepared",
backend=backend,
capability_key="round.radius",
target_value="0.75",
object_id="round:60",
object_signature=round_signature,
)
_assert(round_radius_prepared.get("ok") is True, f"round.radius should be productized and prepare an edit job: {round_radius_prepared}")
round_radius_job = read_json(round_radius_prepared["job_path"])
_assert(round_radius_job.get("target", {}).get("backendOperation") == "change_round_radius", f"round.radius should route to change_round_radius: {round_radius_job}")
_assert(round_radius_job.get("target", {}).get("value") == 0.75, f"round.radius target should be numeric: {round_radius_job}")
round_radius_signature = round_radius_job.get("object", {}).get("geometrySignature", {})
_assert(round_radius_signature.get("isConstantRound") is True, f"round.radius should preserve constant round evidence: {round_radius_job}")
_assert(round_radius_signature.get("radius") == 0.5, f"round.radius should preserve current radius: {round_radius_job}")
round_delete_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "round-delete-prepared",
backend=backend,
capability_key="feature.delete_round_or_chamfer",
target_value="",
object_id="round:60",
object_signature=round_signature,
)
_assert(round_delete_prepared.get("ok") is True, f"round/chamfer delete should be productized and prepare an edit job: {round_delete_prepared}")
round_delete_job = read_json(round_delete_prepared["job_path"])
_assert(round_delete_job.get("target", {}).get("backendOperation") == "delete_round_or_chamfer", f"round delete should route to delete_round_or_chamfer: {round_delete_job}")
_assert(round_delete_job.get("target", {}).get("value") is True, f"command target should be true: {round_delete_job}")
chamfer_signature = {
"objectType": "chamfer",
"faceIds": [63],
"bodyIndex": 0,
"faceOrdinal": 63,
"globalFaceOrdinal": 83,
"distance": 0.8,
"distance1": 0.8,
"distance2": 0.8,
"chamferType": "EqualDistanceChamfer",
"isEqualDistanceChamfer": True,
}
chamfer_distance_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "chamfer-distance-prepared",
backend=backend,
capability_key="chamfer.distance",
target_value="1.2",
object_id="chamfer:63",
object_signature=chamfer_signature,
)
_assert(chamfer_distance_prepared.get("ok") is True, f"chamfer.distance should be productized and prepare an edit job: {chamfer_distance_prepared}")
chamfer_distance_job = read_json(chamfer_distance_prepared["job_path"])
_assert(chamfer_distance_job.get("target", {}).get("backendOperation") == "change_chamfer_distance", f"chamfer.distance should route to change_chamfer_distance: {chamfer_distance_job}")
_assert(chamfer_distance_job.get("target", {}).get("value") == 1.2, f"chamfer.distance target should be numeric: {chamfer_distance_job}")
chamfer_job_signature = chamfer_distance_job.get("object", {}).get("geometrySignature", {})
_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}")
pattern_signature = {
"objectType": "linear_pattern",
"faceIds": [85, 94, 87, 96, 89, 98],
"bodyIndex": 0,
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
"patternInstances": [
{
"sourceObjectId": "hole:a",
"center": [0.0, 0.0, 0.0],
"faceIds": [85, 94],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 12, "globalFaceOrdinal": 85},
{"bodyIndex": 0, "faceOrdinal": 19, "globalFaceOrdinal": 94},
],
},
{
"sourceObjectId": "hole:b",
"center": [5.0, 0.0, 0.0],
"faceIds": [87, 96],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 22, "globalFaceOrdinal": 87},
{"bodyIndex": 0, "faceOrdinal": 29, "globalFaceOrdinal": 96},
],
},
{
"sourceObjectId": "hole:c",
"center": [10.0, 0.0, 0.0],
"faceIds": [89, 98],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 89},
{"bodyIndex": 0, "faceOrdinal": 39, "globalFaceOrdinal": 98},
],
},
],
}
pattern_spacing_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="7.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_signature,
)
_assert(pattern_spacing_prepared.get("ok") is True, f"pattern.spacing should be productized and prepare an edit job: {pattern_spacing_prepared}")
pattern_spacing_job = read_json(pattern_spacing_prepared["job_path"])
_assert(pattern_spacing_job.get("target", {}).get("backendOperation") == "change_pattern_spacing", f"pattern.spacing should route to change_pattern_spacing: {pattern_spacing_job}")
_assert(pattern_spacing_job.get("target", {}).get("value") == 7.5, f"pattern.spacing target should be numeric: {pattern_spacing_job}")
pattern_job_signature = pattern_spacing_job.get("object", {}).get("geometrySignature", {})
_assert(pattern_job_signature.get("spacing") == 5.0, f"pattern spacing signature should be preserved: {pattern_spacing_job}")
_assert(len(pattern_job_signature.get("patternInstances") or []) == 3, f"pattern instance locators should be preserved: {pattern_spacing_job}")
pattern_segment_signature = dict(pattern_signature)
pattern_segment_signature["segmentIndex"] = 1
pattern_segment_signature["movingSide"] = "after"
pattern_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_signature,
)
_assert(pattern_segment_prepared.get("ok") is True, f"pattern.segment_spacing should prepare an edit job: {pattern_segment_prepared}")
pattern_segment_job = read_json(pattern_segment_prepared["job_path"])
_assert(
pattern_segment_job.get("target", {}).get("backendOperation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should route to change_pattern_segment_spacing: {pattern_segment_job}",
)
_assert(pattern_segment_job.get("target", {}).get("value") == 6.5, f"pattern.segment_spacing target should be numeric: {pattern_segment_job}")
_assert(
pattern_segment_job.get("object", {}).get("geometrySignature", {}).get("segmentIndex") == 1,
f"pattern.segment_spacing should preserve the selected adjacent segment: {pattern_segment_job}",
)
pattern_segment_before_signature = dict(pattern_segment_signature)
pattern_segment_before_signature["movingSide"] = "before"
pattern_segment_before_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-before-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_before_signature,
)
_assert(
pattern_segment_before_prepared.get("ok") is True
and read_json(pattern_segment_before_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "before",
f"pattern.segment_spacing should preserve fix-right/move-left semantics: {pattern_segment_before_prepared}",
)
pattern_segment_split_signature = dict(pattern_segment_signature)
pattern_segment_split_signature["movingSide"] = "split"
pattern_segment_split_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-split-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_split_signature,
)
_assert(
pattern_segment_split_prepared.get("ok") is True
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}",
)
body_pattern_signature = {
"objectType": "linear_pattern",
"patternKind": "body",
"instanceKind": "body",
"faceIds": [300, 301, 302],
"bodyIndices": [20, 21, 22],
"axis": [0.0, 1.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 20.0, 0.0], [0.0, 25.0, 0.0], [0.0, 30.0, 0.0]],
"patternInstances": [
{"sourceObjectId": "body:20", "instanceKind": "body", "center": [0.0, 20.0, 0.0], "bodyIndex": 20, "bodyLocators": [{"bodyIndex": 20}], "faceIds": [300]},
{"sourceObjectId": "body:21", "instanceKind": "body", "center": [0.0, 25.0, 0.0], "bodyIndex": 21, "bodyLocators": [{"bodyIndex": 21}], "faceIds": [301]},
{"sourceObjectId": "body:22", "instanceKind": "body", "center": [0.0, 30.0, 0.0], "bodyIndex": 22, "bodyLocators": [{"bodyIndex": 22}], "faceIds": [302]},
],
}
body_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="8",
object_id="pattern:parts-20-22",
object_signature=body_pattern_signature,
)
_assert(
body_pattern_prepared.get("ok") is False
and body_pattern_prepared.get("reason") == "body-pattern-spacing-missing-component-locators",
f"body pattern spacing should be blocked until component occurrence locators are available: {body_pattern_prepared}",
)
body_segment_pattern_signature = dict(body_pattern_signature)
body_segment_pattern_signature["segmentIndex"] = 0
body_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6",
object_id="pattern:parts-20-22",
object_signature=body_segment_pattern_signature,
)
_assert(
body_segment_prepared.get("ok") is False
and body_segment_prepared.get("reason") == "body-pattern-spacing-missing-component-locators",
f"body pattern local segment spacing should also require component occurrence locators: {body_segment_prepared}",
)
component_body_pattern_signature = dict(body_pattern_signature)
component_body_pattern_signature["componentInstanceCount"] = 3
component_body_pattern_signature["patternInstances"] = [
{
**dict(item),
"componentLocators": [
{
"componentIndex": index,
"componentPath": [index],
"componentBodyIndex": 0,
"bodyIndex": item.get("bodyIndex"),
"componentName": f"Part {index + 1}",
}
],
}
for index, item in enumerate(body_pattern_signature["patternInstances"])
]
component_body_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "component-body-pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="8",
object_id="pattern:components-20-22",
object_signature=component_body_pattern_signature,
)
_assert(
component_body_pattern_prepared.get("ok") is True,
f"body pattern spacing should prepare when every member has a component occurrence locator: {component_body_pattern_prepared}",
)
component_body_pattern_job = read_json(component_body_pattern_prepared["job_path"])
component_instances = component_body_pattern_job.get("object", {}).get("geometrySignature", {}).get("patternInstances") or []
_assert(
all(item.get("componentLocators") for item in component_instances if isinstance(item, dict)),
f"component occurrence locators should be preserved in pattern.spacing job: {component_body_pattern_job}",
)
component_body_segment_signature = dict(component_body_pattern_signature)
component_body_segment_signature["segmentIndex"] = 0
component_body_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "component-body-pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6",
object_id="pattern:components-20-22",
object_signature=component_body_segment_signature,
)
_assert(
component_body_segment_prepared.get("ok") is True,
f"body pattern local segment spacing should prepare with component occurrence locators: {component_body_segment_prepared}",
)
blocked_pattern_signature = dict(pattern_signature)
blocked_pattern_signature["supportPatternFit"] = {
"supportFaceIds": [92],
"localUnitScale": 0.001,
"maxSpacing": 0.0012,
"maxSpacingLocal": 1.2,
"instanceCount": 3,
}
blocked_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-spacing-blocked",
backend=backend,
capability_key="pattern.spacing",
target_value=0.002,
object_id="pattern:holes-a-b-c",
object_signature=blocked_pattern_signature,
)
_assert(
blocked_pattern_prepared.get("ok") is False
and blocked_pattern_prepared.get("reason") == "pattern-spacing-exceeds-support",
f"pattern spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_pattern_prepared}",
)
blocked_segment_signature = dict(pattern_segment_signature)
blocked_segment_signature["supportPatternFit"] = {
"supportFaceIds": [92],
"localUnitScale": 0.001,
"maxSegmentSpacing": 0.0013,
"maxSegmentSpacingLocal": 1.3,
"instanceCount": 3,
}
blocked_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-blocked",
backend=backend,
capability_key="pattern.segment_spacing",
target_value=0.002,
object_id="pattern:holes-a-b-c",
object_signature=blocked_segment_signature,
)
_assert(
blocked_segment_prepared.get("ok") is False
and blocked_segment_prepared.get("reason") == "pattern-spacing-exceeds-support",
f"pattern segment spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_segment_prepared}",
)
planned = prepare_scdm_edit_job( planned = prepare_scdm_edit_job(
step_path, step_path,
output_dir=root / "unsupported", output_dir=root / "unsupported",
backend=backend, backend=backend,
capability_key="slot.width", capability_key="pattern.instance_position",
target_value="1", target_value="1",
object_signature=signature, object_signature=signature,
) )
@@ -305,6 +722,30 @@ def main() -> int:
) )
_assert(slot_success.get("ok") is True, f"fake slot.position edit should succeed: {slot_success}") _assert(slot_success.get("ok") is True, f"fake slot.position edit should succeed: {slot_success}")
_assert(slot_success.get("backend_operation") == "move_slot", f"slot.position should report move_slot: {slot_success}") _assert(slot_success.get("backend_operation") == "move_slot", f"slot.position should report move_slot: {slot_success}")
slot_width_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-width-success",
backend=backend,
capability_key="slot.width",
target_value=2.5,
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_width_success.get("ok") is True, f"fake slot.width edit should succeed: {slot_width_success}")
_assert(slot_width_success.get("backend_operation") == "change_slot_width", f"slot.width should report change_slot_width: {slot_width_success}")
slot_depth_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-success",
backend=backend,
capability_key="slot.depth",
target_value=2.0,
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_depth_success.get("ok") is True, f"fake slot.depth edit should succeed: {slot_depth_success}")
_assert(slot_depth_success.get("backend_operation") == "change_slot_depth", f"slot.depth should report change_slot_depth: {slot_depth_success}")
boss_success = run_scdm_edit_job( boss_success = run_scdm_edit_job(
step_path, step_path,
@@ -318,6 +759,97 @@ def main() -> int:
) )
_assert(boss_success.get("ok") is True, f"fake boss.position edit should succeed: {boss_success}") _assert(boss_success.get("ok") is True, f"fake boss.position edit should succeed: {boss_success}")
_assert(boss_success.get("backend_operation") == "move_boss", f"boss.position should report move_boss: {boss_success}") _assert(boss_success.get("backend_operation") == "move_boss", f"boss.position should report move_boss: {boss_success}")
boss_height_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-height-success",
backend=backend,
capability_key="boss.height",
target_value=5.5,
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_height_success.get("ok") is True, f"fake boss.height edit should succeed: {boss_height_success}")
_assert(boss_height_success.get("backend_operation") == "change_boss_height", f"boss.height should report change_boss_height: {boss_height_success}")
boss_diameter_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-success",
backend=backend,
capability_key="boss.diameter",
target_value=4.5,
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_diameter_success.get("ok") is True, f"fake boss.diameter edit should succeed: {boss_diameter_success}")
_assert(boss_diameter_success.get("backend_operation") == "change_boss_diameter", f"boss.diameter should report change_boss_diameter: {boss_diameter_success}")
round_radius_success = run_scdm_edit_job(
step_path,
output_dir=root / "round-radius-success",
backend=backend,
capability_key="round.radius",
target_value=0.75,
object_id="round:60",
object_signature=round_signature,
runner=_successful_runner,
)
_assert(round_radius_success.get("ok") is True, f"fake round.radius edit should succeed: {round_radius_success}")
_assert(round_radius_success.get("backend_operation") == "change_round_radius", f"round.radius should report change_round_radius: {round_radius_success}")
round_delete_success = run_scdm_edit_job(
step_path,
output_dir=root / "round-delete-success",
backend=backend,
capability_key="feature.delete_round_or_chamfer",
target_value="",
object_id="round:60",
object_signature=round_signature,
runner=_successful_runner,
)
_assert(round_delete_success.get("ok") is True, f"fake round/chamfer delete edit should succeed: {round_delete_success}")
_assert(round_delete_success.get("backend_operation") == "delete_round_or_chamfer", f"round/chamfer delete should report delete_round_or_chamfer: {round_delete_success}")
chamfer_distance_success = run_scdm_edit_job(
step_path,
output_dir=root / "chamfer-distance-success",
backend=backend,
capability_key="chamfer.distance",
target_value=1.2,
object_id="chamfer:63",
object_signature=chamfer_signature,
runner=_successful_runner,
)
_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}")
pattern_spacing_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-spacing-success",
backend=backend,
capability_key="pattern.spacing",
target_value=7.5,
object_id="pattern:holes-a-b-c",
object_signature=pattern_signature,
runner=_successful_runner,
)
_assert(pattern_spacing_success.get("ok") is True, f"fake pattern.spacing edit should succeed: {pattern_spacing_success}")
_assert(pattern_spacing_success.get("backend_operation") == "change_pattern_spacing", f"pattern.spacing should report change_pattern_spacing: {pattern_spacing_success}")
pattern_segment_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-success",
backend=backend,
capability_key="pattern.segment_spacing",
target_value=6.5,
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_signature,
runner=_successful_runner,
)
_assert(pattern_segment_success.get("ok") is True, f"fake pattern.segment_spacing edit should succeed: {pattern_segment_success}")
_assert(
pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should report change_pattern_segment_spacing: {pattern_segment_success}",
)
missing_output = run_scdm_edit_job( missing_output = run_scdm_edit_job(
step_path, step_path,
+663 -28
View File
@@ -95,11 +95,15 @@ def main() -> int:
_assert("StandardHoles" in script and "FindStandardHoleOptions" in script and "getattr(standard_holes, 'Find'" in script, "probe script should try SCDM StandardHoles.Find before geometric fallback") _assert("StandardHoles" in script and "FindStandardHoleOptions" in script and "getattr(standard_holes, 'Find'" in script, "probe script should try SCDM StandardHoles.Find before geometric fallback")
_assert("availableCommands" in script and "ConstantRound" in script and "Chamfer" in script, "probe script should report SCDM command availability") _assert("availableCommands" in script and "ConstantRound" in script and "Chamfer" in script, "probe script should report SCDM command availability")
_assert("RoundInfo" in script and "_round_info_from_face" in script and "change_round_radius" in script, "probe script should collect SCDM round diagnostics") _assert("RoundInfo" in script and "_round_info_from_face" in script and "change_round_radius" in script, "probe script should collect SCDM round diagnostics")
_assert("ChamferInfo" in script and "_chamfer_info_from_face" in script and "change_chamfer_distance" in script, "probe script should collect SCDM chamfer diagnostics")
_assert("SlotInfo" in script and "_slot_info_from_face" in script and "change_slot_depth" in script, "probe script should collect SCDM slot diagnostics")
_assert("backendCommandCandidates" in script, "probe script should write raw command candidates") _assert("backendCommandCandidates" in script, "probe script should write raw command candidates")
for token in ("_geometry_from_edge", "Length", "StartPoint", "EndPoint", "adjacentFaceOrdinals"): for token in ("_geometry_from_edge", "Length", "StartPoint", "EndPoint", "adjacentFaceOrdinals"):
_assert(token in script, f"probe script should enrich Edge raw geometry with {token}") _assert(token in script, f"probe script should enrich Edge raw geometry with {token}")
for token in ("_record_face_adjacency", "_face_adjacency_rows", "edgeGeometrySummary", "_final_edge_geometry_summary", "_feature_inventory"): for token in ("_record_face_adjacency", "_face_adjacency_rows", "edgeGeometrySummary", "_final_edge_geometry_summary", "_feature_inventory"):
_assert(token in script, f"probe script should summarize SCDM topology evidence with {token}") _assert(token in script, f"probe script should summarize SCDM topology evidence with {token}")
for token in ("_component_entries", "_component_body_locator_map", "componentInstances", "componentLocators"):
_assert(token in script, f"probe script should preserve SCDM component occurrence evidence with {token}")
generated = generate_scdm_probe_script(job_path) generated = generate_scdm_probe_script(job_path)
_assert("JOB_PATH =" in generated and job_path.name in generated, "generated probe script should embed the job path") _assert("JOB_PATH =" in generated and job_path.name in generated, "generated probe script should embed the job path")
@@ -123,7 +127,10 @@ def main() -> int:
{"name": "Move", "available": True}, {"name": "Move", "available": True},
{"name": "Fill", "available": True}, {"name": "Fill", "available": True},
{"name": "Delete", "available": True}, {"name": "Delete", "available": True},
{"name": "Chamfer", "available": True},
{"name": "ConstantRound", "available": True}, {"name": "ConstantRound", "available": True},
{"name": "ChamferInfo", "available": True},
{"name": "SlotInfo", "available": True},
{"name": "SomeFutureCommand", "available": False}, {"name": "SomeFutureCommand", "available": False},
], ],
"faceAdjacency": [ "faceAdjacency": [
@@ -144,18 +151,30 @@ def main() -> int:
"maxEdgeLength": 8.0, "maxEdgeLength": 8.0,
}, },
"featureInventory": { "featureInventory": {
"objectTypeCounts": {"face": 2, "hole": 1, "edge": 1, "slot": 1, "round": 1}, "objectTypeCounts": {"face": 2, "hole": 1, "edge": 1, "slot": 1, "round": 1, "chamfer": 1},
"surfaceTypeCounts": {"plane": 1, "cylinder": 3}, "surfaceTypeCounts": {"plane": 1, "cylinder": 3},
"curveTypeCounts": {"Line": 5, "Circle": 4}, "curveTypeCounts": {"Line": 5, "Circle": 4},
"operationCounts": { "operationCounts": {
"pull_face_offset": 1, "pull_face_offset": 1,
"change_hole_diameter": 1, "change_hole_diameter": 1,
"change_slot_width": 1, "change_slot_width": 1,
"change_slot_depth": 1,
"move_slot": 1, "move_slot": 1,
"change_boss_height": 1, "change_boss_height": 1,
"move_boss": 1, "move_boss": 1,
"change_chamfer_distance": 1,
}, },
}, },
"componentInstances": [
{
"backendId": "component:0",
"componentIndex": 0,
"componentPath": [0],
"componentName": "零件 3",
"contentBodyCount": 1,
"placementTranslation": [0.0, 20.0, 0.0],
}
],
}, },
"summary": {"bodyCount": 1, "objectCount": 10, "faceCount": 6, "edgeCount": 1, "holeFaceCount": 0}, "summary": {"bodyCount": 1, "objectCount": 10, "faceCount": 6, "edgeCount": 1, "holeFaceCount": 0},
"objects": [ "objects": [
@@ -233,6 +252,34 @@ def main() -> int:
], ],
"rawLimitations": [], "rawLimitations": [],
}, },
{
"backendId": "body:2/face:100",
"objectType": "face",
"geometry": {
"surfaceType": "plane",
"center": [0.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [100], "bodyIndex": 2, "faceOrdinal": 100, "globalFaceOrdinal": 100},
"backendCommandCandidates": [
{"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}},
],
"rawLimitations": [],
},
{
"backendId": "body:2/face:101",
"objectType": "face",
"geometry": {
"surfaceType": "plane",
"center": [0.0, 0.0, 1.2],
"normal": [0.0, 0.0, -1.0],
},
"topologyHint": {"faceIds": [101], "bodyIndex": 2, "faceOrdinal": 101, "globalFaceOrdinal": 101},
"backendCommandCandidates": [
{"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}},
],
"rawLimitations": [],
},
{ {
"backendId": "body:8/face:5", "backendId": "body:8/face:5",
"objectType": "face", "objectType": "face",
@@ -262,7 +309,17 @@ def main() -> int:
{ {
"backendId": "round:1", "backendId": "round:1",
"objectType": "round", "objectType": "round",
"geometry": {"radius": 1.0}, "geometry": {
"radius": 1.0,
"roundInfo": {
"radius": 1.0,
"diameter": 2.0,
"isConstant": True,
"isRound": True,
"type": "ConstantRound",
},
},
"topologyHint": {"faceIds": [60], "bodyIndex": 0, "faceOrdinal": 60, "globalFaceOrdinal": 60},
"backendCommandCandidates": [ "backendCommandCandidates": [
{"operation": "change_round_radius", "enabled": True}, {"operation": "change_round_radius", "enabled": True},
{"operation": "delete_round_or_chamfer", "enabled": True}, {"operation": "delete_round_or_chamfer", "enabled": True},
@@ -272,10 +329,16 @@ def main() -> int:
{ {
"backendId": "slot:1", "backendId": "slot:1",
"objectType": "slot", "objectType": "slot",
"geometry": {"width": 2.0, "depth": 1.5, "center": [1.0, 2.0, 3.0]}, "geometry": {"width": 2.0, "depth": 1.5, "depthAxis": [0.0, 0.0, -1.0], "center": [1.0, 2.0, 3.0]},
"topologyHint": {"faceIds": [30, 31, 32], "bodyIndex": 0, "faceOrdinal": 30}, "topologyHint": {
"faceIds": [30, 31, 32],
"bodyIndex": 0,
"faceOrdinal": 30,
"depthFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 42}],
},
"backendCommandCandidates": [ "backendCommandCandidates": [
{"operation": "change_slot_width", "enabled": True}, {"operation": "change_slot_width", "enabled": True},
{"operation": "change_slot_depth", "enabled": True},
{"operation": "move_slot", "enabled": True, "parameterFields": {"center": [1.0, 2.0, 3.0]}}, {"operation": "move_slot", "enabled": True, "parameterFields": {"center": [1.0, 2.0, 3.0]}},
], ],
"rawLimitations": [], "rawLimitations": [],
@@ -284,9 +347,16 @@ def main() -> int:
"backendId": "boss:1", "backendId": "boss:1",
"objectType": "cylindrical_boss", "objectType": "cylindrical_boss",
"geometry": {"diameter": 3.0, "height": 4.0, "center": [0.0, 0.0, 2.0]}, "geometry": {"diameter": 3.0, "height": 4.0, "center": [0.0, 0.0, 2.0]},
"topologyHint": {"faceIds": [50, 51, 52], "bodyIndex": 0, "faceOrdinal": 50}, "topologyHint": {
"faceIds": [50, 51, 52],
"bodyIndex": 0,
"faceOrdinal": 50,
"heightFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 52, "globalFaceOrdinal": 72}],
"diameterFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 50, "globalFaceOrdinal": 70}],
},
"backendCommandCandidates": [ "backendCommandCandidates": [
{"operation": "change_boss_height", "enabled": True}, {"operation": "change_boss_height", "enabled": True},
{"operation": "change_boss_diameter", "enabled": True},
{"operation": "move_boss", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 2.0]}}, {"operation": "move_boss", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 2.0]}},
], ],
"rawLimitations": [], "rawLimitations": [],
@@ -294,7 +364,18 @@ def main() -> int:
{ {
"backendId": "chamfer:1", "backendId": "chamfer:1",
"objectType": "chamfer", "objectType": "chamfer",
"geometry": {"distance": 0.8}, "geometry": {
"distance": 0.8,
"chamferInfo": {
"distance": 0.8,
"distance1": 0.8,
"distance2": 0.8,
"isEqualDistance": True,
"isChamfer": True,
"type": "EqualDistanceChamfer",
},
},
"topologyHint": {"faceIds": [63], "bodyIndex": 0, "faceOrdinal": 63, "globalFaceOrdinal": 63},
"backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}], "backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}],
"rawLimitations": [], "rawLimitations": [],
}, },
@@ -305,6 +386,48 @@ def main() -> int:
"backendCommandCandidates": [{"operation": "change_pattern_spacing", "enabled": True}], "backendCommandCandidates": [{"operation": "change_pattern_spacing", "enabled": True}],
"rawLimitations": [], "rawLimitations": [],
}, },
{
"backendId": "body:20",
"objectType": "body",
"geometry": {"center": [0.0, 20.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {
"bodyIndex": 20,
"faceIds": [300],
"faceOrdinals": [0],
"globalFaceOrdinals": [300],
"componentLocators": [{"componentIndex": 0, "componentPath": [0], "componentBodyIndex": 0, "bodyIndex": 20}],
},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:21",
"objectType": "body",
"geometry": {"center": [0.0, 25.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {
"bodyIndex": 21,
"faceIds": [301],
"faceOrdinals": [0],
"globalFaceOrdinals": [301],
"componentLocators": [{"componentIndex": 1, "componentPath": [1], "componentBodyIndex": 0, "bodyIndex": 21}],
},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:22",
"objectType": "body",
"geometry": {"center": [0.0, 30.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {
"bodyIndex": 22,
"faceIds": [302],
"faceOrdinals": [0],
"globalFaceOrdinals": [302],
"componentLocators": [{"componentIndex": 2, "componentPath": [2], "componentBodyIndex": 0, "bodyIndex": 22}],
},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{ {
"backendId": "shell:1", "backendId": "shell:1",
"objectType": "shell", "objectType": "shell",
@@ -342,7 +465,21 @@ def main() -> int:
_assert(edge_signature.get("length") == 3.14, f"Edge length should be preserved: {edge_signature}") _assert(edge_signature.get("length") == 3.14, f"Edge length should be preserved: {edge_signature}")
_assert(edge_signature.get("startPoint") == [0.0, 0.0, 0.0], f"Edge start point should be preserved: {edge_signature}") _assert(edge_signature.get("startPoint") == [0.0, 0.0, 0.0], f"Edge start point should be preserved: {edge_signature}")
_assert(edge_signature.get("adjacentFaceOrdinals") == [3, 4], f"Edge adjacent faces should be preserved: {edge_signature}") _assert(edge_signature.get("adjacentFaceOrdinals") == [3, 4], f"Edge adjacent faces should be preserved: {edge_signature}")
component_signature = geometry_signature(
{
"backendId": "body:20",
"objectType": "body",
"geometry": {"center": [0.0, 20.0, 0.0]},
"topologyHint": {
"bodyIndex": 20,
"bodyLocators": [{"bodyIndex": 20, "componentIndex": 0, "componentPath": [0], "componentBodyIndex": 0}],
"componentLocators": [{"componentIndex": 0, "componentPath": [0], "componentBodyIndex": 0, "bodyIndex": 20}],
},
}
)
_assert(component_signature.get("componentLocators"), f"component locators should be preserved in geometry signatures: {component_signature}")
_assert(cache.get("modelFingerprint") == "abc123", f"model fingerprint should be copied: {cache}") _assert(cache.get("modelFingerprint") == "abc123", f"model fingerprint should be copied: {cache}")
_assert(int(cache.get("mapperRevision") or 0) >= 2, f"SCDM cache should carry the mapper revision for disk reuse: {cache}")
_assert(cache.get("backendVersion") == "v222", f"backend version should be copied: {cache}") _assert(cache.get("backendVersion") == "v222", f"backend version should be copied: {cache}")
_assert({"hole.diameter", "hole.position"} <= _capability_keys(cache, "hole:1"), f"hole caps missing: {cache}") _assert({"hole.diameter", "hole.position"} <= _capability_keys(cache, "hole:1"), f"hole caps missing: {cache}")
objects = cache.get("objects") objects = cache.get("objects")
@@ -375,24 +512,63 @@ def main() -> int:
_assert({str(spec.get("scdm_capability_key")) for spec in face_specs} == {"face.offset"}, f"Face cache should map to offset only: {face_specs}") _assert({str(spec.get("scdm_capability_key")) for spec in face_specs} == {"face.offset"}, f"Face cache should map to offset only: {face_specs}")
gated_face_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(9,), execution_ready={"face.offset"}) gated_face_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(9,), execution_ready={"face.offset"})
_assert(gated_face_specs and all(spec.get("enabled") is True for spec in gated_face_specs), f"capability gate should enable verified face offset: {gated_face_specs}") _assert(gated_face_specs and all(spec.get("enabled") is True for spec in gated_face_specs), f"capability gate should enable verified face offset: {gated_face_specs}")
_assert({"slot.position"} <= _capability_keys(cache, "slot:1"), f"slot.position should now be productized: {cache}") _assert({"slot.width", "slot.depth", "slot.position"} <= _capability_keys(cache, "slot:1"), f"slot width/depth/position should now be productized: {cache}")
slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"slot.position"}) slot_object = next((item for item in objects if isinstance(item, dict) and item.get("objectId") == "slot:slot:1"), None)
_assert(isinstance(slot_object, dict), f"slot object should be normalized: {cache}")
slot_signature = slot_object.get("geometrySignature")
_assert(isinstance(slot_signature, dict) and slot_signature.get("depthFaceLocators"), f"slot depth locator should be preserved: {slot_signature}")
_assert(isinstance(slot_signature, dict) and slot_signature.get("depthAxis") == [0.0, 0.0, -1.0], f"slot depth axis should be preserved: {slot_signature}")
slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"slot.width", "slot.depth", "slot.position"})
_assert( _assert(
{str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.position"}, {str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.width", "slot.depth", "slot.position"},
f"slot cache should expose only productized slot.position: {slot_specs}", f"slot cache should expose productized slot.width, slot.depth and slot.position: {slot_specs}",
) )
_assert(slot_specs and slot_specs[0].get("enabled") is True, f"slot.position should enable when runner gate is ready: {slot_specs}") _assert(slot_specs and all(spec.get("enabled") is True for spec in slot_specs), f"slot width/depth/position should enable when runner gate is ready: {slot_specs}")
slot_width_spec = next((spec for spec in slot_specs if spec.get("scdm_capability_key") == "slot.width"), None)
_assert(slot_width_spec and slot_width_spec.get("value_type") == "positive", f"slot.width should use positive numeric input: {slot_specs}")
slot_depth_spec = next((spec for spec in slot_specs if spec.get("scdm_capability_key") == "slot.depth"), None)
_assert(slot_depth_spec and slot_depth_spec.get("value_type") == "positive", f"slot.depth should use positive numeric input: {slot_specs}")
blocked_slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"face.offset"}) blocked_slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"face.offset"})
_assert(blocked_slot_specs and all(spec.get("enabled") is False for spec in blocked_slot_specs), f"slot.position should honor runner gate: {blocked_slot_specs}") _assert(blocked_slot_specs and all(spec.get("enabled") is False for spec in blocked_slot_specs), f"slot capabilities should honor runner gate: {blocked_slot_specs}")
_assert({"boss.position"} <= _capability_keys(cache, "boss:1"), f"boss.position should now be productized: {cache}") _assert({"boss.diameter", "boss.height", "boss.position"} <= _capability_keys(cache, "boss:1"), f"boss diameter/height/position should now be productized: {cache}")
boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"boss.position"}) boss_object = next((item for item in objects if isinstance(item, dict) and item.get("objectType") == "cylindrical_boss"), None)
_assert(isinstance(boss_object, dict), f"boss object should be normalized: {cache}")
boss_signature = boss_object.get("geometrySignature")
_assert(isinstance(boss_signature, dict) and boss_signature.get("heightFaceLocators"), f"boss height locator should be preserved: {boss_signature}")
_assert(isinstance(boss_signature, dict) and boss_signature.get("diameterFaceLocators"), f"boss diameter locator should be preserved: {boss_signature}")
boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"boss.diameter", "boss.height", "boss.position"})
_assert( _assert(
{str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.position"}, {str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.diameter", "boss.height", "boss.position"},
f"boss cache should expose only productized boss.position: {boss_specs}", f"boss cache should expose productized boss.diameter, boss.height and boss.position: {boss_specs}",
) )
_assert(boss_specs and boss_specs[0].get("enabled") is True, f"boss.position should enable when runner gate is ready: {boss_specs}") _assert(boss_specs and all(spec.get("enabled") is True for spec in boss_specs), f"boss diameter/height/position should enable when runner gate is ready: {boss_specs}")
blocked_boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"slot.position"}) blocked_boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"slot.position"})
_assert(blocked_boss_specs and all(spec.get("enabled") is False for spec in blocked_boss_specs), f"boss.position should honor runner gate: {blocked_boss_specs}") _assert(blocked_boss_specs and all(spec.get("enabled") is False for spec in blocked_boss_specs), f"boss capabilities should honor runner gate: {blocked_boss_specs}")
_assert({"round.radius", "feature.delete_round_or_chamfer"} <= _capability_keys(cache, "round:1"), f"round radius/delete should now be productized: {cache}")
round_object = next((item for item in objects if isinstance(item, dict) and item.get("objectId") == "round:round:1"), None)
_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_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"},
f"round cache should expose productized radius and delete command: {round_specs}",
)
round_radius_spec = next((spec for spec in round_specs if spec.get("scdm_capability_key") == "round.radius"), None)
round_delete_spec = next((spec for spec in round_specs if spec.get("scdm_capability_key") == "feature.delete_round_or_chamfer"), None)
_assert(round_radius_spec and round_radius_spec.get("value_type") == "positive" and round_radius_spec.get("enabled") is True, f"round.radius should be executable when runner gate is ready: {round_specs}")
_assert(round_delete_spec and round_delete_spec.get("value_type") == "command" and round_delete_spec.get("enabled") is True, f"round delete command should be executable when runner gate is ready: {round_specs}")
_assert({"chamfer.distance"} <= _capability_keys(cache, "chamfer:1"), f"chamfer distance should now be productized: {cache}")
chamfer_object = next((item for item in objects if isinstance(item, dict) and item.get("objectId") == "chamfer:chamfer:1"), None)
_assert(isinstance(chamfer_object, dict), f"chamfer object should be normalized: {cache}")
chamfer_signature = chamfer_object.get("geometrySignature")
_assert(isinstance(chamfer_signature, dict) and chamfer_signature.get("isEqualDistanceChamfer") is True, f"chamfer equal-distance evidence should be preserved: {chamfer_signature}")
chamfer_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(63,), execution_ready={"chamfer.distance"})
_assert(
{str(spec.get("scdm_capability_key")) for spec in chamfer_specs} == {"chamfer.distance"},
f"chamfer cache should expose productized chamfer.distance: {chamfer_specs}",
)
_assert(chamfer_specs and chamfer_specs[0].get("value_type") == "positive" and chamfer_specs[0].get("enabled") is True, f"chamfer.distance should be executable when runner gate is ready: {chamfer_specs}")
raw_without_local_ids = { raw_without_local_ids = {
"schemaVersion": 1, "schemaVersion": 1,
@@ -452,6 +628,163 @@ def main() -> int:
grouped_specs = property_specs_from_scdm_cache(enriched_group, selected_face_ids=(96,), execution_ready=True) grouped_specs = property_specs_from_scdm_cache(enriched_group, selected_face_ids=(96,), execution_ready=True)
grouped_keys = {str(spec.get("scdm_capability_key")) for spec in grouped_specs} grouped_keys = {str(spec.get("scdm_capability_key")) for spec in grouped_specs}
_assert({"hole.diameter", "hole.position", "feature.fill"} <= grouped_keys, f"local Face IDs should attach to SCDM cylinder groups: {enriched_group}") _assert({"hole.diameter", "hole.position", "feature.fill"} <= grouped_keys, f"local Face IDs should attach to SCDM cylinder groups: {enriched_group}")
body_pattern_without_face_ids = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "Move", "available": True}]},
"objects": [
{
"backendId": "body:20",
"objectType": "body",
"geometry": {"center": [0.0, 20.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 20, "faceOrdinals": [0], "globalFaceOrdinals": [300]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:21",
"objectType": "body",
"geometry": {"center": [0.0, 25.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 21, "faceOrdinals": [0], "globalFaceOrdinals": [301]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:22",
"objectType": "body",
"geometry": {"center": [0.0, 30.0, 0.0], "bboxSize": [1.0, 2.0, 3.0], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 22, "faceOrdinals": [0], "globalFaceOrdinals": [302]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
],
}
enriched_body_pattern = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(body_pattern_without_face_ids),
[
{"faceId": 300, "bodyIndex": 20, "faceOrdinal": 0, "globalFaceOrdinal": 300, "surfaceType": "plane"},
{"faceId": 301, "bodyIndex": 21, "faceOrdinal": 0, "globalFaceOrdinal": 301, "surfaceType": "plane"},
{"faceId": 302, "bodyIndex": 22, "faceOrdinal": 0, "globalFaceOrdinal": 302, "surfaceType": "plane"},
],
)
body_pattern_specs_from_ordinals = property_specs_from_scdm_cache(enriched_body_pattern, selected_face_ids=(301,), execution_ready={"pattern.spacing"})
_assert(
any(spec.get("scdm_capability_key") == "pattern.spacing" for spec in body_pattern_specs_from_ordinals),
f"body pattern spacing should attach local Face IDs from SCDM ordinals: {enriched_body_pattern}",
)
unit_scaled_face_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "m"},
"diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": True}]},
"objects": [
{
"backendId": "body:0/face:9",
"objectType": "face",
"geometry": {"surfaceType": "plane", "axis": [-1.0, 0.0, 0.0], "planeOffset": -0.00125},
"topologyHint": {"bodyIndex": 0, "faceOrdinal": 9, "globalFaceOrdinal": 9},
"backendCommandCandidates": [{"operation": "pull_face_offset", "enabled": True}],
"rawLimitations": [],
}
],
}
unit_scaled_face_cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(unit_scaled_face_raw),
[
{
"faceId": 9,
"bodyIndex": 0,
"faceOrdinal": 9,
"globalFaceOrdinal": 9,
"surfaceType": "plane",
"axis": [-1.0, 0.0, 0.0],
"planeOffset": -1.25,
}
],
)
unit_scaled_face_signature = unit_scaled_face_cache["objects"][0]["geometrySignature"] # type: ignore[index]
_assert(abs(float(unit_scaled_face_signature.get("localUnitScale") or 0.0) - 0.001) <= 1.0e-12, f"ordinal-matched Face should infer SCDM/local unit scale: {unit_scaled_face_signature}")
unit_scaled_face_specs = property_specs_from_scdm_cache(unit_scaled_face_cache, selected_face_ids=(9,), execution_ready=True)
_assert(
unit_scaled_face_specs and unit_scaled_face_specs[0].get("current_text") == "-1.25",
f"SCDM length values should display in local model units: {unit_scaled_face_specs}",
)
unit_scaled_body_pattern_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "m"},
"diagnostics": {"availableCommands": [{"name": "Move", "available": True}]},
"objects": [
{
"backendId": "body:20",
"objectType": "body",
"geometry": {"center": [0.0, 0.020, 0.0], "bboxSize": [0.001, 0.002, 0.003], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 20, "faceOrdinals": [0], "globalFaceOrdinals": [300]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:21",
"objectType": "body",
"geometry": {"center": [0.0, 0.025, 0.0], "bboxSize": [0.001, 0.002, 0.003], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 21, "faceOrdinals": [0], "globalFaceOrdinals": [301]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "body:22",
"objectType": "body",
"geometry": {"center": [0.0, 0.030, 0.0], "bboxSize": [0.001, 0.002, 0.003], "faceCount": 6, "edgeCount": 12},
"topologyHint": {"bodyIndex": 22, "faceOrdinals": [0], "globalFaceOrdinals": [302]},
"backendCommandCandidates": [{"operation": "move_body", "enabled": True}],
"rawLimitations": [],
},
],
}
unit_scaled_body_pattern_cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(unit_scaled_body_pattern_raw),
[
{"faceId": 300, "bodyIndex": 20, "faceOrdinal": 0, "globalFaceOrdinal": 300, "surfaceType": "plane", "bboxMin": [-0.5, 19.0, 0.0], "bboxMax": [0.5, 21.0, 1.0]},
{"faceId": 301, "bodyIndex": 21, "faceOrdinal": 0, "globalFaceOrdinal": 301, "surfaceType": "plane", "bboxMin": [-0.5, 24.0, 0.0], "bboxMax": [0.5, 26.0, 1.0]},
{"faceId": 302, "bodyIndex": 22, "faceOrdinal": 0, "globalFaceOrdinal": 302, "surfaceType": "plane", "bboxMin": [-0.5, 29.0, 0.0], "bboxMax": [0.5, 31.0, 1.0]},
{"faceId": 399, "bodyIndex": 99, "faceOrdinal": 0, "globalFaceOrdinal": 399, "surfaceType": "plane", "axis": [0.0, 0.0, 1.0], "planeOffset": 0.0, "bboxMin": [-1.0, 18.0, 0.0], "bboxMax": [1.0, 32.0, 0.0], "area": 28.0},
],
)
unit_scaled_body_pattern = next(
item for item in unit_scaled_body_pattern_cache["objects"] if item.get("objectType") == "linear_pattern" # type: ignore[index]
)
unit_scaled_body_signature = unit_scaled_body_pattern["geometrySignature"] # type: ignore[index]
_assert(abs(float(unit_scaled_body_signature.get("localUnitScale") or 0.0) - 0.001) <= 1.0e-12, f"body pattern should infer unit scale from local body centers: {unit_scaled_body_signature}")
_assert(unit_scaled_body_signature.get("supportFaceIds") == [399], f"body pattern should expose its support Face: {unit_scaled_body_signature}")
fit = unit_scaled_body_signature.get("supportPatternFit")
_assert(isinstance(fit, dict) and fit.get("supportFaceIds") == [399], f"body pattern should record support fit limits: {unit_scaled_body_signature}")
_assert(abs(float(fit.get("maxSpacingLocal") or 0.0) - 6.0) <= 1.0e-9, f"support fit should limit centered spacing: {fit}")
unit_scaled_body_specs = property_specs_from_scdm_cache(
{"objects": [unit_scaled_body_pattern]},
selected_face_ids=(399,),
execution_ready={"pattern.spacing", "pattern.segment_spacing"},
)
_assert(
unit_scaled_body_specs and unit_scaled_body_specs[0].get("current_text") == "5",
f"support Face should expose pattern spacing in local units: {unit_scaled_body_specs}",
)
_assert(
unit_scaled_body_specs[0].get("max_value") == 6.0,
f"support Face pattern spacing should expose a local-unit hard limit: {unit_scaled_body_specs}",
)
local_segment_spec = next(
(spec for spec in unit_scaled_body_specs if spec.get("scdm_capability_key") == "pattern.segment_spacing"),
None,
)
_assert(
isinstance(local_segment_spec, dict)
and str(local_segment_spec.get("label") or "").startswith("Solid20-Solid21")
and str(local_segment_spec.get("range_hint") or "").startswith("固定前项,移动后侧:")
and "固定 Solid20" in str(local_segment_spec.get("range_hint") or "")
and "平移 Solid21" in str(local_segment_spec.get("range_hint") or ""),
f"local mapped pattern segment labels should use visible Solid IDs and explain motion semantics: {unit_scaled_body_specs}",
)
missing_command_raw = { missing_command_raw = {
"schemaVersion": 1, "schemaVersion": 1,
@@ -479,6 +812,218 @@ 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) 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(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}") _assert("OffsetFaces" in str(missing_command_specs[0].get("disabled_tip") or ""), f"disabled reason should name the missing SCDM command: {missing_command_specs}")
missing_current_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": "slot:missing-center",
"objectType": "slot",
"geometry": {"width": 2.0},
"topologyHint": {"faceIds": [44]},
"backendCommandCandidates": [{"operation": "move_slot", "enabled": True}],
"rawLimitations": [],
}
],
}
missing_current_specs = property_specs_from_scdm_cache(map_scdm_raw_features(missing_current_raw), selected_face_ids=(44,), execution_ready={"slot.width", "slot.position"})
slot_position_missing_current = next((spec for spec in missing_current_specs if spec.get("scdm_capability_key") == "slot.position"), None)
_assert(slot_position_missing_current and slot_position_missing_current.get("enabled") is False, f"missing current value should disable slot.position: {missing_current_specs}")
_assert("没有返回可用于编辑的当前值" in str(slot_position_missing_current.get("disabled_tip") or ""), f"missing current value should be explained: {missing_current_specs}")
slot_missing_depth_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": "slot:missing-depth-locator",
"objectType": "slot",
"geometry": {"width": 2.0, "depth": 1.5, "depthAxis": [0.0, 0.0, -1.0], "center": [1.0, 2.0, 3.0]},
"topologyHint": {"faceIds": [45], "bodyIndex": 0, "faceOrdinal": 45},
"backendCommandCandidates": [{"operation": "change_slot_depth", "enabled": True}],
"rawLimitations": [],
}
],
}
slot_missing_depth_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(slot_missing_depth_locator_raw),
selected_face_ids=(45,),
execution_ready={"slot.depth"},
)
slot_depth_without_locator = next((spec for spec in slot_missing_depth_locator_specs if spec.get("scdm_capability_key") == "slot.depth"), None)
_assert(slot_depth_without_locator and slot_depth_without_locator.get("enabled") is False, f"slot.depth should require a bottom face locator: {slot_missing_depth_locator_specs}")
_assert("槽底面定位信息" in str(slot_depth_without_locator.get("disabled_tip") or ""), f"slot.depth missing locator should be explained: {slot_missing_depth_locator_specs}")
slot_missing_depth_axis_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": "slot:missing-depth-axis",
"objectType": "slot",
"geometry": {"width": 2.0, "depth": 1.5, "center": [1.0, 2.0, 3.0]},
"topologyHint": {"faceIds": [46], "bodyIndex": 0, "faceOrdinal": 46, "depthFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 46}]},
"backendCommandCandidates": [{"operation": "change_slot_depth", "enabled": True}],
"rawLimitations": [],
}
],
}
slot_missing_depth_axis_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(slot_missing_depth_axis_raw),
selected_face_ids=(46,),
execution_ready={"slot.depth"},
)
slot_depth_without_axis = next((spec for spec in slot_missing_depth_axis_specs if spec.get("scdm_capability_key") == "slot.depth"), None)
_assert(slot_depth_without_axis and slot_depth_without_axis.get("enabled") is False, f"slot.depth should require a depth axis: {slot_missing_depth_axis_specs}")
_assert("槽深方向" in str(slot_depth_without_axis.get("disabled_tip") or ""), f"slot.depth missing axis should be explained: {slot_missing_depth_axis_specs}")
boss_missing_height_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": "boss:missing-height-locator",
"objectType": "cylindrical_boss",
"geometry": {"diameter": 3.0, "height": 4.0, "center": [0.0, 0.0, 2.0]},
"topologyHint": {"faceIds": [55], "bodyIndex": 0, "faceOrdinal": 55},
"backendCommandCandidates": [{"operation": "change_boss_height", "enabled": True}],
"rawLimitations": [],
}
],
}
boss_missing_height_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(boss_missing_height_locator_raw),
selected_face_ids=(55,),
execution_ready={"boss.height"},
)
boss_height_without_locator = next((spec for spec in boss_missing_height_locator_specs if spec.get("scdm_capability_key") == "boss.height"), None)
_assert(boss_height_without_locator and boss_height_without_locator.get("enabled") is False, f"boss.height should require a top face locator: {boss_missing_height_locator_specs}")
_assert("顶面定位信息" in str(boss_height_without_locator.get("disabled_tip") or ""), f"boss.height missing locator should be explained: {boss_missing_height_locator_specs}")
boss_missing_diameter_locator_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": True}]},
"objects": [
{
"backendId": "boss:missing-diameter-locator",
"objectType": "cylindrical_boss",
"geometry": {"diameter": 3.0, "height": 4.0, "center": [0.0, 0.0, 2.0]},
"topologyHint": {"faceIds": [56], "bodyIndex": 0, "faceOrdinal": 56},
"backendCommandCandidates": [{"operation": "change_boss_diameter", "enabled": True}],
"rawLimitations": [],
}
],
}
boss_missing_diameter_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(boss_missing_diameter_locator_raw),
selected_face_ids=(56,),
execution_ready={"boss.diameter"},
)
boss_diameter_without_locator = next((spec for spec in boss_missing_diameter_locator_specs if spec.get("scdm_capability_key") == "boss.diameter"), None)
_assert(boss_diameter_without_locator and boss_diameter_without_locator.get("enabled") is False, f"boss.diameter should require a side face locator: {boss_missing_diameter_locator_specs}")
_assert("侧壁定位信息" in str(boss_diameter_without_locator.get("disabled_tip") or ""), f"boss.diameter missing locator should be explained: {boss_missing_diameter_locator_specs}")
round_missing_constant_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "ConstantRound", "available": True}]},
"objects": [
{
"backendId": "round:missing-constant",
"objectType": "round",
"geometry": {"roundInfo": {"radius": 1.0, "isRound": True}},
"topologyHint": {"faceIds": [61], "bodyIndex": 0, "faceOrdinal": 61},
"backendCommandCandidates": [{"operation": "change_round_radius", "enabled": True}],
"rawLimitations": [],
}
],
}
round_missing_constant_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(round_missing_constant_raw),
selected_face_ids=(61,),
execution_ready={"round.radius"},
)
round_without_constant = next((spec for spec in round_missing_constant_specs if spec.get("scdm_capability_key") == "round.radius"), None)
_assert(round_without_constant and round_without_constant.get("enabled") is False, f"round.radius should require constant-round evidence: {round_missing_constant_specs}")
_assert("等半径圆角证据" in str(round_without_constant.get("disabled_tip") or ""), f"round.radius missing constant evidence should be explained: {round_missing_constant_specs}")
round_missing_locator_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "ConstantRound", "available": True}]},
"objects": [
{
"backendId": "round:missing-locator",
"objectType": "round",
"geometry": {"roundInfo": {"radius": 1.0, "isConstant": True, "isRound": True}},
"topologyHint": {"faceIds": [62]},
"backendCommandCandidates": [{"operation": "change_round_radius", "enabled": True}],
"rawLimitations": [],
}
],
}
round_missing_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(round_missing_locator_raw),
selected_face_ids=(62,),
execution_ready={"round.radius"},
)
round_without_locator = next((spec for spec in round_missing_locator_specs if spec.get("scdm_capability_key") == "round.radius"), None)
_assert(round_without_locator and round_without_locator.get("enabled") is False, f"round.radius should require SCDM face locator evidence: {round_missing_locator_specs}")
_assert("可定位的圆角面" in str(round_without_locator.get("disabled_tip") or ""), f"round.radius missing locator should be explained: {round_missing_locator_specs}")
chamfer_missing_equal_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "Chamfer", "available": True}]},
"objects": [
{
"backendId": "chamfer:missing-equal",
"objectType": "chamfer",
"geometry": {"chamferInfo": {"distance": 0.8, "isChamfer": True}},
"topologyHint": {"faceIds": [64], "bodyIndex": 0, "faceOrdinal": 64},
"backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}],
"rawLimitations": [],
}
],
}
chamfer_missing_equal_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(chamfer_missing_equal_raw),
selected_face_ids=(64,),
execution_ready={"chamfer.distance"},
)
chamfer_without_equal = next((spec for spec in chamfer_missing_equal_specs if spec.get("scdm_capability_key") == "chamfer.distance"), None)
_assert(chamfer_without_equal and chamfer_without_equal.get("enabled") is False, f"chamfer.distance should require equal-distance evidence: {chamfer_missing_equal_specs}")
_assert("等距倒角证据" in str(chamfer_without_equal.get("disabled_tip") or ""), f"chamfer.distance missing equal evidence should be explained: {chamfer_missing_equal_specs}")
chamfer_missing_locator_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "Chamfer", "available": True}]},
"objects": [
{
"backendId": "chamfer:missing-locator",
"objectType": "chamfer",
"geometry": {"chamferInfo": {"distance": 0.8, "isEqualDistance": True, "isChamfer": True}},
"topologyHint": {"faceIds": [65]},
"backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}],
"rawLimitations": [],
}
],
}
chamfer_missing_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(chamfer_missing_locator_raw),
selected_face_ids=(65,),
execution_ready={"chamfer.distance"},
)
chamfer_without_locator = next((spec for spec in chamfer_missing_locator_specs if spec.get("scdm_capability_key") == "chamfer.distance"), None)
_assert(chamfer_without_locator and chamfer_without_locator.get("enabled") is False, f"chamfer.distance should require SCDM face locator evidence: {chamfer_missing_locator_specs}")
_assert("可定位的倒角面" in str(chamfer_without_locator.get("disabled_tip") or ""), f"chamfer.distance missing locator should be explained: {chamfer_missing_locator_specs}")
diagnostics = cache.get("diagnostics") diagnostics = cache.get("diagnostics")
_assert(isinstance(diagnostics, dict), f"cache diagnostics should be present: {cache}") _assert(isinstance(diagnostics, dict), f"cache diagnostics should be present: {cache}")
raw_summary = diagnostics.get("raw_summary") raw_summary = diagnostics.get("raw_summary")
@@ -499,11 +1044,18 @@ def main() -> int:
and feature_inventory.get("operationCounts", {}).get("change_slot_width") == 1, and feature_inventory.get("operationCounts", {}).get("change_slot_width") == 1,
f"SCDM feature inventory should be preserved: {cache}", f"SCDM feature inventory should be preserved: {cache}",
) )
component_instances = diagnostics.get("component_instances")
_assert(
isinstance(component_instances, list)
and component_instances
and component_instances[0].get("componentPath") == [0],
f"SCDM component occurrence diagnostics should be preserved: {cache}",
)
geometry_hints = diagnostics.get("geometry_candidate_hints") 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) and geometry_hints, f"SCDM structural candidate hints should be produced: {cache}")
hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)} hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)}
_assert( _assert(
{"slot.width", "boss.height", "round.radius", "chamfer.distance", "pattern.spacing", "shell.thickness"} <= hint_keys, {"pattern.instance_position", "shell.thickness"} <= 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}", f"S7 geometry hints should cover planned feature families: {hint_keys}",
) )
derived_candidates = diagnostics.get("derived_feature_candidates") derived_candidates = diagnostics.get("derived_feature_candidates")
@@ -516,20 +1068,103 @@ def main() -> int:
_assert(pattern_signature.get("instanceCount") == 3, f"linear pattern count should be preserved: {pattern_signature}") _assert(pattern_signature.get("instanceCount") == 3, f"linear pattern count should be preserved: {pattern_signature}")
_assert(pattern_signature.get("axis") == [1.0, 0.0, 0.0], f"linear pattern axis should be preserved: {pattern_signature}") _assert(pattern_signature.get("axis") == [1.0, 0.0, 0.0], f"linear pattern axis should be preserved: {pattern_signature}")
_assert(len(pattern_signature.get("instanceCenters") or []) == 3, f"linear pattern centers should be preserved: {pattern_signature}") _assert(len(pattern_signature.get("instanceCenters") or []) == 3, f"linear pattern centers should be preserved: {pattern_signature}")
_assert(len(pattern_signature.get("patternInstances") or []) == 3, f"linear pattern instance locators should be preserved: {pattern_signature}")
_assert({"pattern.spacing"} <= _capability_keys(cache, "derived:linear_pattern"), f"derived linear pattern should expose productized spacing: {cache}")
thin_wall_candidate = next((item for item in derived_candidates if isinstance(item, dict) and item.get("objectType") == "thin_wall"), None)
_assert(isinstance(thin_wall_candidate, dict), f"paired planar faces should produce a thin-wall candidate: {derived_candidates}")
thin_wall_signature = thin_wall_candidate.get("geometrySignature")
_assert(isinstance(thin_wall_signature, dict), f"thin-wall candidate should keep a geometry signature: {thin_wall_candidate}")
_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}")
body_pattern_candidate = next(
(
item
for item in derived_candidates
if isinstance(item, dict)
and item.get("objectType") == "linear_pattern"
and isinstance(item.get("geometrySignature"), dict)
and item.get("geometrySignature", {}).get("patternKind") == "body"
),
None,
)
_assert(isinstance(body_pattern_candidate, dict), f"repeated bodies should produce a body linear-pattern candidate: {derived_candidates}")
body_pattern_signature = body_pattern_candidate.get("geometrySignature")
_assert(isinstance(body_pattern_signature, dict), f"body pattern should keep a geometry signature: {body_pattern_candidate}")
_assert(body_pattern_signature.get("spacing") == 5.0, f"body pattern spacing should be preserved: {body_pattern_signature}")
_assert(body_pattern_signature.get("axis") == [0.0, 1.0, 0.0], f"body pattern axis should be preserved: {body_pattern_signature}")
_assert(body_pattern_signature.get("bodyIndices") == [20, 21, 22], f"body pattern body indices should be preserved: {body_pattern_signature}")
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_spacing = [
spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.spacing"
]
_assert(body_pattern_spacing, f"selecting a body member face should keep body pattern spacing as a recognized capability: {body_pattern_specs}")
_assert(
body_pattern_spacing[0].get("enabled") is True,
f"body pattern spacing should be executable when every member has a component occurrence locator: {body_pattern_spacing}",
)
body_segment_specs = [
spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.segment_spacing"
]
_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 "")
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}",
)
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),
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}",
)
for mode in body_segment_modes.values():
if not isinstance(mode, dict):
continue
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_specs[0].get("scope_text") or "")
and "移动后侧" in str(body_segment_specs[0].get("scope_text") or ""),
f"local pattern spacing should expose motion semantics in the intent column: {body_segment_specs}",
)
body_segment_signature = body_segment_specs[0].get("scdm_geometry_signature")
_assert(
isinstance(body_segment_signature, dict)
and body_segment_signature.get("segmentIndex") == 0
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"})
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}")
face_segment_specs = [
spec for spec in pattern_specs if spec.get("scdm_capability_key") == "pattern.segment_spacing"
]
_assert(len(face_segment_specs) == 2, f"three face pattern members should expose two local spacing segments: {pattern_specs}")
_assert(
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}",
)
not_productized = diagnostics.get("discovered_not_productized") not_productized = diagnostics.get("discovered_not_productized")
_assert(isinstance(not_productized, list) and not_productized, f"round candidate should stay diagnostic only: {cache}") _assert(isinstance(not_productized, list) and not_productized, f"non-productized candidates should stay diagnostic only: {cache}")
planned = diagnostics.get("planned_not_productized") planned = diagnostics.get("planned_not_productized")
_assert(isinstance(planned, list), f"planned diagnostics should be present: {cache}") _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)} planned_keys = {str(item.get("capabilityKey")) for item in planned if isinstance(item, dict)}
expected_planned = { expected_planned = {
"slot.width",
"slot.depth",
"boss.height",
"boss.diameter",
"round.radius",
"feature.delete_round_or_chamfer",
"chamfer.distance",
"pattern.spacing",
"pattern.instance_position", "pattern.instance_position",
"shell.thickness", "shell.thickness",
} }
+487
View File
@@ -44,6 +44,29 @@ def _hole(object_id: str, face_id: int, *, diameter: float, center: tuple[float,
} }
def _feature(
object_id: str,
object_type: str,
face_id: int,
*,
center: tuple[float, float, float],
capability_key: str,
) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": object_type,
"geometrySignature": {
"objectType": object_type,
"faceIds": [face_id],
"center": list(center),
"axis": [0.0, 0.0, 1.0],
},
"capabilities": [
{"key": capability_key, "currentValue": list(center)},
],
}
def _cache(*objects: dict[str, object]) -> dict[str, object]: def _cache(*objects: dict[str, object]) -> dict[str, object]:
return { return {
"schemaVersion": 1, "schemaVersion": 1,
@@ -128,6 +151,419 @@ def main() -> int:
position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0]) position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0])
_assert(position_check.get("ok") is True, f"position target should pass: {position_check}") _assert(position_check.get("ok") is True, f"position target should pass: {position_check}")
before_slot = _cache(_feature("slot:30", "slot", 30, center=(1.0, 2.0, 3.0), capability_key="slot.position"))
after_slot = _cache(_feature("slot:40", "slot", 40, center=(1.0, 2.0, 6.0), capability_key="slot.position"))
slot_before_signature = before_slot["objects"][0]["geometrySignature"] # type: ignore[index]
slot_match = match_scdm_object_by_signature(slot_before_signature, after_slot, capability_key="slot.position")
_assert(slot_match.get("status") == "unique", f"moved slot should match without old center lock: {slot_match}")
slot_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_before_signature,
before_cache=before_slot,
after_cache=after_slot,
capability_key="slot.position",
expected_target=[1.0, 2.0, 6.0],
edited_object_id="slot:30",
)
_assert(slot_ok.get("ok") is True, f"slot.position result should validate target center: {slot_ok}")
_assert(slot_ok.get("targetCheck", {}).get("ok") is True, f"slot target should be checked: {slot_ok}")
before_slot_width = _cache(
{
"objectId": "slot:30",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [30],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.0,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.0}],
}
)
after_slot_width = _cache(
{
"objectId": "slot:40",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [40],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.5,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.5}],
}
)
slot_width_before_signature = before_slot_width["objects"][0]["geometrySignature"] # type: ignore[index]
slot_width_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_width_before_signature,
before_cache=before_slot_width,
after_cache=after_slot_width,
capability_key="slot.width",
expected_target=2.5,
edited_object_id="slot:30",
)
_assert(slot_width_ok.get("ok") is True, f"slot.width result should validate target width: {slot_width_ok}")
slot_width_mismatch = check_scdm_target(after_slot_width["objects"][0], capability_key="slot.width", expected_target=2.1) # type: ignore[index]
_assert(slot_width_mismatch.get("ok") is False and slot_width_mismatch.get("reason") == "target-mismatch", f"slot.width mismatch should fail: {slot_width_mismatch}")
before_slot_depth = _cache(
{
"objectId": "slot:31",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [31],
"center": [1.0, 2.0, 3.0],
"depth": 1.5,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 1.5}],
}
)
after_slot_depth = _cache(
{
"objectId": "slot:41",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [41],
"center": [1.0, 2.0, 3.0],
"depth": 2.0,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 2.0}],
}
)
slot_depth_before_signature = before_slot_depth["objects"][0]["geometrySignature"] # type: ignore[index]
slot_depth_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_depth_before_signature,
before_cache=before_slot_depth,
after_cache=after_slot_depth,
capability_key="slot.depth",
expected_target=2.0,
edited_object_id="slot:31",
)
_assert(slot_depth_ok.get("ok") is True, f"slot.depth result should validate target depth: {slot_depth_ok}")
slot_depth_mismatch = check_scdm_target(after_slot_depth["objects"][0], capability_key="slot.depth", expected_target=1.5) # type: ignore[index]
_assert(slot_depth_mismatch.get("ok") is False and slot_depth_mismatch.get("reason") == "target-mismatch", f"slot.depth mismatch should fail: {slot_depth_mismatch}")
boss_after = _feature("boss:50", "cylindrical_boss", 50, center=(3.0, 0.0, 2.0), capability_key="boss.position")
boss_check = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[3.0, 0.0, 2.0])
_assert(boss_check.get("ok") is True, f"boss.position target should pass: {boss_check}")
boss_mismatch = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[4.0, 0.0, 2.0])
_assert(boss_mismatch.get("ok") is False and boss_mismatch.get("reason") == "target-mismatch", f"boss.position mismatch should fail: {boss_mismatch}")
before_boss_height = _cache(
{
"objectId": "boss:50",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [50, 51, 52],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.height", "currentValue": 4.0}],
}
)
after_boss_height = _cache(
{
"objectId": "boss:55",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [55, 56, 57],
"center": [0.0, 0.0, 2.75],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 5.5,
},
"capabilities": [{"key": "boss.height", "currentValue": 5.5}],
}
)
boss_height_signature = before_boss_height["objects"][0]["geometrySignature"] # type: ignore[index]
boss_height_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_height_signature,
before_cache=before_boss_height,
after_cache=after_boss_height,
capability_key="boss.height",
expected_target=5.5,
edited_object_id="boss:50",
)
_assert(boss_height_ok.get("ok") is True, f"boss.height result should validate target height: {boss_height_ok}")
boss_height_mismatch = check_scdm_target(after_boss_height["objects"][0], capability_key="boss.height", expected_target=4.5) # type: ignore[index]
_assert(boss_height_mismatch.get("ok") is False and boss_height_mismatch.get("reason") == "target-mismatch", f"boss.height mismatch should fail: {boss_height_mismatch}")
before_boss_diameter = _cache(
{
"objectId": "boss:60",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [60, 61, 62],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 3.0}],
}
)
after_boss_diameter = _cache(
{
"objectId": "boss:63",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [63, 64, 65],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 4.5,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 4.5}],
}
)
boss_diameter_signature = before_boss_diameter["objects"][0]["geometrySignature"] # type: ignore[index]
boss_diameter_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_diameter_signature,
before_cache=before_boss_diameter,
after_cache=after_boss_diameter,
capability_key="boss.diameter",
expected_target=4.5,
edited_object_id="boss:60",
)
_assert(boss_diameter_ok.get("ok") is True, f"boss.diameter result should validate target diameter: {boss_diameter_ok}")
boss_diameter_mismatch = check_scdm_target(after_boss_diameter["objects"][0], capability_key="boss.diameter", expected_target=3.5) # type: ignore[index]
_assert(boss_diameter_mismatch.get("ok") is False and boss_diameter_mismatch.get("reason") == "target-mismatch", f"boss.diameter mismatch should fail: {boss_diameter_mismatch}")
before_round_radius = _cache(
{
"objectId": "round:70",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [70],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.5,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.5}],
}
)
after_round_radius = _cache(
{
"objectId": "round:71",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [71],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.75,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.75}],
}
)
round_radius_signature = before_round_radius["objects"][0]["geometrySignature"] # type: ignore[index]
round_radius_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_radius_signature,
before_cache=before_round_radius,
after_cache=after_round_radius,
capability_key="round.radius",
expected_target=0.75,
edited_object_id="round:70",
)
_assert(round_radius_ok.get("ok") is True, f"round.radius result should validate target radius: {round_radius_ok}")
round_radius_mismatch = check_scdm_target(after_round_radius["objects"][0], capability_key="round.radius", expected_target=0.5) # type: ignore[index]
_assert(round_radius_mismatch.get("ok") is False and round_radius_mismatch.get("reason") == "target-mismatch", f"round.radius mismatch should fail: {round_radius_mismatch}")
before_chamfer_distance = _cache(
{
"objectId": "chamfer:80",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [80],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 0.8,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 0.8}],
}
)
after_chamfer_distance = _cache(
{
"objectId": "chamfer:81",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [81],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 1.2,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 1.2}],
}
)
chamfer_distance_signature = before_chamfer_distance["objects"][0]["geometrySignature"] # type: ignore[index]
chamfer_distance_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=chamfer_distance_signature,
before_cache=before_chamfer_distance,
after_cache=after_chamfer_distance,
capability_key="chamfer.distance",
expected_target=1.2,
edited_object_id="chamfer:80",
)
_assert(chamfer_distance_ok.get("ok") is True, f"chamfer.distance result should validate target distance: {chamfer_distance_ok}")
chamfer_distance_mismatch = check_scdm_target(after_chamfer_distance["objects"][0], capability_key="chamfer.distance", expected_target=0.8) # type: ignore[index]
_assert(chamfer_distance_mismatch.get("ok") is False and chamfer_distance_mismatch.get("reason") == "target-mismatch", f"chamfer.distance mismatch should fail: {chamfer_distance_mismatch}")
before_pattern_spacing = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 5.0}],
}
)
after_pattern_spacing = _cache(
{
"objectId": "pattern:holes-new",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [90, 91, 92],
"center": [7.5, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 7.5,
"pitch": 7.5,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [7.5, 0.0, 0.0], [15.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 7.5}],
}
)
pattern_spacing_before_signature = before_pattern_spacing["objects"][0]["geometrySignature"] # type: ignore[index]
pattern_spacing_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=pattern_spacing_before_signature,
before_cache=before_pattern_spacing,
after_cache=after_pattern_spacing,
capability_key="pattern.spacing",
expected_target=7.5,
edited_object_id="pattern:holes",
)
_assert(pattern_spacing_ok.get("ok") is True, f"pattern.spacing result should validate target spacing: {pattern_spacing_ok}")
pattern_spacing_mismatch = check_scdm_target(after_pattern_spacing["objects"][0], capability_key="pattern.spacing", expected_target=5.0) # type: ignore[index]
_assert(pattern_spacing_mismatch.get("ok") is False and pattern_spacing_mismatch.get("reason") == "target-mismatch", f"pattern.spacing mismatch should fail: {pattern_spacing_mismatch}")
before_pattern_segment = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"segmentIndex": 1,
"movingSide": "after",
"patternInstances": [
{"sourceObjectId": "a", "center": [0.0, 0.0, 0.0], "faceIds": [85]},
{"sourceObjectId": "b", "center": [5.0, 0.0, 0.0], "faceIds": [87]},
{"sourceObjectId": "c", "center": [10.0, 0.0, 0.0], "faceIds": [89]},
],
},
"capabilities": [{"key": "pattern.segment_spacing", "currentValue": 5.0}],
},
_hole("hole:unrelated", 999, diameter=1.0, center=(100.0, 0.0, 0.0)),
)
after_pattern_segment = _cache(_hole("hole:unrelated-new", 999, diameter=1.0, center=(100.0, 0.0, 0.0)))
pattern_segment_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"result": {
"applied": {
"segmentSpacing": 6.5,
"targetSpacing": 6.5,
"segmentIndex": 1,
"spacingMode": "segment_after",
}
},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
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(
pattern_segment_ok.get("ok") is True
and pattern_segment_ok.get("targetCheck", {}).get("spacingMode") == "segment_after",
f"pattern.segment_spacing should validate from the applied edit result even when the old uniform pattern no longer matches: {pattern_segment_ok}",
)
pattern_segment_mismatch = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.0, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
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(
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}",
)
shell_thickness_ok = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=1.6,
)
_assert(shell_thickness_ok.get("ok") is True, f"shell.thickness result should validate target thickness: {shell_thickness_ok}")
shell_thickness_mismatch = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=2.0,
)
_assert(shell_thickness_mismatch.get("ok") is False and shell_thickness_mismatch.get("reason") == "target-mismatch", f"shell.thickness mismatch should fail: {shell_thickness_mismatch}")
summary_before = _cache_with_summary( summary_before = _cache_with_summary(
{"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396}, {"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396},
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)), _hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
@@ -154,6 +590,57 @@ def main() -> int:
summary_fill = check_scdm_summary_delta(summary_before, summary_after_bad, capability_key="feature.fill") summary_fill = check_scdm_summary_delta(summary_before, summary_after_bad, capability_key="feature.fill")
_assert(summary_fill.get("ok") is None and summary_fill.get("reason") == "skipped-command-feature", f"fill should skip summary count guard: {summary_fill}") _assert(summary_fill.get("ok") is None and summary_fill.get("reason") == "skipped-command-feature", f"fill should skip summary count guard: {summary_fill}")
fill_before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
fill_after = _cache(_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)))
fill_signature = fill_before["objects"][0]["geometrySignature"] # type: ignore[index]
fill_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_after,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(fill_ok.get("ok") is True, f"feature.fill should pass when the edited feature disappears: {fill_ok}")
_assert(fill_ok.get("removalCheck", {}).get("ok") is True, f"feature.fill should record removal evidence: {fill_ok}")
fill_still_present = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_before,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(
fill_still_present.get("ok") is False and fill_still_present.get("reason") == "feature-still-present",
f"feature.fill should fail when the edited feature still matches: {fill_still_present}",
)
fill_no_cache = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
capability_key="feature.fill",
)
_assert(
fill_no_cache.get("ok") is False and fill_no_cache.get("reason") == "removal-check-unavailable",
f"feature.fill should require a new cache for removal verification: {fill_no_cache}",
)
round_before = _cache(_feature("round:60", "round", 60, center=(1.0, 0.0, 2.0), capability_key="feature.delete_round_or_chamfer"))
round_after = _cache(_feature("hole:85", "hole", 85, center=(5.0, 0.0, 2.0), capability_key="hole.diameter"))
round_delete_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=round_before,
after_cache=round_after,
capability_key="feature.delete_round_or_chamfer",
edited_object_id="round:60",
)
_assert(round_delete_ok.get("ok") is True, f"round/chamfer delete should pass when the edited feature disappears: {round_delete_ok}")
_assert(round_delete_ok.get("targetCheck", {}).get("reason") == "removed", f"round/chamfer delete should use removal target check: {round_delete_ok}")
ambiguous_after = _cache( ambiguous_after = _cache(
_hole("hole:100", 100, diameter=0.75, center=(0.5, 1.0, 9.5)), _hole("hole:100", 100, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:101", 101, diameter=0.75, center=(0.5, 1.0, 9.5)), _hole("hole:101", 101, diameter=0.75, center=(0.5, 1.0, 9.5)),
+34 -13
View File
@@ -100,8 +100,8 @@ def main() -> int:
"confidence": "low", "confidence": "low",
}, },
{ {
"capabilityKey": "pattern.spacing", "capabilityKey": "pattern.instance_position",
"displayName": "阵列间距", "displayName": "阵列实例位置",
"evidenceCount": 2, "evidenceCount": 2,
"confidence": "low", "confidence": "low",
}, },
@@ -114,9 +114,7 @@ def main() -> int:
} }
], ],
"planned_not_productized": [ "planned_not_productized": [
{"capabilityKey": "slot.width", "objectType": "slot"}, {"capabilityKey": "shell.thickness", "objectType": "shell"},
{"capabilityKey": "slot.width", "objectType": "slot"},
{"capabilityKey": "round.radius", "objectType": "round"},
], ],
"discovered_not_productized": [ "discovered_not_productized": [
{"objectType": "mystery_feature"}, {"objectType": "mystery_feature"},
@@ -126,14 +124,30 @@ def main() -> int:
} }
progress = summarize_scdm_capability_progress( progress = summarize_scdm_capability_progress(
feature_cache=progress_cache, feature_cache=progress_cache,
execution_ready={"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"}, execution_ready={
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
},
) )
summary = progress.get("summary") summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}") _assert(isinstance(summary, dict), f"capability progress should include summary: {progress}")
_assert(summary.get("productized") == 6, f"productized capability count should include S5 plus Move-based S7 entries: {summary}") _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") == 5, f"runner-ready capability count should honor UI gate: {summary}") _assert(summary.get("runnerReady") == 15, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("executableCapabilities") == 2, f"blocked/ungated capabilities should not be executable: {summary}") _assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}")
_assert(summary.get("plannedDetected") == 3, f"planned S7 detections should be counted: {summary}") _assert(summary.get("plannedDetected") == 1, f"planned S7 detections should be counted: {summary}")
_assert(summary.get("discoveredNotProductized") == 2, f"unknown discoveries 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(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}")
_assert( _assert(
@@ -147,9 +161,16 @@ def main() -> int:
evidence_lines = "\n".join(str(line) for line in progress.get("probeEvidence", {}).get("lines", [])) evidence_lines = "\n".join(str(line) for line in progress.get("probeEvidence", {}).get("lines", []))
_assert("偏移:已开放" in productized_lines, f"open SCDM capability should be visible: {productized_lines}") _assert("偏移:已开放" in productized_lines, f"open SCDM capability should be visible: {productized_lines}")
_assert("直径:已开放但被后端阻止" in productized_lines, f"blocked SCDM capability should be explicit: {productized_lines}") _assert("直径:已开放但被后端阻止" in productized_lines, f"blocked SCDM capability should be explicit: {productized_lines}")
_assert("填孔/删除小特征:已识别待执行器" in productized_lines, f"recognized but ungated capability should be visible: {productized_lines}") _assert("填孔/删除小特征:已开放" in productized_lines, f"recognized command capability should be visible as open: {productized_lines}")
_assert(":已识别待验证" in planned_lines, f"planned S7 candidate should be visible: {planned_lines}") _assert(":已开放待识别" in productized_lines, f"productized slot.depth should be visible: {productized_lines}")
_assert("凸台高度:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_lines}") _assert("凸台直径:已开放待识别" in productized_lines, f"productized boss.diameter should be visible: {productized_lines}")
_assert("凸台高度:已开放待识别" in productized_lines, f"productized boss.height should be visible: {productized_lines}")
_assert("圆角半径:已开放待识别" in productized_lines, f"productized round.radius should be visible: {productized_lines}")
_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("Face 邻接 2 组" in evidence_lines and "圆边 4 条" in evidence_lines, f"probe evidence lines should be readable: {evidence_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("对象分布" 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}") _assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}")
+19 -3
View File
@@ -274,7 +274,23 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.scdm_feature_cache_state = "empty" self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = "" self.scdm_feature_cache_message = ""
self.scdm_feature_cache_path = "" self.scdm_feature_cache_path = ""
self.scdm_edit_runner_ready = {"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"} self.scdm_edit_runner_ready = {
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
}
self.load_in_progress = False self.load_in_progress = False
self.load_thread: QThread | None = None self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None self.load_worker: LoadWorker | None = None
@@ -1050,7 +1066,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
mode_box = QWidget() mode_box = QWidget()
mode_box.setMinimumHeight(62) mode_box.setMinimumHeight(62)
help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。") help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征。")
self.mode_section_title = QLabel("选择模式", mode_box) self.mode_section_title = QLabel("选择模式", mode_box)
self.mode_section_title.setObjectName("modeSectionTitle") self.mode_section_title.setObjectName("modeSectionTitle")
self.mode_section_title.setFixedSize(74, 20) self.mode_section_title.setFixedSize(74, 20)
@@ -1085,7 +1101,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_combo.setMaximumWidth(112) self.mode_combo.setMaximumWidth(112)
help_tip( help_tip(
self.mode_combo, self.mode_combo,
"选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选", "选择模式决定鼠标点击模型时要选什么:Part、Solid、Face、Edge,或用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征",
) )
self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode())) self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
mode_pick_layout.addWidget(self.mouse_mode_label) mode_pick_layout.addWidget(self.mouse_mode_label)
+17
View File
@@ -229,17 +229,34 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
def scdm_local_face_signatures(self) -> list[dict[str, object]]: def scdm_local_face_signatures(self) -> list[dict[str, object]]:
"""Return cheap geometry hints used to map SCDM raw objects back to local Face IDs.""" """Return cheap geometry hints used to map SCDM raw objects back to local Face IDs."""
signatures: list[dict[str, object]] = [] signatures: list[dict[str, object]] = []
face_ordinal_by_solid: dict[int, int] = {}
for face_id, face in enumerate(self.faces): for face_id, face in enumerate(self.faces):
try: try:
surf = BRepAdaptor_Surface(face) surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType() surface_type = surf.GetType()
except Exception: except Exception:
continue continue
solid_id = int(self.face_solid_ids[face_id]) if face_id < len(self.face_solid_ids) else -1
part_id = int(self.face_part_ids[face_id]) if face_id < len(self.face_part_ids) else -1
face_ordinal = face_ordinal_by_solid.get(solid_id, 0)
face_ordinal_by_solid[solid_id] = face_ordinal + 1
signature: dict[str, object] = { signature: dict[str, object] = {
"faceId": int(face_id), "faceId": int(face_id),
"logicalFaceId": self.face_logical_id(face_id), "logicalFaceId": self.face_logical_id(face_id),
"partId": part_id,
"solidId": solid_id,
"bodyIndex": solid_id,
"faceOrdinal": face_ordinal,
"globalFaceOrdinal": int(face_id),
"surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"), "surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
} }
try:
bounds = _shape_bounds_info(face)
signature["bboxMin"] = bounds.get("bbox_min")
signature["bboxMax"] = bounds.get("bbox_max")
signature["bboxSize"] = bounds.get("bbox_size")
except Exception:
pass
try: try:
if surface_type == GeomAbs_Plane: if surface_type == GeomAbs_Plane:
plane = surf.Plane() plane = surf.Plane()
+38
View File
@@ -144,6 +144,44 @@ def evaluate_relation_formula(
return _coerce_formula_value(value) return _coerce_formula_value(value)
def validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None:
target_to_formula: dict[str, RelationFormula] = {}
for formula in formulas:
target_token = formula.target.token
if target_token in target_to_formula:
raise RelationFormulaError(f"同一目标参数只能由一条关系式控制:{target_token}")
target_to_formula[target_token] = formula
target_tokens = set(target_to_formula)
graph: dict[str, list[str]] = {}
for target_token, formula in target_to_formula.items():
reference_tokens = [ref.token for ref in formula.references]
if target_token in reference_tokens:
raise RelationFormulaError(f"关系式不能引用自身:{target_token}")
graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens]
visit_state: dict[str, str] = {}
stack: list[str] = []
def visit(token: str) -> None:
state = visit_state.get(token)
if state == "visiting":
start_index = stack.index(token) if token in stack else 0
cycle = [*stack[start_index:], token]
raise RelationFormulaError(f"关系式存在循环依赖:{' -> '.join(cycle)}")
if state == "visited":
return
visit_state[token] = "visiting"
stack.append(token)
for dependency in graph.get(token, []):
visit(dependency)
stack.pop()
visit_state[token] = "visited"
for target_token in graph:
visit(target_token)
def relation_value_to_text(value: object) -> str: def relation_value_to_text(value: object) -> str:
value = _coerce_formula_value(value) value = _coerce_formula_value(value)
if isinstance(value, Vector3): if isinstance(value, Vector3):
+21 -19
View File
@@ -94,22 +94,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
default_intent="修改槽宽", default_intent="修改槽宽",
backend_operation="change_slot_width", backend_operation="change_slot_width",
post_check="target_slot_width", post_check="target_slot_width",
productized=False, required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.2", roadmap_stage="S7.2",
block_reason="槽宽属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"slot.depth": ScdmCapabilityDefinition( "slot.depth": ScdmCapabilityDefinition(
key="slot.depth", key="slot.depth",
display_name="槽深", display_name="槽深",
object_types=("slot", "obround_slot", "rectangular_slot"), object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number", value_kind="number",
current_fields=("geometry.depth",), current_fields=("geometry.slotInfo.depth", "geometry.depth"),
default_intent="修改槽深", default_intent="修改槽深",
backend_operation="change_slot_depth", backend_operation="change_slot_depth",
post_check="target_slot_depth", post_check="target_slot_depth",
productized=False, required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.2", roadmap_stage="S7.2",
block_reason="槽深属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"slot.position": ScdmCapabilityDefinition( "slot.position": ScdmCapabilityDefinition(
key="slot.position", key="slot.position",
@@ -132,9 +130,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
default_intent="修改凸台高度", default_intent="修改凸台高度",
backend_operation="change_boss_height", backend_operation="change_boss_height",
post_check="target_boss_height", post_check="target_boss_height",
productized=False, required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.3", roadmap_stage="S7.3",
block_reason="凸台高度属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"boss.diameter": ScdmCapabilityDefinition( "boss.diameter": ScdmCapabilityDefinition(
key="boss.diameter", key="boss.diameter",
@@ -145,9 +142,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
default_intent="修改凸台直径", default_intent="修改凸台直径",
backend_operation="change_boss_diameter", backend_operation="change_boss_diameter",
post_check="target_boss_diameter", post_check="target_boss_diameter",
productized=False, required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.3", roadmap_stage="S7.3",
block_reason="凸台直径属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"boss.position": ScdmCapabilityDefinition( "boss.position": ScdmCapabilityDefinition(
key="boss.position", key="boss.position",
@@ -166,28 +162,24 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
display_name="圆角半径", display_name="圆角半径",
object_types=("round", "fillet"), object_types=("round", "fillet"),
value_kind="number", value_kind="number",
current_fields=("geometry.radius",), current_fields=("geometry.roundInfo.radius", "geometry.radius"),
default_intent="修改圆角半径", default_intent="修改圆角半径",
backend_operation="change_round_radius", backend_operation="change_round_radius",
post_check="target_round_radius", post_check="target_round_radius",
required_backend_command_groups=(("ConstantRound",),), required_backend_command_groups=(("ConstantRound",),),
productized=False,
roadmap_stage="S7.4", roadmap_stage="S7.4",
block_reason="圆角半径属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"chamfer.distance": ScdmCapabilityDefinition( "chamfer.distance": ScdmCapabilityDefinition(
key="chamfer.distance", key="chamfer.distance",
display_name="倒角距离", display_name="倒角距离",
object_types=("chamfer",), object_types=("chamfer",),
value_kind="number", value_kind="number",
current_fields=("geometry.distance", "geometry.offset"), current_fields=("geometry.chamferInfo.distance", "geometry.distance", "geometry.offset"),
default_intent="修改倒角距离", default_intent="修改倒角距离",
backend_operation="change_chamfer_distance", backend_operation="change_chamfer_distance",
post_check="target_chamfer_distance", post_check="target_chamfer_distance",
required_backend_command_groups=(("Chamfer",),), required_backend_command_groups=(("Chamfer",),),
productized=False,
roadmap_stage="S7.4", roadmap_stage="S7.4",
block_reason="倒角距离属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"feature.delete_round_or_chamfer": ScdmCapabilityDefinition( "feature.delete_round_or_chamfer": ScdmCapabilityDefinition(
key="feature.delete_round_or_chamfer", key="feature.delete_round_or_chamfer",
@@ -199,9 +191,7 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
backend_operation="delete_round_or_chamfer", backend_operation="delete_round_or_chamfer",
post_check="target_feature_removed", post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)), required_backend_command_groups=(("Fill",), ("Delete",)),
productized=False,
roadmap_stage="S7.4", roadmap_stage="S7.4",
block_reason="删除圆角/倒角属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"pattern.spacing": ScdmCapabilityDefinition( "pattern.spacing": ScdmCapabilityDefinition(
key="pattern.spacing", key="pattern.spacing",
@@ -212,9 +202,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
default_intent="修改阵列间距", default_intent="修改阵列间距",
backend_operation="change_pattern_spacing", backend_operation="change_pattern_spacing",
post_check="target_pattern_spacing", post_check="target_pattern_spacing",
productized=False, required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"pattern.segment_spacing": ScdmCapabilityDefinition(
key="pattern.segment_spacing",
display_name="局部间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改相邻阵列成员间距",
backend_operation="change_pattern_segment_spacing",
post_check="target_pattern_segment_spacing",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5", roadmap_stage="S7.5",
block_reason="阵列间距属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"pattern.instance_position": ScdmCapabilityDefinition( "pattern.instance_position": ScdmCapabilityDefinition(
key="pattern.instance_position", key="pattern.instance_position",
@@ -318,6 +319,7 @@ def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_
if object_type in {"pattern", "linear_pattern"}: if object_type in {"pattern", "linear_pattern"}:
if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")): if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")):
keys.append("pattern.spacing") keys.append("pattern.spacing")
keys.append("pattern.segment_spacing")
if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")): if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")):
keys.append("pattern.instance_position") keys.append("pattern.instance_position")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+296 -4
View File
@@ -241,9 +241,18 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" geometry['surfaceType'] = 'plane'\n" " geometry['surfaceType'] = 'plane'\n"
" elif 'cylinder' in lowered:\n" " elif 'cylinder' in lowered:\n"
" geometry['surfaceType'] = 'cylinder'\n" " geometry['surfaceType'] = 'cylinder'\n"
" slot_info = _slot_info_from_face(face, geometry)\n"
" if slot_info:\n"
" geometry['slotInfo'] = slot_info\n"
" for key in ('width', 'depth', 'center', 'depthAxis'):\n"
" if slot_info.get(key) is not None:\n"
" geometry[key] = slot_info.get(key)\n"
" round_info = _round_info_from_face(face, geometry)\n" " round_info = _round_info_from_face(face, geometry)\n"
" if round_info:\n" " if round_info:\n"
" geometry['roundInfo'] = round_info\n" " geometry['roundInfo'] = round_info\n"
" chamfer_info = _chamfer_info_from_face(face, geometry)\n"
" if chamfer_info:\n"
" geometry['chamferInfo'] = chamfer_info\n"
" return geometry\n" " return geometry\n"
"\n" "\n"
"def _round_info_from_face(face, geometry):\n" "def _round_info_from_face(face, geometry):\n"
@@ -274,6 +283,101 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" pass\n" " pass\n"
" return payload\n" " return payload\n"
"\n" "\n"
"def _same_object(left, right):\n"
" try:\n"
" if left is right:\n"
" return True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return left == right\n"
" except Exception:\n"
" return False\n"
"\n"
"def _slot_info_from_face(face, geometry):\n"
" slot_info_type = globals().get('SlotInfo')\n"
" if slot_info_type is None:\n"
" return {}\n"
" try:\n"
" info = slot_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for key, attrs in (\n"
" ('width', ('Width', 'SlotWidth', 'Diameter')),\n"
" ('depth', ('Depth', 'SlotDepth', 'Height')),\n"
" ):\n"
" value = _float_attr(info, attrs)\n"
" if value is not None:\n"
" payload[key] = value\n"
" center = _xyz(_first_path_value(info, ('Center', 'AxisCenter', 'Frame.Origin')))\n"
" if center:\n"
" payload['center'] = center\n"
" depth_axis = _xyz(_first_path_value(info, ('DepthAxis', 'DepthDirection', 'Direction', 'Frame.DirZ')))\n"
" if depth_axis:\n"
" payload['depthAxis'] = depth_axis\n"
" for attr in ('IsBlind', 'IsThrough', 'IsSlot'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFace', 'DepthFace', 'FloorFace'):\n"
" try:\n"
" if _same_object(getattr(info, attr), face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFaces', 'DepthFaces', 'FloorFaces'):\n"
" try:\n"
" for item in _items(getattr(info, attr)):\n"
" if _same_object(item, face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _chamfer_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'plane':\n"
" return {}\n"
" chamfer_info_type = globals().get('ChamferInfo')\n"
" if chamfer_info_type is None:\n"
" return {}\n"
" try:\n"
" info = chamfer_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Distance', 'ChamferDistance', 'Offset', 'Width'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['distance'] = value\n"
" break\n"
" distance1 = _float_attr(info, ('Distance1', 'distance1', 'FirstDistance'))\n"
" distance2 = _float_attr(info, ('Distance2', 'distance2', 'SecondDistance'))\n"
" if distance1 is not None:\n"
" payload['distance1'] = distance1\n"
" if distance2 is not None:\n"
" payload['distance2'] = distance2\n"
" if distance1 is not None and distance2 is not None and abs(distance1 - distance2) <= max(abs(distance1), abs(distance2), 1.0) * 1e-6:\n"
" payload.setdefault('distance', distance1)\n"
" payload['isEqualDistance'] = True\n"
" for attr in ('IsEqualDistance', 'IsSymmetric', 'IsChamfer'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" if payload.get('isSymmetric') is True:\n"
" payload['isEqualDistance'] = True\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _path_value(value, expr):\n" "def _path_value(value, expr):\n"
" current = value\n" " current = value\n"
" for part in expr.split('.'):\n" " for part in expr.split('.'):\n"
@@ -462,10 +566,21 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n" " result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" if object_type == 'hole' and surface_type == 'cylinder':\n" " if object_type == 'hole' and surface_type == 'cylinder':\n"
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n" " result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n"
" if object_type in ('slot', 'obround_slot', 'rectangular_slot'):\n"
" if geometry.get('width') is not None:\n"
" result.append({'operation': 'change_slot_width', 'enabled': True, 'parameterFields': {'width': geometry.get('width')}})\n"
" if geometry.get('depth') is not None:\n"
" result.append({'operation': 'change_slot_depth', 'enabled': True, 'parameterFields': {'depth': geometry.get('depth')}})\n"
" if geometry.get('center') is not None:\n"
" result.append({'operation': 'move_slot', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" round_info = geometry.get('roundInfo')\n" " round_info = geometry.get('roundInfo')\n"
" if isinstance(round_info, dict) and round_info.get('radius') is not None:\n" " if isinstance(round_info, dict) and round_info.get('radius') is not None:\n"
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n" " result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n" " result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" chamfer_info = geometry.get('chamferInfo')\n"
" if isinstance(chamfer_info, dict) and chamfer_info.get('distance') is not None:\n"
" result.append({'operation': 'change_chamfer_distance', 'enabled': True, 'parameterFields': {'distance': chamfer_info.get('distance')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" return result\n" " return result\n"
"\n" "\n"
"def _open_step(path):\n" "def _open_step(path):\n"
@@ -503,6 +618,167 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" except Exception:\n" " except Exception:\n"
" return value\n" " return value\n"
"\n" "\n"
"def _safe_str(value):\n"
" if value is None:\n"
" return ''\n"
" try:\n"
" return str(value)\n"
" except Exception:\n"
" return _safe_name(value)\n"
"\n"
"def _matrix_payload(matrix):\n"
" if matrix is None:\n"
" return {}\n"
" payload = {'type': _safe_name(matrix), 'text': _safe_str(matrix)}\n"
" translation = _xyz(_path_value(matrix, 'Translation'))\n"
" if translation:\n"
" payload['translation'] = translation\n"
" for attr in ('OffsetX', 'OffsetY', 'OffsetZ'):\n"
" value = _float_attr(matrix, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload[attr] = value\n"
" return payload\n"
"\n"
"def _moniker_text(value):\n"
" try:\n"
" return _safe_str(getattr(value, 'Moniker'))\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _component_name(component):\n"
" for attr in ('Name', 'DisplayName'):\n"
" try:\n"
" text = _safe_str(getattr(component, attr)).strip()\n"
" if text:\n"
" return text\n"
" except Exception:\n"
" pass\n"
" return ''\n"
"\n"
"def _immediate_components(part):\n"
" if part is None:\n"
" return []\n"
" try:\n"
" items = _items(getattr(part, 'Components'))\n"
" if items:\n"
" return items\n"
" except Exception:\n"
" pass\n"
" return []\n"
"\n"
"def _component_content(component):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" return value\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _component_locator(component, component_index, component_path):\n"
" locator = {\n"
" 'backendId': 'component:' + '.'.join(str(item) for item in component_path),\n"
" 'componentIndex': component_index,\n"
" 'componentPath': list(component_path),\n"
" 'componentName': _component_name(component),\n"
" }\n"
" try:\n"
" locator['componentMoniker'] = _moniker_text(component)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" content = getattr(component, 'Content')\n"
" locator['contentMoniker'] = _moniker_text(content)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" template = getattr(component, 'Template')\n"
" locator['templateMoniker'] = _moniker_text(template)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" placement = _matrix_payload(getattr(component, 'Placement'))\n"
" if placement:\n"
" locator['placement'] = placement\n"
" if placement.get('translation'):\n"
" locator['placementTranslation'] = placement.get('translation')\n"
" except Exception:\n"
" pass\n"
" return locator\n"
"\n"
"def _component_entries(root):\n"
" result = []\n"
" queue = [(root, [])]\n"
" while queue:\n"
" part, path = queue.pop(0)\n"
" if part is None or len(path) > 8:\n"
" continue\n"
" for child_index, component in enumerate(_immediate_components(part)):\n"
" component_path = list(path) + [child_index]\n"
" content = _component_content(component)\n"
" entry = {\n"
" 'component': component,\n"
" 'content': content,\n"
" 'locator': _component_locator(component, len(result), component_path),\n"
" }\n"
" result.append(entry)\n"
" if content is not None:\n"
" queue.append((content, component_path))\n"
" return result\n"
"\n"
"def _component_body_locator_map(component_entries):\n"
" result = {}\n"
" for entry in component_entries:\n"
" content = entry.get('content')\n"
" if content is None:\n"
" continue\n"
" for component_body_index, body in enumerate(_items(_maybe_call(content, 'Bodies'))):\n"
" locator = dict(entry.get('locator') or {})\n"
" locator['componentBodyIndex'] = component_body_index\n"
" key = str(id(body))\n"
" result.setdefault(key, []).append(locator)\n"
" try:\n"
" master = getattr(body, 'Master')\n"
" result.setdefault(str(id(master)), []).append(locator)\n"
" except Exception:\n"
" pass\n"
" return result\n"
"\n"
"def _body_locators_for_body(component_body_locators, body, body_index):\n"
" result = [{'bodyIndex': body_index}]\n"
" seen = set(['body:' + str(body_index)])\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" item = dict(locator)\n"
" item['bodyIndex'] = body_index\n"
" key = str(item.get('componentIndex')) + ':' + '.'.join(str(value) for value in item.get('componentPath', []) or []) + ':' + str(item.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(item)\n"
" return result\n"
"\n"
"def _component_locators_for_body(component_body_locators, body):\n"
" result = []\n"
" seen = set()\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" key = str(locator.get('componentIndex')) + ':' + '.'.join(str(value) for value in locator.get('componentPath', []) or []) + ':' + str(locator.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(dict(locator))\n"
" return result\n"
"\n"
"def _component_inventory(component_entries):\n"
" result = []\n"
" for entry in component_entries:\n"
" locator = dict(entry.get('locator') or {})\n"
" content = entry.get('content')\n"
" locator['contentBodyCount'] = len(_items(_maybe_call(content, 'Bodies'))) if content is not None else 0\n"
" locator['childComponentCount'] = len(_immediate_components(content)) if content is not None else 0\n"
" result.append(locator)\n"
" return result\n"
"\n"
"def _body_faces(body):\n" "def _body_faces(body):\n"
" for name in ('Faces', 'GetFaces'):\n" " for name in ('Faces', 'GetFaces'):\n"
" items = _items(_maybe_call(body, name))\n" " items = _items(_maybe_call(body, name))\n"
@@ -596,7 +872,7 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" return set(str(id(face)) for face in faces)\n" " return set(str(id(face)) for face in faces)\n"
"\n" "\n"
"def _available_commands():\n" "def _available_commands():\n"
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo')\n" " names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo', 'ChamferInfo', 'SlotInfo')\n"
" result = []\n" " result = []\n"
" for name in names:\n" " for name in names:\n"
" result.append({'name': name, 'available': globals().get(name) is not None})\n" " result.append({'name': name, 'available': globals().get(name) is not None})\n"
@@ -612,6 +888,8 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" _open_step(model.get('path'))\n" " _open_step(model.get('path'))\n"
" root = _root_part()\n" " root = _root_part()\n"
" bodies = _all_bodies(root)\n" " bodies = _all_bodies(root)\n"
" component_entries = _component_entries(root)\n"
" component_body_locators = _component_body_locator_map(component_entries)\n"
" hole_face_markers = _hole_face_markers(bodies)\n" " hole_face_markers = _hole_face_markers(bodies)\n"
" objects = []\n" " objects = []\n"
" face_adjacency = {}\n" " face_adjacency = {}\n"
@@ -620,24 +898,37 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" edge_counter = 0\n" " edge_counter = 0\n"
" for body_index, body in enumerate(bodies):\n" " for body_index, body in enumerate(bodies):\n"
" body_faces = _body_faces(body)\n" " body_faces = _body_faces(body)\n"
" body_locators = _body_locators_for_body(component_body_locators, body, body_index)\n"
" component_locators = _component_locators_for_body(component_body_locators, body)\n"
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n" " face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n"
" for face_index, face in enumerate(body_faces):\n" " for face_index, face in enumerate(body_faces):\n"
" geometry = _geometry_from_face(face)\n" " geometry = _geometry_from_face(face)\n"
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n" " object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n"
" if object_type == 'face' and isinstance(geometry.get('slotInfo'), dict) and (geometry.get('depth') is not None or geometry.get('width') is not None):\n"
" object_type = 'slot'\n"
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n" " if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n"
" object_type = 'round'\n" " object_type = 'round'\n"
" if object_type == 'face' and isinstance(geometry.get('chamferInfo'), dict) and geometry.get('chamferInfo', {}).get('distance') is not None:\n"
" object_type = 'chamfer'\n"
" topology_hint = {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" topology_hint['componentLocators'] = component_locators\n"
" if object_type == 'slot' and isinstance(geometry.get('slotInfo'), dict) and geometry.get('slotInfo', {}).get('depthFaceIsCurrent') is True:\n"
" topology_hint['depthFaceLocators'] = [dict(topology_hint)]\n"
" objects.append({\n" " objects.append({\n"
" 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n" " 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n"
" 'objectType': object_type,\n" " 'objectType': object_type,\n"
" 'geometry': geometry,\n" " 'geometry': geometry,\n"
" 'topologyHint': {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter},\n" " 'topologyHint': topology_hint,\n"
" 'backendCommandCandidates': _command_candidates(object_type, geometry),\n" " 'backendCommandCandidates': _command_candidates(object_type, geometry),\n"
" 'rawLimitations': [],\n" " 'rawLimitations': [],\n"
" })\n" " })\n"
" face_counter += 1\n" " face_counter += 1\n"
" for edge_index, edge in enumerate(_body_edges(body)):\n" " for edge_index, edge in enumerate(_body_edges(body)):\n"
" geometry = _geometry_from_edge(edge)\n" " geometry = _geometry_from_edge(edge)\n"
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter}\n" " edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" edge_topology['componentLocators'] = component_locators\n"
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n" " edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n"
" _add_edge_geometry_summary(edge_geometry_summary, geometry)\n" " _add_edge_geometry_summary(edge_geometry_summary, geometry)\n"
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n" " _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n"
@@ -661,8 +952,9 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
" 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n" " 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n"
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n" " 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n"
" 'featureInventory': _feature_inventory(objects),\n" " 'featureInventory': _feature_inventory(objects),\n"
" 'componentInstances': _component_inventory(component_entries),\n"
" },\n" " },\n"
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers)},\n" " 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers), 'componentCount': len(component_entries)},\n"
" }\n" " }\n"
" _write_json(raw_path, payload)\n" " _write_json(raw_path, payload)\n"
" except Exception as exc:\n" " except Exception as exc:\n"
+460 -6
View File
@@ -27,9 +27,12 @@ def property_specs_from_scdm_cache(
continue continue
for capability in capabilities: for capability in capabilities:
if isinstance(capability, Mapping): if isinstance(capability, Mapping):
if str(capability.get("key") or "") == "pattern.segment_spacing":
continue
spec = _capability_spec(item, capability, execution_ready=execution_ready) spec = _capability_spec(item, capability, execution_ready=execution_ready)
if spec is not None: if spec is not None:
specs.append(spec) specs.append(spec)
specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready))
return specs return specs
@@ -38,6 +41,7 @@ def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edg
if not isinstance(signature, Mapping): if not isinstance(signature, Mapping):
return False return False
object_faces = set(_int_values(signature.get("faceIds"))) object_faces = set(_int_values(signature.get("faceIds")))
object_faces.update(_int_values(signature.get("supportFaceIds")))
object_edges = set(_int_values(signature.get("edgeIds"))) 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)) return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids))
@@ -55,14 +59,23 @@ def _capability_spec(
value_kind = str(capability.get("valueKind") or "number") value_kind = str(capability.get("valueKind") or "number")
current = capability.get("currentValue") current = capability.get("currentValue")
value_type = _value_type(value_kind, key) value_type = _value_type(value_kind, key)
signature = raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {}
unit_scale = _unit_scale(signature if isinstance(signature, Mapping) else {})
current_display = _display_value(current, key=key, value_type=value_type, unit_scale=unit_scale)
command_value = value_type == "command" command_value = value_type == "command"
current_text = "可执行" if command_value else _format_value(current, value_type=value_type) current_text = "可执行" if command_value else _format_value(current_display, value_type=value_type)
target_text = "执行" if command_value else _format_value(current, value_type=value_type) target_text = "执行" if command_value else _format_value(current_display, value_type=value_type)
capability_block = str(capability.get("blockReason") or "").strip() capability_block = str(capability.get("blockReason") or "").strip()
object_block = str(raw_object.get("blockReason") or "").strip() object_block = str(raw_object.get("blockReason") or "").strip()
block_reason = capability_block or object_block block_reason = capability_block or object_block
if not command_value and not _current_value_available(current_display, value_type=value_type):
block_reason = block_reason or f"SCDM 已识别“{label}”,但没有返回可用于编辑的当前值。"
backend_operation = str(capability.get("backendOperation") or "") backend_operation = str(capability.get("backendOperation") or "")
post_check = str(capability.get("postCheck") or "") post_check = str(capability.get("postCheck") or "")
max_value = _display_max_value(key=key, signature=signature if isinstance(signature, Mapping) else {}, unit_scale=unit_scale)
range_hint = "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。"
if key == "pattern.spacing" and max_value is not None:
range_hint = f"该阵列受承载面范围限制,保持阵列中心不变时最大间距约 {max_value:g};超过后会跑出承载面。"
can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason) can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason)
if can_execute: if can_execute:
disabled_tip = "" disabled_tip = ""
@@ -79,7 +92,9 @@ def _capability_spec(
return { return {
"key": f"scdm:{key}", "key": f"scdm:{key}",
"label": label, "label": label,
"current_raw": current if current is not None else "", "current_raw": current_display if current_display is not None else "",
"scdm_current_raw": current if current is not None else "",
"scdm_unit_scale": unit_scale,
"current_text": current_text, "current_text": current_text,
"target_text": target_text, "target_text": target_text,
"editable": True, "editable": True,
@@ -90,24 +105,443 @@ def _capability_spec(
"value_type": value_type, "value_type": value_type,
"enabled_tip": enabled_tip, "enabled_tip": enabled_tip,
"disabled_tip": disabled_tip, "disabled_tip": disabled_tip,
"range_hint": "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。", "range_hint": range_hint,
"min_value": 0.0 if value_type == "positive" else None, "min_value": 0.0 if value_type == "positive" else None,
"min_exclusive": True if value_type == "positive" else False, "min_exclusive": True if value_type == "positive" else False,
"max_value": max_value,
"scdm_object_id": raw_object.get("objectId"), "scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"), "scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": key, "scdm_capability_key": key,
"scdm_backend_operation": backend_operation, "scdm_backend_operation": backend_operation,
"scdm_post_check": post_check, "scdm_post_check": post_check,
"scdm_geometry_signature": raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {}, "scdm_geometry_signature": signature if isinstance(signature, Mapping) else {},
} }
def _unit_scale(signature: Mapping[str, object]) -> float:
try:
value = float(str(signature.get("localUnitScale")).strip())
except (TypeError, ValueError):
return 1.0
return value if value > 0 else 1.0
def _display_value(value: object, *, key: str, value_type: str, unit_scale: float) -> object:
if unit_scale <= 0 or abs(unit_scale - 1.0) <= 1.0e-12 or not _uses_length_units(key, value_type):
return value
if value_type == "vector3":
values = _float_values(value)
if len(values) == 3:
return [item / unit_scale for item in values]
return value
try:
return float(str(value).strip()) / unit_scale
except (TypeError, ValueError):
return value
def _uses_length_units(key: str, value_type: str) -> bool:
if value_type == "vector3":
return True
suffixes = (
".diameter",
".radius",
".offset",
".width",
".depth",
".height",
".distance",
".thickness",
".spacing",
".segment_spacing",
".position",
)
return key.endswith(suffixes)
def _display_max_value(*, key: str, signature: Mapping[str, object], unit_scale: float) -> float | None:
if key != "pattern.spacing":
return None
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
value = fit.get("maxSpacingLocal")
try:
result = float(str(value).strip())
except (TypeError, ValueError):
backend_value = fit.get("maxSpacing")
try:
return float(str(backend_value).strip()) / unit_scale if unit_scale > 0 else None
except (TypeError, ValueError):
return None
return result if result > 0 else None
def _pattern_segment_spacing_specs(
raw_object: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() != "linear_pattern":
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
axis = _unit_vector(_float_values(signature.get("axis")))
if len(axis) != 3:
return []
instances = _sorted_pattern_instances(signature, axis)
if len(instances) < 2:
return []
unit_scale = _unit_scale(signature)
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):
left = instances[segment_index]
right = instances[segment_index + 1]
left_label = _segment_instance_label(left, segment_index + 1)
right_label = _segment_instance_label(right, segment_index + 2)
segment_label = f"{left_label}-{right_label}间距"
current = max(0.0, float(right["projection"]) - float(left["projection"]))
if current <= 0:
continue
current_display = current / unit_scale if unit_scale > 0 else current
scope_modes = _segment_scope_modes(
signature,
instances,
segment_index,
current,
current_display,
unit_scale,
left_label=left_label,
right_label=right_label,
segment_label=segment_label,
can_execute=can_execute,
)
default_mode = scope_modes.get("fix_left_move_right", {}) if isinstance(scope_modes, Mapping) else {}
max_display = default_mode.get("max_value")
range_hint = str(default_mode.get("range_hint") or "")
enabled_tip = str(default_mode.get("enabled_tip") or range_hint)
segment_signature = default_mode.get("scdm_geometry_signature")
if not isinstance(segment_signature, Mapping):
segment_signature = _segment_signature(
signature,
segment_index,
current,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side="after",
motion_semantics="fix_left_move_right_group",
)
specs.append(
{
"key": f"scdm:pattern.segment_spacing:{segment_index}",
"label": segment_label,
"current_raw": current_display,
"scdm_current_raw": current,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="positive"),
"target_text": _format_value(current_display, value_type="positive"),
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": "固定前项,移动后侧",
"scope_modes": scope_modes,
"scope_default": "fix_left_move_right",
"action": "apply_scdm_property_edit",
"value_type": "positive",
"enabled_tip": enabled_tip,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"min_value": 0.0,
"min_exclusive": True,
"max_value": max_display,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.segment_spacing",
"scdm_backend_operation": "change_pattern_segment_spacing",
"scdm_post_check": "target_pattern_segment_spacing",
"scdm_geometry_signature": segment_signature,
}
)
return specs
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)):
return []
result: list[dict[str, object]] = []
for index, item in enumerate(value):
if not isinstance(item, Mapping):
continue
center = _float_values(item.get("center") or item.get("instanceCenter"))
if len(center) != 3:
continue
result.append(
{
"index": index,
"source": item,
"center": center,
"projection": _point_projection(center, axis),
}
)
result.sort(key=lambda item: float(item["projection"]))
return result
def _segment_max_spacing_display(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_display: float,
unit_scale: float,
*,
moving_side: str = "after",
) -> float | None:
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
projection_min = _float_or_none(fit.get("supportProjectionMinLocal"))
projection_max = _float_or_none(fit.get("supportProjectionMaxLocal"))
member_span = _float_or_none(fit.get("memberSpanLocal"))
if projection_min is None or projection_max is None or member_span is None or member_span <= 0:
return None
first_projection = float(instances[0]["projection"])
last_projection = float(instances[-1]["projection"])
first_projection_display = first_projection / unit_scale if unit_scale > 0 else first_projection
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"}:
extra = backward_capacity
elif moving_side in {"split", "both", "center"}:
extra = 2.0 * min(backward_capacity, forward_capacity)
else:
extra = forward_capacity
return max(current_display, current_display + max(0.0, extra))
def _segment_scope_modes(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_backend: float,
current_display: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
segment_label: str,
can_execute: bool,
) -> dict[str, dict[str, object]]:
modes: dict[str, dict[str, object]] = {}
for key, label, moving_side, semantics, description in (
(
"fix_left_move_right",
"固定前项,移动后侧",
"after",
"fix_left_move_right_group",
f"固定 {left_label},平移 {right_label} 及其右侧所有阵列成员,右侧已有间距保持不变。",
),
(
"fix_right_move_left",
"固定后项,移动前侧",
"before",
"fix_right_move_left_group",
f"固定 {right_label},平移 {left_label} 及其左侧所有阵列成员,左侧已有间距保持不变。",
),
(
"split_keep_center",
"两侧均分,中心不变",
"split",
"split_groups_keep_segment_center",
f"{left_label} 及左侧向前移动一半,{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=moving_side,
motion_semantics=semantics,
max_display=max_display,
)
range_hint = f"{label}{description} 对象段:{segment_label},沿阵列方向由 {left_label}{right_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,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
}
modes["move_single_right"] = {
"label": "只移动后项(未开放)",
"enabled": False,
"disabled_tip": (
f"只移动后项(未开放):只移动 {right_label} 会同时改变它和右侧下一个成员的间距,容易破坏阵列规律;"
"需要交互确认后再开放。"
),
"range_hint": f"只移动后项(未开放):该策略暂不执行。对象段:{segment_label}",
"scdm_geometry_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",
),
}
return modes
def _segment_signature(
signature: Mapping[str, object],
segment_index: int,
current_backend: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
moving_side: str,
motion_semantics: str,
max_display: object = None,
) -> dict[str, object]:
result = dict(signature)
segment_fit = dict(result.get("supportPatternFit") if isinstance(result.get("supportPatternFit"), Mapping) else {})
max_number = _float_or_none(max_display)
if max_number is not None and max_number > 0:
segment_fit["maxSegmentSpacingLocal"] = max_number
segment_fit["maxSegmentSpacing"] = max_number * unit_scale if unit_scale > 0 else max_number
result["supportPatternFit"] = segment_fit
result["segmentIndex"] = segment_index
result["segmentLabel"] = f"{left_label}-{right_label}"
result["segmentLeftLabel"] = left_label
result["segmentRightLabel"] = right_label
result["segmentSpacing"] = current_backend
result["movingSide"] = moving_side
result["motionSemantics"] = motion_semantics
axis = _unit_vector(_float_values(signature.get("axis")))
instances = _sorted_pattern_instances(signature, axis) if len(axis) == 3 else []
if segment_index < len(instances) - 1:
result["segmentLeft"] = _segment_instance_reference(instances[segment_index], label=left_label)
result["segmentRight"] = _segment_instance_reference(instances[segment_index + 1], label=right_label)
result["localUnitScale"] = unit_scale
return result
def _segment_instance_reference(item: Mapping[str, object], *, label: str = "") -> dict[str, object]:
source = item.get("source")
if not isinstance(source, Mapping):
return {}
return {
"displayLabel": label,
"sourceObjectId": source.get("sourceObjectId"),
"faceIds": _int_values(source.get("faceIds")),
"bodyIndex": _int_or_none(source.get("bodyIndex")),
"componentLocators": source.get("componentLocators") or source.get("bodyLocators") or [],
}
def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
source = item.get("source")
if not isinstance(source, Mapping):
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")])))
if local_solid_ids:
return f"Solid{local_solid_ids[0]}{'' if len(local_solid_ids) > 1 else ''}"
local_part_ids = sorted(set(_int_values(source.get("localPartIds") or [source.get("localPartId")])))
if local_part_ids:
return f"Part{local_part_ids[0]}{'' if len(local_part_ids) > 1 else ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"), include_index=False)
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"零件{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 ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"))
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"零件{body_index}"
source_id = str(source.get("sourceObjectId") or "").strip()
if source_id:
return source_id
return f"成员{ordinal}"
def _component_locator_label(value: object, *, include_index: bool = True) -> str:
if not isinstance(value, (list, tuple)):
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
for key in ("componentName", "name", "displayName"):
text = str(locator.get(key) or "").strip()
if text:
return text
if not include_index:
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
component_index = _int_or_none(locator.get("componentIndex"))
if component_index is not None:
return f"组件{component_index + 1}"
return ""
def _unit_vector(values: list[float]) -> list[float]:
if len(values) != 3:
return []
length = sum(item * item for item in values) ** 0.5
if length <= 1.0e-12:
return []
return [item / length for item in values]
def _point_projection(point: list[float], axis: list[float]) -> float:
return sum(float(point[index]) * float(axis[index]) for index in range(3))
def _float_or_none(value: object) -> float | None:
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _value_type(value_kind: str, key: str) -> str: def _value_type(value_kind: str, key: str) -> str:
if value_kind == "vector3": if value_kind == "vector3":
return "vector3" return "vector3"
if value_kind == "command": if value_kind == "command":
return "command" return "command"
if key.endswith(".diameter") or key.endswith(".radius"): positive_suffixes = (".diameter", ".radius", ".width", ".depth", ".height", ".distance", ".thickness", ".spacing", ".segment_spacing")
if key.endswith(positive_suffixes):
return "positive" return "positive"
return "number" return "number"
@@ -132,6 +566,19 @@ def _format_value(value: object, *, value_type: str) -> str:
return str(value) return str(value)
def _current_value_available(value: object, *, value_type: str) -> bool:
if value is None or value == "":
return False
if value_type == "vector3":
return len(_float_values(value)) == 3
if value_type in {"number", "positive"}:
try:
return float(str(value).strip()) > 0 if value_type == "positive" else True
except (TypeError, ValueError):
return False
return True
def _float_values(value: object) -> list[float]: def _float_values(value: object) -> list[float]:
if isinstance(value, (str, bytes)) or value is None: if isinstance(value, (str, bytes)) or value is None:
return [] return []
@@ -164,4 +611,11 @@ def _int_values(value: object) -> list[int]:
return result return result
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
__all__ = ["property_specs_from_scdm_cache"] __all__ = ["property_specs_from_scdm_cache"]
+286 -10
View File
@@ -79,10 +79,75 @@ def validate_scdm_edit_result(
"editResult": dict(edit_result), "editResult": dict(edit_result),
} }
if _is_removal_capability(capability_key) and (not before_signature or not after_cache):
return {
"ok": False,
"reason": "removal-check-unavailable",
"message": "Removed feature cannot be verified without the old feature signature and the new SCDM cache.",
"summaryCheck": summary_check,
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
if capability_key == "pattern.segment_spacing":
check = _check_pattern_segment_spacing_edit_result(edit_result, expected_target, tolerance=tolerance)
if check.get("ok") is not True:
return {
"ok": False,
"reason": str(check.get("reason") or "target-check-failed"),
"message": str(check.get("message") or "SCDM result did not reach the target segment spacing."),
"targetCheck": check,
"summaryCheck": summary_check,
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
return {
"ok": True,
"reason": "ok",
"message": "SCDM edit result passed the available validation checks.",
"output_step": str(output_step),
"matchedObject": None,
"targetCheck": check,
"removalCheck": {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."},
"summaryCheck": summary_check,
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
matched: dict[str, object] | None = None 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: if before_signature and after_cache:
match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key) match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "") status = str(match.get("status") or "")
if _is_removal_capability(capability_key):
removal_check = _check_removed_object_match(match)
if removal_check.get("ok") is not True:
return {
"ok": False,
"reason": str(removal_check.get("reason") or "feature-still-present"),
"message": str(removal_check.get("message") or "Removed feature is still present in the new SCDM cache."),
"removalCheck": removal_check,
"match": match,
"brep": brep,
"editResult": dict(edit_result),
}
check = {"ok": True, "reason": "removed", "message": "Target feature disappeared from the new SCDM cache."}
return {
"ok": True,
"reason": "ok",
"message": "SCDM edit result passed the available validation checks.",
"output_step": str(output_step),
"matchedObject": None,
"targetCheck": check,
"removalCheck": removal_check,
"summaryCheck": summary_check,
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
if status != "unique": if status != "unique":
return { return {
"ok": False, "ok": False,
@@ -118,6 +183,7 @@ def validate_scdm_edit_result(
"output_step": str(output_step), "output_step": str(output_step),
"matchedObject": matched, "matchedObject": matched,
"targetCheck": check, "targetCheck": check,
"removalCheck": removal_check,
"summaryCheck": summary_check, "summaryCheck": summary_check,
"topologyCheck": topology_check, "topologyCheck": topology_check,
"brep": brep, "brep": brep,
@@ -251,6 +317,8 @@ def check_scdm_unedited_objects(
continue continue
if edited_signature and _same_signature_subject(signature, edited_signature): if edited_signature and _same_signature_subject(signature, edited_signature):
continue continue
if edited_signature and _is_expected_pattern_spacing_subject(signature, edited_signature, capability_key=capability_key):
continue
checked += 1 checked += 1
match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key) match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "") status = str(match.get("status") or "")
@@ -324,15 +392,88 @@ def check_scdm_target(
actual_vector = _vector(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "center")) actual_vector = _vector(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "center"))
expected_vector = _vector(expected_target) expected_vector = _vector(expected_target)
return _vector_check(actual_vector, expected_vector, "hole.position", tolerance) return _vector_check(actual_vector, expected_vector, "hole.position", tolerance)
if capability_key in {"slot.position", "boss.position", "pattern.instance_position"}:
actual_vector = _vector(
_capability_value(raw_object, capability_key)
or _geometry_value(raw_object, "center")
or _geometry_value(raw_object, "axisCenter")
or _geometry_value(raw_object, "instanceCenter")
)
expected_vector = _vector(expected_target)
return _vector_check(actual_vector, expected_vector, capability_key, tolerance)
if capability_key == "slot.width":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "width"))
expected = _number(expected_target)
return _number_check(actual, expected, "slot.width", tolerance)
if capability_key == "slot.depth":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "depth"))
expected = _number(expected_target)
return _number_check(actual, expected, "slot.depth", tolerance)
if capability_key == "boss.height":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "height"))
expected = _number(expected_target)
return _number_check(actual, expected, "boss.height", tolerance)
if capability_key == "boss.diameter":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "diameter"))
if actual is None:
radius = _number(_geometry_value(raw_object, "radius"))
actual = radius * 2.0 if radius is not None else None
expected = _number(expected_target)
return _number_check(actual, expected, "boss.diameter", tolerance)
if capability_key == "round.radius":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "radius"))
expected = _number(expected_target)
return _number_check(actual, expected, "round.radius", tolerance)
if capability_key == "chamfer.distance":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "distance") or _geometry_value(raw_object, "offset"))
expected = _number(expected_target)
return _number_check(actual, expected, "chamfer.distance", tolerance)
if capability_key == "pattern.spacing":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "spacing") or _geometry_value(raw_object, "pitch"))
expected = _number(expected_target)
return _number_check(actual, expected, "pattern.spacing", tolerance)
if capability_key == "pattern.segment_spacing":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "segmentSpacing") or _geometry_value(raw_object, "spacing") or _geometry_value(raw_object, "pitch"))
expected = _number(expected_target)
return _number_check(actual, expected, "pattern.segment_spacing", tolerance)
if capability_key == "shell.thickness":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "thickness"))
expected = _number(expected_target)
return _number_check(actual, expected, "shell.thickness", tolerance)
if capability_key == "face.offset": if capability_key == "face.offset":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "offset") or _geometry_value(raw_object, "planeOffset")) actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "offset") or _geometry_value(raw_object, "planeOffset"))
expected = _number(expected_target) expected = _number(expected_target)
return _number_check(actual, expected, "face.offset", tolerance) return _number_check(actual, expected, "face.offset", tolerance)
if capability_key == "feature.fill": if _is_removal_capability(capability_key):
return {"ok": True, "reason": "not-applicable", "message": "feature.fill is checked by object disappearance in the caller."} return {"ok": True, "reason": "not-applicable", "message": f"{capability_key} is checked by object disappearance in the caller."}
return {"ok": None, "reason": "unsupported-post-check", "message": f"No target checker is registered for {capability_key}."} return {"ok": None, "reason": "unsupported-post-check", "message": f"No target checker is registered for {capability_key}."}
def _check_pattern_segment_spacing_edit_result(
edit_result: Mapping[str, object],
expected_target: object,
*,
tolerance: float,
) -> dict[str, object]:
applied = edit_result.get("applied")
nested = edit_result.get("result")
if not isinstance(applied, Mapping) and isinstance(nested, Mapping):
applied = nested.get("applied")
if not isinstance(applied, Mapping):
return {
"ok": False,
"reason": "missing-edit-applied",
"message": "SCDM edit result did not report the applied local segment spacing.",
}
actual = _number(applied.get("segmentSpacing") or applied.get("targetSpacing"))
expected = _number(expected_target)
check = _number_check(actual, expected, "pattern.segment_spacing", tolerance)
if check.get("ok") is True:
check["segmentIndex"] = applied.get("segmentIndex")
check["spacingMode"] = applied.get("spacingMode")
return check
def check_scdm_summary_delta( def check_scdm_summary_delta(
before_cache: Mapping[str, object], before_cache: Mapping[str, object],
after_cache: Mapping[str, object], after_cache: Mapping[str, object],
@@ -401,6 +542,27 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
if before_surface and before_surface == after_surface: if before_surface and before_surface == after_surface:
score += 1.0 score += 1.0
before_components = _component_locator_keys(before.get("componentLocators") or before.get("bodyLocators"))
after_components = _component_locator_keys(after.get("componentLocators") or after.get("bodyLocators"))
if before_components and after_components:
if before_components & after_components:
score += 3.0
else:
return 0.0
before_body = _int_or_none(before.get("bodyIndex"))
after_body = _int_or_none(after.get("bodyIndex"))
if before_body is not None and after_body is not None and before_body == after_body:
score += 2.0
before_face_ordinal = _int_or_none(before.get("faceOrdinal"))
after_face_ordinal = _int_or_none(after.get("faceOrdinal"))
if before_face_ordinal is not None and before_face_ordinal == after_face_ordinal:
score += 1.0
before_edge_ordinal = _int_or_none(before.get("edgeOrdinal"))
after_edge_ordinal = _int_or_none(after.get("edgeOrdinal"))
if before_edge_ordinal is not None and before_edge_ordinal == after_edge_ordinal:
score += 1.0
before_faces = set(_int_values(before.get("faceIds"))) before_faces = set(_int_values(before.get("faceIds")))
after_faces = set(_int_values(after.get("faceIds"))) after_faces = set(_int_values(after.get("faceIds")))
if before_faces and after_faces: if before_faces and after_faces:
@@ -413,8 +575,13 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
if before_edges and after_edges and before_edges & after_edges: if before_edges and after_edges and before_edges & after_edges:
score += 0.5 score += 0.5
if capability_key != "hole.position": if not _is_position_capability(capability_key):
center_score = _vector_distance_score(_vector(before.get("center")), _vector(after.get("center"))) before_center = _vector(before.get("center"))
after_center = _vector(after.get("center"))
if _is_removal_capability(capability_key) and before_center and after_center:
if _vector_error(before_center, after_center) > _vector_tolerance(before_center, after_center):
return 0.0
center_score = _vector_distance_score(before_center, after_center)
score += center_score score += center_score
axis_score = _axis_score(_vector(before.get("axis")), _vector(after.get("axis"))) axis_score = _axis_score(_vector(before.get("axis")), _vector(after.get("axis")))
@@ -426,6 +593,98 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
return score return score
def _is_position_capability(capability_key: str) -> bool:
return capability_key in {"hole.position", "slot.position", "boss.position", "pattern.instance_position"} or capability_key.endswith(".position")
def _is_removal_capability(capability_key: str) -> bool:
return capability_key in {"feature.fill", "feature.delete_round_or_chamfer"} or capability_key.endswith(".remove") or capability_key.startswith("feature.delete")
def _check_removed_object_match(match: Mapping[str, object]) -> dict[str, object]:
status = str(match.get("status") or "")
if status == "none":
return {"ok": True, "reason": "removed", "message": "Edited feature is no longer present in the new SCDM cache."}
if status == "unique":
return {
"ok": False,
"reason": "feature-still-present",
"message": "SCDM reported success, but the edited feature still matches an object in the new cache.",
"match": dict(match),
}
if status == "multiple":
return {
"ok": False,
"reason": "feature-removal-ambiguous",
"message": "SCDM reported success, but multiple new objects still match the edited feature.",
"match": dict(match),
}
return {
"ok": False,
"reason": f"feature-removal-{status or 'failed'}",
"message": str(match.get("message") or "Removed feature could not be verified."),
"match": dict(match),
}
def _is_expected_pattern_spacing_subject(
signature: Mapping[str, object],
edited_signature: Mapping[str, object],
*,
capability_key: str,
) -> bool:
if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}:
return False
if str(edited_signature.get("objectType") or "") != "linear_pattern":
return False
touched_faces = set(_int_values(edited_signature.get("faceIds")))
touched_bodies = set(_int_values(edited_signature.get("bodyIndices")))
touched_components = _component_locator_keys(edited_signature.get("componentLocators"))
for instance in _pattern_instances(edited_signature):
touched_faces.update(_int_values(instance.get("faceIds")))
body_index = _int_or_none(instance.get("bodyIndex"))
if body_index is not None:
touched_bodies.add(body_index)
touched_bodies.update(_int_values(instance.get("bodyIndices")))
touched_components.update(_component_locator_keys(instance.get("componentLocators") or instance.get("bodyLocators")))
subject_faces = set(_int_values(signature.get("faceIds")))
if touched_faces and subject_faces and touched_faces.intersection(subject_faces):
return True
subject_body = _int_or_none(signature.get("bodyIndex"))
if subject_body is not None and subject_body in touched_bodies:
return True
subject_bodies = set(_int_values(signature.get("bodyIndices")))
if touched_bodies and subject_bodies and touched_bodies.intersection(subject_bodies):
return True
subject_components = _component_locator_keys(signature.get("componentLocators") or signature.get("bodyLocators"))
return bool(touched_components and subject_components and touched_components.intersection(subject_components))
def _pattern_instances(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 _component_locator_keys(value: object) -> set[str]:
if not isinstance(value, (list, tuple)):
return set()
result: set[str] = set()
for locator in value:
if not isinstance(locator, Mapping):
continue
path = _ordered_int_values(locator.get("componentPath"))
if path:
result.add("path:" + ".".join(str(item) for item in path))
continue
component_index = _int_or_none(locator.get("componentIndex"))
if component_index is not None:
result.add("index:" + str(component_index))
return result
def _signature_has_enough_identity(signature: Mapping[str, object]) -> bool: def _signature_has_enough_identity(signature: Mapping[str, object]) -> bool:
if _vector(signature.get("center")) and _vector(signature.get("axis")): if _vector(signature.get("center")) and _vector(signature.get("axis")):
return True return True
@@ -473,12 +732,13 @@ def _unchanged_signature_still_matches(before: Mapping[str, object], after: Mapp
if abs(float(before_diameter) - float(after_diameter)) > tolerance: if abs(float(before_diameter) - float(after_diameter)) > tolerance:
return False return False
before_offset = _number(before.get("planeOffset")) for key in ("planeOffset", "width", "depth", "height", "distance", "spacing", "pitch", "thickness"):
after_offset = _number(after.get("planeOffset")) before_value = _number(before.get(key))
if before_offset is not None and after_offset is not None: after_value = _number(after.get(key))
tolerance = max(abs(before_offset), abs(after_offset), 1.0) * 1.0e-5 if before_value is not None and after_value is not None:
if abs(float(before_offset) - float(after_offset)) > tolerance: tolerance = max(abs(before_value), abs(after_value), 1.0) * 1.0e-5
return False if abs(float(before_value) - float(after_value)) > tolerance:
return False
return True return True
@@ -645,6 +905,22 @@ def _int_values(value: object) -> list[int]:
return result return result
def _ordered_int_values(value: object) -> list[int]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[int] = []
for item in values:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
def _raw_summary(cache: Mapping[str, object]) -> Mapping[str, object]: def _raw_summary(cache: Mapping[str, object]) -> Mapping[str, object]:
diagnostics = cache.get("diagnostics") diagnostics = cache.get("diagnostics")
if not isinstance(diagnostics, Mapping): if not isinstance(diagnostics, Mapping):
+16 -2
View File
@@ -1105,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
SELECTION_MODE_LABELS = { SELECTION_MODE_LABELS = {
"Part": "零件", "Part": "Part",
"Solid": "Solid", "Solid": "Solid",
"Face": "Face", "Face": "Face",
"Edge": "Edge", "Edge": "Edge",
"Feature": "特征", "Feature": "Feature",
} }
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()} SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
SELECTION_MODE_VALUES.update(
{
"装配零件": "Part",
"零件": "Part",
"实体": "Solid",
"": "Face",
"": "Edge",
"智能特征": "Feature",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"特征": "Feature",
}
)
SURFACE_VALUE_LABELS = { SURFACE_VALUE_LABELS = {
+86 -16
View File
@@ -21,9 +21,9 @@ from PySide6.QtWidgets import (
from .model import StepModel from .model import StepModel
from .asitus_bridge import run_asitus_hole_recognition from .asitus_bridge import run_asitus_hole_recognition
from .records import OperationRecord from .records import OperationRecord
from .scdm_feature_mapper import attach_local_face_ids_to_scdm_cache, map_scdm_raw_features from .scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION, attach_local_face_ids_to_scdm_cache, map_scdm_raw_features
from .scdm_probe import run_scdm_probe from .scdm_probe import run_scdm_probe
from .scdm_schema import write_json from .scdm_schema import default_scdm_work_dir, file_fingerprint, read_json, write_json
from .ui_helpers import * # noqa: F403 from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker from .workers import EditWorker, LoadWorker, ScanWorker
@@ -1308,11 +1308,13 @@ class WindowCoreMixin:
f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。" f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。"
) )
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict) pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
if large_interaction_model and not pending_scdm_reload: 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() self._defer_large_model_recognition_preloads()
else: else:
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload) QTimer.singleShot(160, self._start_asitus_hole_recognition_preload)
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload)) if pending_scdm_reload or not scdm_cache_restored:
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload))
def _large_model_interaction_mode(self, stats: object | None = None) -> bool: def _large_model_interaction_mode(self, stats: object | None = None) -> bool:
if stats is not None: if stats is not None:
@@ -1354,9 +1356,84 @@ class WindowCoreMixin:
if hasattr(self, "_update_current_capability_panel"): if hasattr(self, "_update_current_capability_panel"):
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:
self.scdm_feature_cache = dict(cache)
self.scdm_feature_cache_state = "ready"
self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。"
self.scdm_feature_cache_path = str(cache_path or "")
if self.model is not None:
try:
self.model.scdm_feature_cache = dict(cache)
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
def _restore_scdm_feature_cache_from_disk(self) -> bool:
if self.model is None or self.step_path is None:
return False
step_path = Path(self.step_path)
try:
fingerprint = file_fingerprint(step_path)
except OSError:
return False
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():
try:
candidate = read_json(cache_path)
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)
except Exception:
cache = None
if cache is None:
return False
self._install_scdm_feature_cache(
cache,
cache_path=str(cache_path),
message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。",
)
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
def _start_scdm_probe_preload(self, *, force: bool = False) -> None: def _start_scdm_probe_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None: if self.model is None or self.step_path is None:
return return
if not force and self._current_scdm_feature_cache_matches_loaded_step():
return
if not force and self._large_model_interaction_mode(): if not force and self._large_model_interaction_mode():
self._defer_large_model_recognition_preloads() self._defer_large_model_recognition_preloads()
return return
@@ -1476,21 +1553,14 @@ class WindowCoreMixin:
return return
if isinstance(result.get("backend"), dict): if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"]) self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache = dict(cache) self._install_scdm_feature_cache(
self.scdm_feature_cache_state = "ready" dict(cache),
self.scdm_feature_cache_message = "SCDM 可修改参数识别完成。" cache_path=str(result.get("cache_path") or ""),
self.scdm_feature_cache_path = str(result.get("cache_path") or "") message="SCDM 可修改参数识别完成。",
try: )
self.model.scdm_feature_cache = dict(cache)
except Exception:
pass
objects = cache.get("objects") objects = cache.get("objects")
count = len(objects) if isinstance(objects, list) else 0 count = len(objects) if isinstance(objects, list) else 0
self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。") self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
if hasattr(self, "_finish_pending_scdm_edit_reload"): if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=True) self._finish_pending_scdm_edit_reload(cache_ready=True)
finally: finally:
+42 -3
View File
@@ -42,6 +42,7 @@ from .relation_formulas import (
parse_relation_formula, parse_relation_formula,
relation_value_to_text, relation_value_to_text,
rewrite_relation_formula_ids, rewrite_relation_formula_ids,
validate_relation_formula_graph,
) )
from .scdm_backend import resolve_scdm_backend from .scdm_backend import resolve_scdm_backend
from .scdm_edit_runner import run_scdm_edit_job from .scdm_edit_runner import run_scdm_edit_job
@@ -3100,6 +3101,7 @@ class WindowStateMixin:
text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else "" text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else ""
try: try:
formula = parse_relation_formula(text) formula = parse_relation_formula(text)
self._validate_relation_formula_graph(formula)
if not replay_active: if not replay_active:
self._validate_relation_formula_references(formula) self._validate_relation_formula_references(formula)
except RelationFormulaError as exc: except RelationFormulaError as exc:
@@ -3453,6 +3455,18 @@ class WindowStateMixin:
for ref in formula.references: for ref in formula.references:
self._relation_value_for_ref(ref) self._relation_value_for_ref(ref)
def _validate_relation_formula_graph(self, new_formula) -> None:
formulas = []
for item in getattr(self, "relation_formula_items", []) or []:
if not bool(item.get("enabled", True)):
continue
try:
formulas.append(parse_relation_formula(str(item.get("text") or "")))
except RelationFormulaError:
continue
formulas.append(new_formula)
validate_relation_formula_graph(formulas)
def _relation_parameter_supported(self, ref: ObjectParameterRef, *, target: bool = False) -> None: def _relation_parameter_supported(self, ref: ObjectParameterRef, *, target: bool = False) -> None:
if target and self._relation_visible_spec_for_ref(ref) is not None: if target and self._relation_visible_spec_for_ref(ref) is not None:
return return
@@ -4565,7 +4579,7 @@ class WindowStateMixin:
return inner_wires > 0 or boundary_edges >= 16 return inner_wires > 0 or boundary_edges >= 16
def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str: def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str:
if self.selected_kind not in {"feature", "face", "edge"}: if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}:
return "" return ""
if self.model is None: if self.model is None:
return "" return ""
@@ -8915,8 +8929,12 @@ class WindowStateMixin:
def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object: def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object:
value_type = str(spec.get("value_type") or "number") value_type = str(spec.get("value_type") or "number")
unit_scale = _float_or_none(spec.get("scdm_unit_scale"))
if unit_scale is None or unit_scale <= 0:
unit_scale = 1.0
if value_type == "vector3": if value_type == "vector3":
return list(self._parse_property_vector3(text)) values = list(self._parse_property_vector3(text))
return [value * unit_scale for value in values] if self._scdm_property_uses_length_units(spec) else values
if value_type in {"number", "positive"}: if value_type in {"number", "positive"}:
try: try:
value = float(text) value = float(text)
@@ -8924,11 +8942,32 @@ class WindowStateMixin:
raise ValueError("请输入数字形式的目标值。") from exc raise ValueError("请输入数字形式的目标值。") from exc
if value_type == "positive" and value <= 0: if value_type == "positive" and value <= 0:
raise ValueError("请输入大于 0 的目标值。") raise ValueError("请输入大于 0 的目标值。")
return value return value * unit_scale if self._scdm_property_uses_length_units(spec) else value
if value_type == "command": if value_type == "command":
return True return True
return text return text
@staticmethod
def _scdm_property_uses_length_units(spec: dict[str, object]) -> bool:
key = str(spec.get("scdm_capability_key") or spec.get("key") or "")
value_type = str(spec.get("value_type") or "")
if value_type == "vector3":
return True
suffixes = (
".diameter",
".radius",
".offset",
".width",
".depth",
".height",
".distance",
".thickness",
".spacing",
".segment_spacing",
".position",
)
return key.endswith(suffixes)
@Slot(object) @Slot(object)
def _finish_scdm_edit_action(self, result: object) -> None: def _finish_scdm_edit_action(self, result: object) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_edit_action(result)): if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_edit_action(result)):