diff --git a/README.md b/README.md index 5f3604f..43dc28b 100644 --- a/README.md +++ b/README.md @@ -10,96 +10,104 @@ - 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 -- 后续过渡到 `SCDM-first` 后端:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚和结果映射。 -- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点。 +- 当前统一走 `SCDM-first` 主线:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚、结果映射、参数导出和关系式管理。 +- 当前开发顺序按一条路线闭环:SCDM 识别与缓存 -> 能力字典和参数表 -> 参数化建模任务 -> 结果校验和 ID 续接 -> 按 Face、孔、槽、凸台、圆角/倒角、阵列、壳体逐项适配;本地 OCCT / Analysis Situs 只作为兜底和对照。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 ## 软件整体架构 -当前项目大致是这样分层的: +当前项目大致是这样分层的。行数按当前 Git 跟踪的可读源码/脚本统计,排除 README、STEP 模型、图标、`.git`、`local`、`third_party` 和临时文件。 ```text -python-occt -├── main.py +python-occt(源码/脚本 84,740 行) +├── main.py(7 行) │ └── 程序入口,支持普通启动、烟测、隔离子进程 worker 入口 │ -├── step_editor/ -│ ├── app.py +├── step_editor/(62,303 行) +│ ├── app.py(1,976 行) │ │ └── 主窗口初始化、左侧操作面板、按钮和布局 │ │ -│ ├── window_core.py +│ ├── window_core.py(4,171 行) │ │ └── STEP 加载、VTK 视图、模型显示、拾取、高亮、坐标轴、FPS、显示刷新 │ │ -│ ├── window_actions.py +│ ├── window_actions.py(9,677 行) │ │ └── 导出、参数化建模动作、后台任务、隔离子进程、操作历史 │ │ -│ ├── window_state.py +│ ├── window_state.py(10,169 行) │ │ └── 选择状态、参数表状态、按钮启用/禁用、撤销/重做、历史定位 │ │ -│ ├── model.py +│ ├── model.py(5,713 行) │ │ └── StepModel 核心对象,保存 shape、Face、Edge、Solid、拓扑缓存和 mixin 组合 │ │ -│ ├── features.py +│ ├── features.py(3,591 行) │ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面 │ │ -│ ├── recognition_graph.py +│ ├── recognition_graph.py(539 行) │ │ └── 内部 Face 几何图和通孔拆面识别缓存,记录平面/圆柱、邻接、共面、平行、垂直、同轴等基础关系 │ │ -│ ├── asitus_bridge.py +│ ├── asitus_bridge.py(308 行) │ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号 │ │ -│ ├── scdm_backend.py +│ ├── scdm_backend.py(623 行) │ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测 │ │ -│ ├── scdm_probe.py +│ ├── scdm_probe.py(680 行) │ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定 │ │ -│ ├── scdm_edit_runner.py +│ ├── scdm_edit_runner.py(1,145 行) │ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json │ │ -│ ├── scdm_result_validator.py +│ ├── scdm_result_validator.py(770 行) │ │ └── 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.py(1,821 行) │ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI │ │ -│ ├── operations.py +│ ├── operations.py(14,604 行) │ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角 │ │ -│ ├── geometry_utils.py +│ ├── geometry_utils.py(1,427 行) │ │ └── 通用 OCCT 几何工具、布尔结果处理、B-Rep 校验、拓扑辅助 │ │ -│ ├── polydata.py +│ ├── polydata.py(425 行) │ │ └── 把 OCCT Shape / Face / Edge 转成 VTK polydata,用于显示和拾取 │ │ -│ ├── step_io.py +│ ├── step_io.py(160 行) │ │ └── STEP/XCAF 读取、STEP 导出、内部 BREP 临时交换 │ │ -│ ├── export.py +│ ├── export.py(179 行) │ │ └── 导出完整模型、零件、Solid、Face、Edge,以及质量检查 │ │ -│ ├── isolated_edit_worker.py +│ ├── isolated_edit_worker.py(238 行) │ │ └── 高风险几何编辑的隔离子进程入口 │ │ -│ ├── workers.py +│ ├── workers.py(51 行) │ │ └── Qt 后台任务 worker,避免主界面被计算堵死 │ │ -│ ├── ui_helpers.py / widgets.py / info_panel.py +│ ├── ui_helpers.py / widgets.py / info_panel.py(1,682 行) │ │ └── UI 辅助函数、自定义控件、属性信息显示 │ │ -│ └── recognition_priority.py +│ └── recognition_priority.py(238 行) │ └── 特征识别优先级,让常用、稳定、语义清楚的特征排前面 │ -├── scripts/ +├── scripts/(21,988 行) │ └── 各类专项验证脚本和一级编辑总回归入口 │ +├── tools/asitus_probe/(386 行) +│ └── Analysis Situs probe 的 C++ 源码和 CMake 配置 +│ +├── environment.yml / .gitignore(56 行) +│ └── Conda 环境和 Git 忽略规则 +│ └── assets/models/ └── 仓库自带测试 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”,而是下面这条闭环能稳定跑通: @@ -107,103 +115,153 @@ python-occt 导入 STEP -> 自动找到可用的 SpaceClaim.exe,并缓存路径、来源、版本和验证结果 -> 本软件显示模型并建立当前 Face / Edge / Solid 基础索引 --> 生成 scdm_probe_job.json,说明要让 SCDM 扫描哪个 STEP、输出到哪里、使用哪个适配器 --> SCDM probe 打开 STEP,读取结构化几何对象、可执行命令候选、失败/限制原因 --> 写出 scdm_raw_features.json,保留 SCDM 原始结构化结果,不依赖 UI 显示名称 --> 本软件把 raw 结果映射到能力字典,生成 scdm_feature_cache.json --> 用户点击对象时,参数表只显示 cache 中已产品化、可执行、可校验的参数 --> 用户修改目标值后,生成 scdm_edit_job.json 和对应 SCDM 修改脚本 --> SpaceClaim.exe /RunScript 在后台隔离执行,成功输出 result.step,失败输出 error.json --> 本软件用 OCCT 重新读 result.step,做 B-Rep 校验、目标值回测和非预期变形检查 --> 校验通过后替换当前模型,重新跑 probe 刷新 scdm_feature_cache.json +-> 本软件把“请识别这个 STEP”的任务写给 SCDM,并用 /RunScript 启动 SpaceClaim +-> SCDM 在后台打开 STEP,识别可编辑对象、参数、命令候选和限制原因,并把结果写回给本软件 +-> 本软件把 SCDM 结果翻译成能力字典和中文参数,并缓存起来,后续点击对象直接查缓存 +-> 用户点击对象时,参数表只显示已经产品化、可执行、可校验的参数 +-> 用户修改目标值后,本软件把“改哪个对象、改成多少、失败怎么回滚”的修改任务写给 SCDM +-> SpaceClaim.exe /RunScript 在后台执行修改,成功写出新的 STEP,失败写出错误原因 +-> 本软件用 OCCT 重新读取新的 STEP,做 B-Rep 校验、目标值回测和非预期变形检查 +-> 校验通过后替换当前模型,重新让 SCDM 识别并刷新缓存 -> 用新缓存更新 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 -SCDM-first 实施路线 -├── S0. 后端发现与缓存 -│ ├── [x] 新建 step_editor/scdm_backend.py -│ ├── [x] 读取 local/scdm_backend.json,缓存可用时直接复用 SpaceClaim.exe -│ ├── [x] 缓存失效时自动发现:Windows 注册表 -> 环境变量 -> ANSYS 常见安装目录 -> PATH -│ ├── [x] 自动发现失败时,后端返回需要用户配置;导入模型后的 SCDM probe 会自动弹出路径选择框,用户选择 SpaceClaim.exe 后写回缓存 -│ ├── [x] 找到后可运行最小 /RunScript 烟测,确认能启动、能写报告、许可证可用 -│ ├── [x] 写回 local/scdm_backend.json:path、source、version、verifiedAt、runScriptOk、licenseOk -│ └── [x] 验收:scripts/verify_scdm_backend.py 覆盖缓存、发现、禁用开关和 /RunScript 命令;SCDM 不可用时主程序仍可走 OCCT 降级模式 +识别阶段: +本软件写识别任务 JSON + 识别脚本 scdm_probe.py +-> 用 SpaceClaim.exe /RunScript=scdm_probe.py 启动 SCDM +-> SCDM 打开 STEP,并把“识别到什么、哪些参数可改、有哪些限制”写回 JSON +-> 本软件读取结果,翻译成中文能力和参数表,并缓存起来 + +修改阶段: +本软件写修改任务 JSON + 修改脚本 scdm_edit.py +-> 用 SpaceClaim.exe /RunScript=scdm_edit.py 启动 SCDM +-> SCDM 按任务修改模型,并输出新的 STEP 或失败原因 +-> 本软件读取新 STEP,校验通过才替换当前模型;失败就回滚 +``` + +这些中转文件都放在项目的 `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 任务与原始识别输出 -│ ├── [x] 新建 step_editor/scdm_probe.py,负责生成 scdm_probe_job.json 和临时 SCDM probe 脚本 -│ ├── [x] Probe 输入:STEP 路径、单位、模型指纹、输出目录、SCDM 版本、扫描范围 -│ ├── [~] Probe 动作:当前脚本已防御式遍历 Body / Face / Edge,Edge raw 已输出长度、起终点、圆弧半径和相邻 Face 摘要,并会先尝试 `StandardHoles.Find` / `GetHoleFaces`,失败或为空再回退到圆柱碎片聚合;圆柱面会尝试 `RoundInfo.Create` 生成圆角诊断摘要,Chamfer / Fill 的深度 API 识别仍待继续补 -│ ├── [x] Probe 输出:scdm_raw_features.json,包含 geometry、topologyHint、backendCommandCandidates、rawLimitations、availableCommands、Face 邻接、Edge 几何摘要和 featureInventory -│ ├── [x] 不依赖 SCDM UI 文案;只记录 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,仍需继续拿更多样例适配 +├── 1. SCDM 识别与缓存 +│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP] +│ ├── [x] [scdm_raw_features.json -> 保存 SCDM 结构化对象、几何属性、命令候选和限制原因] +│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数] +│ ├── [x] [cache 命中 -> 点击对象只查缓存,不重复启动 SCDM] +│ ├── [~] [probe 深度 -> Face / Edge / Body 已稳定遍历,Hole / Round API 真实样例继续补] +│ └── [x] [不能产品化的 raw 结果 -> 只进诊断,不进客户参数表] │ -├── S2. 统一 Schema 与能力字典 -│ ├── [x] 新建 step_editor/scdm_schema.py,定义 raw feature、normalized feature、capability、edit job 的字段 -│ ├── [x] 新建 step_editor/scdm_capabilities.py,定义产品能力 key、中文名、单位、输入类型、默认建模意图、SCDM 命令、所需后端命令组和校验器 -│ ├── [x] 第一批能力:hole.diameter、hole.position、face.offset、feature.fill -│ ├── [x] S7 后续能力先以 productized=false 进入能力字典和诊断缓存,不直接显示成客户可执行参数 -│ ├── [x] 未进入能力字典的 SCDM 结果只进入诊断和日志,状态为 discovered_not_productized -│ ├── [x] 每个能力都必须声明 postCheck,例如目标孔径、目标轴心、目标偏移或目标特征消失 -│ └── [x] 验收:假 raw JSON 经过映射后,只产出已定义能力;未定义能力不会出现在产品化 cache +├── 2. 能力字典、参数表和用户入口 +│ ├── [x] [capability key -> 中文名、单位、输入类型、建模意图、后端命令和 postCheck] +│ ├── [x] [参数表 -> 只显示已产品化、可执行、可校验的参数] +│ ├── [x] [不可改 -> UI 阶段直接说明是未实现、命令不可用、识别不完整还是风险过高] +│ ├── [x] [参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮] +│ ├── [x] [导出参数 -> 勾选输入参数后生成组件目录 main.py 和参数 schema] +│ └── [~] [关系式 -> 作为参数表上游输入,计算后回填目标值,已阻止自引用/重复目标/循环依赖] │ -├── S3. SCDM 缓存生成与失效策略 -│ ├── [x] 新建 step_editor/scdm_feature_mapper.py,把 scdm_raw_features.json 映射为 scdm_feature_cache.json -│ ├── [x] cache 记录:modelFingerprint、backendVersion、objects、capabilities、geometrySignature、blockReason -│ ├── [x] SCDM raw 没有 Python Face ID 时,用本软件当前 StepModel 的轻量几何签名补回 faceIds,支持米/mm 单位比例匹配 -│ ├── [x] cache 会额外保留 geometry_candidate_hints:当 SCDM 只给出平面/圆柱面/圆边/直边这些底层证据,而没有明确 slot/boss/round 对象时,先把 S7 候选记为“几何证据待分类”,不进入客户参数表 -│ ├── [x] 导入模型、重新加载、SCDM 参数化修改成功、撤销和重做后都会标记 cache 失效并后台刷新 -│ ├── [x] cache 可用时点击对象只查窗口/model 上的 scdm_feature_cache,不重新启动 SCDM -│ ├── [x] cache 正在生成时,UI 状态栏显示“正在后台识别 SCDM 可修改参数”,不阻塞旋转查看模型 -│ └── [~] 验收:raw->cache 映射已由 scripts/verify_scdm_probe_pipeline.py 覆盖;window_core.py 已接后台预热,真实 SCDM 运行耗时和失败提示待样例机验证 +├── 3. SCDM 参数化建模执行 +│ ├── [x] [scdm_edit_job.json -> 记录模型、对象签名、能力、目标值、输出路径和超时] +│ ├── [x] [/RunScript 后台执行 -> 成功写 result.step/result.json,失败写 error.json] +│ ├── [x] [face.offset / hole.diameter / hole.position -> ICEPAK 真实 SCDM 回测通过] +│ ├── [~] [slot.width / slot.depth / slot.position / boss.diameter / boss.height / boss.position -> runner、UI gate 和目标校验已接,真实样例待补] +│ └── [~] [round.radius / chamfer.distance / feature.fill / feature.delete_round_or_chamfer -> 脚本和假 runner 已接,真实样例待补] │ -├── S4. 参数表接入 -│ ├── [x] 修改 window_state.py 的参数表数据源:存在 SCDM cache 时优先转成参数表 spec,现有 OCCT/Analysis Situs 能力作为兜底;对大模型多内孔平面位置调整这类本地已验证快路径,参数表会优先走 OCCT 专用一级边界重建,避免 SCDM 直接建模长时间计算 -│ ├── [x] 选中对象后,用当前 Face / Edge 索引、逻辑 ID、整孔 Face 组和 SCDM->Python faceIds 映射匹配 cache 中的 geometrySignature -│ ├── [~] 匹配成功且 capability 已接入执行器时展示能力字典里的中文参数、当前值、建模意图、目标值和输入参数勾选框;参数表只展示 enabled 能力,未开放能力只进状态提示/诊断;当前按能力开放 `face.offset`、`hole.diameter`、`hole.position`、`slot.position`、`boss.position` -│ ├── [x] 匹配失败或能力未开放时给出短原因:后台识别中、SCDM 未识别、能力未产品化、脚本命令不可用、后端失败;当前状态栏和下方诊断信息都会显示 SCDM 后端状态、当前选择状态、可执行能力和未开放原因 -│ ├── [x] 保留现有关系式、参数导出和统一“参数化建模”按钮,不新增一套 SCDM 专用 UI;数值型 SCDM 能力改目标值后执行,命令型能力在选中该行后由同一个按钮执行 -│ └── [~] 验收:scripts/verify_scdm_probe_pipeline.py 已验证 Face / Hole 参数 spec 转换、能力级启用开关和圆柱碎片组到本地 Face ID 的回填;scripts/verify_property_card_editor_ui.py 已覆盖 SCDM 状态摘要和选择诊断同步;真实 UI 长流程回归待继续 +├── 4. 结果校验、回滚和 ID 续接 +│ ├── [x] [OCCT 回读 result.step -> 检查输出文件、B-Rep 和基础模型规模] +│ ├── [~] [目标回测 -> 已覆盖数值目标、位置目标、目标特征消失和未编辑对象漂移] +│ ├── [~] [ID 续接 -> 简单唯一匹配已接,歧义/拆分/合并样例继续补] +│ ├── [~] [关系式刷新 -> 修改后按新 cache 重写 Face / Edge / 特征 ID] +│ └── [x] [失败 -> 回滚原模型并给出明确原因] │ -├── S5. 修改任务执行闭环 -│ ├── [x] 新建 step_editor/scdm_edit_runner.py,生成 scdm_edit_job.json 和临时 SCDM 修改脚本 -│ ├── [x] edit job 记录:模型路径、对象签名、capabilityKey、目标值、输出 STEP、超时、回滚文件 -│ ├── [~] 第一批执行:face.offset、孔径、孔位置已在本机 SCDM v222 + ICEPAK 样例真机验证;S7.2 的槽位置和 S7.3 的凸台位置已接入 `Move` 脚本、edit job、cache 映射和 UI gate,真实槽/凸台样例回测待补;命令型能力的统一按钮承载已补,填孔/删除小特征已补 `Fill.Execute` / `Delete.Execute` 脚本适配但仍待真实 STEP 验证后再开放 -│ ├── [~] SpaceClaim.exe /RunScript 已接入 UI 后台任务,成功后加载 result.step,重新触发 SCDM probe,并在 cache 刷新后再通知关系式/批量执行继续;校验通过后会写入撤销历史,撤销/重做会触发 SCDM cache 失效和后台刷新 -│ ├── [x] 成功写 result.step / result.json;失败写 error.json,包含 SCDM 命令、对象、参数和失败原因 -│ └── [~] 验收: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 失效已接入,真实长流程回归待继续 +├── 5. 当前已开放或正在开放的 SCDM 能力 +│ ├── [x] [Face 偏移 -> face.offset / OffsetFaces] +│ ├── [x] [孔直径 -> hole.diameter / StandardHoles 或同轴 OffsetFaces] +│ ├── [x] [孔位置 -> hole.position / Move] +│ ├── [x] [填孔/删除小特征 -> feature.fill / Fill 或 Delete] +│ ├── [~] [槽宽 -> slot.width / OffsetFaces,真实 STEP 回测待补] +│ ├── [~] [槽深 -> 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 续接和关系式刷新 -│ ├── [~] 用 OCCT 读取 result.step,校验 B-Rep 有效性、Solid 数量、目标参数、关键特征是否仍存在;当前 UI 已拒绝缺失/空输出 STEP,并在重新 probe 后调用 OCCT B-Rep 检查、目标值回测、未编辑对象漂移检查和 raw summary 全局规模漂移检查,圆角链/孔面数量等更专门几何质量规则待继续 -│ ├── [x] 修改前后保存 geometrySignature,用新 cache 做旧对象到新对象的唯一匹配 -│ ├── [~] 唯一匹配成功时更新 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 +├── 6. 下一批只按 SCDM 能力适配 +│ ├── [ ] [阵列实例位置 -> pattern.instance_position] +│ └── [~] [壳体厚度 -> shell.thickness,薄壁候选配对已进 cache,真实命令和 STEP 回测待补] │ -├── S7. 能力扩展顺序 -│ ├── [~] 第二批:slot.position 已接入能力字典、参数表、`move_slot` 执行脚本和 fake runner 回归,真实槽 STEP 回测待补;slot.width、slot.depth 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补 -│ ├── [~] 第三批:boss.position 已接入能力字典、参数表、`move_boss` 执行脚本和 fake runner 回归,真实凸台 STEP 回测待补;boss.height、boss.diameter 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补 -│ ├── [~] 第四批:round.radius、chamfer.distance、feature.delete_round_or_chamfer 已进入能力字典和 planned_not_productized 诊断;probe 已补 `RoundInfo` 摘要入口,真实 SCDM 命令和样例回测待补 -│ ├── [~] 第五批:pattern.spacing、pattern.instance_position、shell.thickness 已进入能力字典和 planned_not_productized 诊断;重复圆柱孔中心已能派生 linear_pattern 候选并写入 derived_feature_candidates,真实 SCDM 命令和样例回测待补 -│ ├── [x] 内部能力进度报告:`scdm_status.summarize_scdm_capability_progress` 会统计能力字典、执行器开放名单、当前 cache 已识别能力、后端命令阻止项、planned_not_productized、discovered_not_productized、geometry_candidate_hints、derived_feature_candidates,以及 SCDM probe 的对象/曲面/命令候选分布,供开发诊断和内部日志解释当前进度;客户 UI 只展示能识别/不能识别、能修改/不能修改 -│ └── [~] 每新增一个 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 样例待补 -│ -└── 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 修改闭环 +└── 7. 交付与兜底边界 + ├── [x] [软件进度 -> 只展示能识别、不能识别、能修改、不能修改] + ├── [x] [日志 -> 记录本次使用 SCDM / OCCT / Analysis Situs 哪个后端] + ├── [x] [Analysis Situs -> 只做辅助定位、兜底识别和开源对照] + ├── [x] [本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展] + ├── [ ] [商业 SCDM -> 不打包进本软件,只检测客户本机安装和许可证] + └── [ ] [SCDM 之外的新能力 -> 等 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 ``` -当前未完成: +当前不再作为主线推进的内容: -- `[ ]` 还没有把 Analysis Situs 的完整 AAG 关系图作为主识别数据源;现在已经消费孔组、AAG 邻接/角度摘要和基础几何关系摘要,并用它们增强孔、槽、凸台、圆角候选的置信度和排序,但编辑计划的主判断仍以本项目现有 `StepModel` / `recognition_graph.py` 为准。 -- `[ ]` 还没有用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;槽、凸台和圆角当前只是辅助识别提示,不等同于外部特征引擎直接给出的可编辑特征。 -- `[ ]` 还没有让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;目前它们只作为外部证据增强识别图和置信度,真正能不能改仍由现有 `StepModel`、`recognition_graph.py`、编辑计划和结果守门共同决定。 +- `[ ]` 不再把 Analysis Situs 的完整 AAG 关系图接成主识别数据源;主识别和主修改优先走 SCDM,Analysis Situs 只增强孔、槽、凸台、圆角候选的置信度和排序。 +- `[ ]` 不再优先用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;这些高频能力按 SCDM capability 字典逐项产品化。 +- `[ ]` 不再让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;它们只作为外部证据,真正能不能改由 SCDM cache、能力字典、编辑计划和结果守门共同决定。 - `[ ]` 还没有把 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` 或明确跳过外部识别。 -2. 继续扩展倒角、壳体、阵列孔等候选的辅助识别提示,仍然只参与“识别和排序”,不直接绕过一级编辑守门。 -3. 最后再评估是否需要 pybind11 直连;在稳定性没证明之前,CLI 隔离比直接把 C++ 库塞进主进程更安全。 +1. SCDM 不可用,需要无商业后端兜底识别孔组或轻量关系。 +2. 需要开源结果和 SCDM probe 结果做对照,帮助判断 SCDM 识别是否漏掉明显几何关系。 ## 怎么运行 @@ -294,7 +351,7 @@ python main.py assets\models\cube_10mm.step python scripts\generate_cube_step.py ``` -10. 验证几何修改基线。当前推进和测试优先级按用户常用操作排序:先 Face 面编辑,再孔、槽、凸台、圆角/倒角、壳体和 Edge,最后再扩二级/三级关系。完整一级编辑回归可以用一个入口串起来: +10. 验证几何修改基线。当前主线按 SCDM capability 适配推进;下面这个入口仍保留历史 OCCT/一级编辑回归,用来证明本地兜底能力、UI 和文档守门没有退化: ```powershell python scripts\verify_first_level_edit_suites.py @@ -391,194 +448,6 @@ git diff --check 需要注意: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 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 @@ -637,7 +506,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 能导出选中的零件。 - 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 -- 当前开发顺序固定按上面的“参数化编辑路线图”推进:先把交互规则和 Face 打稳,再进入孔、槽、凸台、圆角/倒角、Edge、壳体/解析曲面,最后扩展二级/三级关系;每一类都要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。 +- 当前开发顺序固定按上面的 `SCDM-first 主路线` 推进:先打通 SCDM 识别、能力字典、参数表、执行、校验和 ID 续接闭环,再按 SCDM 已能结构化表达的 Face、孔、槽、凸台、圆角/倒角、阵列、壳体能力逐项适配。历史 OCCT/一级编辑套件继续作为兜底回归,不再作为新增能力主线。 ## 能改哪些,一分钟版 @@ -684,11 +553,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 - 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 - 支持选择模式: - - 零件 + - Part - Solid - Face - Edge - - 特征 + - Feature - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 - 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。 @@ -701,7 +570,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - Face / 特征的 `复制 ID` 也优先复制 `逻辑 Face ID`,属性区会同时显示 `逻辑选择 ID` 和 `当前拓扑 Face ID`;这样编辑后拓扑编号变化时,用户仍能用原来的逻辑 ID 回到同一个面区域。 - `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 -- `特征` 模式现在会把点到的Face解释为几何特征候选: +- `Feature` 模式现在会把点到的Face解释为几何特征候选: - 平面会识别为可拉伸/切除平面候选。 - 平面会尝试查找同一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,本机 `D:\softwaresInstallDir\ANSYS Inc\v222\SCDM\SpaceClaim.exe` 已通过真实 `/RunScript` smoke;对 `assets/models/ICEPAK-NATURAL.stp` 的真实 probe 可打开模型并输出 13 个 Body、158 个 Face、396 条 Edge,396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。 -Face 阶段的当前验收口径(R1 已收口): +Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口): - 已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`,以及壳体厚度、圆柱端盖/圆柱侧面高度这类挂在 Face 入口上的高频编辑;支持 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 和受限的 `保持关系`。`中心` 移动后端保留,但特征参数表暂不开放。 - 已验收:Face 的 `一级关系` 定义为选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为 R1 自动传播范围。 - 已验收:选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域、同域/共面碎片、一级平行/垂直关系、一级同轴圆柱事实、边界 Edge/Vertex、共享边相邻 Face,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示。 - 可用但受限:带内孔平面、曲面 Solid 上的平面、自由曲面、非矩形面、复杂端盖、接近切穿的内切等场景会按明确守门执行;能稳定改就走隔离/回滚,不能稳定改就提前 blocked 并说明原因。 -- 未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建、孔底/槽底/台阶等二级/三级传播、跨特征约束求解和特征组联动;这些属于 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_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、保持关系、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。 - `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度`、`面内宽度`、`偏移` 这些用户可理解的名称,暂时隐藏 `中心`,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。 - `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 -孔/槽阶段的当前验收口径(R2/R3 已收口): +孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口): - 已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度` 和 `封堵/删除孔`。孔径按同轴重切孔壁执行,轴心移动会先补旧孔再切新孔,盲孔深度会按加深切削或变浅补料处理,结果必须仍能回读目标孔特征。 - 已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`、`圆弧角度`、`开口角度`、`轴心`、`总长度` 和 `中心距`。普通半圆槽按局部圆柱槽重切,长圆槽按两端圆弧配对后重建,修改后必须仍能识别为目标槽或半孔。 @@ -1028,18 +897,18 @@ Face 阶段的当前验收口径(R1 已收口): - 已验收:孔/槽局部布尔和重建会走隔离 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_hole_slot_edit_suite.py` 必须通过,覆盖孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。 - `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征会暴露侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。 - `python scripts\verify_hole_slot_isolated_edit.py` 必须通过,确保孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距都能通过隔离执行通道验证。 -0~7 其它阶段的当前基线: +本地 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 boss --stage round-chamfer --stage shell --stage analytic` 覆盖凸台、圆角/倒角、壳体厚度和解析曲面当前可用范围。复杂链式特征、通用抽壳历史恢复和跨特征传播仍不承诺。 -- R2/R3 孔槽收口后,下一步优先把 Edge、凸台、圆角/倒角、壳体和解析曲面也按“已验收 / 可用但受限 / 未实现”继续收口。 +- 这些阶段后续只作为本地兜底能力维护;新增客户入口优先从 SCDM capability 路线开放。 ## 怎么使用 @@ -1056,11 +925,11 @@ Face 阶段的当前验收口径(R1 已收口): 左侧 `选择模式` 用来切换鼠标选择模式: -- `零件`:选择整个零件。 +- `Part`:选择整个零件。 - `Solid`:选择实体。 - `Face`:选择面。 - `Edge`:选择边。 -- `特征`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 +- `Feature`:按几何特征候选方式选择。平面会识别为可拉伸/切除平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 - 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 @@ -1204,7 +1073,7 @@ Face 阶段的当前验收口径(R1 已收口): - 它会按目标直径或半径比例均匀缩放所属特征或 Solid,适合“整个所属对象一起放大/缩小”的意图。 - 它不适合只想改孔壁的场景,因为高度、厚度和同一对象上的其它尺寸会一起变化。 - `槽/半孔宽度` + `调整槽/半孔宽度`: - - 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 + - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - 输入目标槽宽后点击 `调整槽/半孔宽度`。 - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 @@ -1325,7 +1194,7 @@ Face 阶段的当前验收口径(R1 已收口): - 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - `圆角半径` / `圆角弧长` 的 `重建圆角`: - - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 + - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `重建圆角`。 - 输入目标值后点击 `参数化建模`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 @@ -1414,7 +1283,7 @@ Face 阶段的当前验收口径(R1 已收口): - `导出选中零件`:导出当前选中的零件。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face;如果当前选择已经带有特征区域信息,则会导出这片已知区域。 -- `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。 +- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中特征,再导出当前特征高亮的Face集合。 - `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `导出参数`:在 `特征参数` 表的 `输入参数` 列勾选需要外部驱动的尺寸行后,点击按钮会在项目根目录写出 `data.json`,并在 `nodes/` 下生成一个 FlowEditor 风格的参数化组件 `main.py`。组件脚本会把勾选的输入参数直接嵌入 `INPUT_PARAMETERS`,通过 `PARAMETERS / NODE_INFO` 暴露给节点设计器,并提供 `execute(inputs, params, context)` 调用入口;组件目录里不会额外生成 `data.json` 或 `step_edit_config.json`。执行后返回 `output_step`,指向修改后的 STEP 文件。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 @@ -1483,6 +1352,7 @@ pythonocc-step-editor/ verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数 + verify_relation_formula_rules.py # 验证关系式自引用、重复目标和循环依赖会在添加阶段阻止 verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成 verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明 @@ -1605,7 +1475,7 @@ git diff --check - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。 - `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。 -- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 +- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、关系式规则、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 - `scripts/verify_scdm_backend.py`:纯 Python 检查 SCDM 后端发现和缓存逻辑,不依赖 OCC;覆盖环境变量、常见安装目录、`local/scdm_backend.json`、禁用开关和 `/RunScript` 命令生成。 - `scripts/verify_scdm_status.py`:纯 Python 检查 SCDM 运行状态摘要,不依赖 OCC;覆盖未配置、已配置、后台识别中、识别成功、识别失败、cache 失效和缓存来源显示。 - `scripts/verify_scdm_probe_pipeline.py`:纯 Python 检查 SCDM probe job、RunScript 脚本生成、raw 识别结果到产品化 cache 的映射;覆盖第一批 `hole.diameter`、`hole.position` 和 `face.offset` 能力。 diff --git a/scripts/verify_first_level_acceptance_docs.py b/scripts/verify_first_level_acceptance_docs.py index 824326e..3252d52 100644 --- a/scripts/verify_first_level_acceptance_docs.py +++ b/scripts/verify_first_level_acceptance_docs.py @@ -47,27 +47,30 @@ def _verify_readme_mentions(readme: str) -> None: "当前整体验证基线", "不等于 CAD 级完成", "Face 阶段的当前验收口径", - "参数化编辑路线图", - "用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚", - "`[x]` 已实现", - "`[~]` 部分实现/进行中", - "`[ ]` 未实现", - "`[x]` 不是“所有 CAD 形态都能改”", - "R1 Face 是当前主线的第一阶段收口对象", - "R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里", - "STEP/B-Rep 参数化编辑主线", - "[不能修改 -> 立即说明原因]", - "[一级影响范围 -> 明确显示]", - "Face 阶段的当前验收口径(R1 已收口)", + "SCDM-first 主路线", + "核心路线只保留下面这一棵树", + "SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM", + "本软件不再把手写一级、二级、三级传播当成新增能力主线", + "`[x]` 已适配", + "`[~]` 部分适配", + "`[ ]` 待适配", + "[scdm_probe_job.json -> /RunScript 扫描 STEP]", + "[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", + "[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]", + "[Face 偏移 -> face.offset / OffsetFaces]", + "[槽宽 -> slot.width / OffsetFaces", + "[槽深 -> slot.depth / Move 或 OffsetFaces", + "[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]", + "Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口)", "已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`", "未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建", - "孔/槽阶段的当前验收口径(R2/R3 已收口)", + "孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口)", "已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`", "已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`", "未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动", "2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`", "覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径", - "0~7 其它阶段的当前基线", + "本地 OCCT 兜底能力的当前基线", "verify_first_level_edit_suites.py --quick", "verify_first_level_edit_suites.py --stage face", "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: - start_marker = "STEP/B-Rep 参数化编辑主线" - end_marker = "└── 8. 二级 / 三级关系" + start_marker = "SCDM-first 主路线" + end_marker = "└── 7. 交付与兜底边界" start = readme.find(start_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] deferred_scope = readme[end:] required_active_fragments = ( - "├── 0. 先让用户知道“能不能改”", - "│ ├── [x] [不能修改 -> 立即说明原因]", - "│ └── [x] [路线图 -> 验收脚本守门]", - "├── 1. 平面 Face,第一条主线", - "├── 2. 孔,第二条主线", - "├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征", - "├── 4. 凸台 / Boss,从切除特征扩展到加料特征", - "├── 5. 圆角 / 倒角,从主形体扩展到边修饰", - "├── 6. Edge 一级编辑,补齐底层直接改边能力", - "├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力", + "├── 0. 后端发现与可用性", + "│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]", + "├── 1. SCDM 识别与缓存", + "│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]", + "│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", + "├── 2. 能力字典、参数表和用户入口", + "├── 3. SCDM 参数化建模执行", + "├── 4. 结果校验、回滚和 ID 续接", + "├── 5. 当前已开放或正在开放的 SCDM 能力", + "[阵列间距 -> 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: _assert(fragment in active_scope, f"README active roadmap missing: {fragment}") forbidden_active_fragments = ( + "SCDM-first 统一实施路线", + "SCDM-first Capability 适配路线图", + "SCDM-first Capability 适配路线", + "├── 6. Edge 一级编辑,补齐底层直接改边能力", + "└── 8. 二级 / 三级关系", "[Face 二级传播", - "[孔组 ->", "多槽组 ->", - "二级传播 ->", - "三级传播 ->", - "二级 / 三级关系", ) 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("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard") + _assert("[Analysis Situs -> 只做辅助定位、兜底识别和开源对照]" in deferred_scope, "README should keep Analysis Situs as auxiliary boundary") + _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: diff --git a/scripts/verify_first_level_edit_suites.py b/scripts/verify_first_level_edit_suites.py index d794fda..c67921e 100644 --- a/scripts/verify_first_level_edit_suites.py +++ b/scripts/verify_first_level_edit_suites.py @@ -70,6 +70,7 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("Smoke test", ("main.py", "--smoke-test")), ("Property editor specs", ("verify_property_editor_specs.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",)), ("SCDM backend discovery", ("verify_scdm_backend.py",)), ("SCDM runtime status", ("verify_scdm_status.py",)), diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 9131a0f..7b69e03 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -49,6 +49,7 @@ from step_editor.window_state import ( RELATION_FORMULA_OBJECT_COMPLETION_LIMIT, WindowStateMixin, ) +from step_editor.ui_helpers import _selection_mode_label, _selection_mode_value class _StatusBar: @@ -86,7 +87,23 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.scdm_feature_cache = None self.scdm_feature_cache_state = "empty" 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) 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}") +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: class _OperationRecordProbe(WindowActionMixin): 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("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") + _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( "_defer_large_model_recognition_preloads" in loaded_body, "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", ) preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload") + _assert( + "_current_scdm_feature_cache_matches_loaded_step()" in preload_body, + "SCDM preload should skip relaunching SpaceClaim when a current cache is already installed", + ) _assert( "local_face_signatures = self._scdm_local_face_signatures()" not in preload_body, "SCDM preload must not build all local Face signatures on the UI thread", @@ -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") +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: 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") @@ -1891,6 +2022,8 @@ def main() -> int: not 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(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") @@ -1952,6 +2085,7 @@ def main() -> int: _assert_relation_formula_ids_follow_model_remap() _assert_mouse_selection_guards() _assert_scdm_selection_diagnostics() + _assert_solid_selection_does_not_expand_face_scdm_specs() _assert_operation_record_backend_sources() _assert_scdm_auto_prompt() _assert_property_ui_reroute_guards() diff --git a/scripts/verify_relation_formula_rules.py b/scripts/verify_relation_formula_rules.py new file mode 100644 index 0000000..01e7a1b --- /dev/null +++ b/scripts/verify_relation_formula_rules.py @@ -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()) diff --git a/scripts/verify_scdm_edit_runner.py b/scripts/verify_scdm_edit_runner.py index 640ef10..3cc9939 100644 --- a/scripts/verify_scdm_edit_runner.py +++ b/scripts/verify_scdm_edit_runner.py @@ -168,19 +168,40 @@ def main() -> int: "change_hole_diameter", "move_hole_axis", "move_slot", + "change_slot_width", + "change_slot_depth", "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", "fill_feature", + "delete_round_or_chamfer", "StandardHoles.ModifyHoleRadius", + "ConstantRound.ModifyRadius", + "Chamfer.ModifyDistance", "OffsetFaces.Execute", "Move.Translate", "MoveOptions", + "ConstantRoundOptions", + "ChamferOptions", "OffsetFaceOptions", "FillOptions", "FillMode", "Delete.Execute", "DocumentSave", "scdmFaceLocators", + "heightFaceLocators", + "depthFaceLocators", + "diameterFaceLocators", + "patternInstances", + "bodyLocators", + "instanceKind", "result.json", "error.json", ): @@ -197,6 +218,10 @@ def main() -> int: "faceOrdinals": [30, 31, 32], "globalFaceOrdinal": 40, "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], "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("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}") + 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 = { "objectType": "cylindrical_boss", @@ -223,6 +279,10 @@ def main() -> int: "faceOrdinals": [50, 51, 52], "globalFaceOrdinal": 70, "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], "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("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}") + 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( step_path, output_dir=root / "unsupported", backend=backend, - capability_key="slot.width", + capability_key="pattern.instance_position", target_value="1", 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("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( 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("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( step_path, diff --git a/scripts/verify_scdm_probe_pipeline.py b/scripts/verify_scdm_probe_pipeline.py index 18ae9ff..6a5a121 100644 --- a/scripts/verify_scdm_probe_pipeline.py +++ b/scripts/verify_scdm_probe_pipeline.py @@ -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("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("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") for token in ("_geometry_from_edge", "Length", "StartPoint", "EndPoint", "adjacentFaceOrdinals"): _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"): _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) _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": "Fill", "available": True}, {"name": "Delete", "available": True}, + {"name": "Chamfer", "available": True}, {"name": "ConstantRound", "available": True}, + {"name": "ChamferInfo", "available": True}, + {"name": "SlotInfo", "available": True}, {"name": "SomeFutureCommand", "available": False}, ], "faceAdjacency": [ @@ -144,18 +151,30 @@ def main() -> int: "maxEdgeLength": 8.0, }, "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}, "curveTypeCounts": {"Line": 5, "Circle": 4}, "operationCounts": { "pull_face_offset": 1, "change_hole_diameter": 1, "change_slot_width": 1, + "change_slot_depth": 1, "move_slot": 1, "change_boss_height": 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}, "objects": [ @@ -233,6 +252,34 @@ def main() -> int: ], "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", "objectType": "face", @@ -262,7 +309,17 @@ def main() -> int: { "backendId": "round:1", "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": [ {"operation": "change_round_radius", "enabled": True}, {"operation": "delete_round_or_chamfer", "enabled": True}, @@ -272,10 +329,16 @@ def main() -> int: { "backendId": "slot:1", "objectType": "slot", - "geometry": {"width": 2.0, "depth": 1.5, "center": [1.0, 2.0, 3.0]}, - "topologyHint": {"faceIds": [30, 31, 32], "bodyIndex": 0, "faceOrdinal": 30}, + "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, + "depthFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 42}], + }, "backendCommandCandidates": [ {"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]}}, ], "rawLimitations": [], @@ -284,9 +347,16 @@ def main() -> int: "backendId": "boss:1", "objectType": "cylindrical_boss", "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": [ {"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]}}, ], "rawLimitations": [], @@ -294,7 +364,18 @@ def main() -> int: { "backendId": "chamfer:1", "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}], "rawLimitations": [], }, @@ -305,6 +386,48 @@ def main() -> int: "backendCommandCandidates": [{"operation": "change_pattern_spacing", "enabled": True}], "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", "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("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}") + 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(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({"hole.diameter", "hole.position"} <= _capability_keys(cache, "hole:1"), f"hole caps missing: {cache}") 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}") 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({"slot.position"} <= _capability_keys(cache, "slot:1"), f"slot.position should now be productized: {cache}") - slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"slot.position"}) + _assert({"slot.width", "slot.depth", "slot.position"} <= _capability_keys(cache, "slot:1"), f"slot width/depth/position should now be productized: {cache}") + 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( - {str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.position"}, - f"slot cache should expose only productized slot.position: {slot_specs}", + {str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.width", "slot.depth", "slot.position"}, + 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"}) - _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({"boss.position"} <= _capability_keys(cache, "boss:1"), f"boss.position should now be productized: {cache}") - boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"boss.position"}) + _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.diameter", "boss.height", "boss.position"} <= _capability_keys(cache, "boss:1"), f"boss diameter/height/position should now be productized: {cache}") + 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( - {str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.position"}, - f"boss cache should expose only productized boss.position: {boss_specs}", + {str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.diameter", "boss.height", "boss.position"}, + 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"}) - _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 = { "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_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}") + 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 = { "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) _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}") + 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") _assert(isinstance(diagnostics, dict), f"cache diagnostics should be present: {cache}") raw_summary = diagnostics.get("raw_summary") @@ -499,11 +1044,18 @@ def main() -> int: and feature_inventory.get("operationCounts", {}).get("change_slot_width") == 1, 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") _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)} _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}", ) 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("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("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") - _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") _assert(isinstance(planned, list), f"planned diagnostics should be present: {cache}") planned_keys = {str(item.get("capabilityKey")) for item in planned if isinstance(item, dict)} expected_planned = { - "slot.width", - "slot.depth", - "boss.height", - "boss.diameter", - "round.radius", - "feature.delete_round_or_chamfer", - "chamfer.distance", - "pattern.spacing", "pattern.instance_position", "shell.thickness", } diff --git a/scripts/verify_scdm_result_validator.py b/scripts/verify_scdm_result_validator.py index b3d08e1..00c0138 100644 --- a/scripts/verify_scdm_result_validator.py +++ b/scripts/verify_scdm_result_validator.py @@ -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]: return { "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]) _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( {"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396}, _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") _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( _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)), diff --git a/scripts/verify_scdm_status.py b/scripts/verify_scdm_status.py index d0d78e6..2d3a448 100644 --- a/scripts/verify_scdm_status.py +++ b/scripts/verify_scdm_status.py @@ -100,8 +100,8 @@ def main() -> int: "confidence": "low", }, { - "capabilityKey": "pattern.spacing", - "displayName": "阵列间距", + "capabilityKey": "pattern.instance_position", + "displayName": "阵列实例位置", "evidenceCount": 2, "confidence": "low", }, @@ -114,9 +114,7 @@ def main() -> int: } ], "planned_not_productized": [ - {"capabilityKey": "slot.width", "objectType": "slot"}, - {"capabilityKey": "slot.width", "objectType": "slot"}, - {"capabilityKey": "round.radius", "objectType": "round"}, + {"capabilityKey": "shell.thickness", "objectType": "shell"}, ], "discovered_not_productized": [ {"objectType": "mystery_feature"}, @@ -126,14 +124,30 @@ def main() -> int: } progress = summarize_scdm_capability_progress( 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") _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("runnerReady") == 5, 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("plannedDetected") == 3, f"planned S7 detections should be counted: {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") == 15, f"runner-ready capability count should honor UI gate: {summary}") + _assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}") + _assert(summary.get("plannedDetected") == 1, f"planned S7 detections should be counted: {summary}") _assert(summary.get("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( @@ -147,9 +161,16 @@ def main() -> int: 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"blocked SCDM capability should be explicit: {productized_lines}") - _assert("填孔/删除小特征:已识别待执行器" in productized_lines, f"recognized but ungated capability should be visible: {productized_lines}") - _assert("槽宽:已识别待验证" in planned_lines, f"planned S7 candidate should be visible: {planned_lines}") - _assert("凸台高度:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_lines}") + _assert("填孔/删除小特征:已开放" in productized_lines, f"recognized command capability should be visible as open: {productized_lines}") + _assert("槽深:已开放待识别" in productized_lines, f"productized slot.depth should be visible: {productized_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("对象分布" in evidence_lines and "命令候选分布" in evidence_lines, f"probe inventory lines should be readable: {evidence_lines}") _assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}") diff --git a/step_editor/app.py b/step_editor/app.py index 787c065..3944acb 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -274,7 +274,23 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.scdm_feature_cache_state = "empty" self.scdm_feature_cache_message = "" 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_thread: QThread | None = None self.load_worker: LoadWorker | None = None @@ -1050,7 +1066,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf mode_box = QWidget() 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.setObjectName("modeSectionTitle") self.mode_section_title.setFixedSize(74, 20) @@ -1085,7 +1101,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.mode_combo.setMaximumWidth(112) help_tip( 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())) mode_pick_layout.addWidget(self.mouse_mode_label) diff --git a/step_editor/model.py b/step_editor/model.py index c4d37ed..2e62d73 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -229,17 +229,34 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd 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.""" signatures: list[dict[str, object]] = [] + face_ordinal_by_solid: dict[int, int] = {} for face_id, face in enumerate(self.faces): try: surf = BRepAdaptor_Surface(face) surface_type = surf.GetType() except Exception: 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] = { "faceId": int(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}"), } + 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: if surface_type == GeomAbs_Plane: plane = surf.Plane() diff --git a/step_editor/relation_formulas.py b/step_editor/relation_formulas.py index 8c6c18d..e05fabf 100644 --- a/step_editor/relation_formulas.py +++ b/step_editor/relation_formulas.py @@ -144,6 +144,44 @@ def evaluate_relation_formula( 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: value = _coerce_formula_value(value) if isinstance(value, Vector3): diff --git a/step_editor/scdm_capabilities.py b/step_editor/scdm_capabilities.py index 8f3b811..f0ca96a 100644 --- a/step_editor/scdm_capabilities.py +++ b/step_editor/scdm_capabilities.py @@ -94,22 +94,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { default_intent="修改槽宽", backend_operation="change_slot_width", post_check="target_slot_width", - productized=False, + required_backend_command_groups=(("OffsetFaces",),), roadmap_stage="S7.2", - block_reason="槽宽属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "slot.depth": ScdmCapabilityDefinition( key="slot.depth", display_name="槽深", object_types=("slot", "obround_slot", "rectangular_slot"), value_kind="number", - current_fields=("geometry.depth",), + current_fields=("geometry.slotInfo.depth", "geometry.depth"), default_intent="修改槽深", backend_operation="change_slot_depth", post_check="target_slot_depth", - productized=False, + required_backend_command_groups=(("Move",), ("OffsetFaces",)), roadmap_stage="S7.2", - block_reason="槽深属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "slot.position": ScdmCapabilityDefinition( key="slot.position", @@ -132,9 +130,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { default_intent="修改凸台高度", backend_operation="change_boss_height", post_check="target_boss_height", - productized=False, + required_backend_command_groups=(("Move",), ("OffsetFaces",)), roadmap_stage="S7.3", - block_reason="凸台高度属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "boss.diameter": ScdmCapabilityDefinition( key="boss.diameter", @@ -145,9 +142,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { default_intent="修改凸台直径", backend_operation="change_boss_diameter", post_check="target_boss_diameter", - productized=False, + required_backend_command_groups=(("OffsetFaces",),), roadmap_stage="S7.3", - block_reason="凸台直径属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "boss.position": ScdmCapabilityDefinition( key="boss.position", @@ -166,28 +162,24 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { display_name="圆角半径", object_types=("round", "fillet"), value_kind="number", - current_fields=("geometry.radius",), + current_fields=("geometry.roundInfo.radius", "geometry.radius"), default_intent="修改圆角半径", backend_operation="change_round_radius", post_check="target_round_radius", required_backend_command_groups=(("ConstantRound",),), - productized=False, roadmap_stage="S7.4", - block_reason="圆角半径属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "chamfer.distance": ScdmCapabilityDefinition( key="chamfer.distance", display_name="倒角距离", object_types=("chamfer",), value_kind="number", - current_fields=("geometry.distance", "geometry.offset"), + current_fields=("geometry.chamferInfo.distance", "geometry.distance", "geometry.offset"), default_intent="修改倒角距离", backend_operation="change_chamfer_distance", post_check="target_chamfer_distance", required_backend_command_groups=(("Chamfer",),), - productized=False, roadmap_stage="S7.4", - block_reason="倒角距离属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "feature.delete_round_or_chamfer": ScdmCapabilityDefinition( key="feature.delete_round_or_chamfer", @@ -199,9 +191,7 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { backend_operation="delete_round_or_chamfer", post_check="target_feature_removed", required_backend_command_groups=(("Fill",), ("Delete",)), - productized=False, roadmap_stage="S7.4", - block_reason="删除圆角/倒角属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "pattern.spacing": ScdmCapabilityDefinition( key="pattern.spacing", @@ -212,9 +202,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { default_intent="修改阵列间距", backend_operation="change_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", - block_reason="阵列间距属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", ), "pattern.instance_position": ScdmCapabilityDefinition( 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 _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")): keys.append("pattern.spacing") + keys.append("pattern.segment_spacing") if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")): keys.append("pattern.instance_position") diff --git a/step_editor/scdm_edit_runner.py b/step_editor/scdm_edit_runner.py index 0e98f09..316c1dd 100644 --- a/step_editor/scdm_edit_runner.py +++ b/step_editor/scdm_edit_runner.py @@ -49,6 +49,20 @@ def prepare_scdm_edit_job( "capability_key": definition.key, "roadmap_stage": definition.roadmap_stage, } + converted_target = _target_value_for_job(target_value, value_kind=definition.value_kind) + preflight = _preflight_scdm_edit_job( + definition.key, + converted_target, + object_signature if isinstance(object_signature, Mapping) else {}, + ) + if preflight.get("ok") is False: + return { + "ok": False, + "reason": str(preflight.get("reason") or "target-preflight-failed"), + "message": str(preflight.get("message") or "SCDM edit target is outside the supported range."), + "capability_key": definition.key, + "preflight": preflight, + } fingerprint = file_fingerprint(source) work_dir = _edit_work_dir(source, fingerprint=fingerprint, output_dir=output_dir, project_root=project_root).resolve(strict=False) @@ -84,7 +98,7 @@ def prepare_scdm_edit_job( "capabilityKey": definition.key, "displayName": definition.display_name, "valueKind": definition.value_kind, - "value": _target_value_for_job(target_value, value_kind=definition.value_kind), + "value": converted_target, "text": str(target_value), "backendOperation": operation, "postCheck": definition.post_check, @@ -267,6 +281,146 @@ def _target_value_for_job(value: object, *, value_kind: str) -> object: return _json_safe(value) +def _preflight_scdm_edit_job( + capability_key: str, + target_value: object, + object_signature: Mapping[str, object], +) -> dict[str, object]: + if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}: + return {"ok": True, "reason": "ok"} + if _is_body_pattern_signature(object_signature): + if not _body_pattern_has_component_locators(object_signature): + return { + "ok": False, + "reason": "body-pattern-spacing-missing-component-locators", + "message": ( + "SCDM 已识别到实体/组件阵列间距,但缓存里没有每个阵列成员的组件实例定位。" + "已阻止执行,避免把共享实体定义或整列装配一起移走。" + ), + } + try: + target = float(str(target_value).strip()) + except (TypeError, ValueError): + return {"ok": True, "reason": "ok"} + if target <= 0: + return { + "ok": False, + "reason": "target-value-illegal", + "message": "目标阵列间距必须大于 0。", + } + fit = object_signature.get("supportPatternFit") + if not isinstance(fit, Mapping): + return {"ok": True, "reason": "ok"} + max_key = "maxSegmentSpacing" if capability_key == "pattern.segment_spacing" else "maxSpacing" + max_local_key = "maxSegmentSpacingLocal" if capability_key == "pattern.segment_spacing" else "maxSpacingLocal" + max_spacing = _float_or_none(fit.get(max_key)) + if max_spacing is None or max_spacing <= 0: + return {"ok": True, "reason": "ok"} + tolerance = max(abs(max_spacing) * 1.0e-6, 1.0e-12) + if target <= max_spacing + tolerance: + return {"ok": True, "reason": "ok"} + max_local = _float_or_none(fit.get(max_local_key)) + unit_scale = _float_or_none(fit.get("localUnitScale")) + if max_local is None and unit_scale is not None and unit_scale > 0: + max_local = max_spacing / unit_scale + target_local = target + if unit_scale is not None and unit_scale > 0: + target_local = target / unit_scale + support_ids = fit.get("supportFaceIds") + label = "局部阵列间距" if capability_key == "pattern.segment_spacing" else "阵列间距" + max_label = "该段最大安全间距" if capability_key == "pattern.segment_spacing" else "保持阵列中心不变时最大安全间距" + return { + "ok": False, + "reason": "pattern-spacing-exceeds-support", + "message": ( + f"目标{label} {target_local:g} 会超出支撑面范围;" + f"{max_label} {max_local if max_local is not None else max_spacing:g}。" + f"支撑面 Face: {support_ids or 'unknown'}。" + ), + "targetSpacing": target, + "targetSpacingLocal": target_local, + max_key: max_spacing, + max_local_key: max_local, + "supportFaceIds": support_ids, + } + + +def _float_or_none(value: object) -> float | None: + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _int_or_none_value(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _is_body_pattern_signature(signature: Mapping[str, object]) -> bool: + pattern_kind = str(signature.get("patternKind") or "").strip().lower() + instance_kind = str(signature.get("instanceKind") or "").strip().lower() + if pattern_kind == "body" or instance_kind in {"body", "part", "component"}: + return True + body_indices = signature.get("bodyIndices") + if isinstance(body_indices, (list, tuple)) and body_indices: + return True + instances = signature.get("patternInstances") + if isinstance(instances, (list, tuple)): + for item in instances: + if not isinstance(item, Mapping): + continue + item_kind = str(item.get("instanceKind") or "").strip().lower() + if item_kind in {"body", "part", "component"}: + return True + locators = item.get("bodyLocators") + if isinstance(locators, (list, tuple)) and locators: + return True + return False + + +def _body_pattern_has_component_locators(signature: Mapping[str, object]) -> bool: + instances = signature.get("patternInstances") + if not isinstance(instances, (list, tuple)) or len(instances) < 3: + return False + seen_paths: set[tuple[int, ...]] = set() + for item in instances: + if not isinstance(item, Mapping): + return False + locators = item.get("componentLocators") + if not isinstance(locators, (list, tuple)): + locators = item.get("bodyLocators") + path = _first_component_path(locators) + if not path: + return False + if path in seen_paths: + return False + seen_paths.add(path) + return True + + +def _first_component_path(value: object) -> tuple[int, ...]: + if not isinstance(value, (list, tuple)): + return () + for locator in value: + if not isinstance(locator, Mapping): + continue + raw_path = locator.get("componentPath") + if isinstance(raw_path, (list, tuple)): + try: + path = tuple(int(item) for item in raw_path) + except (TypeError, ValueError): + path = () + if path: + return path + component_index = _int_or_none_value(locator.get("componentIndex")) + if component_index is not None: + return (component_index,) + return () + + def _parse_vector3(value: object) -> list[float]: if isinstance(value, (list, tuple)): items = list(value) @@ -540,6 +694,81 @@ def _bodies(root): return [] +def _immediate_components(part): + for name in ('Components',): + value = _maybe_call(part, name) + if value is None: + try: + value = getattr(part, name) + except Exception: + value = None + items = _items(value) + if items: + return items + return [] + + +def _component_content(component): + for name in ('Content', 'ContentMaster', 'Template', 'Part'): + try: + value = getattr(component, name) + if value is not None: + return value + except Exception: + pass + return None + + +def _component_entries(root): + result = [] + queue = [(root, [])] + while queue: + part, path = queue.pop(0) + if part is None or len(path) > 8: + continue + components = _immediate_components(part) + for child_index, component in enumerate(components): + component_path = list(path) + [child_index] + content = _component_content(component) + result.append({ + 'component': component, + 'content': content, + 'componentIndex': len(result), + 'componentPath': component_path, + }) + if content is not None: + queue.append((content, component_path)) + return result + + +def _component_path(locator): + raw = locator.get('componentPath') if isinstance(locator, dict) else None + if isinstance(raw, list) and raw: + result = [] + for item in raw: + number = _int_or_none(item) + if number is None: + return [] + result.append(number) + return result + return [] + + +def _locate_one_component(root, locator): + if not isinstance(locator, dict): + return None + entries = _component_entries(root) + wanted_path = _component_path(locator) + if wanted_path: + for entry in entries: + if entry.get('componentPath') == wanted_path: + return entry.get('component') + wanted_index = _int_or_none(locator.get('componentIndex')) + if wanted_index is not None and 0 <= wanted_index < len(entries): + return entries[wanted_index].get('component') + return None + + def _faces(body): for name in ('Faces', 'GetFaces'): value = _maybe_call(body, name) @@ -561,6 +790,13 @@ def _flat_faces(bodies): return result +def _locate_one_body(bodies, body_index): + body_index = _int_or_none(body_index) + if body_index is not None and 0 <= body_index < len(bodies): + return bodies[body_index] + return None + + def _int_or_none(value): try: return int(value) @@ -636,6 +872,130 @@ def _locate_faces(signature): raise Exception('object_not_found: geometrySignature does not locate a unique SCDM face') +def _locate_named_faces(signature, locator_keys, face_ordinal_keys, global_face_ordinal_keys): + root = _root_part() + bodies = _bodies(root) + body_index = _int_or_none(signature.get('bodyIndex')) + result = [] + for key in locator_keys: + locators = signature.get(key) + if not isinstance(locators, list): + continue + for locator in locators: + if not isinstance(locator, dict): + continue + face = _locate_one_face( + bodies, + _int_or_none(locator.get('bodyIndex')), + _int_or_none(locator.get('faceOrdinal')), + _int_or_none(locator.get('globalFaceOrdinal')), + ) + if face is not None: + _append_unique_face(result, face) + for key in face_ordinal_keys: + ordinals = signature.get(key) + if not isinstance(ordinals, list) or body_index is None: + continue + for item in ordinals: + face = _locate_one_face(bodies, body_index, _int_or_none(item), None) + if face is not None: + _append_unique_face(result, face) + for key in global_face_ordinal_keys: + ordinals = signature.get(key) + if not isinstance(ordinals, list): + continue + for item in ordinals: + face = _locate_one_face(bodies, None, None, _int_or_none(item)) + if face is not None: + _append_unique_face(result, face) + return result + + +def _locate_height_faces(signature): + faces = _locate_named_faces( + signature, + ('heightFaceLocators', 'topFaceLocators'), + ('heightFaceOrdinals', 'topFaceOrdinals'), + ('globalHeightFaceOrdinals', 'globalTopFaceOrdinals'), + ) + if faces: + return faces + raise Exception('object_signature_missing_height_face_locator') + + +def _locate_depth_faces(signature): + faces = _locate_named_faces( + signature, + ('depthFaceLocators', 'bottomFaceLocators'), + ('depthFaceOrdinals', 'bottomFaceOrdinals'), + ('globalDepthFaceOrdinals', 'globalBottomFaceOrdinals'), + ) + if faces: + return faces + raise Exception('object_signature_missing_depth_face_locator') + + +def _locate_pattern_instance_faces(instance, fallback_body_index): + signature = dict(instance) + if signature.get('bodyIndex') is None and fallback_body_index is not None: + signature['bodyIndex'] = fallback_body_index + instance_kind = str(signature.get('instanceKind') or '').lower() + root = _root_part() + bodies = _bodies(root) + if instance_kind in ('body', 'part', 'component'): + body_locators = signature.get('bodyLocators') + if isinstance(body_locators, list): + for locator in body_locators: + if not isinstance(locator, dict): + continue + body = _locate_one_body(bodies, locator.get('bodyIndex')) + if body is not None: + return [body] + body = _locate_one_body(bodies, signature.get('bodyIndex')) + if body is not None: + return [body] + try: + return _locate_faces(signature) + except Exception: + body = _locate_one_body(bodies, signature.get('bodyIndex')) + if body is not None and instance_kind in ('body', 'part', 'component'): + return [body] + raise + + +def _locate_pattern_instance_items(instance, fallback_body_index): + signature = dict(instance) + if signature.get('bodyIndex') is None and fallback_body_index is not None: + signature['bodyIndex'] = fallback_body_index + root = _root_part() + component_locators = signature.get('componentLocators') + if not isinstance(component_locators, list): + component_locators = signature.get('bodyLocators') + if isinstance(component_locators, list): + components = [] + for locator in component_locators: + component = _locate_one_component(root, locator) + if component is not None: + components.append(component) + if len(components) == 1: + return {'kind': 'component', 'items': components} + if len(components) > 1: + raise Exception('object_not_unique: pattern instance resolves to multiple SCDM components') + return {'kind': 'face', 'items': _locate_pattern_instance_faces(signature, fallback_body_index)} + + +def _locate_diameter_faces(signature): + faces = _locate_named_faces( + signature, + ('diameterFaceLocators', 'sideFaceLocators'), + ('diameterFaceOrdinals', 'sideFaceOrdinals'), + ('globalDiameterFaceOrdinals', 'globalSideFaceOrdinals'), + ) + if faces: + return faces + raise Exception('object_signature_missing_diameter_face_locator') + + def _selection(items): selection = globals().get('Selection') if selection is not None: @@ -763,6 +1123,30 @@ def _make_vector(x, y, z): return None +def _make_translation_matrix(delta): + matrix_type = _geometry_type('Matrix') + if matrix_type is None: + return None + vector = _make_vector(delta[0], delta[1], delta[2]) + if vector is None: + return None + try: + return matrix_type.CreateTranslation(vector) + except Exception: + return None + + +def _translate_component_occurrence(component, delta): + matrix = _make_translation_matrix(delta) + if matrix is None: + raise Exception('capability_not_implemented: Matrix.CreateTranslation command not available') + try: + component.Transform(matrix) + return {'command': 'Component.Transform', 'delta': delta, 'apiSignature': 'Component.Transform(Matrix.CreateTranslation)'} + except Exception as exc: + raise Exception('capability_not_implemented: Component.Transform failed; ' + str(exc)) + + def _make_direction(x, y, z): direction_type = _geometry_type('Direction') if direction_type is None: @@ -1029,6 +1413,92 @@ def move_slot(job): raise Exception('capability_not_implemented: slot.position adapter failed; ' + str(exc)) +def change_slot_width(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: slot width must be positive') + signature = job['object'].get('geometrySignature') or {} + current_width = None + try: + current_width = float(signature.get('width')) + except Exception: + current_width = None + if current_width is None: + try: + current_width = float(signature.get('diameter')) + except Exception: + current_width = None + if current_width is None: + try: + current_width = float(signature.get('radius')) * 2.0 + except Exception: + current_width = None + if current_width is None or current_width <= 0: + raise Exception('object_signature_missing_width') + # Open slots are usually internal cut walls; SpaceClaim positive OffsetFaces + # follows the wall normal into the void, which reduces the measured width. + half_delta = (current_width - target) / 2.0 + if abs(half_delta) <= 1e-12: + return {'command': 'noop', 'targetWidth': target, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _offset_faces(selection, half_delta, signature) + applied['targetWidth'] = target + applied['widthDelta'] = target - current_width + applied['halfOffset'] = half_delta + return applied + except Exception as exc: + raise Exception('capability_not_implemented: slot.width adapter failed; ' + str(exc)) + + +def change_slot_depth(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: slot depth must be positive') + signature = job['object'].get('geometrySignature') or {} + current_depth = None + try: + current_depth = float(signature.get('depth')) + except Exception: + current_depth = None + if current_depth is None or current_depth <= 0: + raise Exception('object_signature_missing_depth') + depth_axis = signature.get('depthAxis') or signature.get('depthDirection') or [] + if not isinstance(depth_axis, list) or len(depth_axis) != 3: + raise Exception('object_signature_missing_depth_axis') + axis_length = _vector_length([float(depth_axis[0]), float(depth_axis[1]), float(depth_axis[2])]) + if axis_length <= 1e-12: + raise Exception('object_signature_missing_depth_axis') + delta_distance = target - current_depth + if abs(delta_distance) <= 1e-12: + return {'command': 'noop', 'targetDepth': target, 'reason': 'target already reached'} + faces = _locate_depth_faces(signature) + selection = _selection(faces) + delta = [float(depth_axis[index]) / axis_length * delta_distance for index in range(3)] + errors = [] + try: + applied = _translate_selection(selection, delta) + applied['targetDepth'] = target + applied['depthDelta'] = delta_distance + applied['depthFaceCount'] = len(faces) + return applied + except Exception as exc: + errors.append('Move.Translate: ' + str(exc)) + try: + depth_signature = dict(signature) + depth_signature['normal'] = [float(depth_axis[index]) / axis_length for index in range(3)] + applied = _offset_faces(selection, delta_distance, depth_signature) + applied['targetDepth'] = target + applied['depthDelta'] = delta_distance + applied['depthFaceCount'] = len(faces) + applied['moveErrors'] = errors + return applied + except Exception as exc: + errors.append('OffsetFaces.Execute: ' + str(exc)) + raise Exception('capability_not_implemented: slot.depth adapter failed; ' + '; '.join(errors)) + + def move_boss(job): target = _vector3(job['target']['value']) signature = job['object'].get('geometrySignature') or {} @@ -1048,6 +1518,526 @@ def move_boss(job): raise Exception('capability_not_implemented: boss.position adapter failed; ' + str(exc)) +def _component_locator_key(locator): + if not isinstance(locator, dict): + return '' + path = _component_path(locator) + if path: + return 'path:' + '.'.join(str(item) for item in path) + component_index = _int_or_none(locator.get('componentIndex')) + if component_index is not None: + return 'index:' + str(component_index) + return '' + + +def _signature_has_component_pattern_locators(signature): + instances = signature.get('patternInstances') + if not isinstance(instances, list) or len(instances) < 3: + return False + seen = set() + for instance in instances: + if not isinstance(instance, dict): + return False + locators = instance.get('componentLocators') + if not isinstance(locators, list): + locators = instance.get('bodyLocators') + if not isinstance(locators, list): + return False + keys = [] + for locator in locators: + key = _component_locator_key(locator) + if key: + keys.append(key) + if len(keys) != 1: + return False + if keys[0] in seen: + return False + seen.add(keys[0]) + return True + + +def change_pattern_spacing(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: pattern spacing must be positive') + signature = job['object'].get('geometrySignature') or {} + pattern_kind = str(signature.get('patternKind') or '').strip().lower() + instance_kind = str(signature.get('instanceKind') or '').strip().lower() + body_indices = signature.get('bodyIndices') + body_pattern = pattern_kind == 'body' or instance_kind in ('body', 'part', 'component') or (isinstance(body_indices, list) and body_indices) + if body_pattern and not _signature_has_component_pattern_locators(signature): + raise Exception( + 'object_signature_missing_component_locator: body pattern spacing requires one component occurrence locator per instance' + ) + current_spacing = None + for key in ('spacing', 'pitch'): + try: + current_spacing = float(signature.get(key)) + break + except Exception: + pass + if current_spacing is None or current_spacing <= 0: + raise Exception('object_signature_missing_pattern_spacing') + support_fit = signature.get('supportPatternFit') or {} + if isinstance(support_fit, dict): + max_spacing = None + try: + max_spacing = float(support_fit.get('maxSpacing')) + except Exception: + max_spacing = None + if max_spacing is not None and max_spacing > 0: + tolerance = max(abs(max_spacing) * 1e-6, 1e-12) + if target > max_spacing + tolerance: + local_unit_scale = None + try: + local_unit_scale = float(support_fit.get('localUnitScale')) + except Exception: + local_unit_scale = None + target_local = target / local_unit_scale if local_unit_scale and local_unit_scale > 0 else target + max_local = support_fit.get('maxSpacingLocal') + try: + max_local = float(max_local) + except Exception: + max_local = max_spacing / local_unit_scale if local_unit_scale and local_unit_scale > 0 else max_spacing + raise Exception( + 'target_value_illegal: pattern spacing exceeds support face range; ' + + 'target=' + str(target_local) + + ', max=' + str(max_local) + + ', supportFaceIds=' + str(support_fit.get('supportFaceIds')) + ) + axis = signature.get('axis') or [] + if not isinstance(axis, list) or len(axis) != 3: + raise Exception('object_signature_missing_pattern_axis') + axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])]) + if axis_length <= 1e-12: + raise Exception('object_signature_missing_pattern_axis') + axis_unit = [float(axis[index]) / axis_length for index in range(3)] + instances = signature.get('patternInstances') + if not isinstance(instances, list) or len(instances) < 3: + raise Exception('object_signature_missing_pattern_instances') + body_index = _int_or_none(signature.get('bodyIndex')) + located = [] + for instance in instances: + if not isinstance(instance, dict): + continue + center = instance.get('center') or instance.get('instanceCenter') or [] + if not isinstance(center, list) or len(center) != 3: + continue + located_items = _locate_pattern_instance_items(instance, body_index) if body_pattern else {'kind': 'face', 'items': _locate_pattern_instance_faces(instance, body_index)} + projection = sum(float(center[index]) * axis_unit[index] for index in range(3)) + located.append({ + 'center': [float(center[0]), float(center[1]), float(center[2])], + 'items': located_items.get('items') or [], + 'kind': located_items.get('kind') or 'face', + 'projection': projection, + }) + if len(located) < 3: + raise Exception('object_signature_missing_pattern_instances') + located.sort(key=lambda item: item['projection']) + if abs(target - current_spacing) <= 1e-12: + return {'command': 'noop', 'targetSpacing': target, 'reason': 'target already reached'} + center_projection = sum(item['projection'] for item in located) / len(located) + mid_index = (len(located) - 1) * 0.5 + moves = [] + for index, item in enumerate(located): + desired_projection = center_projection + (index - mid_index) * target + delta_distance = desired_projection - item['projection'] + delta = [axis_unit[axis_index] * delta_distance for axis_index in range(3)] + if _vector_length(delta) <= 1e-12: + continue + moves.append({'items': item['items'], 'kind': item['kind'], 'delta': delta, 'itemCount': len(item['items'])}) + if not moves: + return {'command': 'noop', 'targetSpacing': target, 'reason': 'instance centers already satisfy target spacing'} + applied_moves = [] + errors = [] + for move_index, move in enumerate(moves): + try: + if move.get('kind') == 'component': + items = move.get('items') or [] + if len(items) != 1: + raise Exception('component_pattern_instance_not_unique') + applied = _translate_component_occurrence(items[0], move['delta']) + else: + applied = _translate_selection(_selection(move['items']), move['delta']) + applied['instanceIndex'] = move_index + 1 + applied['targetKind'] = move.get('kind') + applied['itemCount'] = move['itemCount'] + applied_moves.append(applied) + except Exception as exc: + errors.append('Move.Translate instance ' + str(move_index + 1) + ': ' + str(exc)) + break + if errors: + raise Exception('capability_not_implemented: pattern.spacing adapter failed; ' + '; '.join(errors)) + command_name = 'Component.Transform instances' if all(move.get('targetKind') == 'component' for move in applied_moves) else 'Move.Translate instances' + return { + 'command': command_name, + 'targetSpacing': target, + 'previousSpacing': current_spacing, + 'spacingMode': 'centered', + 'instanceCount': len(located), + 'movedInstanceCount': len(applied_moves), + 'moves': applied_moves, + } + + +def change_pattern_segment_spacing(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: pattern segment spacing must be positive') + signature = job['object'].get('geometrySignature') or {} + pattern_kind = str(signature.get('patternKind') or '').strip().lower() + instance_kind = str(signature.get('instanceKind') or '').strip().lower() + body_indices = signature.get('bodyIndices') + body_pattern = pattern_kind == 'body' or instance_kind in ('body', 'part', 'component') or (isinstance(body_indices, list) and body_indices) + if body_pattern and not _signature_has_component_pattern_locators(signature): + raise Exception( + 'object_signature_missing_component_locator: body pattern segment spacing requires one component occurrence locator per instance' + ) + segment_index = _int_or_none(signature.get('segmentIndex')) + if segment_index is None or segment_index < 0: + raise Exception('object_signature_missing_pattern_segment_index') + support_fit = signature.get('supportPatternFit') or {} + if isinstance(support_fit, dict): + max_segment = None + try: + max_segment = float(support_fit.get('maxSegmentSpacing')) + except Exception: + max_segment = None + if max_segment is not None and max_segment > 0: + tolerance = max(abs(max_segment) * 1e-6, 1e-12) + if target > max_segment + tolerance: + local_unit_scale = None + try: + local_unit_scale = float(support_fit.get('localUnitScale')) + except Exception: + local_unit_scale = None + target_local = target / local_unit_scale if local_unit_scale and local_unit_scale > 0 else target + max_local = support_fit.get('maxSegmentSpacingLocal') + try: + max_local = float(max_local) + except Exception: + max_local = max_segment / local_unit_scale if local_unit_scale and local_unit_scale > 0 else max_segment + raise Exception( + 'target_value_illegal: pattern segment spacing exceeds support face range; ' + + 'target=' + str(target_local) + + ', max=' + str(max_local) + + ', supportFaceIds=' + str(support_fit.get('supportFaceIds')) + ) + axis = signature.get('axis') or [] + if not isinstance(axis, list) or len(axis) != 3: + raise Exception('object_signature_missing_pattern_axis') + axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])]) + if axis_length <= 1e-12: + raise Exception('object_signature_missing_pattern_axis') + axis_unit = [float(axis[index]) / axis_length for index in range(3)] + instances = signature.get('patternInstances') + if not isinstance(instances, list) or len(instances) < 2: + raise Exception('object_signature_missing_pattern_instances') + body_index = _int_or_none(signature.get('bodyIndex')) + located = [] + for instance in instances: + if not isinstance(instance, dict): + continue + center = instance.get('center') or instance.get('instanceCenter') or [] + if not isinstance(center, list) or len(center) != 3: + continue + located_items = _locate_pattern_instance_items(instance, body_index) if body_pattern else {'kind': 'face', 'items': _locate_pattern_instance_faces(instance, body_index)} + projection = sum(float(center[index]) * axis_unit[index] for index in range(3)) + located.append({ + 'center': [float(center[0]), float(center[1]), float(center[2])], + 'items': located_items.get('items') or [], + 'kind': located_items.get('kind') or 'face', + 'projection': projection, + }) + if len(located) < 2: + raise Exception('object_signature_missing_pattern_instances') + located.sort(key=lambda item: item['projection']) + if segment_index >= len(located) - 1: + raise Exception('object_signature_invalid_pattern_segment_index') + current_spacing = located[segment_index + 1]['projection'] - located[segment_index]['projection'] + if current_spacing <= 0: + raise Exception('object_signature_invalid_pattern_segment_spacing') + delta_distance = target - current_spacing + if abs(delta_distance) <= 1e-12: + return { + 'command': 'noop', + 'targetSpacing': target, + 'previousSpacing': current_spacing, + 'segmentIndex': segment_index, + 'reason': 'target already reached', + } + moving_side = str(signature.get('movingSide') or 'after').strip().lower() + moves = [] + spacing_mode = 'segment_after' + if moving_side in ('after', 'right'): + delta = [axis_unit[index] * delta_distance for index in range(3)] + moves = [ + {'items': item['items'], 'kind': item['kind'], 'delta': delta, 'itemCount': len(item['items'])} + for item in located[segment_index + 1:] + ] + elif moving_side in ('before', 'left'): + delta = [axis_unit[index] * -delta_distance for index in range(3)] + moves = [ + {'items': item['items'], 'kind': item['kind'], 'delta': delta, 'itemCount': len(item['items'])} + for item in located[:segment_index + 1] + ] + spacing_mode = 'segment_before' + elif moving_side in ('split', 'both', 'center'): + left_delta = [axis_unit[index] * (-delta_distance * 0.5) for index in range(3)] + right_delta = [axis_unit[index] * (delta_distance * 0.5) for index in range(3)] + moves = [ + {'items': item['items'], 'kind': item['kind'], 'delta': left_delta, 'itemCount': len(item['items'])} + for item in located[:segment_index + 1] + ] + moves.extend( + {'items': item['items'], 'kind': item['kind'], 'delta': right_delta, 'itemCount': len(item['items'])} + for item in located[segment_index + 1:] + ) + spacing_mode = 'segment_split' + else: + raise Exception('capability_not_implemented: unsupported pattern segment spacing motion semantics: ' + moving_side) + if not moves: + raise Exception('object_signature_invalid_pattern_segment_index') + applied_moves = [] + errors = [] + for move_index, move in enumerate(moves): + try: + if move.get('kind') == 'component': + items = move.get('items') or [] + if len(items) != 1: + raise Exception('component_pattern_instance_not_unique') + applied = _translate_component_occurrence(items[0], move['delta']) + else: + applied = _translate_selection(_selection(move['items']), move['delta']) + applied['relativeMoveIndex'] = move_index + 1 + applied['targetKind'] = move.get('kind') + applied['itemCount'] = move['itemCount'] + applied_moves.append(applied) + except Exception as exc: + errors.append('Move.Translate segment instance ' + str(move_index + 1) + ': ' + str(exc)) + break + if errors: + raise Exception('capability_not_implemented: pattern.segment_spacing adapter failed; ' + '; '.join(errors)) + command_name = 'Component.Transform segment' if all(move.get('targetKind') == 'component' for move in applied_moves) else 'Move.Translate segment' + return { + 'command': command_name, + 'targetSpacing': target, + 'segmentSpacing': target, + 'previousSpacing': current_spacing, + 'segmentIndex': segment_index, + 'movingSide': moving_side, + 'spacingMode': spacing_mode, + 'instanceCount': len(located), + 'movedInstanceCount': len(applied_moves), + 'moves': applied_moves, + } + + +def change_boss_height(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: boss height must be positive') + signature = job['object'].get('geometrySignature') or {} + current_height = None + try: + current_height = float(signature.get('height')) + except Exception: + current_height = None + if current_height is None or current_height <= 0: + raise Exception('object_signature_missing_height') + axis = signature.get('axis') or [] + if not isinstance(axis, list) or len(axis) != 3: + raise Exception('object_signature_missing_axis') + axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])]) + if axis_length <= 1e-12: + raise Exception('object_signature_missing_axis') + delta_distance = target - current_height + if abs(delta_distance) <= 1e-12: + return {'command': 'noop', 'targetHeight': target, 'reason': 'target already reached'} + faces = _locate_height_faces(signature) + selection = _selection(faces) + delta = [float(axis[index]) / axis_length * delta_distance for index in range(3)] + errors = [] + try: + applied = _translate_selection(selection, delta) + applied['targetHeight'] = target + applied['heightDelta'] = delta_distance + applied['heightFaceCount'] = len(faces) + return applied + except Exception as exc: + errors.append('Move.Translate: ' + str(exc)) + try: + applied = _offset_faces(selection, delta_distance, signature) + applied['targetHeight'] = target + applied['heightDelta'] = delta_distance + applied['heightFaceCount'] = len(faces) + applied['moveErrors'] = errors + return applied + except Exception as exc: + errors.append('OffsetFaces.Execute: ' + str(exc)) + raise Exception('capability_not_implemented: boss.height adapter failed; ' + '; '.join(errors)) + + +def change_boss_diameter(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: boss diameter must be positive') + signature = job['object'].get('geometrySignature') or {} + current_radius = None + try: + current_radius = float(signature.get('radius')) + except Exception: + current_radius = None + if current_radius is None: + try: + current_radius = float(signature.get('diameter')) / 2.0 + except Exception: + current_radius = None + if current_radius is None or current_radius <= 0: + raise Exception('object_signature_missing_diameter') + target_radius = target / 2.0 + radial_delta = target_radius - current_radius + if abs(radial_delta) <= 1e-12: + return {'command': 'noop', 'targetDiameter': target, 'reason': 'target already reached'} + faces = _locate_diameter_faces(signature) + selection = _selection(faces) + try: + applied = _offset_faces(selection, radial_delta, signature) + applied['targetDiameter'] = target + applied['targetRadius'] = target_radius + applied['radialDelta'] = radial_delta + applied['diameterFaceCount'] = len(faces) + return applied + except Exception as exc: + raise Exception('capability_not_implemented: boss.diameter adapter failed; ' + str(exc)) + + +def _constant_round_radius(selection, faces, target): + constant_round = _command_type('ConstantRound') + if constant_round is None: + raise Exception('capability_not_implemented: ConstantRound command not available') + options = _new_options('ConstantRoundOptions') + errors = [] + for label, method in ( + ('ConstantRound.ModifyRadius', 'ModifyRadius'), + ('ConstantRound.SetRadius', 'SetRadius'), + ('ConstantRound.ChangeRadius', 'ChangeRadius'), + ('ConstantRound.Execute', 'Execute'), + ): + func = getattr(constant_round, method, None) + if func is None: + continue + variants = [] + if options is not None: + variants.append((selection, target, options, None)) + variants.append((selection, target, options)) + variants.append((faces, target, options, None)) + variants.append((faces, target, options)) + variants.append((selection, target, None)) + variants.append((selection, target)) + variants.append((faces, target, None)) + variants.append((faces, target)) + result = _call_variants(label, func, variants) + if result.get('ok'): + return {'command': label, 'targetRadius': target, 'apiSignature': result.get('signature')} + errors.extend(result.get('errors') or []) + raise Exception('capability_not_implemented: ConstantRound radius edit failed; ' + '; '.join(errors)) + + +def change_round_radius(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: round radius must be positive') + signature = job['object'].get('geometrySignature') or {} + if signature.get('isConstantRound') is not True: + raise Exception('object_signature_missing_constant_round_evidence') + current_radius = None + try: + current_radius = float(signature.get('radius')) + except Exception: + current_radius = None + if current_radius is None or current_radius <= 0: + raise Exception('object_signature_missing_round_radius') + if abs(target - current_radius) <= 1e-12: + return {'command': 'noop', 'targetRadius': target, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _constant_round_radius(selection, faces, target) + applied['roundFaceCount'] = len(faces) + applied['previousRadius'] = current_radius + return applied + except Exception as exc: + raise Exception('capability_not_implemented: round.radius adapter failed; ' + str(exc)) + + +def _chamfer_distance(selection, faces, target): + chamfer = _command_type('Chamfer') + if chamfer is None: + raise Exception('capability_not_implemented: Chamfer command not available') + options = _new_options('ChamferOptions') + errors = [] + for label, method in ( + ('Chamfer.ModifyDistance', 'ModifyDistance'), + ('Chamfer.SetDistance', 'SetDistance'), + ('Chamfer.ChangeDistance', 'ChangeDistance'), + ('Chamfer.Execute', 'Execute'), + ): + func = getattr(chamfer, method, None) + if func is None: + continue + variants = [] + if options is not None: + variants.append((selection, target, options, None)) + variants.append((selection, target, options)) + variants.append((selection, target, target, options, None)) + variants.append((selection, target, target, options)) + variants.append((faces, target, options, None)) + variants.append((faces, target, options)) + variants.append((faces, target, target, options, None)) + variants.append((faces, target, target, options)) + variants.append((selection, target, None)) + variants.append((selection, target)) + variants.append((selection, target, target, None)) + variants.append((selection, target, target)) + variants.append((faces, target, None)) + variants.append((faces, target)) + variants.append((faces, target, target, None)) + variants.append((faces, target, target)) + result = _call_variants(label, func, variants) + if result.get('ok'): + return {'command': label, 'targetDistance': target, 'apiSignature': result.get('signature')} + errors.extend(result.get('errors') or []) + raise Exception('capability_not_implemented: Chamfer distance edit failed; ' + '; '.join(errors)) + + +def change_chamfer_distance(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: chamfer distance must be positive') + signature = job['object'].get('geometrySignature') or {} + if signature.get('isEqualDistanceChamfer') is not True: + raise Exception('object_signature_missing_equal_distance_chamfer_evidence') + current_distance = None + try: + current_distance = float(signature.get('distance')) + except Exception: + current_distance = None + if current_distance is None or current_distance <= 0: + raise Exception('object_signature_missing_chamfer_distance') + if abs(target - current_distance) <= 1e-12: + return {'command': 'noop', 'targetDistance': target, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _chamfer_distance(selection, faces, target) + applied['chamferFaceCount'] = len(faces) + applied['previousDistance'] = current_distance + return applied + except Exception as exc: + raise Exception('capability_not_implemented: chamfer.distance adapter failed; ' + str(exc)) + + def pull_face_offset(job): target = _float_value(job['target']['value']) signature = job['object'].get('geometrySignature') or {} @@ -1078,6 +2068,17 @@ def fill_feature(job): raise Exception('capability_not_implemented: feature.fill adapter failed; ' + str(exc)) +def delete_round_or_chamfer(job): + faces = _locate_faces(job['object'].get('geometrySignature') or {}) + selection = _selection(faces) + try: + applied = _fill_selection(selection) + applied['targetFeatureRemoved'] = True + return applied + except Exception as exc: + raise Exception('capability_not_implemented: feature.delete_round_or_chamfer adapter failed; ' + str(exc)) + + def _apply_edit(job): operation = job['target'].get('backendOperation') or '' if operation == 'change_hole_diameter': @@ -1086,12 +2087,30 @@ def _apply_edit(job): return move_hole_axis(job) if operation == 'move_slot': return move_slot(job) + if operation == 'change_slot_width': + return change_slot_width(job) + if operation == 'change_slot_depth': + return change_slot_depth(job) if operation == 'move_boss': return move_boss(job) + if operation == 'change_pattern_spacing': + return change_pattern_spacing(job) + if operation == 'change_pattern_segment_spacing': + return change_pattern_segment_spacing(job) + if operation == 'change_boss_height': + return change_boss_height(job) + if operation == 'change_boss_diameter': + return change_boss_diameter(job) + if operation == 'change_round_radius': + return change_round_radius(job) + if operation == 'change_chamfer_distance': + return change_chamfer_distance(job) if operation == 'pull_face_offset': return pull_face_offset(job) if operation == 'fill_feature': return fill_feature(job) + if operation == 'delete_round_or_chamfer': + return delete_round_or_chamfer(job) raise Exception('unsupported_backend_operation: ' + operation) diff --git a/step_editor/scdm_feature_mapper.py b/step_editor/scdm_feature_mapper.py index 6ced867..182544d 100644 --- a/step_editor/scdm_feature_mapper.py +++ b/step_editor/scdm_feature_mapper.py @@ -8,6 +8,9 @@ from .scdm_capabilities import capability_definition, planned_capability_keys, p from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json +SCDM_FEATURE_CACHE_REVISION = 4 + + def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]: objects = [] diagnostics: dict[str, object] = { @@ -33,6 +36,9 @@ def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object for key, value in feature_inventory.items() if isinstance(value, Mapping) } + component_instances = raw_diagnostics.get("componentInstances") + if isinstance(component_instances, list): + diagnostics["component_instances"] = [dict(item) for item in component_instances if isinstance(item, Mapping)] raw_summary = raw_payload.get("summary") if isinstance(raw_summary, Mapping): diagnostics["raw_summary"] = dict(raw_summary) @@ -131,6 +137,7 @@ def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object return { "schemaVersion": SCDM_CACHE_SCHEMA_VERSION, + "mapperRevision": SCDM_FEATURE_CACHE_REVISION, "source": "SCDM", "createdAt": utc_now(), "modelFingerprint": payload_model_fingerprint(raw_payload), @@ -447,7 +454,11 @@ def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str, def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]: - return _derived_linear_pattern_objects(source_objects) + return [ + *_derived_linear_pattern_objects(source_objects), + *_derived_body_linear_pattern_objects(source_objects), + *_derived_thin_wall_objects(source_objects), + ] def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[str, object]]: @@ -468,6 +479,10 @@ def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[s if len(center) != 3 or len(axis) != 3 or radius is None or radius <= 0: continue topology = _mapping(raw_object.get("topologyHint")) + locators = _face_locators(topology.get("scdmFaceLocators")) + fallback_locator = _locator_from_raw_object(raw_object) + if not locators and any(fallback_locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")): + locators = [fallback_locator] key = ( _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))), tuple(axis), @@ -480,11 +495,12 @@ def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[s "axis": axis, "radius": float(radius), "faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")), + "faceOrdinals": _int_list(topology.get("faceOrdinals") or [topology.get("faceOrdinal")]), "globalFaceOrdinals": _int_list( topology.get("globalFaceOrdinals") - or topology.get("faceOrdinals") or [topology.get("globalFaceOrdinal"), topology.get("faceOrdinal")] ), + "scdmFaceLocators": locators, } ) @@ -505,10 +521,21 @@ def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[s face_ids: list[int] = [] global_face_ordinals: list[int] = [] centers = [] + pattern_instances: list[dict[str, object]] = [] for member in candidate["members"]: centers.append(list(member["center"])) face_ids.extend(_int_list(member.get("faceIds"))) global_face_ordinals.extend(_int_list(member.get("globalFaceOrdinals"))) + pattern_instances.append( + { + "sourceObjectId": str(member.get("objectId") or ""), + "center": list(member["center"]), + "faceIds": _int_list(member.get("faceIds")), + "faceOrdinals": _int_list(member.get("faceOrdinals")), + "globalFaceOrdinals": _int_list(member.get("globalFaceOrdinals")), + "scdmFaceLocators": _face_locators(member.get("scdmFaceLocators")), + } + ) center = [ round(sum(float(item[index]) for item in centers) / len(centers), 6) for index in range(3) @@ -527,15 +554,18 @@ def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[s "radius": radius, "diameter": radius * 2.0, "sourceObjectIds": list(member_ids), + "patternInstances": pattern_instances, }, "topologyHint": { "bodyIndex": body_index, "faceIds": sorted(set(face_ids)), "globalFaceOrdinals": sorted(set(global_face_ordinals)), }, - "backendCommandCandidates": [], + "backendCommandCandidates": [ + {"operation": "change_pattern_spacing", "enabled": True, "parameterFields": {"spacing": spacing}}, + ], "rawLimitations": [ - "Derived from repeated cylindrical feature centers; SCDM edit command is not productized yet.", + "Derived from repeated cylindrical feature centers; spacing edit uses SCDM Move on each instance.", ], } ) @@ -597,6 +627,368 @@ def _linear_pattern_candidates(instances: list[dict[str, object]]) -> list[dict[ return candidates +def _derived_body_linear_pattern_objects(source_objects: list[object]) -> list[dict[str, object]]: + body_summaries = _body_pattern_source_instances(source_objects) + groups: dict[tuple[object, ...], list[dict[str, object]]] = {} + for item in body_summaries: + signature = tuple(item.get("shapeSignature") or ()) + if not signature: + continue + groups.setdefault(signature, []).append(item) + + result: list[dict[str, object]] = [] + seen: set[tuple[object, ...]] = set() + for signature, instances in groups.items(): + if len(instances) < 3: + continue + candidates = _linear_pattern_candidates(instances) + for candidate in candidates: + members = list(candidate["members"]) + member_ids = tuple(str(item.get("objectId") or "") for item in members) + pattern_axis = tuple(round(float(value), 6) for value in candidate["direction"]) + spacing = round(float(candidate["spacing"]), 6) + key = (signature, pattern_axis, spacing, member_ids) + if key in seen: + continue + seen.add(key) + centers = [list(item["center"]) for item in members] + center = [ + round(sum(float(item[index]) for item in centers) / len(centers), 6) + for index in range(3) + ] + face_ids = sorted(set(value for item in members for value in _int_list(item.get("faceIds")))) + face_ordinals = sorted(set(value for item in members for value in _int_list(item.get("faceOrdinals")))) + global_face_ordinals = sorted(set(value for item in members for value in _int_list(item.get("globalFaceOrdinals")))) + body_indices = [ + value + for value in (_int_or_none(item.get("bodyIndex")) for item in members) + if value is not None + ] + pattern_instances = [ + { + "sourceObjectId": str(item.get("objectId") or ""), + "instanceKind": "body", + "center": list(item["center"]), + "bodyIndex": _int_or_none(item.get("bodyIndex")), + "bodyLocators": _body_locators(item.get("bodyLocators")), + "componentLocators": _component_locators(item.get("componentLocators") or item.get("bodyLocators")), + "faceIds": _int_list(item.get("faceIds")), + "faceOrdinals": _int_list(item.get("faceOrdinals")), + "globalFaceOrdinals": _int_list(item.get("globalFaceOrdinals")), + "scdmFaceLocators": _face_locators(item.get("scdmFaceLocators")), + } + for item in members + ] + result.append( + { + "backendId": "derived:body_linear_pattern:" + "|".join(member_ids), + "objectType": "linear_pattern", + "geometry": { + "patternKind": "body", + "instanceKind": "body", + "center": center, + "axis": list(pattern_axis), + "spacing": spacing, + "pitch": spacing, + "instanceCount": len(members), + "instanceCenters": centers, + "sourceObjectIds": list(member_ids), + "bodyIndices": body_indices, + "componentInstanceCount": sum( + 1 + for item in members + if _component_locators(item.get("componentLocators") or item.get("bodyLocators")) + ), + "patternInstances": pattern_instances, + }, + "topologyHint": { + "bodyIndices": body_indices, + "faceIds": face_ids, + "faceOrdinals": face_ordinals, + "globalFaceOrdinals": global_face_ordinals, + }, + "backendCommandCandidates": [ + {"operation": "change_pattern_spacing", "enabled": True, "parameterFields": {"spacing": spacing}}, + ], + "rawLimitations": [ + "Derived from repeated SCDM Body/Part geometry centers; spacing edit opens only when every member has a component occurrence locator.", + ], + } + ) + if len(result) >= 24: + return result + return result + + +def _body_pattern_source_instances(source_objects: list[object]) -> list[dict[str, object]]: + explicit: list[dict[str, object]] = [] + grouped_faces: dict[int, dict[str, object]] = {} + for raw_object in source_objects: + if not isinstance(raw_object, Mapping): + continue + object_type = str(raw_object.get("objectType") or "").strip().lower() + geometry = _mapping(raw_object.get("geometry")) + topology = _mapping(raw_object.get("topologyHint")) + body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))) + if object_type in {"body", "part", "component"} and body_index is not None: + center = _rounded_vector(geometry.get("center") or geometry.get("bboxCenter") or geometry.get("axisCenter")) + if len(center) == 3: + explicit.append( + { + "objectId": _object_id(raw_object), + "center": center, + "bodyIndex": body_index, + "bodyLocators": _body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators") or [{"bodyIndex": body_index}]), + "componentLocators": _component_locators(topology.get("componentLocators") or geometry.get("componentLocators")), + "faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")), + "faceOrdinals": _int_list(topology.get("faceOrdinals") or geometry.get("faceOrdinals")), + "globalFaceOrdinals": _int_list(topology.get("globalFaceOrdinals") or geometry.get("globalFaceOrdinals")), + "scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators") or geometry.get("scdmFaceLocators")), + "shapeSignature": _body_shape_signature_from_object(raw_object), + } + ) + continue + if object_type != "face" or body_index is None: + continue + center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter")) + if len(center) != 3: + continue + item = grouped_faces.setdefault( + int(body_index), + { + "objectId": f"body:{int(body_index)}", + "bodyIndex": int(body_index), + "centers": [], + "faceIds": [], + "faceOrdinals": [], + "globalFaceOrdinals": [], + "scdmFaceLocators": [], + "bodyLocators": [], + "componentLocators": [], + "surfaceTypeCounts": {}, + "radiusBuckets": {}, + }, + ) + item["centers"].append(center) # type: ignore[index,union-attr] + item["faceIds"].extend(_int_list(topology.get("faceIds") or geometry.get("faceIds"))) # type: ignore[index,union-attr] + face_ordinal = _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))) + if face_ordinal is not None: + item["faceOrdinals"].append(face_ordinal) # type: ignore[index,union-attr] + global_face_ordinal = _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))) + if global_face_ordinal is not None: + item["globalFaceOrdinals"].append(global_face_ordinal) # type: ignore[index,union-attr] + locator = _locator_from_raw_object(raw_object) + if any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")): + item["scdmFaceLocators"].append(locator) # type: ignore[index,union-attr] + item["bodyLocators"].extend(_body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators"))) # type: ignore[index,union-attr] + item["componentLocators"].extend(_component_locators(topology.get("componentLocators") or geometry.get("componentLocators"))) # type: ignore[index,union-attr] + surface_key = _surface_key(geometry.get("surfaceType") or geometry.get("surface")) + if surface_key: + counts = item["surfaceTypeCounts"] # type: ignore[index] + counts[surface_key] = counts.get(surface_key, 0) + 1 # type: ignore[union-attr] + radius = _rounded_number(geometry.get("radius")) + if radius is not None and radius > 0: + buckets = item["radiusBuckets"] # type: ignore[index] + bucket_key = round(float(radius), 6) + buckets[bucket_key] = buckets.get(bucket_key, 0) + 1 # type: ignore[union-attr] + if explicit: + return [item for item in explicit if item.get("shapeSignature")] + result: list[dict[str, object]] = [] + for body_index, item in grouped_faces.items(): + centers = [list(center) for center in item.get("centers", []) if isinstance(center, list) and len(center) == 3] + locators = _face_locators(item.get("scdmFaceLocators")) + if len(centers) < 2 or not locators: + continue + center = [ + round(sum(float(point[index]) for point in centers) / len(centers), 6) + for index in range(3) + ] + signature = _body_shape_signature_from_summary(item) + if not signature: + continue + body_locators = _body_locators(item.get("bodyLocators")) or [{"bodyIndex": int(body_index)}] + component_locators = _component_locators(item.get("componentLocators") or body_locators) + result.append( + { + "objectId": str(item.get("objectId") or f"body:{body_index}"), + "center": center, + "bodyIndex": int(body_index), + "bodyLocators": body_locators, + "componentLocators": component_locators, + "faceIds": sorted(set(_int_list(item.get("faceIds")))), + "faceOrdinals": sorted(set(_int_list(item.get("faceOrdinals")))), + "globalFaceOrdinals": sorted(set(_int_list(item.get("globalFaceOrdinals")))), + "scdmFaceLocators": locators, + "shapeSignature": signature, + } + ) + return result + + +def _body_shape_signature_from_object(raw_object: Mapping[str, object]) -> tuple[object, ...]: + geometry = _mapping(raw_object.get("geometry")) + bbox_size = _rounded_vector(geometry.get("bboxSize") or geometry.get("size") or geometry.get("boxSize")) + face_count = _int_or_none(geometry.get("faceCount")) + edge_count = _int_or_none(geometry.get("edgeCount")) + signature: list[object] = [] + if len(bbox_size) == 3: + signature.append(("bbox", tuple(round(abs(float(value)), 5) for value in sorted(bbox_size)))) + if face_count is not None: + signature.append(("faces", face_count)) + if edge_count is not None: + signature.append(("edges", edge_count)) + return tuple(signature) + + +def _body_shape_signature_from_summary(summary: Mapping[str, object]) -> tuple[object, ...]: + face_count = len(_int_list(summary.get("faceOrdinals"))) or len(_face_locators(summary.get("scdmFaceLocators"))) + if face_count < 2: + return () + surface_counts = _mapping(summary.get("surfaceTypeCounts")) + radius_buckets = _mapping(summary.get("radiusBuckets")) + return ( + ("faces", face_count), + ("surfaces", tuple(sorted((str(key), int(value)) for key, value in surface_counts.items()))), + ("radii", tuple(sorted((float(key), int(value)) for key, value in radius_buckets.items()))), + ) + + +def _derived_thin_wall_objects(source_objects: list[object]) -> list[dict[str, object]]: + groups: dict[tuple[object, ...], list[dict[str, object]]] = {} + for raw_object in source_objects: + if not isinstance(raw_object, Mapping): + continue + if str(raw_object.get("objectType") or "").strip().lower() != "face": + continue + geometry = _mapping(raw_object.get("geometry")) + if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "plane": + continue + center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter")) + normal = _unit_vector(_rounded_vector(geometry.get("normal") or geometry.get("axis"))) + axis = _canonical_axis(normal) + if len(center) != 3 or len(axis) != 3: + continue + locator = _locator_from_raw_object(raw_object) + if not any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")): + continue + topology = _mapping(raw_object.get("topologyHint")) + body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))) + key = (body_index, tuple(axis)) + groups.setdefault(key, []).append( + { + "objectId": _object_id(raw_object), + "center": center, + "axis": axis, + "faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")), + "faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))), + "globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))), + "scdmFaceLocators": [locator], + } + ) + + result: list[dict[str, object]] = [] + seen: set[tuple[object, ...]] = set() + for (body_index, axis_key), faces in groups.items(): + if len(faces) < 2: + continue + candidates = _thin_wall_pair_candidates(faces, list(axis_key)) + for candidate in candidates: + left = candidate["left"] + right = candidate["right"] + source_ids = (str(left.get("objectId") or ""), str(right.get("objectId") or "")) + ordinals = tuple( + value + for value in ( + _int_or_none(left.get("faceOrdinal")), + _int_or_none(right.get("faceOrdinal")), + ) + if value is not None + ) + global_ordinals = tuple( + value + for value in ( + _int_or_none(left.get("globalFaceOrdinal")), + _int_or_none(right.get("globalFaceOrdinal")), + ) + if value is not None + ) + key = (body_index, tuple(axis_key), round(float(candidate["thickness"]), 6), tuple(sorted(source_ids))) + if key in seen: + continue + seen.add(key) + locators = [ + locator + for item in (left, right) + for locator in _face_locators(item.get("scdmFaceLocators")) + ] + centers = [list(left["center"]), list(right["center"])] + center = [ + round((float(centers[0][index]) + float(centers[1][index])) * 0.5, 6) + for index in range(3) + ] + result.append( + { + "backendId": "derived:thin_wall:" + "|".join(source_ids), + "objectType": "thin_wall", + "geometry": { + "surfaceType": "plane", + "center": center, + "axis": list(axis_key), + "thicknessAxis": list(axis_key), + "thickness": round(float(candidate["thickness"]), 6), + "wallFaceCenters": centers, + "sourceObjectIds": list(source_ids), + "wallFaceLocators": locators, + }, + "topologyHint": { + "bodyIndex": body_index, + "faceIds": sorted(set(_int_list(left.get("faceIds")) + _int_list(right.get("faceIds")))), + "faceOrdinals": list(ordinals), + "globalFaceOrdinals": list(global_ordinals), + "scdmFaceLocators": locators, + "wallFaceLocators": locators, + }, + "backendCommandCandidates": [ + {"operation": "change_shell_thickness", "enabled": True, "parameterFields": {"thickness": round(float(candidate["thickness"]), 6)}}, + ], + "rawLimitations": [ + "Derived from paired planar SCDM faces; shell.thickness remains non-productized until real SCDM edit samples pass.", + ], + } + ) + if len(result) >= 24: + return result + return result + + +def _thin_wall_pair_candidates(faces: list[dict[str, object]], axis: list[float]) -> list[dict[str, object]]: + pairs: list[dict[str, object]] = [] + for left_index in range(len(faces)): + for right_index in range(left_index + 1, len(faces)): + left = faces[left_index] + right = faces[right_index] + delta = [ + float(right["center"][index]) - float(left["center"][index]) + for index in range(3) + ] + along = sum(delta[index] * axis[index] for index in range(3)) + thickness = abs(along) + if thickness <= 1.0e-9: + continue + delta_length_sq = sum(value * value for value in delta) + perpendicular = math.sqrt(max(delta_length_sq - along * along, 0.0)) + if perpendicular > max(thickness * 0.25, 1.0e-4): + continue + pairs.append({"left": left, "right": right, "thickness": thickness, "perpendicular": perpendicular}) + if not pairs: + return [] + min_thickness = min(float(item["thickness"]) for item in pairs) + tolerance = max(min_thickness * 0.05, 1.0e-6) + filtered = [item for item in pairs if abs(float(item["thickness"]) - min_thickness) <= tolerance] + filtered.sort(key=lambda item: (float(item["thickness"]), float(item["perpendicular"]))) + return filtered[:12] + + def _point_cloud_diagonal(points: list[list[float]]) -> float: if not points: return 0.0 @@ -615,11 +1007,17 @@ def _unit_vector(values: list[float]) -> list[float]: def geometry_signature(raw_object: Mapping[str, object]) -> dict[str, object]: geometry = _mapping(raw_object.get("geometry")) topology = _mapping(raw_object.get("topologyHint")) + round_info = _mapping(geometry.get("roundInfo")) + chamfer_info = _mapping(geometry.get("chamferInfo")) + slot_info = _mapping(geometry.get("slotInfo")) return { "objectType": str(raw_object.get("objectType") or ""), + "patternKind": str(geometry.get("patternKind") or topology.get("patternKind") or ""), + "instanceKind": str(geometry.get("instanceKind") or topology.get("instanceKind") or ""), "faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")), "edgeIds": _int_list(topology.get("edgeIds") or geometry.get("edgeIds")), "bodyIndex": _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))), + "bodyIndices": _int_list(topology.get("bodyIndices") or geometry.get("bodyIndices")), "faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))), "faceOrdinals": _int_list(topology.get("faceOrdinals") or geometry.get("faceOrdinals")), "edgeOrdinal": _int_or_none(_first_present(topology.get("edgeOrdinal"), geometry.get("edgeOrdinal"))), @@ -628,6 +1026,42 @@ def geometry_signature(raw_object: Mapping[str, object]) -> dict[str, object]: "globalEdgeOrdinal": _int_or_none(_first_present(topology.get("globalEdgeOrdinal"), geometry.get("globalEdgeOrdinal"))), "adjacentFaceOrdinals": _int_list(topology.get("adjacentFaceOrdinals") or geometry.get("adjacentFaceOrdinals")), "adjacentFaceCount": _int_or_none(_first_present(topology.get("adjacentFaceCount"), geometry.get("adjacentFaceCount"))), + "heightFaceOrdinals": _int_list( + topology.get("heightFaceOrdinals") + or topology.get("topFaceOrdinals") + or geometry.get("heightFaceOrdinals") + or geometry.get("topFaceOrdinals") + ), + "globalHeightFaceOrdinals": _int_list( + topology.get("globalHeightFaceOrdinals") + or topology.get("globalTopFaceOrdinals") + or geometry.get("globalHeightFaceOrdinals") + or geometry.get("globalTopFaceOrdinals") + ), + "depthFaceOrdinals": _int_list( + topology.get("depthFaceOrdinals") + or topology.get("bottomFaceOrdinals") + or geometry.get("depthFaceOrdinals") + or geometry.get("bottomFaceOrdinals") + ), + "globalDepthFaceOrdinals": _int_list( + topology.get("globalDepthFaceOrdinals") + or topology.get("globalBottomFaceOrdinals") + or geometry.get("globalDepthFaceOrdinals") + or geometry.get("globalBottomFaceOrdinals") + ), + "diameterFaceOrdinals": _int_list( + topology.get("diameterFaceOrdinals") + or topology.get("sideFaceOrdinals") + or geometry.get("diameterFaceOrdinals") + or geometry.get("sideFaceOrdinals") + ), + "globalDiameterFaceOrdinals": _int_list( + topology.get("globalDiameterFaceOrdinals") + or topology.get("globalSideFaceOrdinals") + or geometry.get("globalDiameterFaceOrdinals") + or geometry.get("globalSideFaceOrdinals") + ), "surfaceType": str(geometry.get("surfaceType") or geometry.get("surface") or ""), "curveType": str(geometry.get("curveType") or ""), "center": _rounded_vector(geometry.get("center") or geometry.get("axisCenter")), @@ -636,15 +1070,68 @@ def geometry_signature(raw_object: Mapping[str, object]) -> dict[str, object]: "endPoint": _rounded_vector(geometry.get("endPoint")), "midPoint": _rounded_vector(geometry.get("midPoint")), "length": _rounded_number(geometry.get("length")), - "radius": _rounded_number(geometry.get("radius")), - "diameter": _rounded_number(geometry.get("diameter")), + "width": _rounded_number(geometry.get("width")), + "depth": _rounded_number(_first_present(slot_info.get("depth"), geometry.get("depth"))), + "depthAxis": _rounded_vector( + _first_present( + slot_info.get("depthAxis"), + slot_info.get("depthDirection"), + geometry.get("depthAxis"), + geometry.get("depthDirection"), + ) + ), + "height": _rounded_number(geometry.get("height")), + "thickness": _rounded_number(geometry.get("thickness")), + "thicknessAxis": _rounded_vector(geometry.get("thicknessAxis") or geometry.get("thicknessDirection")), + "distance": _rounded_number(_first_present(chamfer_info.get("distance"), geometry.get("distance"), geometry.get("offset"))), + "distance1": _rounded_number(_first_present(chamfer_info.get("distance1"), geometry.get("distance1"))), + "distance2": _rounded_number(_first_present(chamfer_info.get("distance2"), geometry.get("distance2"))), + "radius": _rounded_number(_first_present(round_info.get("radius"), geometry.get("radius"))), + "diameter": _rounded_number(_first_present(round_info.get("diameter"), geometry.get("diameter"))), + "roundType": str(round_info.get("type") or geometry.get("roundType") or ""), + "isConstantRound": _bool_or_none(_first_present(round_info.get("isConstant"), geometry.get("isConstant"), geometry.get("constant"))), + "isRound": _bool_or_none(_first_present(round_info.get("isRound"), geometry.get("isRound"))), + "chamferType": str(chamfer_info.get("type") or geometry.get("chamferType") or ""), + "isEqualDistanceChamfer": _bool_or_none( + _first_present( + chamfer_info.get("isEqualDistance"), + chamfer_info.get("isSymmetric"), + geometry.get("isEqualDistanceChamfer"), + geometry.get("isEqualDistance"), + ) + ), "spacing": _rounded_number(geometry.get("spacing")), "pitch": _rounded_number(geometry.get("pitch")), "instanceCount": _int_or_none(geometry.get("instanceCount")), "instanceCenters": _rounded_vector_list(geometry.get("instanceCenters")), + "patternInstances": _pattern_instances(geometry.get("patternInstances") or topology.get("patternInstances")), "sourceObjectIds": _string_list(geometry.get("sourceObjectIds")), + "bodyLocators": _body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators")), + "componentLocators": _component_locators(topology.get("componentLocators") or geometry.get("componentLocators")), "planeOffset": _rounded_number(geometry.get("planeOffset")), "scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators")), + "heightFaceLocators": _face_locators( + topology.get("heightFaceLocators") + or topology.get("topFaceLocators") + or geometry.get("heightFaceLocators") + or geometry.get("topFaceLocators") + ), + "depthFaceLocators": _face_locators( + topology.get("depthFaceLocators") + or topology.get("bottomFaceLocators") + or geometry.get("depthFaceLocators") + or geometry.get("bottomFaceLocators") + ), + "diameterFaceLocators": _face_locators( + topology.get("diameterFaceLocators") + or topology.get("sideFaceLocators") + or geometry.get("diameterFaceLocators") + or geometry.get("sideFaceLocators") + ), + "wallFaceLocators": _face_locators( + topology.get("wallFaceLocators") + or geometry.get("wallFaceLocators") + ), } @@ -666,20 +1153,32 @@ def attach_local_face_ids_to_scdm_cache( item = dict(raw_object) signature = dict(item.get("geometrySignature") if isinstance(item.get("geometrySignature"), Mapping) else {}) if not _int_list(signature.get("faceIds")): - if str(signature.get("objectType") or "") in {"cylindrical_face_group", "cylindrical_hole"}: - match = _match_local_face_group_signatures(signature, local_signatures, min_score=min_score, unique_margin=unique_margin) + ordinal_face_ids = _face_ids_from_signature_ordinals(signature, local_signatures) + if ordinal_face_ids: + signature["faceIds"] = ordinal_face_ids + signature["localOrdinalMatch"] = True + match = { + "status": "ordinal", + "message": "Matched local Face IDs from SCDM ordinal locators.", + "faceIds": ordinal_face_ids, + "score": None, + } else: - match = _match_local_face_signature(signature, local_signatures, min_score=min_score, unique_margin=unique_margin) + if str(signature.get("objectType") or "") in {"cylindrical_face_group", "cylindrical_hole"}: + match = _match_local_face_group_signatures(signature, local_signatures, min_score=min_score, unique_margin=unique_margin) + else: + match = _match_local_face_signature(signature, local_signatures, min_score=min_score, unique_margin=unique_margin) + status = str(match.get("status") or "") + if status in {"unique", "group"}: + face_ids = _int_list(match.get("faceIds")) + face_id = _int_or_none(match.get("faceId")) + if not face_ids and face_id is not None: + face_ids = [face_id] + if face_ids: + signature["faceIds"] = face_ids + signature["localMatchScore"] = match.get("score") + signature["localUnitScale"] = match.get("unitScale") status = str(match.get("status") or "") - if status in {"unique", "group"}: - face_ids = _int_list(match.get("faceIds")) - face_id = _int_or_none(match.get("faceId")) - if not face_ids and face_id is not None: - face_ids = [face_id] - if face_ids: - signature["faceIds"] = face_ids - signature["localMatchScore"] = match.get("score") - signature["localUnitScale"] = match.get("unitScale") mapping_rows.append( { "objectId": item.get("objectId"), @@ -690,6 +1189,16 @@ def attach_local_face_ids_to_scdm_cache( "message": match.get("message"), } ) + unit_scale = _inferred_local_unit_scale(signature, local_signatures) + if unit_scale is not None: + signature["localUnitScale"] = unit_scale + support_face_ids = _pattern_support_face_ids(signature, local_signatures) + if support_face_ids: + signature["supportFaceIds"] = support_face_ids + support_fit = _pattern_support_fit(signature, local_signatures, support_face_ids, unit_scale or 1.0) + if support_fit: + signature["supportPatternFit"] = support_fit + _attach_pattern_instance_local_display_ids(signature, local_signatures) item["geometrySignature"] = signature objects.append(item) diagnostics["local_face_mapping"] = mapping_rows @@ -698,6 +1207,393 @@ def attach_local_face_ids_to_scdm_cache( return result +def _face_ids_from_signature_ordinals( + signature: Mapping[str, object], + local_signatures: list[dict[str, object]], +) -> list[int]: + global_lookup: dict[int, int] = {} + body_lookup: dict[tuple[int, int], int] = {} + for local in local_signatures: + face_id = _int_or_none(local.get("faceId")) + if face_id is None: + continue + global_ordinal = _int_or_none(local.get("globalFaceOrdinal")) + if global_ordinal is not None: + global_lookup[global_ordinal] = face_id + body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId"))) + face_ordinal = _int_or_none(local.get("faceOrdinal")) + if body_index is not None and face_ordinal is not None: + body_lookup[(body_index, face_ordinal)] = face_id + + result: list[int] = [] + for value in _int_list(signature.get("globalFaceOrdinals") or [signature.get("globalFaceOrdinal")]): + face_id = global_lookup.get(value) + if face_id is not None: + result.append(face_id) + + body_indices = _int_list(signature.get("bodyIndices")) + if not body_indices: + body_index = _int_or_none(signature.get("bodyIndex")) + body_indices = [body_index] if body_index is not None else [] + for body_index in body_indices: + for face_ordinal in _int_list(signature.get("faceOrdinals") or [signature.get("faceOrdinal")]): + face_id = body_lookup.get((int(body_index), int(face_ordinal))) + if face_id is not None: + result.append(face_id) + + for locator in _face_locators(signature.get("scdmFaceLocators")): + face_id = None + global_ordinal = _int_or_none(locator.get("globalFaceOrdinal")) + if global_ordinal is not None: + face_id = global_lookup.get(global_ordinal) + if face_id is None: + body_index = _int_or_none(locator.get("bodyIndex")) + face_ordinal = _int_or_none(locator.get("faceOrdinal")) + if body_index is not None and face_ordinal is not None: + face_id = body_lookup.get((body_index, face_ordinal)) + if face_id is not None: + result.append(face_id) + + return sorted(set(result)) + + +def _attach_pattern_instance_local_display_ids( + signature: dict[str, object], + local_signatures: list[dict[str, object]], +) -> None: + instances = signature.get("patternInstances") + if not isinstance(instances, list): + return + local_by_face_id = { + int(face_id): local + for local in local_signatures + for face_id in [_int_or_none(local.get("faceId"))] + if face_id is not None + } + updated_instances: list[object] = [] + for instance in instances: + if not isinstance(instance, Mapping): + updated_instances.append(instance) + continue + row = dict(instance) + face_ids = _int_list(row.get("faceIds")) + if not face_ids: + face_ids = _face_ids_from_signature_ordinals(row, local_signatures) + if face_ids: + row["faceIds"] = face_ids + solid_ids = sorted( + set( + int(value) + for face_id in face_ids + for value in [ + _int_or_none( + _first_present( + local_by_face_id.get(int(face_id), {}).get("solidId"), + local_by_face_id.get(int(face_id), {}).get("bodyIndex"), + ) + ) + ] + if value is not None and int(value) >= 0 + ) + ) + part_ids = sorted( + set( + int(value) + for face_id in face_ids + for value in [_int_or_none(local_by_face_id.get(int(face_id), {}).get("partId"))] + if value is not None and int(value) >= 0 + ) + ) + if solid_ids: + row["localSolidIds"] = solid_ids + if len(solid_ids) == 1: + row["localSolidId"] = solid_ids[0] + if part_ids: + row["localPartIds"] = part_ids + if len(part_ids) == 1: + row["localPartId"] = part_ids[0] + updated_instances.append(row) + signature["patternInstances"] = updated_instances + + +def _inferred_local_unit_scale( + signature: Mapping[str, object], + local_signatures: list[dict[str, object]], +) -> float | None: + existing = _number(signature.get("localUnitScale")) + if existing is not None and existing > 0: + return existing + if str(signature.get("patternKind") or "").lower() == "body": + scale = _body_pattern_unit_scale(signature, local_signatures) + if scale is not None and scale > 0: + return scale + by_face_id = { + int(face_id): local + for local in local_signatures + for face_id in [_int_or_none(local.get("faceId"))] + if face_id is not None + } + scored: list[tuple[float, float]] = [] + for face_id in _int_list(signature.get("faceIds"))[:32]: + local = by_face_id.get(int(face_id)) + if local is None: + continue + score, scale = _local_signature_score(signature, local) + if score > 0 and scale > 0: + scored.append((score, scale)) + if scored: + scored.sort(key=lambda item: item[0], reverse=True) + return scored[0][1] + return None + + +def _body_pattern_unit_scale( + signature: Mapping[str, object], + local_signatures: list[dict[str, object]], +) -> float | None: + if str(signature.get("patternKind") or "").lower() != "body": + return None + axis = _unit_vector(_rounded_vector(signature.get("axis"))) + if len(axis) != 3: + return None + scdm_spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch"))) + if scdm_spacing is None or scdm_spacing <= 0: + return None + body_boxes = _local_body_boxes(local_signatures) + body_indices = _int_list(signature.get("bodyIndices")) + centers = [_box_center(body_boxes[index]) for index in body_indices if index in body_boxes] + if len(centers) < 2: + return None + projections = sorted(sum(center[index] * axis[index] for index in range(3)) for center in centers) + spacings = [ + projections[index + 1] - projections[index] + for index in range(len(projections) - 1) + if projections[index + 1] - projections[index] > 1.0e-9 + ] + if not spacings: + return None + local_spacing = sum(spacings) / len(spacings) + if local_spacing <= 0: + return None + return float(scdm_spacing) / float(local_spacing) + + +def _pattern_support_face_ids( + signature: Mapping[str, object], + local_signatures: list[dict[str, object]], +) -> list[int]: + if str(signature.get("patternKind") or "").lower() != "body": + return [] + body_indices = _int_list(signature.get("bodyIndices")) + if len(body_indices) < 3: + return [] + body_boxes = _local_body_boxes(local_signatures) + member_boxes = [body_boxes[index] for index in body_indices if index in body_boxes] + if len(member_boxes) < 3: + return [] + pattern_box = _merge_boxes(member_boxes) + pattern_size = [pattern_box[1][index] - pattern_box[0][index] for index in range(3)] + diagonal = math.sqrt(sum(item * item for item in pattern_size)) + tolerance = max(diagonal * 1.0e-5, 1.0e-5) + pattern_body_set = set(body_indices) + candidates: list[tuple[float, int]] = [] + for local in local_signatures: + face_id = _int_or_none(local.get("faceId")) + body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId"))) + if face_id is None or body_index in pattern_body_set: + continue + if _surface_key(local.get("surfaceType")) != "plane": + continue + axis = _unit_vector(_rounded_vector(local.get("axis"))) + if len(axis) != 3: + continue + face_box = _box_from_local_signature(local) + if face_box is None: + continue + plane = _rounded_number(local.get("planeOffset")) + if plane is None: + continue + overlap = _pattern_projection_overlap_score(pattern_box, face_box, axis) + if overlap <= 0: + continue + distance = _pattern_plane_touch_distance(pattern_box, axis, plane) + if distance > tolerance: + continue + area = _number(local.get("area")) or _box_area_estimate(face_box) + candidates.append((overlap * max(area, 1.0) - distance, int(face_id))) + candidates.sort(reverse=True) + return [face_id for _score, face_id in candidates[:4]] + + +def _pattern_support_fit( + signature: Mapping[str, object], + local_signatures: list[dict[str, object]], + support_face_ids: list[int], + unit_scale: float, +) -> dict[str, object]: + if not support_face_ids: + return {} + axis = _unit_vector(_rounded_vector(signature.get("axis"))) + if len(axis) != 3: + return {} + body_boxes = _local_body_boxes(local_signatures) + member_boxes = [body_boxes[index] for index in _int_list(signature.get("bodyIndices")) if index in body_boxes] + if len(member_boxes) < 3: + return {} + member_centers = [_box_center(box) for box in member_boxes] + center_projections = sorted(_point_projection(center, axis) for center in member_centers) + if len(center_projections) < 3: + return {} + member_span = max(_box_projection_span(box, axis) for box in member_boxes) + if member_span <= 0: + return {} + local_by_face_id = { + int(face_id): local + for local in local_signatures + for face_id in [_int_or_none(local.get("faceId"))] + if face_id is not None + } + support_candidates = [] + for face_id in support_face_ids: + local = local_by_face_id.get(int(face_id)) + if local is None: + continue + box = _box_from_local_signature(local) + if box is None: + continue + projection_min, projection_max = _box_projection_range(box, axis) + pattern_center_projection = sum(center_projections) / len(center_projections) + half_member_span = member_span * 0.5 + left_capacity = pattern_center_projection - projection_min - half_member_span + right_capacity = projection_max - pattern_center_projection - half_member_span + max_spacing_local = (2.0 * min(left_capacity, right_capacity)) / (len(center_projections) - 1) + if max_spacing_local <= 0: + continue + support_candidates.append( + { + "faceId": int(face_id), + "supportSpanLocal": projection_max - projection_min, + "supportProjectionMinLocal": projection_min, + "supportProjectionMaxLocal": projection_max, + "memberSpanLocal": member_span, + "patternCenterProjectionLocal": pattern_center_projection, + "leftCapacityLocal": left_capacity, + "rightCapacityLocal": right_capacity, + "maxSpacingLocal": max_spacing_local, + } + ) + if not support_candidates: + return {} + support_candidates.sort(key=lambda item: float(item["maxSpacingLocal"]), reverse=True) + selected = support_candidates[0] + safe_unit_scale = unit_scale if unit_scale > 0 else 1.0 + max_spacing_local = float(selected["maxSpacingLocal"]) + current_spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch"))) + selected.update( + { + "supportFaceIds": support_face_ids, + "instanceCount": len(center_projections), + "axis": axis, + "localUnitScale": safe_unit_scale, + "maxSpacing": max_spacing_local * safe_unit_scale, + "currentSpacing": current_spacing, + "currentSpacingLocal": (current_spacing / safe_unit_scale) if current_spacing is not None and safe_unit_scale > 0 else None, + } + ) + return selected + + +def _local_body_boxes(local_signatures: list[dict[str, object]]) -> dict[int, tuple[list[float], list[float]]]: + boxes: dict[int, tuple[list[float], list[float]]] = {} + for local in local_signatures: + body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId"))) + box = _box_from_local_signature(local) + if body_index is None or box is None: + continue + if body_index not in boxes: + boxes[body_index] = ([*box[0]], [*box[1]]) + continue + existing = boxes[body_index] + boxes[body_index] = ( + [min(existing[0][index], box[0][index]) for index in range(3)], + [max(existing[1][index], box[1][index]) for index in range(3)], + ) + return boxes + + +def _box_from_local_signature(local: Mapping[str, object]) -> tuple[list[float], list[float]] | None: + bbox_min = _rounded_vector(local.get("bboxMin")) + bbox_max = _rounded_vector(local.get("bboxMax")) + if len(bbox_min) != 3 or len(bbox_max) != 3: + return None + return bbox_min, bbox_max + + +def _merge_boxes(boxes: list[tuple[list[float], list[float]]]) -> tuple[list[float], list[float]]: + return ( + [min(box[0][index] for box in boxes) for index in range(3)], + [max(box[1][index] for box in boxes) for index in range(3)], + ) + + +def _box_center(box: tuple[list[float], list[float]]) -> list[float]: + return [(box[0][index] + box[1][index]) * 0.5 for index in range(3)] + + +def _point_projection(point: list[float], axis: list[float]) -> float: + return sum(float(point[index]) * float(axis[index]) for index in range(3)) + + +def _box_projection_range(box: tuple[list[float], list[float]], axis: list[float]) -> tuple[float, float]: + values = [] + for x in (box[0][0], box[1][0]): + for y in (box[0][1], box[1][1]): + for z in (box[0][2], box[1][2]): + values.append(_point_projection([x, y, z], axis)) + return (min(values), max(values)) if values else (0.0, 0.0) + + +def _box_projection_span(box: tuple[list[float], list[float]], axis: list[float]) -> float: + left, right = _box_projection_range(box, axis) + return max(0.0, right - left) + + +def _pattern_projection_overlap_score( + pattern_box: tuple[list[float], list[float]], + face_box: tuple[list[float], list[float]], + normal: list[float], +) -> float: + normal_axis = max(range(3), key=lambda index: abs(normal[index])) + overlaps = [] + for axis_index in range(3): + if axis_index == normal_axis: + continue + overlap = min(pattern_box[1][axis_index], face_box[1][axis_index]) - max(pattern_box[0][axis_index], face_box[0][axis_index]) + reference = max(pattern_box[1][axis_index] - pattern_box[0][axis_index], 1.0e-9) + overlaps.append(max(0.0, overlap) / reference) + if not overlaps or any(value <= 0 for value in overlaps): + return 0.0 + return sum(overlaps) / len(overlaps) + + +def _pattern_plane_touch_distance( + pattern_box: tuple[list[float], list[float]], + normal: list[float], + plane_offset: float, +) -> float: + distances = [] + for x in (pattern_box[0][0], pattern_box[1][0]): + for y in (pattern_box[0][1], pattern_box[1][1]): + for z in (pattern_box[0][2], pattern_box[1][2]): + distances.append(abs(x * normal[0] + y * normal[1] + z * normal[2] - plane_offset)) + return min(distances) if distances else float("inf") + + +def _box_area_estimate(box: tuple[list[float], list[float]]) -> float: + sizes = sorted(max(0.0, box[1][index] - box[0][index]) for index in range(3)) + return sizes[1] * sizes[2] + + def _match_local_face_signature( scdm_signature: Mapping[str, object], local_signatures: list[dict[str, object]], @@ -820,7 +1716,10 @@ def _capability_payload( definition = capability_definition(key) if definition is None: return None - block_reason = _missing_backend_command_reason(key, available_command_names) + block_reason = _first_non_empty( + _missing_backend_command_reason(key, available_command_names), + _missing_geometry_reason(key, raw_object), + ) return { "key": definition.key, "displayName": definition.display_name, @@ -847,12 +1746,128 @@ def _current_value(fields: tuple[str, ...], raw_object: Mapping[str, object]) -> return radius * 2.0 continue if field.startswith("geometry."): - key = field.split(".", 1)[1] - if key in geometry and geometry.get(key) is not None: - return geometry.get(key) + value = _path_value(geometry, field.split(".", 1)[1]) + if value is not None: + return value return None +def _missing_geometry_reason(key: str, raw_object: Mapping[str, object]) -> str: + if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing"}: + return "" + signature = geometry_signature(raw_object) + if key == "pattern.spacing": + spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch"))) + if spacing is None or spacing <= 0: + return "SCDM 已识别阵列对象,但没有返回可用于编辑的当前阵列间距。" + if ( + str(signature.get("patternKind") or "").strip().lower() == "body" + or str(signature.get("instanceKind") or "").strip().lower() in {"body", "part", "component"} + or _int_list(signature.get("bodyIndices")) + ): + axis = _rounded_vector(signature.get("axis")) + if len(axis) != 3: + return "SCDM 已识别实体/组件阵列间距,但没有返回线性阵列方向,暂不能稳定改间距。" + instances = _pattern_instances(signature.get("patternInstances")) + if len(instances) < 3: + return "SCDM 已识别实体/组件阵列间距,但没有返回阵列成员实例,暂不能稳定改间距。" + if any(not _component_locators(item.get("componentLocators") or item.get("bodyLocators")) for item in instances): + return "SCDM 已识别实体/组件阵列间距,但没有返回每个成员的组件实例定位,暂不能稳定改间距。" + return "" + axis = _rounded_vector(signature.get("axis")) + if len(axis) != 3: + return "SCDM 已识别阵列间距,但没有返回线性阵列方向,暂不能稳定改间距。" + instances = _pattern_instances(signature.get("patternInstances")) + if len(instances) < 3: + return "SCDM 已识别阵列间距,但没有返回至少三个可定位实例,暂不能稳定改间距。" + if any(not _pattern_instance_has_locator(item) for item in instances): + return "SCDM 已识别阵列间距,但没有返回每个阵列实例的可定位 Face,暂不能稳定改间距。" + return "" + if key == "slot.depth": + depth = _rounded_number(signature.get("depth")) + if depth is None or depth <= 0: + return "SCDM 已识别槽对象,但没有返回可用于编辑的当前槽深。" + if not _slot_depth_axis(signature): + return "SCDM 已识别槽深,但没有返回槽深方向,暂不能稳定改槽深。" + if not _has_depth_face_locator(signature): + return "SCDM 已识别槽深,但没有返回可推动的槽底面定位信息,暂不能稳定改槽深。" + return "" + if key == "round.radius": + if not _has_scdm_face_locator(signature): + return "SCDM 已识别圆角半径,但没有返回可定位的圆角面,暂不能稳定改半径。" + if signature.get("isConstantRound") is not True: + return "SCDM 已识别圆角半径,但没有返回等半径圆角证据,暂不能稳定改半径。" + return "" + if key == "chamfer.distance": + if not _has_scdm_face_locator(signature): + return "SCDM 已识别倒角距离,但没有返回可定位的倒角面,暂不能稳定改距离。" + distance = _rounded_number(signature.get("distance")) + if distance is None or distance <= 0: + return "SCDM 已识别倒角对象,但没有返回可用于编辑的当前倒角距离。" + if signature.get("isEqualDistanceChamfer") is not True: + return "SCDM 已识别倒角距离,但没有返回等距倒角证据,暂不能稳定改距离。" + return "" + if key == "boss.height": + if ( + _face_locators(signature.get("heightFaceLocators")) + or _int_list(signature.get("heightFaceOrdinals")) + or _int_list(signature.get("globalHeightFaceOrdinals")) + ): + return "" + return "SCDM 已识别凸台高度,但没有返回可推动的顶面定位信息,暂不能稳定改高度。" + if ( + _face_locators(signature.get("diameterFaceLocators")) + or _int_list(signature.get("diameterFaceOrdinals")) + or _int_list(signature.get("globalDiameterFaceOrdinals")) + ): + return "" + return "SCDM 已识别凸台直径,但没有返回可偏移的侧壁定位信息,暂不能稳定改直径。" + + +def _has_scdm_face_locator(signature: Mapping[str, object]) -> bool: + if _face_locators(signature.get("scdmFaceLocators")): + return True + if _int_list(signature.get("faceOrdinals")) or _int_list(signature.get("globalFaceOrdinals")): + return True + if _int_or_none(signature.get("faceOrdinal")) is not None: + return True + return _int_or_none(signature.get("globalFaceOrdinal")) is not None + + +def _has_depth_face_locator(signature: Mapping[str, object]) -> bool: + if _face_locators(signature.get("depthFaceLocators")): + return True + if _int_list(signature.get("depthFaceOrdinals")) or _int_list(signature.get("globalDepthFaceOrdinals")): + return True + return False + + +def _slot_depth_axis(signature: Mapping[str, object]) -> list[float]: + return _rounded_vector(signature.get("depthAxis")) + + +def _pattern_instance_has_locator(instance: Mapping[str, object]) -> bool: + if _component_locators(instance.get("componentLocators") or instance.get("bodyLocators")): + return True + if _body_locators(instance.get("bodyLocators")): + return True + if _int_or_none(instance.get("bodyIndex")) is not None and str(instance.get("instanceKind") or "").lower() in {"body", "part", "component"}: + return True + if _face_locators(instance.get("scdmFaceLocators")): + return True + if _int_list(instance.get("faceOrdinals")) or _int_list(instance.get("globalFaceOrdinals")): + return True + return False + + +def _first_non_empty(*values: str) -> str: + for value in values: + text = str(value or "").strip() + if text: + return text + return "" + + def _available_command_names(value: object) -> set[str] | None: if not isinstance(value, list): return None @@ -899,12 +1914,16 @@ def _object_id(raw_object: Mapping[str, object]) -> str: def _locator_from_raw_object(raw_object: Mapping[str, object]) -> dict[str, object]: geometry = _mapping(raw_object.get("geometry")) topology = _mapping(raw_object.get("topologyHint")) - return { + locator = { "backendId": str(raw_object.get("backendId") or ""), "bodyIndex": _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))), "faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))), "globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))), } + component_locators = _component_locators(topology.get("componentLocators") or geometry.get("componentLocators")) + if component_locators: + locator["componentLocators"] = component_locators + return locator def _face_locators(value: object) -> list[dict[str, object]]: @@ -925,6 +1944,73 @@ def _face_locators(value: object) -> list[dict[str, object]]: return result +def _body_locators(value: object) -> list[dict[str, object]]: + if not isinstance(value, (list, tuple)): + return [] + result: list[dict[str, object]] = [] + seen: set[tuple[object, ...]] = set() + for item in value: + if not isinstance(item, Mapping): + continue + locator = { + "backendId": str(item.get("backendId") or ""), + "bodyIndex": _int_or_none(item.get("bodyIndex")), + "componentIndex": _int_or_none(item.get("componentIndex")), + "componentPath": _ordered_int_list(item.get("componentPath")), + "componentBodyIndex": _int_or_none(item.get("componentBodyIndex")), + "componentName": str(item.get("componentName") or ""), + } + if locator.get("bodyIndex") is not None or locator.get("componentIndex") is not None or locator.get("componentPath"): + key = ( + locator.get("bodyIndex"), + locator.get("componentIndex"), + tuple(locator.get("componentPath") or []), + locator.get("componentBodyIndex"), + ) + if key in seen: + continue + seen.add(key) + result.append(locator) + return result + + +def _component_locators(value: object) -> list[dict[str, object]]: + if not isinstance(value, (list, tuple)): + return [] + result: list[dict[str, object]] = [] + seen: set[tuple[object, ...]] = set() + for item in value: + if not isinstance(item, Mapping): + continue + path = _ordered_int_list(item.get("componentPath")) + locator = { + "backendId": str(item.get("backendId") or ""), + "componentIndex": _int_or_none(item.get("componentIndex")), + "componentPath": path, + "componentBodyIndex": _int_or_none(item.get("componentBodyIndex")), + "bodyIndex": _int_or_none(item.get("bodyIndex")), + "componentName": str(item.get("componentName") or ""), + "contentMoniker": str(item.get("contentMoniker") or ""), + "templateMoniker": str(item.get("templateMoniker") or ""), + "placementTranslation": _rounded_vector(item.get("placementTranslation")), + } + if locator.get("componentIndex") is None and not path: + continue + key = ( + locator.get("componentIndex"), + tuple(path), + locator.get("componentBodyIndex"), + locator.get("bodyIndex"), + locator.get("contentMoniker"), + locator.get("templateMoniker"), + ) + if key in seen: + continue + seen.add(key) + result.append(locator) + return result + + def _first_int(values: Iterable[object]) -> int | None: for value in values: number = _int_or_none(value) @@ -944,6 +2030,17 @@ def _mapping(value: object) -> Mapping[str, object]: return value if isinstance(value, Mapping) else {} +def _path_value(mapping: Mapping[str, object], path: str) -> object: + current: object = mapping + for part in path.split("."): + if not isinstance(current, Mapping): + return None + current = current.get(part) + if current is None: + return None + return current + + def _int_list(value: object) -> list[int]: if isinstance(value, (str, bytes)) or value is None: return [] @@ -960,6 +2057,22 @@ def _int_list(value: object) -> list[int]: return sorted(set(result)) +def _ordered_int_list(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 _int_or_none(value: object) -> int | None: try: return int(value) @@ -967,6 +2080,20 @@ def _int_or_none(value: object) -> int | None: return None +def _bool_or_none(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"true", "1", "yes", "y"}: + return True + if text in {"false", "0", "no", "n"}: + return False + if isinstance(value, (int, float)): + return bool(value) + return None + + def _first_present(*values: object) -> object: for value in values: if value is not None and value != "": @@ -1102,6 +2229,40 @@ def _rounded_vector_list(value: object) -> list[list[float]]: return result +def _pattern_instances(value: object) -> list[dict[str, object]]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + result: list[dict[str, object]] = [] + for item in values: + if not isinstance(item, Mapping): + continue + center = _rounded_vector(item.get("center") or item.get("instanceCenter")) + if len(center) != 3: + continue + result.append( + { + "sourceObjectId": str(item.get("sourceObjectId") or item.get("objectId") or ""), + "instanceKind": str(item.get("instanceKind") or ""), + "center": center, + "bodyIndex": _int_or_none(item.get("bodyIndex")), + "bodyLocators": _body_locators(item.get("bodyLocators")), + "componentLocators": _component_locators(item.get("componentLocators") or item.get("bodyLocators")), + "faceIds": _int_list(item.get("faceIds")), + "faceOrdinals": _int_list(item.get("faceOrdinals") or [item.get("faceOrdinal")]), + "globalFaceOrdinals": _int_list( + item.get("globalFaceOrdinals") + or [item.get("globalFaceOrdinal")] + ), + "scdmFaceLocators": _face_locators(item.get("scdmFaceLocators") or item.get("faceLocators")), + } + ) + return result + + def _string_list(value: object) -> list[str]: if isinstance(value, (str, bytes)) or value is None: return [] @@ -1134,4 +2295,5 @@ __all__ = [ "geometry_signature", "map_scdm_raw_features", "map_scdm_raw_features_file", + "SCDM_FEATURE_CACHE_REVISION", ] diff --git a/step_editor/scdm_probe.py b/step_editor/scdm_probe.py index 80c1ae1..7c71711 100644 --- a/step_editor/scdm_probe.py +++ b/step_editor/scdm_probe.py @@ -241,9 +241,18 @@ def generate_scdm_probe_script(job_path: str | Path) -> str: " geometry['surfaceType'] = 'plane'\n" " elif 'cylinder' in lowered:\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" " if 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" "\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" " return payload\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" " current = value\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" " if object_type == 'hole' and surface_type == 'cylinder':\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" " 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': '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" "\n" "def _open_step(path):\n" @@ -503,6 +618,167 @@ def generate_scdm_probe_script(job_path: str | Path) -> str: " except Exception:\n" " return value\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" " for name in ('Faces', 'GetFaces'):\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" "\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" " for name in names:\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" " root = _root_part()\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" " objects = []\n" " face_adjacency = {}\n" @@ -620,24 +898,37 @@ def generate_scdm_probe_script(job_path: str | Path) -> str: " edge_counter = 0\n" " for body_index, body in enumerate(bodies):\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" " for face_index, face in enumerate(body_faces):\n" " geometry = _geometry_from_face(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" " 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" " 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n" " 'objectType': object_type,\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" " 'rawLimitations': [],\n" " })\n" " face_counter += 1\n" " for edge_index, edge in enumerate(_body_edges(body)):\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" " _add_edge_geometry_summary(edge_geometry_summary, 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" " 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n" " 'featureInventory': _feature_inventory(objects),\n" + " 'componentInstances': _component_inventory(component_entries),\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" " _write_json(raw_path, payload)\n" " except Exception as exc:\n" diff --git a/step_editor/scdm_property_specs.py b/step_editor/scdm_property_specs.py index 193fa6f..b02c7c5 100644 --- a/step_editor/scdm_property_specs.py +++ b/step_editor/scdm_property_specs.py @@ -27,9 +27,12 @@ def property_specs_from_scdm_cache( continue for capability in capabilities: if isinstance(capability, Mapping): + if str(capability.get("key") or "") == "pattern.segment_spacing": + continue spec = _capability_spec(item, capability, execution_ready=execution_ready) if spec is not None: specs.append(spec) + specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready)) return specs @@ -38,6 +41,7 @@ def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edg if not isinstance(signature, Mapping): return False object_faces = set(_int_values(signature.get("faceIds"))) + object_faces.update(_int_values(signature.get("supportFaceIds"))) object_edges = set(_int_values(signature.get("edgeIds"))) return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids)) @@ -55,14 +59,23 @@ def _capability_spec( value_kind = str(capability.get("valueKind") or "number") current = capability.get("currentValue") 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" - current_text = "可执行" if command_value else _format_value(current, value_type=value_type) - target_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_display, value_type=value_type) capability_block = str(capability.get("blockReason") or "").strip() object_block = str(raw_object.get("blockReason") or "").strip() 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 "") 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) if can_execute: disabled_tip = "" @@ -79,7 +92,9 @@ def _capability_spec( return { "key": f"scdm:{key}", "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, "target_text": target_text, "editable": True, @@ -90,24 +105,443 @@ def _capability_spec( "value_type": value_type, "enabled_tip": enabled_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_exclusive": True if value_type == "positive" else False, + "max_value": max_value, "scdm_object_id": raw_object.get("objectId"), "scdm_source_backend_id": raw_object.get("sourceBackendId"), "scdm_capability_key": key, "scdm_backend_operation": backend_operation, "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: if value_kind == "vector3": return "vector3" if value_kind == "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 "number" @@ -132,6 +566,19 @@ def _format_value(value: object, *, value_type: str) -> str: 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]: if isinstance(value, (str, bytes)) or value is None: return [] @@ -164,4 +611,11 @@ def _int_values(value: object) -> list[int]: 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"] diff --git a/step_editor/scdm_result_validator.py b/step_editor/scdm_result_validator.py index 5bc0b90..5e77771 100644 --- a/step_editor/scdm_result_validator.py +++ b/step_editor/scdm_result_validator.py @@ -79,10 +79,75 @@ def validate_scdm_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 + 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: match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key) status = str(match.get("status") or "") + if _is_removal_capability(capability_key): + 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": return { "ok": False, @@ -118,6 +183,7 @@ def validate_scdm_edit_result( "output_step": str(output_step), "matchedObject": matched, "targetCheck": check, + "removalCheck": removal_check, "summaryCheck": summary_check, "topologyCheck": topology_check, "brep": brep, @@ -251,6 +317,8 @@ def check_scdm_unedited_objects( continue if edited_signature and _same_signature_subject(signature, edited_signature): continue + if edited_signature and _is_expected_pattern_spacing_subject(signature, edited_signature, capability_key=capability_key): + continue checked += 1 match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key) 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")) expected_vector = _vector(expected_target) 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": actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "offset") or _geometry_value(raw_object, "planeOffset")) expected = _number(expected_target) return _number_check(actual, expected, "face.offset", tolerance) - if capability_key == "feature.fill": - return {"ok": True, "reason": "not-applicable", "message": "feature.fill is checked by object disappearance in the caller."} + if _is_removal_capability(capability_key): + 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}."} +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( before_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: 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"))) after_faces = set(_int_values(after.get("faceIds"))) 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: score += 0.5 - if capability_key != "hole.position": - center_score = _vector_distance_score(_vector(before.get("center")), _vector(after.get("center"))) + if not _is_position_capability(capability_key): + 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 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 +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: if _vector(signature.get("center")) and _vector(signature.get("axis")): 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: return False - before_offset = _number(before.get("planeOffset")) - after_offset = _number(after.get("planeOffset")) - if before_offset is not None and after_offset is not None: - tolerance = max(abs(before_offset), abs(after_offset), 1.0) * 1.0e-5 - if abs(float(before_offset) - float(after_offset)) > tolerance: - return False + for key in ("planeOffset", "width", "depth", "height", "distance", "spacing", "pitch", "thickness"): + before_value = _number(before.get(key)) + after_value = _number(after.get(key)) + if before_value is not None and after_value is not None: + tolerance = max(abs(before_value), abs(after_value), 1.0) * 1.0e-5 + if abs(float(before_value) - float(after_value)) > tolerance: + return False return True @@ -645,6 +905,22 @@ def _int_values(value: object) -> list[int]: 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]: diagnostics = cache.get("diagnostics") if not isinstance(diagnostics, Mapping): diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 0b2f5a8..fa8be1e 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -1105,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 SELECTION_MODE_LABELS = { - "Part": "零件", + "Part": "Part", "Solid": "Solid", "Face": "Face", "Edge": "Edge", - "Feature": "特征", + "Feature": "Feature", } 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 = { diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 446c7b2..480b071 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -21,9 +21,9 @@ from PySide6.QtWidgets import ( from .model import StepModel from .asitus_bridge import run_asitus_hole_recognition 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_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 .workers import EditWorker, LoadWorker, ScanWorker @@ -1308,11 +1308,13 @@ class WindowCoreMixin: f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。" ) 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() else: 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: if stats is not None: @@ -1354,9 +1356,84 @@ class WindowCoreMixin: if hasattr(self, "_update_current_capability_panel"): self._update_current_capability_panel() + def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> None: + 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: if self.model is None or self.step_path is None: return + if not force and self._current_scdm_feature_cache_matches_loaded_step(): + return if not force and self._large_model_interaction_mode(): self._defer_large_model_recognition_preloads() return @@ -1476,21 +1553,14 @@ class WindowCoreMixin: return if isinstance(result.get("backend"), dict): self.scdm_backend_status = dict(result["backend"]) - self.scdm_feature_cache = dict(cache) - self.scdm_feature_cache_state = "ready" - self.scdm_feature_cache_message = "SCDM 可修改参数识别完成。" - self.scdm_feature_cache_path = str(result.get("cache_path") or "") - try: - self.model.scdm_feature_cache = dict(cache) - except Exception: - pass + self._install_scdm_feature_cache( + dict(cache), + cache_path=str(result.get("cache_path") or ""), + message="SCDM 可修改参数识别完成。", + ) objects = cache.get("objects") count = len(objects) if isinstance(objects, list) else 0 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"): self._finish_pending_scdm_edit_reload(cache_ready=True) finally: diff --git a/step_editor/window_state.py b/step_editor/window_state.py index a9809cf..af6e25c 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -42,6 +42,7 @@ from .relation_formulas import ( parse_relation_formula, relation_value_to_text, rewrite_relation_formula_ids, + validate_relation_formula_graph, ) from .scdm_backend import resolve_scdm_backend 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 "" try: formula = parse_relation_formula(text) + self._validate_relation_formula_graph(formula) if not replay_active: self._validate_relation_formula_references(formula) except RelationFormulaError as exc: @@ -3453,6 +3455,18 @@ class WindowStateMixin: for ref in formula.references: 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: if target and self._relation_visible_spec_for_ref(ref) is not None: return @@ -4565,7 +4579,7 @@ class WindowStateMixin: return inner_wires > 0 or boundary_edges >= 16 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 "" if self.model is None: return "" @@ -8915,8 +8929,12 @@ class WindowStateMixin: def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object: 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": - 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"}: try: value = float(text) @@ -8924,11 +8942,32 @@ class WindowStateMixin: raise ValueError("请输入数字形式的目标值。") from exc if value_type == "positive" and value <= 0: raise ValueError("请输入大于 0 的目标值。") - return value + return value * unit_scale if self._scdm_property_uses_length_units(spec) else value if value_type == "command": return True 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) 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)):