diff --git a/.gitignore b/.gitignore index 6786ffe..197d9d9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,13 +26,20 @@ assets/screenshots/ local/ tmp.md +tmp_scdm* data.json nodes/ Analysis-Component/ occt_feature_editor/ third_party/ +# Local SCDM experiment outputs; keep committed sample models explicit. +assets/models/*_scdm_probe_*.stp +assets/models/*_scdm_*_inner_*.stp +assets/models/*_scdm_*_outer_*.stp + # Local reference docs; keep them on disk, never commit them. +概念.md Face一级关系专项测试说明.md Creo软件具备哪些建模形式.md 中文版Creo+4.0从入门到精通.pdf diff --git a/README.md b/README.md index 56359af..5f3604f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 +- 后续过渡到 `SCDM-first` 后端:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚和结果映射。 - 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 @@ -47,6 +48,21 @@ python-occt │ ├── asitus_bridge.py │ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号 │ │ +│ ├── scdm_backend.py +│ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测 +│ │ +│ ├── scdm_probe.py +│ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定 +│ │ +│ ├── scdm_edit_runner.py +│ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json +│ │ +│ ├── scdm_result_validator.py +│ │ └── SCDM 结果校验和 ID 续接工具,负责输出文件检查、目标值回测、旧对象到新对象的唯一匹配和关系式 ID 重写 +│ │ +│ ├── scdm_schema.py / scdm_capabilities.py / scdm_feature_mapper.py / scdm_property_specs.py +│ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI +│ │ │ ├── operations.py │ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角 │ │ @@ -81,6 +97,110 @@ python-occt └── 仓库自带测试 STEP 模型 ``` +## SCDM 后端过渡路线 + +本阶段目标:把 SpaceClaim/SCDM 接成优先几何后端。SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责显示、选择、能力映射、任务生成、异步执行、结果校验、回滚、ID 续接和参数导出。SCDM 不接管本软件 UI,也不把 SCDM 原始界面文字直接展示给客户。 + +交付判断不是“调用了 SCDM”,而是下面这条闭环能稳定跑通: + +```text +导入 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 +-> 用新缓存更新 Face / Edge / 特征 ID 映射和关系式引用 +``` + +核心实现拆分如下: + +```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 降级模式 +│ +├── 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,仍需继续拿更多样例适配 +│ +├── 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 +│ +├── 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 运行耗时和失败提示待样例机验证 +│ +├── 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 长流程回归待继续 +│ +├── 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 失效已接入,真实长流程回归待继续 +│ +├── 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 +│ +├── 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 修改闭环 +``` + +短期开发只做第一批能力,不追求把 SCDM 识别到的所有候选都立即开放。`scdm_raw_features.json` 保留 SCDM 发现的全部结构化候选,供后续扩展;`scdm_feature_cache.json` 只保存已经映射到能力字典、能执行、能校验、能解释失败原因的产品化能力。`geometry_candidate_hints` 只表示“有几何证据值得继续分类”,不能当作可编辑参数展示给客户。`软件进度` 弹窗只展示客户关心的能力边界:哪些能识别、哪些不能稳定识别、哪些能修改、哪些暂不能修改;cache 数量、probe 证据、Runner 开放数等后台细节留在日志和开发诊断里。这样既能利用 SCDM 的专业识别和直接建模能力,也不会把未验证的内部候选暴露给客户。 + ## Analysis Situs 接入状态 一句话状态:还没有全量接完;当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”这一步,能把 Analysis Situs 识别出的拆面圆柱孔组用于整孔高亮、参数表和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py` 的 `external_*` 关系证据;但还没有把它的完整 AAG/特征分析能力接成通用识别引擎。 @@ -186,7 +306,7 @@ python scripts\verify_first_level_edit_suites.py python scripts\verify_first_level_edit_suites.py --quick ``` -`--quick` 会检查 smoke test、属性表规格、参数表 UI、参数导出组件生成、ICEPAK 真实 STEP 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 +`--quick` 会检查 smoke test、属性表规格、参数表 UI、参数导出组件生成、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 只验证某个阶段时,例如 Edge: @@ -554,9 +674,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 当前已经实现: -- 默认加载 `assets/models/geom_extract.step`。 +- 启动后默认进入空场景,不自动读取 `assets/models/geom_extract.step`;用户点击“导入几何模型”后才加载模型,避免客户打开软件时被大 STEP 阻塞。 - 支持打开其他 `.step` / `.stp` 文件。 -- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。 +- 打开 STEP 时会在 `LoadWorker` 后台线程里完成读取和首屏显示网格生成;失败时当前模型保持不变。大模型不会再把 STEP 读取、B-Rep 网格生成和 VTK polydata 构建直接压在 UI 主线程上,并且默认只先显示面片,几千条 Edge 边线会在切到 Edge 选择或显式请求时再后台生成。 - 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名。 - 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 - 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 @@ -570,8 +690,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - Edge - 特征 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 -- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 +- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。 - 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。 +- SCDM 识别 cache 的本地 Face 映射现在使用轻量几何签名,只读取曲面类型、平面法向/偏移、圆柱轴线/半径等必要信息;不再为每个 Face 调完整 `quick_face_info()`,且签名生成放在后台 worker 内执行,不会在启动 SCDM probe 前卡住 UI。在 `geom_extract.step` 上,这一步从约 83 秒降到约 0.06 秒。 - 拉伸/切除后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回拉伸/切除前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 @@ -789,7 +910,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 - 首次加载默认使用中等清晰度显示网格,避免长期停留在粗网格预览;B-Rep 编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。 - 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。 -- 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。 +- 鼠标悬停高亮做了节流和移动阈值;大模型会直接关闭悬停拾取高亮,避免鼠标路过复杂面片时触发 VTK CellPicker 卡顿。 - 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。 - 可编辑对象和圆柱面候选扫描已经在 `StepModel` 层做参数化缓存;同一模型、同一扫描范围重复打开候选列表时会直接复用结果,编辑、撤销/重做或重新加载模型后缓存会随拓扑刷新清空。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 @@ -884,6 +1005,8 @@ vertices: 3262 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算和一级验收文档一致性。 +- 2026-08-18,新增 `python scripts\verify_scdm_backend.py`、`python scripts\verify_scdm_status.py`、`python scripts\verify_scdm_probe_pipeline.py`、`python scripts\verify_scdm_edit_runner.py` 和 `python scripts\verify_scdm_result_validator.py`,覆盖 SCDM 后端发现、缓存、禁用开关、/RunScript 命令生成、左侧状态摘要、probe job/script 生成、raw->cache 映射、edit job/script/result/error 执行协议,以及结果文件检查、目标值回测、ID 映射和关系式重写;已接入 `--quick`。 +- 2026-08-18,本机 `D:\softwaresInstallDir\ANSYS Inc\v222\SCDM\SpaceClaim.exe` 已通过真实 `/RunScript` smoke;对 `assets/models/ICEPAK-NATURAL.stp` 的真实 probe 可打开模型并输出 13 个 Body、158 个 Face、396 条 Edge,396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。 Face 阶段的当前验收口径(R1 已收口): @@ -1361,6 +1484,11 @@ pythonocc-step-editor/ verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数 verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json + verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成 + verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明 + verify_scdm_probe_pipeline.py # 验证 SCDM probe job/script 生成和 raw 识别结果到产品化 cache 的映射 + verify_scdm_edit_runner.py # 验证 SCDM edit job/script/result/error 执行协议 + verify_scdm_result_validator.py # 验证 SCDM 结果文件检查、目标值回测、全局摘要漂移、ID 映射和关系式重写 verify_asitus_hole_bridge.py # 验证 Analysis Situs 孔组识别结果会映射回 Python Face 编号和 UI 整孔选择 verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 @@ -1477,7 +1605,12 @@ 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、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` 能力。 +- `scripts/verify_scdm_edit_runner.py`:纯 Python 检查 SCDM edit job、临时修改脚本、成功 result.step/result.json、失败 error.json 和缺输出回滚判定;不依赖 OCC,也不需要真实 SCDM。 +- `scripts/verify_scdm_result_validator.py`:纯 Python 检查 SCDM 修改结果的输出 STEP 存在性、目标值回测、raw summary 全局规模漂移、旧对象到新对象的唯一匹配、Face ID 映射、关系式 ID 重写和歧义匹配拒绝。 - `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。 - `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。 - `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 @@ -1521,9 +1654,9 @@ git diff --check - `scripts/verify_asitus_hole_bridge.py`:验证 Analysis Situs `recognize_holes` 输出的 1-based AAG Face 组会先映射成 Python/Qt 里的 Face 编号,再作为整孔区域参与高亮、参数表和一级关系计划;本地 C++ CLI 不存在时只验证解析和映射逻辑。 - `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 - `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。 -- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 +- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划不会退回通用慢路径,避免按钮点击阶段长时间卡住主界面。 - `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示面内长度、面内宽度、中心和偏移,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 编辑;同时验证面积不会作为特征参数表驱动变量出现,目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的额外 `移动量` 行。 -- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表。 +- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表;同时守住普通 OCCT 操作历史里的 backend / execution / recognition 来源记录。 - `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表;`关联探测` 本身保留为诊断信息,不再混入参数表。同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。 - `scripts/verify_shell_edit_suite.py`:集中运行壳体厚度阶段验证,串起壳体厚度局部拉伸/切除、缩放特征、Face 一级事实图、结果校验和属性表分组。 - `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。脚本还会生成开口薄壁盒,验证完整抽壳/开口面编辑会被列为受限能力,而局部壳体厚度仍保留可用计划。 @@ -1639,6 +1772,15 @@ Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过 - 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。 - 工作区可能有用户改动,不能随意 `git checkout --`、`git reset --hard` 或回滚不相关文件。 +### 2026-08-18 geom_extract.step 性能记录 + +- `ICEPAK-NATURAL.stp` 速度快不是因为有原 CAD 建模历史;它仍是 STEP/B-Rep 结果几何。`geom_extract.step` 慢的主要原因是模型规模更大,约 1800 个 Face、近 5000 条 Edge,且 Face594 是带多个内孔的大平面端盖,一级边界和相邻面更多。 +- 大模型点选阶段必须保持轻量:当前 Face / Feature 选择只生成快速一级边界摘要,完整一级关系事实图放到编辑计划或后台任务中计算,避免点一个 Face 就同步深扫全模型。圆柱面 quick 选择只合并直接相接的同圆柱碎面,不触发 Analysis Situs 或内部孔组识别图;超过 1000 个 Face 的模型默认不在 UI 选择路径同步构建内部全模型识别图。即使用户把“特征探测级别”切到相邻/二级,大模型点击选择也会先按“只识别当前特征”处理,深层识别必须走显式扫描或后台任务。 +- Face594 `偏移 57.5 -> 70` 的基线:`face_info` 从约 10.5s 降到约 0.9s,`push_pull_plan` 从约 9.5s 降到约 1.5s,内存 `push_pull_face` 约 3.5s-5.5s;即使 SCDM cache 识别出 `face.offset`,这类大模型多内孔平面位置调整也会优先使用本软件已验证的本地 OCCT 一级边界重建,避免退回 100s 级别的外部后端/导出/启动/回读慢路径。2026-08-18 复测:Face594 核心 UI job 约 9.1s,其中快照/统计约 2.0s、几何计算约 3.7s、校验约 2.2s、面显示约 1.2s;独立后台几何进程单次约 12.6s,但旋转查看更不容易被 Python/OCCT worker 抢占。 +- 大模型编辑后的逻辑 Face ID 保持必须走结果消息快路径:如果子进程返回 `nearest_face` / `verified_face` 且该 Face 通过目标位置回测,直接把旧逻辑 ID 绑定到这个结果 Face;不要再同步扫描全模型寻找同平面候选。2026-08-18 发现 Face594 isolated UI job 曾有约 75s 花在结果 Face 映射兜底扫描,修复后同一路径总耗时约 11.8s,`result_face_mapping` 约 0.013s。 +- 大模型显示阶段:先后台读取主面片,默认跳过全量 Edge overlay;超过 1000 个 Face 或 2500 条 Edge 的模型首屏和编辑后显示会使用交互网格预算,`geom_extract.step` 主显示三角面从约 11 万降到约 4.7 万,旋转和点选优先流畅,局部几何校验仍使用 OCCT B-Rep 而不是显示网格。编辑完成后的操作历史定位优先读取结果消息中的 `verified_face` / `nearest_face`,fallback 只做轻量平面位置查询,不再在 UI 主线程逐个 Face 调完整 `face_info()`;Face594 定位从 120s 级超时降到 0s 级。普通导入大模型后会延后 SCDM/Analysis Situs 全模型预热,优先保证旋转和点选;SCDM 修改结果回读校验仍会强制刷新 cache。旋转相机时仍会临时隐藏已有边线 overlay,停止后恢复,保证画质不降的同时减少交互卡顿。 +- 大模型交互阶段不再把所有 UI 辅助工作绑到点选上:关系式补全只在公式输入框获得焦点或正在输入时刷新,避免每次点 Face 都重建数千个 Face/Edge 候选;左键按下会记录按下位置的对象,背景开始拖动/旋转后即使松手落在模型上也不会误选;旋转期间会临时隐藏选择高亮和拾取点覆盖层,松手后恢复,减少透明 overlay 对帧率的影响。 + ### 2026-08-03 工程师返还代码合并记录 - 已合并特征参数策略:Feature 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。 diff --git a/scripts/verify_first_level_edit_suites.py b/scripts/verify_first_level_edit_suites.py index 7b57b96..d794fda 100644 --- a/scripts/verify_first_level_edit_suites.py +++ b/scripts/verify_first_level_edit_suites.py @@ -71,6 +71,11 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("Property editor specs", ("verify_property_editor_specs.py",)), ("Property table editor UI", ("verify_property_card_editor_ui.py",)), ("Parametric component export", ("verify_parametric_component_export.py",)), + ("SCDM backend discovery", ("verify_scdm_backend.py",)), + ("SCDM runtime status", ("verify_scdm_status.py",)), + ("SCDM probe pipeline", ("verify_scdm_probe_pipeline.py",)), + ("SCDM edit runner", ("verify_scdm_edit_runner.py",)), + ("SCDM result validator", ("verify_scdm_result_validator.py",)), ("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)), ("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)), ("Feature recognition priority", ("verify_feature_recognition_summary.py",)), diff --git a/scripts/verify_large_stepped_cap_push_pull.py b/scripts/verify_large_stepped_cap_push_pull.py index 46cb3fd..d38e615 100644 --- a/scripts/verify_large_stepped_cap_push_pull.py +++ b/scripts/verify_large_stepped_cap_push_pull.py @@ -19,13 +19,19 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.model import StepModel +from step_editor.records import OperationRecord from step_editor.window_actions import WindowActionMixin +from step_editor.window_state import WindowStateMixin class _WindowActionProbe(WindowActionMixin): pass +class _WindowStateProbe(WindowStateMixin): + pass + + def _wire_count(face) -> int: count = 0 explorer = TopExp_Explorer(face, TopAbs_WIRE) @@ -145,6 +151,9 @@ def main() -> int: raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}") if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild": raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}") + stepped_isolation = _WindowActionProbe()._isolation_for_plan(plan, "push_pull_face", [face_id, 89.0]) + if not stepped_isolation or stepped_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit": + raise SystemExit(f"large stepped cap should use the smooth UI background process: {stepped_isolation}") started = time.perf_counter() result = model.push_pull_face(face_id, 89.0) @@ -233,7 +242,13 @@ def main() -> int: multi_face_id = _large_multi_boundary_cap_face(multi_model) multi_logical_id = multi_model.face_region_logical_id(multi_face_id) multi_before_topology = _face_topology_counts(multi_model, multi_face_id) + started = time.perf_counter() multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5) + multi_plan_elapsed = time.perf_counter() - started + if multi_plan_elapsed > 5.0: + raise SystemExit( + f"multi-boundary cap push/pull plan should be quick: {multi_plan_elapsed:.3f}s; plan={multi_plan}" + ) if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9: raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}") if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9: @@ -242,6 +257,22 @@ def main() -> int: raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}") if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild": raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}") + multi_isolation = _WindowActionProbe()._isolation_for_plan(multi_plan, "push_pull_face", [multi_face_id, 34.5]) + if not multi_isolation or multi_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit": + raise SystemExit(f"multi-boundary cap should use the smooth UI background process: {multi_isolation}") + ui_probe = _WindowActionProbe() + ui_probe.model = multi_model + ui_probe.current_info_values = multi_model.quick_face_info(multi_face_id) + ui_plan = ui_probe._push_pull_plan_for_action(multi_face_id, 34.5) + if bool(ui_plan.get("ui_deferred_model_plan")): + raise SystemExit(f"large multi-boundary UI plan should use the fast local plan now: {ui_plan}") + if ui_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild": + raise SystemExit(f"large multi-boundary UI plan should use boundary-shell rebuild: {ui_plan}") + ui_isolation = ui_probe._isolation_for_plan(ui_plan, "push_pull_face", [multi_face_id, 34.5]) + if not ui_isolation or ui_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit": + raise SystemExit(f"large multi-boundary UI plan should use the smooth UI background process: {ui_isolation}") + if ui_probe._edit_preflight_blocker({"parameters": ui_plan}) is not None: + raise SystemExit(f"large multi-boundary UI plan should not be blocked before editing: {ui_plan}") started = time.perf_counter() multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0) multi_inward_elapsed = time.perf_counter() - started @@ -346,6 +377,53 @@ def main() -> int: multi_after_topology, min_inner_wires=5, ) + locator_probe = _WindowStateProbe() + locator_probe.model = multi_model + locator_record = OperationRecord( + summary="test", + detail="test", + operation_name="拉伸/切除平面", + target=f"Face {multi_face_id}", + parameters={ + "part_id": multi_plan.get("part_id"), + "solid_id": multi_plan.get("solid_id"), + "surface": "plane", + "outward_direction": multi_plan.get("outward_direction") or multi_plan.get("plane_direction"), + "target_plane_position": multi_plan.get("target_plane_position"), + "bbox_diagonal": multi_plan.get("bbox_diagonal"), + }, + result_message=multi_result, + target_kind="face", + target_id=multi_face_id, + target_logical_id=multi_logical_id, + ) + started = time.perf_counter() + resolved_after_edit = locator_probe._resolve_record_face_id(locator_record) + locator_elapsed = time.perf_counter() - started + if resolved_after_edit != multi_retained_ids[0] or locator_elapsed > 0.5: + raise SystemExit( + f"large multi-boundary operation history locator should use the fast result Face: " + f"resolved={resolved_after_edit}, expected={multi_retained_ids[0]}, elapsed={locator_elapsed:.3f}s" + ) + no_hint_record = OperationRecord( + summary="test", + detail="test", + operation_name="拉伸/切除平面", + target=f"Face {multi_face_id}", + parameters=locator_record.parameters, + result_message="Planar face push/pull completed without result face hint.", + target_kind="face", + target_id=multi_face_id, + target_logical_id=multi_logical_id, + ) + started = time.perf_counter() + fallback_after_edit = locator_probe._record_plane_position_face_id(no_hint_record) + fallback_elapsed = time.perf_counter() - started + if fallback_after_edit != multi_retained_ids[0] or fallback_elapsed > 1.0: + raise SystemExit( + f"large multi-boundary fallback locator should use lightweight plane positions: " + f"resolved={fallback_after_edit}, expected={multi_retained_ids[0]}, elapsed={fallback_elapsed:.3f}s" + ) with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir: temp_root = Path(temp_dir) @@ -442,7 +520,7 @@ def main() -> int: ) print( "large multi-boundary cap push/pull ok: " - f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, " + f"face_id={multi_face_id}, plan_elapsed={multi_plan_elapsed:.3f}s, elapsed={multi_elapsed:.3f}s, " f"isolated_elapsed={multi_isolated_elapsed:.3f}s, " f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}" ) diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 3e2d5e0..9131a0f 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -4,8 +4,10 @@ import json import math import os from pathlib import Path +import re import sys import tempfile +from types import SimpleNamespace os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -80,11 +82,17 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.step_path = PROJECT_ROOT / "assets" / "models" / "probe.step" self.current_info_values = self._plane_info() self.executed_property_actions: list[tuple[str, str]] = [] + self.scdm_backend_status = None + 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"} layout = QVBoxLayout(self) self.object_edit_box = self self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS)) self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS)) + self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state()) layout.addWidget(self.property_table) self.property_card_scroll = QScrollArea() @@ -100,7 +108,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.property_command_bar = QFrame() self.property_command_layout = QHBoxLayout(self.property_command_bar) self.property_command_help_label = QLabel() - self.current_capability_headline = QLabel() + self.current_capability_button = QPushButton() + self.scdm_backend_status_label = QLabel() + self.scdm_backend_detail_label = QLabel() self.apply_property_button = QPushButton() self.export_parameters_button = QPushButton() self.relation_formula_input = QLineEdit() @@ -160,6 +170,15 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.executed_property_actions.append(("resize_face_height_local", self.face_height_input.text())) self._after_property_edit_finished(success=True) + def apply_scdm_property_edit(self, spec: dict[str, object] | None = None, target_text: str | None = None) -> None: + self.executed_property_actions.append( + ( + "apply_scdm_property_edit", + f"{(spec or {}).get('scdm_capability_key', '')}:{target_text or ''}", + ) + ) + self._after_property_edit_finished(success=True) + def statusBar(self) -> _StatusBar: return _StatusBar() @@ -168,6 +187,26 @@ class _RelationFormulaEventProbe(WindowCoreMixin, _PropertyTableProbe): pass +class _ScdmAutoPromptProbe(_PropertyTableProbe): + def __init__(self) -> None: + super().__init__() + self.prompt_calls: list[tuple[bool, str, str]] = [] + + def configure_scdm_backend(self, *, automatic: bool = False, reason: str = "", message: str = "") -> bool: + self.prompt_calls.append((bool(automatic), str(reason), str(message))) + return False + + +class _PropertyUiRerouteProbe(_PropertyTableProbe): + def __init__(self) -> None: + super().__init__() + self.rerouted_callbacks = 0 + + def _reroute_to_ui_thread(self, _callback) -> bool: + self.rerouted_callbacks += 1 + return True + + class _GlobalRelationCompletionModel: def __init__(self) -> None: self.faces = [object() for _index in range(100)] @@ -203,6 +242,12 @@ class _LargeRelationCompletionModel(_GlobalRelationCompletionModel): raise AssertionError("relation formula completion should not call face_region_logical_id") +class _LargeDisplayModel: + def __init__(self) -> None: + self.faces = [object() for _index in range(1800)] + self.edges = [object() for _index in range(5000)] + + class _RelationFormulaRemapModel: def __init__(self, face_infos: dict[int, dict[str, object]], face_count: int = 120) -> None: self.faces = [object() for _index in range(face_count)] @@ -529,6 +574,59 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None: _assert(preserved_editor.text().strip() == "12", "target value was not preserved after table expand") +def _assert_scdm_command_row_uses_unified_apply() -> None: + probe = _PropertyTableProbe() + probe.scdm_edit_runner_ready = {"feature.fill"} + probe.scdm_feature_cache_state = "ready" + probe.scdm_feature_cache = { + "objects": [ + { + "objectId": "hole:0", + "objectType": "hole", + "geometrySignature": { + "faceIds": [0], + "surfaceType": "cylinder", + "radius": 1.0, + "diameter": 2.0, + "center": [0.0, 0.0, 0.0], + }, + "capabilities": [ + { + "key": "feature.fill", + "displayName": "填孔/删除小特征", + "currentValue": 1, + "valueKind": "command", + "editable": True, + "defaultIntent": "删除并补面", + "backendOperation": "fill_feature", + "postCheck": "target_feature_removed", + } + ], + } + ] + } + probe._refresh_property_editor() + row = _row_by_label(probe, "填孔/删除小特征") + _assert(probe.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) is None, "SCDM command row should not have a target editor") + current_item = probe.property_table.item(row, PROPERTY_CURRENT_COLUMN) + target_item = probe.property_table.item(row, PROPERTY_TARGET_COLUMN) + _assert(current_item is not None and current_item.text() == "可执行", f"command current text should be readable: {current_item.text() if current_item else None}") + _assert(target_item is not None and target_item.text() == "执行", f"command target text should be readable: {target_item.text() if target_item else None}") + _assert(not probe.apply_property_button.isEnabled(), "command row should not enable parametric modeling until selected") + + probe.property_table.selectRow(row) + QApplication.processEvents() + probe._update_property_apply_state() + pending = probe._pending_property_edit_rows() + _assert(len(pending) == 1 and pending[0][0] == row, f"selected command row should be pending: {pending}") + _assert(probe.apply_property_button.isEnabled(), "selected command row should enable unified parametric modeling button") + probe.apply_current_property_edit() + _assert( + probe.executed_property_actions == [("apply_scdm_property_edit", "feature.fill:执行")], + f"selected command row should execute through SCDM property action: {probe.executed_property_actions}", + ) + + def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) -> None: long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。" probe.current_info_values = { @@ -551,6 +649,13 @@ def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) def _assert_relation_formula_editor() -> None: probe = _PropertyTableProbe() probe._refresh_property_editor() + _assert( + not probe.relation_formula_completer_model.stringList(), + "relation formula completions should stay lazy while the formula input is not focused", + ) + probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + probe._update_relation_formula_completions() completions = set(probe.relation_formula_completer_model.stringList()) _assert("Face0.面内长度" in completions, f"relation formula completion missing face length: {completions}") _assert("Face0.面内宽度" in completions, f"relation formula completion missing face width: {completions}") @@ -1222,6 +1327,31 @@ def _assert_mouse_selection_guards() -> None: "left-button press/release on different faces should not change selection", ) + mouse_probe = _MouseSelectionProbe() + mouse_probe._large_model_interaction_mode = lambda stats=None: True + mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}] + mouse_probe._handle_left_button_press(20, 20) + _assert( + len(mouse_probe.pick_targets) == 0, + "large-model left-button press should record the pressed target so background drags cannot select on release", + ) + mouse_probe._handle_left_button_release(20, 20) + _assert( + [target.get("target_id") for target in mouse_probe.selected_targets] == [8], + "large-model plain click should select the target recorded on press", + ) + + mouse_probe = _MouseSelectionProbe() + mouse_probe._large_model_interaction_mode = lambda stats=None: True + mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}] + mouse_probe._handle_left_button_press(20, 20) + mouse_probe._update_left_button_drag_state(40, 20) + mouse_probe._handle_left_button_release(40, 20) + _assert( + len(mouse_probe.pick_targets) == 0 and not mouse_probe.selected_targets, + "large-model drag/rotation may record the press target but must not select on release", + ) + def _assert_quick_blind_depth_spec() -> None: blind_probe = _PropertyTableProbe() @@ -1391,6 +1521,362 @@ def _assert_parameter_export_action() -> None: ) +def _assert_scdm_selection_diagnostics() -> None: + probe = _PropertyTableProbe() + probe.scdm_backend_status = { + "path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe", + "source": "common:D:/softwaresInstallDir/ANSYS Inc", + "version": "v222", + "runScriptOk": True, + "licenseOk": True, + } + 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", + } + ], + } + ] + } + probe._refresh_property_editor() + info = dict(probe.current_info_values) + _assert("SCDM:已配置 v222" in str(info.get("scdm_backend_status")), f"SCDM backend diagnostic missing: {info}") + _assert("识别缓存已就绪" in str(info.get("scdm_runtime_status")), f"SCDM runtime diagnostic missing: {info}") + _assert("SCDM 已识别当前对象" in str(info.get("scdm_selection_status")), f"SCDM selection diagnostic missing: {info}") + _assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}") + + +def _assert_operation_record_backend_sources() -> None: + class _OperationRecordProbe(WindowActionMixin): + pass + + stats = SimpleNamespace(solids=1, faces=6, edges=12) + probe = _OperationRecordProbe() + probe.current_info_values = {} + internal_record = probe._make_operation_record( + operation_name="拉伸/切除平面", + target="Face 1", + parameters={"surface": "plane"}, + result_message="Planar face push/pull completed: nearest_face=2, actual=10, target=10.", + before_stats=stats, + after_stats=stats, + before_geometry={}, + after_geometry={}, + target_kind="face", + target_id=1, + target_logical_id=1, + ) + _assert("backend: OCCT" in internal_record.detail, f"OCCT backend source missing: {internal_record.detail}") + _assert("execution: Qt background worker" in internal_record.detail, f"OCCT execution source missing: {internal_record.detail}") + _assert( + "recognition: internal StepModel" in internal_record.detail, + f"internal recognition source missing: {internal_record.detail}", + ) + + asitus_record = probe._make_operation_record( + operation_name="调整孔径", + target="Face 87", + parameters={ + "surface": "cylinder", + "asitus_relation_status": "ready", + "analysis_situs_feature_hint_summary": "Analysis Situs hint=hole", + }, + result_message="Cylinder resize completed: verified_face=87.", + before_stats=stats, + after_stats=stats, + before_geometry={}, + after_geometry={}, + target_kind="feature", + target_id=87, + target_logical_id=87, + isolation={"operation": "resize_cylinder"}, + ) + _assert("backend: OCCT" in asitus_record.detail, f"OCCT backend source missing for Analysis Situs record: {asitus_record.detail}") + _assert( + "execution: isolated OCCT subprocess" in asitus_record.detail, + f"isolated execution source missing: {asitus_record.detail}", + ) + _assert( + "recognition: Analysis Situs + internal StepModel" in asitus_record.detail, + f"Analysis Situs recognition source missing: {asitus_record.detail}", + ) + _assert( + asitus_record.parameters and asitus_record.parameters.get("recognition_source") == "Analysis Situs + internal StepModel", + f"recognition_source should be stored in record parameters: {asitus_record.parameters}", + ) + + +def _assert_scdm_auto_prompt() -> None: + probe = _ScdmAutoPromptProbe() + probe.maybe_prompt_missing_scdm_backend(reason="probe-failed", message="script failed") + QApplication.processEvents() + _assert(not probe.prompt_calls, f"SCDM prompt should only open for missing backend: {probe.prompt_calls}") + probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="not found") + QApplication.processEvents() + _assert(probe.prompt_calls == [(True, "missing-spaceclaim", "not found")], f"SCDM prompt should open once for missing backend: {probe.prompt_calls}") + probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="still missing") + QApplication.processEvents() + _assert(len(probe.prompt_calls) == 1, f"SCDM prompt should be guarded against repeated popups: {probe.prompt_calls}") + + +def _assert_property_ui_reroute_guards() -> None: + probe = _PropertyUiRerouteProbe() + probe._refresh_property_editor() + probe._clear_property_editor() + probe._update_current_capability_panel() + probe._sync_scdm_selection_diagnostics() + probe._update_relation_formula_completions() + probe._update_property_apply_state() + _assert( + probe.rerouted_callbacks == 6, + f"property UI entry points should reroute before touching widgets: {probe.rerouted_callbacks}", + ) + + +def _function_text(path: Path, name: str) -> str: + text = path.read_text(encoding="utf-8") + marker = f"def {name}" + start = text.find(marker) + _assert(start >= 0, f"missing function {name} in {path}") + tail = text[start + len(marker) :] + match = re.search(r"\n (?:@Slot\([^\n]*\)\n )?def ", tail) + end = start + len(marker) + match.start() if match else len(text) + return text[start:end] + + +def _assert_worker_ui_callbacks_guarded() -> None: + targets = { + "step_editor/window_core.py": ( + "_finish_scdm_probe_preload", + "_fail_scdm_probe_preload", + "_finish_asitus_hole_recognition", + "_fail_asitus_hole_recognition", + "_finish_initial_load", + "_fail_initial_load", + "_finish_deferred_edge_display", + "_fail_deferred_edge_display", + "_finish_load_refine", + "_fail_load_refine", + ), + "step_editor/window_actions.py": ( + "_finish_scan_task_result", + "_fail_scan_task_result", + "_finish_edit_action", + "_fail_edit_action", + ), + "step_editor/window_state.py": ( + "_finish_scdm_edit_action", + "_fail_scdm_edit_action", + "_finish_pending_scdm_edit_reload", + "_fail_pending_scdm_edit_reload", + ), + } + for relative_path, names in targets.items(): + path = PROJECT_ROOT / relative_path + for name in names: + body = _function_text(path, name) + header = body[:420] + _assert( + "_reroute_to_ui_thread" in header or "_is_ui_thread" in header, + f"{relative_path}:{name} should reroute to the UI thread before touching widgets", + ) + + +def _assert_large_model_preload_stays_lightweight() -> None: + model_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "scdm_local_face_signatures") + _assert("quick_face_info" not in model_body, "SCDM local Face signatures must not run full Face recognition") + _assert("SurfaceProperties" not in model_body, "SCDM local Face signatures should avoid full area/center integration") + quick_cylinder_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_quick_cylindrical_feature_hint") + internal_graph_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_can_use_internal_recognition_graph") + _assert( + "len(self.faces) <= 1000" in internal_graph_body, + "large models should not synchronously build the internal full recognition graph", + ) + _assert( + "connected_same_domain_face_ids" not in quick_cylinder_body, + "quick cylinder selection must not trigger full same-domain/recognition graph expansion", + ) + _assert( + "_connected_cocylindrical_face_ids" in quick_cylinder_body, + "quick cylinder selection should only merge directly connected co-cylindrical fragments", + ) + window_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_scdm_local_face_signatures") + _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( + "_defer_large_model_recognition_preloads" in loaded_body, + "large model loads should defer full external recognition preloads by default", + ) + _assert( + "_start_scdm_probe_preload(force=pending_scdm_reload)" in loaded_body, + "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( + "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", + ) + _assert( + "force: bool = False" in preload_body + and "_large_model_interaction_mode()" in preload_body + and "_defer_large_model_recognition_preloads()" in preload_body, + "large-model SCDM preload should be deferred unless a validation path forces it", + ) + _assert( + 'builder = getattr(model, "scdm_local_face_signatures", None)' in preload_body, + "SCDM preload should build local Face signatures inside the background worker", + ) + status_body = _function_text(PROJECT_ROOT / "step_editor/scdm_status.py", "summarize_scdm_runtime") + _assert('state == "deferred"' in status_body, "SCDM status should explain deferred large-model recognition") + + +def _assert_large_model_selection_stays_lightweight() -> None: + selection_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_feature_info_for_selected_face") + _assert( + "_should_defer_selection_first_level_topology" in selection_body, + "large-model selection should defer full first-level topology expansion", + ) + level_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_selection_feature_detection_level") + _assert( + "_large_model_interaction_mode" in level_body and '"current-only"' in level_body, + "large-model feature clicks should force the quick current-feature detection level", + ) + defer_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_should_defer_selection_first_level_topology") + _assert( + "return True" in defer_body, + "large-model selection should defer first-level topology even when the combo requests deeper detection", + ) + _assert( + "_quick_face_first_level_selection_fields" in selection_body, + "large-model selection should use a quick first-level summary", + ) + _assert( + "connected_same_domain_face_ids" not in selection_body + and "_connected_cocylindrical_face_ids" in selection_body, + "large-model feature selection should not trigger full same-domain expansion for cylinders", + ) + quick_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_quick_face_first_level_selection_fields") + _assert( + "face_first_level_topology" not in quick_body and "face_first_level_facts" not in quick_body, + "quick large-model selection summary must not run full topology/fact graph builders", + ) + loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result") + _assert("large_interaction_model" in loaded_body, "large models should be detected after load") + _assert( + "hide_edges_during_camera_interaction" in loaded_body, + "large models should hide edge overlay during camera interaction", + ) + _assert( + "large_model_edge_overlay_skipped = True" in loaded_body, + "large models should skip deferred full-edge overlay during default viewing", + ) + _assert( + "large_model_hover_disabled = large_interaction_model" in loaded_body, + "large models should disable hover picking/highlighting to avoid pointer stalls", + ) + rebuild_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_rebuild_scene") + _assert( + "_empty_edge_polydata()" in rebuild_body and "large_model_edge_overlay_skipped" in rebuild_body, + "large-model scene rebuilds should keep edge overlay lazy by default", + ) + mode_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_on_mode_changed") + _assert( + "_rebuild_deferred_edge_display" in mode_body and '"Edge"' in mode_body, + "Edge selection mode should restore deferred edge display on demand", + ) + hover_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_queue_hover_position") + _assert( + "large_model_hover_disabled" in hover_body, + "large-model hover picking should be suppressed before the VTK picker runs", + ) + action_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_isolation_for_plan") + _assert( + "_prefer_isolated_process_for_large_interactive_edit" in action_body + and "large-model-smooth-ui-isolated-occ-edit" in action_body, + "large complex push/pull rebuilds should use an independent background process for smoother interaction", + ) + job_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_make_edit_job") + _assert( + "skip_before_quality_check" in job_body, + "large-model edit jobs should be able to skip the pre-edit full B-Rep check while preserving post-edit validation", + ) + finish_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_finish_edit_action") + _assert( + "large_model_edge_overlay_skipped" in finish_body + and 'not bool(getattr(self, "large_model_edge_overlay_skipped", False))' in finish_body, + "large-model edit finish should not automatically rebuild full edge overlay", + ) + + +def _assert_large_planar_offset_prefers_local_backend() -> None: + probe = _PropertyTableProbe() + probe.model = _LargeDisplayModel() + probe.selected_kind = "feature" + probe.selected_face_id = 0 + probe.scdm_feature_cache_state = "ready" + probe.scdm_feature_cache = { + "objects": [ + { + "objectId": "face:0", + "objectType": "face", + "geometrySignature": {"objectType": "face", "faceIds": [0], "surfaceType": "plane"}, + "capabilities": [ + { + "key": "face.offset", + "displayName": "偏移", + "currentValue": 57.5, + "editable": True, + "defaultIntent": "推拉平面", + "backendOperation": "pull_face_offset", + "postCheck": "target_face_offset", + } + ], + } + ] + } + action_info = probe._selected_action_info() + action_info.update({"inner_boundary_wires": 5, "boundary_edges": 61}) + specs = probe._property_editor_specs(action_info, action_info) + _assert(specs, "large planar Face should still expose local editable specs") + _assert( + all(str(spec.get("action") or "") != "apply_scdm_property_edit" for spec in specs), + f"large multi-boundary planar offset should not route to SCDM: {specs}", + ) + offset = next((spec for spec in specs if str(spec.get("key") or "") == "face_target_normal_position"), None) + _assert(isinstance(offset, dict), f"local Face offset spec should be present: {specs}") + _assert( + str(offset.get("action") or "") in {"push_pull_face", "push_pull_face_keep_relations"}, + f"local Face offset should use optimized OCCT path: {offset}", + ) + _assert("SCDM" in str(probe.scdm_selection_status_message), "backend preference should explain that SCDM was bypassed") + + +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") + _assert("worker.moveToThread(thread)" in body, "background STEP load worker should move to QThread") + _assert("_finish_initial_load" in body and "_fail_initial_load" in body, "background STEP load should finish through queued callbacks") + _assert( + "_run_deferred_initial_load(path)" not in body, + "background STEP load must not fall back to main-thread deferred loading", + ) + + def main() -> int: app = QApplication.instance() or QApplication([]) top_level_label_probe = _TopLevelPropertyLabelProbe() @@ -1406,13 +1892,55 @@ def main() -> int: f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}", ) _assert_property_table_editor(probe) - _assert("当前支持" in probe.current_capability_headline.text(), "software progress panel did not show supported areas") - _assert("优先:" not in probe.current_capability_headline.text(), "software progress panel should not show priority copy") + _assert(probe.current_capability_button.text() == "软件进度", "software progress should be a compact button") + _assert("当前支持" in probe.current_capability_button.toolTip(), "software progress button tooltip did not show supported areas") + _assert("优先:" not in probe.current_capability_button.toolTip(), "software progress button tooltip should not show priority copy") _assert( - "矩形槽口袋" in probe.current_capability_headline.toolTip() - and "多台阶矩形凸台顶层" in probe.current_capability_headline.toolTip(), - "software progress tooltip should name newly supported prismatic feature edits", + "能改:" in probe.current_capability_button.toolTip() + and "能识别:" in probe.current_capability_button.toolTip() + and "暂不能:" in probe.current_capability_button.toolTip(), + f"software progress tooltip should be customer-facing capability copy: {probe.current_capability_button.toolTip()}", ) + _assert("当前 cache" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide cache internals") + _assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide SCDM internals") + _assert(not hasattr(probe, "configure_scdm_button"), "software progress should not expose a persistent SCDM configure button") + progress_detail = probe._software_progress_detail_text() + _assert("# 软件进度" in progress_detail, "software progress dialog text should be Markdown-like") + _assert("## 已能修改" in progress_detail, "software progress dialog text should list editable capabilities first") + _assert("## 已能识别" in progress_detail, "software progress dialog text should list recognized capabilities") + _assert("## 暂不能修改" in progress_detail, "software progress dialog text should list unsupported edits") + _assert("## 暂不能稳定识别" in progress_detail, "software progress dialog text should list unsupported recognition") + _assert("孔组" in progress_detail and "一级关系" in progress_detail, "software progress dialog text should include recognition scope") + _assert("B-Rep 校验" in progress_detail and "原 CAD 历史树" in progress_detail, "software progress dialog text should explain limits") + _assert("SCDM-first 路线状态" not in progress_detail, "software progress dialog text should hide roadmap internals") + _assert("SCDM 能力报告" not in progress_detail, "software progress dialog text should hide dynamic SCDM internals") + probe.scdm_backend_status = { + "path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe", + "source": "common:D:/softwaresInstallDir/ANSYS Inc", + "version": "v222", + "runScriptOk": True, + "licenseOk": True, + } + probe.scdm_feature_cache_state = "ready" + probe.scdm_feature_cache = { + "objects": [ + {"objectId": "face:1", "capabilities": [{"key": "face.offset"}]}, + {"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]}, + ], + "diagnostics": { + "geometry_candidate_hints": [ + {"capabilityKey": "boss.height", "displayName": "凸台高度", "evidenceCount": 2, "confidence": "low"} + ] + }, + } + probe._update_current_capability_panel() + configured_detail = probe._software_progress_detail_text() + _assert("已配置 v222" not in configured_detail, f"SCDM progress detail should hide backend version: {configured_detail}") + _assert("当前 cache 可执行" not in configured_detail, f"SCDM progress detail should hide cache count: {configured_detail}") + _assert("2 个对象" not in probe.current_capability_button.toolTip(), f"SCDM cache status should stay out of tooltip: {probe.current_capability_button.toolTip()}") + _assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide capability counters: {probe.current_capability_button.toolTip()}") + _assert("几何证据" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide geometry hint counts: {probe.current_capability_button.toolTip()}") + _assert("凸台高度:几何证据待分类" not in configured_detail, f"SCDM detail should hide geometry-only hints: {configured_detail}") _assert_diagnostics_stay_out_of_parameter_table(probe) _assert_relation_formula_editor() @@ -1423,9 +1951,19 @@ def main() -> int: _assert_relation_formula_input_recovers_after_loading() _assert_relation_formula_ids_follow_model_remap() _assert_mouse_selection_guards() + _assert_scdm_selection_diagnostics() + _assert_operation_record_backend_sources() + _assert_scdm_auto_prompt() + _assert_property_ui_reroute_guards() + _assert_worker_ui_callbacks_guarded() + _assert_large_model_preload_stays_lightweight() + _assert_large_model_selection_stays_lightweight() + _assert_large_planar_offset_prefers_local_backend() + _assert_background_load_uses_worker() _assert_quick_blind_depth_spec() _assert_user_facing_failure_messages() _assert_parameter_export_action() + _assert_scdm_command_row_uses_unified_apply() print("property table editor UI ok") if QApplication.instance() is app: diff --git a/scripts/verify_scdm_backend.py b/scripts/verify_scdm_backend.py new file mode 100644 index 0000000..72b7892 --- /dev/null +++ b/scripts/verify_scdm_backend.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import ast +import os +import re +import subprocess +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +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.scdm_backend import ( # noqa: E402 + SCDM_DISABLE_ENV, + SCDM_PATH_ENV_VARS, + ScdmBackendInfo, + default_scdm_cache_path, + discover_scdm_backend_candidates, + load_scdm_backend_cache, + resolve_scdm_backend, + save_scdm_backend_cache, + scdm_run_script_command, + verify_scdm_backend, +) + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +@contextmanager +def _patched_env(values: dict[str, str | None]) -> Iterator[None]: + original = {key: os.environ.get(key) for key in values} + try: + for key, value in values.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + yield + finally: + for key, value in original.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _fake_spaceclaim(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fake", encoding="utf-8") + return path + + +def _fake_runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + script_args = [item for item in command if item.startswith("/RunScript=")] + _assert(script_args, f"missing /RunScript argument: {command}") + script_path = Path(script_args[0].split("=", 1)[1]) + script = script_path.read_text(encoding="utf-8") + match = re.search(r"report_path\s*=\s*(.+)", script) + _assert(match is not None, f"smoke script should define report_path: {script}") + report_path = Path(ast.literal_eval(match.group(1).strip())) + report_path.write_text('{"ok": true, "version": "fake-2022R2", "message": "fake smoke ok"}', encoding="utf-8") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_") as temp: + root = Path(temp) + fake_exe = _fake_spaceclaim(root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe") + + backend = ScdmBackendInfo( + path=fake_exe, + source="test", + version="v222", + verified_at="2026-08-18T00:00:00Z", + run_script_ok=True, + license_ok=True, + message="cached", + ) + cache_path = save_scdm_backend_cache(backend, project_root_override=root) + _assert(cache_path == default_scdm_cache_path(root), f"unexpected cache path: {cache_path}") + loaded = load_scdm_backend_cache(project_root_override=root) + _assert(loaded is not None, "cache should load") + _assert(loaded.path == fake_exe.resolve(strict=False), f"cache should preserve path: {loaded}") + _assert(loaded.run_script_ok is True and loaded.license_ok is True, f"cache should preserve verification: {loaded}") + + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_env_") as temp: + root = Path(temp) + fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe") + env_clear = {name: None for name in SCDM_PATH_ENV_VARS} + env_clear[SCDM_DISABLE_ENV] = None + env_clear["STEP_EDITOR_SCDM_EXE"] = str(fake_exe) + with _patched_env(env_clear): + candidates = discover_scdm_backend_candidates( + include_registry=False, + include_common=False, + include_path=False, + ) + _assert(len(candidates) == 1, f"env discovery should find exactly one candidate: {candidates}") + _assert(candidates[0].source == "env:STEP_EDITOR_SCDM_EXE", f"bad source: {candidates[0]}") + resolved = resolve_scdm_backend( + project_root_override=root, + validate=False, + include_registry=False, + include_common=False, + include_path=False, + ) + _assert(resolved.get("ok") is True, f"env backend should resolve: {resolved}") + _assert(Path(str(resolved.get("path"))) == fake_exe.resolve(strict=False), f"bad resolved path: {resolved}") + _assert(load_scdm_backend_cache(project_root_override=root) is not None, "resolve should write cache") + + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_common_") as temp: + root = Path(temp) + common_root = root / "Program Files" / "ANSYS Inc" + fake_exe = _fake_spaceclaim(common_root / "v231" / "SCDM" / "SpaceClaim.exe") + candidates = discover_scdm_backend_candidates( + include_env=False, + include_registry=False, + include_common=True, + include_path=False, + common_roots=(common_root,), + ) + _assert(candidates and candidates[0].path == fake_exe.resolve(strict=False), f"common discovery failed: {candidates}") + _assert(candidates[0].version == "v231", f"version should be parsed from ANSYS folder: {candidates[0]}") + + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_smoke_") as temp: + root = Path(temp) + fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe") + command = scdm_run_script_command(fake_exe, root / "smoke.py") + _assert(command[0].endswith("SpaceClaim.exe"), f"bad command executable: {command}") + _assert(any(item.startswith("/RunScript=") for item in command), f"bad command script arg: {command}") + smoke = verify_scdm_backend(fake_exe, work_dir=root, runner=_fake_runner) + _assert(smoke.get("ok") is True, f"fake smoke should pass: {smoke}") + _assert(smoke.get("runScriptOk") is True and smoke.get("licenseOk") is True, f"bad smoke flags: {smoke}") + + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_disabled_") as temp: + root = Path(temp) + with _patched_env({SCDM_DISABLE_ENV: "1"}): + resolved = resolve_scdm_backend(project_root_override=root, validate=False) + _assert(resolved.get("ok") is False and resolved.get("reason") == "disabled", f"disable env failed: {resolved}") + + missing = verify_scdm_backend(Path("Z:/not-installed/SpaceClaim.exe")) + _assert(missing.get("ok") is False and missing.get("reason") == "missing-exe", f"missing path should be clean: {missing}") + + print("scdm backend discovery 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 new file mode 100644 index 0000000..640ef10 --- /dev/null +++ b/scripts/verify_scdm_edit_runner.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import ast +import re +import subprocess +import sys +import tempfile +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.scdm_backend import ScdmBackendInfo # noqa: E402 +from step_editor.scdm_edit_runner import generate_scdm_edit_script, prepare_scdm_edit_job, run_scdm_edit_job # noqa: E402 +from step_editor.scdm_schema import read_json, write_json # noqa: E402 + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _fake_spaceclaim(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fake", encoding="utf-8") + return path + + +def _script_path_from_command(command: list[str] | tuple[str, ...]) -> Path: + for item in command: + if item.startswith("/RunScript="): + return Path(item.split("=", 1)[1]) + raise AssertionError(f"missing /RunScript argument: {command}") + + +def _job_path_from_script(script_path: Path) -> Path: + text = script_path.read_text(encoding="utf-8") + match = re.search(r"^JOB_PATH = (.+)$", text, flags=re.MULTILINE) + _assert(match is not None, f"generated script should embed JOB_PATH: {script_path}") + return Path(ast.literal_eval(match.group(1))) + + +def _successful_runner(command, **_kwargs): # type: ignore[no-untyped-def] + script_path = _script_path_from_command(command) + job_path = _job_path_from_script(script_path) + job = read_json(job_path) + outputs = job.get("outputs") + _assert(isinstance(outputs, dict), f"job outputs should be an object: {job}") + output_step = Path(str(outputs["outputStep"])) + output_step.write_text("ISO-10303-21;\n/* fake SCDM output */\nEND-ISO-10303-21;\n", encoding="utf-8") + write_json( + outputs["result"], + { + "ok": True, + "reason": "ok", + "message": "fake edit finished", + "outputStep": str(output_step), + "backendOperation": job.get("target", {}).get("backendOperation"), + }, + ) + return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="") + + +def _failure_runner(command, **_kwargs): # type: ignore[no-untyped-def] + script_path = _script_path_from_command(command) + job_path = _job_path_from_script(script_path) + job = read_json(job_path) + outputs = job.get("outputs") + _assert(isinstance(outputs, dict), f"job outputs should be an object: {job}") + write_json( + outputs["error"], + { + "ok": False, + "reason": "fake-failed", + "message": "fake SCDM command failed", + "backendOperation": job.get("target", {}).get("backendOperation"), + }, + ) + return subprocess.CompletedProcess(command, 7, stdout="", stderr="fake failure") + + +def _missing_output_runner(command, **_kwargs): # type: ignore[no-untyped-def] + script_path = _script_path_from_command(command) + job_path = _job_path_from_script(script_path) + job = read_json(job_path) + outputs = job.get("outputs") + _assert(isinstance(outputs, dict), f"job outputs should be an object: {job}") + write_json( + outputs["result"], + { + "ok": True, + "reason": "ok", + "message": "fake success without STEP", + "outputStep": str(outputs["outputStep"]), + }, + ) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + +def _empty_output_runner(command, **_kwargs): # type: ignore[no-untyped-def] + script_path = _script_path_from_command(command) + job_path = _job_path_from_script(script_path) + job = read_json(job_path) + outputs = job.get("outputs") + _assert(isinstance(outputs, dict), f"job outputs should be an object: {job}") + Path(str(outputs["outputStep"])).write_text("", encoding="utf-8") + write_json( + outputs["result"], + { + "ok": True, + "reason": "ok", + "message": "fake success with empty STEP", + "outputStep": str(outputs["outputStep"]), + }, + ) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_edit_") as temp: + root = Path(temp) + step_path = root / "sample.step" + step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8") + backend = ScdmBackendInfo(path=_fake_spaceclaim(root / "SpaceClaim.exe"), source="test", version="v222") + signature = { + "objectType": "hole", + "faceIds": [85, 94], + "bodyIndex": 0, + "faceOrdinal": 12, + "faceOrdinals": [12, 19], + "scdmFaceLocators": [ + {"bodyIndex": 0, "faceOrdinal": 12, "globalFaceOrdinal": 85}, + {"bodyIndex": 0, "faceOrdinal": 19, "globalFaceOrdinal": 96}, + ], + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 0.0, 1.0], + } + + prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "prepared", + backend=backend, + capability_key="hole.diameter", + target_value="0.75", + object_id="hole:85-94", + object_signature=signature, + timeout_seconds=45.0, + ) + _assert(prepared.get("ok") is True, f"edit job should be prepared: {prepared}") + job_path = Path(str(prepared["job_path"])) + script_path = Path(str(prepared["script_path"])) + _assert(job_path.is_file(), "scdm_edit_job.json should be written") + _assert(script_path.is_file(), "scdm_edit.py should be written") + job = read_json(job_path) + _assert(job.get("adapter") == "spaceclaim-v1", f"bad adapter: {job}") + _assert(job.get("schemaVersion") == 1, f"bad schema version: {job}") + _assert(job.get("model", {}).get("sourceStep") == str(step_path.resolve(strict=False)), f"bad source path: {job}") + _assert(job.get("model", {}).get("rollbackStep") == str(step_path.resolve(strict=False)), f"bad rollback path: {job}") + _assert(job.get("target", {}).get("capabilityKey") == "hole.diameter", f"bad capability: {job}") + _assert(job.get("target", {}).get("backendOperation") == "change_hole_diameter", f"bad operation: {job}") + _assert(job.get("target", {}).get("value") == 0.75, f"target should be numeric: {job}") + _assert(job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [85, 94], f"bad signature: {job}") + _assert(job.get("execution", {}).get("isolatedProcess") is True, f"job should record isolated execution: {job}") + script = script_path.read_text(encoding="utf-8") + for token in ( + "change_hole_diameter", + "move_hole_axis", + "move_slot", + "move_boss", + "pull_face_offset", + "fill_feature", + "StandardHoles.ModifyHoleRadius", + "OffsetFaces.Execute", + "Move.Translate", + "MoveOptions", + "OffsetFaceOptions", + "FillOptions", + "FillMode", + "Delete.Execute", + "DocumentSave", + "scdmFaceLocators", + "result.json", + "error.json", + ): + _assert(token in script, f"generated edit script missing {token}") + _assert("Pull.Execute" not in script, "generated edit script should use documented OffsetFaces instead of Pull.Execute") + generated = generate_scdm_edit_script(job_path) + _assert("JOB_PATH =" in generated and job_path.name in generated, "generated edit script should embed the job path") + + slot_signature = { + "objectType": "slot", + "faceIds": [30, 31, 32], + "bodyIndex": 0, + "faceOrdinal": 30, + "faceOrdinals": [30, 31, 32], + "globalFaceOrdinal": 40, + "globalFaceOrdinals": [40, 41, 42], + "center": [1.0, 2.0, 3.0], + "axis": [1.0, 0.0, 0.0], + } + slot_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "slot-position-prepared", + backend=backend, + capability_key="slot.position", + target_value=[1.0, 2.0, 5.0], + object_id="slot:30-31-32", + object_signature=slot_signature, + ) + _assert(slot_prepared.get("ok") is True, f"slot.position should be productized and prepare an edit job: {slot_prepared}") + slot_job = read_json(slot_prepared["job_path"]) + _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}") + + boss_signature = { + "objectType": "cylindrical_boss", + "faceIds": [50, 51, 52], + "bodyIndex": 0, + "faceOrdinal": 50, + "faceOrdinals": [50, 51, 52], + "globalFaceOrdinal": 70, + "globalFaceOrdinals": [70, 71, 72], + "center": [0.0, 0.0, 2.0], + "axis": [0.0, 0.0, 1.0], + } + boss_prepared = prepare_scdm_edit_job( + step_path, + output_dir=root / "boss-position-prepared", + backend=backend, + capability_key="boss.position", + target_value=[2.0, 0.0, 2.0], + object_id="boss:50-51-52", + object_signature=boss_signature, + ) + _assert(boss_prepared.get("ok") is True, f"boss.position should be productized and prepare an edit job: {boss_prepared}") + boss_job = read_json(boss_prepared["job_path"]) + _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}") + + planned = prepare_scdm_edit_job( + step_path, + output_dir=root / "unsupported", + backend=backend, + capability_key="slot.width", + target_value="1", + object_signature=signature, + ) + _assert( + planned.get("ok") is False and planned.get("reason") == "capability-not-productized", + f"planned capability should fail early with a roadmap reason: {planned}", + ) + + unsupported = prepare_scdm_edit_job( + step_path, + output_dir=root / "unsupported-unknown", + backend=backend, + capability_key="not.real", + target_value="1", + object_signature=signature, + ) + _assert(unsupported.get("ok") is False and unsupported.get("reason") == "unsupported-capability", f"unknown capability should fail early: {unsupported}") + + success = run_scdm_edit_job( + step_path, + output_dir=root / "success", + backend=backend, + capability_key="hole.diameter", + target_value=0.9, + object_id="hole:85-94", + object_signature=signature, + runner=_successful_runner, + ) + _assert(success.get("ok") is True, f"fake edit should succeed: {success}") + _assert(Path(str(success["output_step"])).is_file(), f"output STEP should exist: {success}") + success_backend = success.get("backend") + _assert(isinstance(success_backend, dict), f"successful edit should carry backend status: {success}") + _assert(success_backend.get("runScriptOk") is True, f"successful edit should mark /RunScript usable: {success_backend}") + _assert(success_backend.get("licenseOk") is True, f"successful edit should mark license usable: {success_backend}") + + failed = run_scdm_edit_job( + step_path, + output_dir=root / "failed", + backend=backend, + capability_key="hole.position", + target_value=[0.5, 1.0, 6.0], + object_id="hole:85-94", + object_signature=signature, + runner=_failure_runner, + ) + _assert(failed.get("ok") is False and failed.get("reason") == "fake-failed", f"error.json should drive failure reason: {failed}") + + slot_success = run_scdm_edit_job( + step_path, + output_dir=root / "slot-success", + backend=backend, + capability_key="slot.position", + target_value=[1.0, 2.0, 6.0], + object_id="slot:30-31-32", + object_signature=slot_signature, + runner=_successful_runner, + ) + _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}") + + boss_success = run_scdm_edit_job( + step_path, + output_dir=root / "boss-success", + backend=backend, + capability_key="boss.position", + target_value=[3.0, 0.0, 2.0], + object_id="boss:50-51-52", + object_signature=boss_signature, + runner=_successful_runner, + ) + _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}") + + missing_output = run_scdm_edit_job( + step_path, + output_dir=root / "missing-output", + backend=backend, + capability_key="face.offset", + target_value=5, + object_id="face:9", + object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3}, + runner=_missing_output_runner, + ) + _assert(missing_output.get("ok") is False and missing_output.get("reason") == "missing-output-step", f"missing output STEP should be rejected: {missing_output}") + + empty_output = run_scdm_edit_job( + step_path, + output_dir=root / "empty-output", + backend=backend, + capability_key="face.offset", + target_value=5, + object_id="face:9", + object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3}, + runner=_empty_output_runner, + ) + _assert(empty_output.get("ok") is False and empty_output.get("reason") == "empty-output-step", f"empty output STEP should be rejected: {empty_output}") + + print("scdm edit runner ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_scdm_probe_pipeline.py b/scripts/verify_scdm_probe_pipeline.py new file mode 100644 index 0000000..18ae9ff --- /dev/null +++ b/scripts/verify_scdm_probe_pipeline.py @@ -0,0 +1,550 @@ +from __future__ import annotations + +import ast +import re +import subprocess +import sys +import tempfile +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.scdm_backend import ScdmBackendInfo, load_scdm_backend_cache # noqa: E402 +from step_editor.scdm_feature_mapper import attach_local_face_ids_to_scdm_cache, geometry_signature, map_scdm_raw_features, map_scdm_raw_features_file # noqa: E402 +from step_editor.scdm_probe import generate_scdm_probe_script, prepare_scdm_probe_job, run_scdm_probe # noqa: E402 +from step_editor.scdm_property_specs import property_specs_from_scdm_cache # noqa: E402 +from step_editor.scdm_schema import read_json, write_json # noqa: E402 + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _capability_keys(cache: dict[str, object], object_id_part: str) -> set[str]: + objects = cache.get("objects") + _assert(isinstance(objects, list), f"cache objects should be a list: {cache}") + for item in objects: + if not isinstance(item, dict): + continue + if object_id_part not in str(item.get("objectId") or ""): + continue + capabilities = item.get("capabilities") + _assert(isinstance(capabilities, list), f"capabilities should be a list: {item}") + return {str(capability.get("key")) for capability in capabilities if isinstance(capability, dict)} + return set() + + +def _script_path_from_command(command: list[str] | tuple[str, ...]) -> Path: + for item in command: + if item.startswith("/RunScript="): + return Path(item.split("=", 1)[1]) + raise AssertionError(f"missing /RunScript argument: {command}") + + +def _job_path_from_script(script_path: Path) -> Path: + text = script_path.read_text(encoding="utf-8") + match = re.search(r"^JOB_PATH = (.+)$", text, flags=re.MULTILINE) + _assert(match is not None, f"generated probe script should embed JOB_PATH: {script_path}") + return Path(ast.literal_eval(match.group(1))) + + +def _successful_probe_runner(command, **_kwargs): # type: ignore[no-untyped-def] + script_path = _script_path_from_command(command) + job_path = _job_path_from_script(script_path) + job = read_json(job_path) + outputs = job.get("outputs") + _assert(isinstance(outputs, dict), f"job outputs should be an object: {job}") + write_json( + outputs["rawFeatures"], + { + "schemaVersion": 1, + "backend": job.get("backend", {}), + "model": job.get("model", {}), + "diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": True}]}, + "objects": [], + }, + ) + return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="") + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_probe_") as temp: + root = Path(temp) + step_path = root / "sample.step" + step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8") + backend = ScdmBackendInfo(path=root / "SpaceClaim.exe", source="test", version="v222") + backend.path.write_text("fake", encoding="utf-8") + + prepared = prepare_scdm_probe_job(step_path, output_dir=root / "probe", project_root=root, backend=backend) + _assert(prepared.get("ok") is True, f"probe job should be prepared: {prepared}") + job_path = Path(str(prepared["job_path"])) + script_path = Path(str(prepared["script_path"])) + raw_path = Path(str(prepared["raw_features_path"])) + _assert(job_path.is_file(), "scdm_probe_job.json should be written") + _assert(script_path.is_file(), "scdm_probe.py should be written") + job = read_json(job_path) + _assert(job.get("adapter") == "spaceclaim-v1", f"bad adapter: {job}") + _assert(job.get("outputs", {}).get("rawFeatures") == str(raw_path), f"bad raw output path: {job}") + script = script_path.read_text(encoding="utf-8") + _assert("GetRootPart" in script, "probe script should inspect the active root part") + _assert("GetHoleFaces" in script, "probe script should ask SCDM for standard hole faces") + _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("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}") + 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") + + probe_result = run_scdm_probe(step_path, backend=backend, output_dir=root / "run-probe", project_root=root, runner=_successful_probe_runner) + _assert(probe_result.get("ok") is True, f"fake run_scdm_probe should pass: {probe_result}") + probe_backend = probe_result.get("backend") + _assert(isinstance(probe_backend, dict), f"probe result should carry backend status: {probe_result}") + _assert(probe_backend.get("runScriptOk") is True, f"successful probe should mark /RunScript usable: {probe_backend}") + _assert(probe_backend.get("licenseOk") is True, f"successful probe should mark license usable: {probe_backend}") + cached_backend = load_scdm_backend_cache(project_root_override=root) + _assert(cached_backend is not None and cached_backend.run_script_ok is True, f"successful probe should update backend cache: {cached_backend}") + + raw = { + "schemaVersion": 1, + "backend": {"name": "SCDM", "version": "v222"}, + "model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"}, + "diagnostics": { + "availableCommands": [ + {"name": "StandardHoles", "available": True}, + {"name": "OffsetFaces", "available": True}, + {"name": "Move", "available": True}, + {"name": "Fill", "available": True}, + {"name": "Delete", "available": True}, + {"name": "ConstantRound", "available": True}, + {"name": "SomeFutureCommand", "available": False}, + ], + "faceAdjacency": [ + { + "bodyIndex": 0, + "faceOrdinals": [1, 2], + "edgeCount": 1, + "edgeKinds": {"circular": 1}, + "edges": [{"edgeOrdinal": 7, "globalEdgeOrdinal": 9, "kind": "circular", "radius": 0.25}], + } + ], + "edgeGeometrySummary": { + "totalEdgeCount": 9, + "edgeKindCounts": {"linear": 5, "circular": 4}, + "circularEdgeCount": 4, + "circularRadiusBuckets": [{"radius": "0.25", "count": 4}], + "minEdgeLength": 0.5, + "maxEdgeLength": 8.0, + }, + "featureInventory": { + "objectTypeCounts": {"face": 2, "hole": 1, "edge": 1, "slot": 1, "round": 1}, + "surfaceTypeCounts": {"plane": 1, "cylinder": 3}, + "curveTypeCounts": {"Line": 5, "Circle": 4}, + "operationCounts": { + "pull_face_offset": 1, + "change_hole_diameter": 1, + "change_slot_width": 1, + "move_slot": 1, + "change_boss_height": 1, + "move_boss": 1, + }, + }, + }, + "summary": {"bodyCount": 1, "objectCount": 10, "faceCount": 6, "edgeCount": 1, "holeFaceCount": 0}, + "objects": [ + { + "backendId": "hole:1", + "objectType": "hole", + "geometry": { + "diameter": 0.5, + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 0.0, 1.0], + }, + "topologyHint": {"faceIds": [85, 94], "bodyIndex": 0, "faceOrdinal": 12}, + "backendCommandCandidates": [ + {"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}}, + {"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [0.5, 1.0, 9.5]}}, + ], + "rawLimitations": [], + }, + { + "backendId": "hole:pattern-a", + "objectType": "hole", + "geometry": { + "diameter": 0.5, + "center": [0.0, 0.0, 0.0], + "axis": [0.0, 0.0, 1.0], + }, + "topologyHint": {"faceIds": [200], "bodyIndex": 2, "faceOrdinal": 1}, + "backendCommandCandidates": [ + {"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}}, + {"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 0.0]}}, + ], + "rawLimitations": [], + }, + { + "backendId": "hole:pattern-b", + "objectType": "hole", + "geometry": { + "diameter": 0.5, + "center": [5.0, 0.0, 0.0], + "axis": [0.0, 0.0, 1.0], + }, + "topologyHint": {"faceIds": [201], "bodyIndex": 2, "faceOrdinal": 2}, + "backendCommandCandidates": [ + {"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}}, + {"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [5.0, 0.0, 0.0]}}, + ], + "rawLimitations": [], + }, + { + "backendId": "hole:pattern-c", + "objectType": "hole", + "geometry": { + "diameter": 0.5, + "center": [10.0, 0.0, 0.0], + "axis": [0.0, 0.0, 1.0], + }, + "topologyHint": {"faceIds": [202], "bodyIndex": 2, "faceOrdinal": 3}, + "backendCommandCandidates": [ + {"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}}, + {"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [10.0, 0.0, 0.0]}}, + ], + "rawLimitations": [], + }, + { + "backendId": "face:9", + "objectType": "face", + "geometry": { + "surfaceType": "plane", + "center": [0.0, 0.0, 10.0], + "normal": [0.0, 0.0, 1.0], + }, + "topologyHint": {"faceIds": [9]}, + "backendCommandCandidates": [ + {"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}}, + ], + "rawLimitations": [], + }, + { + "backendId": "body:8/face:5", + "objectType": "face", + "geometry": { + "surfaceType": "cylinder", + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 1.0, 0.0], + "radius": 0.25, + }, + "topologyHint": {"bodyIndex": 8, "faceOrdinal": 5, "globalFaceOrdinal": 85}, + "backendCommandCandidates": [], + "rawLimitations": [], + }, + { + "backendId": "body:8/face:16", + "objectType": "face", + "geometry": { + "surfaceType": "cylinder", + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 1.0, 0.0], + "radius": 0.25, + }, + "topologyHint": {"bodyIndex": 8, "faceOrdinal": 16, "globalFaceOrdinal": 96}, + "backendCommandCandidates": [], + "rawLimitations": [], + }, + { + "backendId": "round:1", + "objectType": "round", + "geometry": {"radius": 1.0}, + "backendCommandCandidates": [ + {"operation": "change_round_radius", "enabled": True}, + {"operation": "delete_round_or_chamfer", "enabled": True}, + ], + "rawLimitations": [], + }, + { + "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}, + "backendCommandCandidates": [ + {"operation": "change_slot_width", "enabled": True}, + {"operation": "move_slot", "enabled": True, "parameterFields": {"center": [1.0, 2.0, 3.0]}}, + ], + "rawLimitations": [], + }, + { + "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}, + "backendCommandCandidates": [ + {"operation": "change_boss_height", "enabled": True}, + {"operation": "move_boss", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 2.0]}}, + ], + "rawLimitations": [], + }, + { + "backendId": "chamfer:1", + "objectType": "chamfer", + "geometry": {"distance": 0.8}, + "backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}], + "rawLimitations": [], + }, + { + "backendId": "pattern:1", + "objectType": "linear_pattern", + "geometry": {"spacing": 5.0, "instanceCenter": [0.0, 5.0, 0.0]}, + "backendCommandCandidates": [{"operation": "change_pattern_spacing", "enabled": True}], + "rawLimitations": [], + }, + { + "backendId": "shell:1", + "objectType": "shell", + "geometry": {"thickness": 1.2}, + "backendCommandCandidates": [{"operation": "change_shell_thickness", "enabled": True}], + "rawLimitations": [], + }, + ], + } + cache = map_scdm_raw_features(raw) + edge_signature = geometry_signature( + { + "backendId": "edge:1", + "objectType": "edge", + "geometry": { + "curveType": "Circle", + "length": 3.14, + "startPoint": [0.0, 0.0, 0.0], + "endPoint": [1.0, 0.0, 0.0], + "midPoint": [0.5, 0.0, 0.0], + "radius": 0.5, + "center": [0.5, 0.5, 0.0], + "axis": [0.0, 0.0, 1.0], + }, + "topologyHint": { + "bodyIndex": 0, + "edgeOrdinal": 7, + "globalEdgeOrdinal": 9, + "adjacentFaceCount": 2, + "adjacentFaceOrdinals": [3, 4], + }, + } + ) + _assert(edge_signature.get("curveType") == "Circle", f"Edge curve type should be preserved: {edge_signature}") + _assert(edge_signature.get("length") == 3.14, f"Edge length should be preserved: {edge_signature}") + _assert(edge_signature.get("startPoint") == [0.0, 0.0, 0.0], f"Edge start point should be preserved: {edge_signature}") + _assert(edge_signature.get("adjacentFaceOrdinals") == [3, 4], f"Edge adjacent faces should be preserved: {edge_signature}") + _assert(cache.get("modelFingerprint") == "abc123", f"model fingerprint should be copied: {cache}") + _assert(cache.get("backendVersion") == "v222", f"backend version should be copied: {cache}") + _assert({"hole.diameter", "hole.position"} <= _capability_keys(cache, "hole:1"), f"hole caps missing: {cache}") + objects = cache.get("objects") + _assert(isinstance(objects, list), f"cache objects should be a list: {cache}") + hole_object = next((item for item in objects if isinstance(item, dict) and item.get("objectId") == "hole:hole:1"), None) + _assert(isinstance(hole_object, dict), f"hole object should be normalized: {cache}") + signature = hole_object.get("geometrySignature") + _assert(isinstance(signature, dict), f"hole signature should be present: {hole_object}") + _assert(signature.get("bodyIndex") == 0 and signature.get("faceOrdinal") == 12, f"SCDM script locator hints should be preserved: {signature}") + _assert({"face.offset"} <= _capability_keys(cache, "face:9"), f"face caps missing: {cache}") + cylinder_group = next( + (item for item in objects if isinstance(item, dict) and "cylindrical_group:body:8/face:5" in str(item.get("objectId") or "")), + None, + ) + _assert(isinstance(cylinder_group, dict), f"split cylinder faces should produce a grouped SCDM object: {cache}") + cylinder_signature = cylinder_group.get("geometrySignature") + _assert(isinstance(cylinder_signature, dict), f"cylinder group should carry a geometry signature: {cylinder_group}") + _assert(cylinder_signature.get("faceOrdinals") == [5, 16], f"cylinder group should preserve all face ordinals: {cylinder_signature}") + _assert(len(cylinder_signature.get("scdmFaceLocators") or []) == 2, f"cylinder group should preserve SCDM face locators: {cylinder_signature}") + _assert({"hole.diameter", "hole.position", "feature.fill"} <= _capability_keys(cache, "cylindrical_group:body:8/face:5"), f"cylinder group caps missing: {cache}") + hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(94,), execution_ready=False) + hole_keys = {str(spec.get("scdm_capability_key")) for spec in hole_specs} + _assert({"hole.diameter", "hole.position"} <= hole_keys, f"SCDM cache should map selected hole Face to UI specs: {hole_specs}") + _assert(all(spec.get("enabled") is False for spec in hole_specs), f"SCDM specs should stay disabled before S5: {hole_specs}") + executable_hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(85,), execution_ready=True) + _assert(any(spec.get("enabled") is True for spec in executable_hole_specs), f"SCDM specs should enable once runner is ready: {executable_hole_specs}") + gated_hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(85,), execution_ready={"face.offset"}) + _assert(all(spec.get("enabled") is False for spec in gated_hole_specs), f"capability gate should keep unverified hole edits disabled: {gated_hole_specs}") + face_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(9,), execution_ready=True) + _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( + {str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.position"}, + f"slot cache should expose only productized 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}") + 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( + {str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.position"}, + f"boss cache should expose only productized 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}") + 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}") + + raw_without_local_ids = { + "schemaVersion": 1, + "backend": {"name": "SCDM", "version": "v222"}, + "model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"}, + "objects": [ + { + "backendId": "face:no-local-id", + "objectType": "face", + "geometry": { + "surfaceType": "plane", + "center": [0.0, 0.0, 0.01], + "axis": [0.0, 0.0, 1.0], + "planeOffset": 0.01, + }, + "topologyHint": {"bodyIndex": 0, "faceOrdinal": 3}, + "backendCommandCandidates": [ + {"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}}, + ], + "rawLimitations": [], + }, + ], + } + enriched = attach_local_face_ids_to_scdm_cache( + map_scdm_raw_features(raw_without_local_ids), + [ + { + "faceId": 9, + "surfaceType": "plane", + "center": [0.0, 0.0, 10.0], + "axis": [0.0, 0.0, 1.0], + "planeOffset": 10.0, + } + ], + ) + enriched_specs = property_specs_from_scdm_cache(enriched, selected_face_ids=(9,), execution_ready=True) + _assert(enriched_specs and enriched_specs[0].get("scdm_capability_key") == "face.offset", f"local Face IDs should be attached from geometry signatures: {enriched}") + enriched_group = attach_local_face_ids_to_scdm_cache( + map_scdm_raw_features(raw), + [ + { + "faceId": 85, + "surfaceType": "cylinder", + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 1.0, 0.0], + "radius": 0.25, + }, + { + "faceId": 96, + "surfaceType": "cylinder", + "center": [0.5, 1.0, 9.5], + "axis": [0.0, 1.0, 0.0], + "radius": 0.25, + }, + ], + ) + 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}") + + missing_command_raw = { + "schemaVersion": 1, + "backend": {"name": "SCDM", "version": "v222"}, + "model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"}, + "diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": False}]}, + "objects": [ + { + "backendId": "face:no-offset-command", + "objectType": "face", + "geometry": { + "surfaceType": "plane", + "center": [0.0, 0.0, 1.0], + "axis": [0.0, 0.0, 1.0], + "planeOffset": 1.0, + }, + "topologyHint": {"faceIds": [12]}, + "backendCommandCandidates": [ + {"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}}, + ], + "rawLimitations": [], + } + ], + } + 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}") + diagnostics = cache.get("diagnostics") + _assert(isinstance(diagnostics, dict), f"cache diagnostics should be present: {cache}") + raw_summary = diagnostics.get("raw_summary") + _assert(isinstance(raw_summary, dict), f"SCDM raw summary should be preserved in cache diagnostics: {cache}") + _assert(raw_summary.get("faceCount") is None or isinstance(raw_summary.get("faceCount"), int), f"raw summary should stay JSON-like: {raw_summary}") + backend_commands = diagnostics.get("backend_commands") + _assert(isinstance(backend_commands, list), f"SCDM backend command inventory should be preserved: {cache}") + command_names = {str(item.get("name")) for item in backend_commands if isinstance(item, dict)} + _assert({"StandardHoles", "ConstantRound"} <= command_names, f"backend command names should be available in diagnostics: {backend_commands}") + face_adjacency = diagnostics.get("face_adjacency") + _assert(isinstance(face_adjacency, list) and face_adjacency, f"SCDM Face adjacency should be preserved in cache diagnostics: {cache}") + edge_summary = diagnostics.get("edge_geometry_summary") + _assert(isinstance(edge_summary, dict) and edge_summary.get("circularEdgeCount") == 4, f"SCDM Edge geometry summary should be preserved: {cache}") + feature_inventory = diagnostics.get("feature_inventory") + _assert( + isinstance(feature_inventory, dict) + and feature_inventory.get("objectTypeCounts", {}).get("hole") == 1 + and feature_inventory.get("operationCounts", {}).get("change_slot_width") == 1, + f"SCDM feature inventory 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, + f"S7 geometry hints should cover planned feature families: {hint_keys}", + ) + derived_candidates = diagnostics.get("derived_feature_candidates") + _assert(isinstance(derived_candidates, list) and derived_candidates, f"repeated holes should produce derived S7 candidates: {cache}") + pattern_candidate = next((item for item in derived_candidates if isinstance(item, dict) and item.get("objectType") == "linear_pattern"), None) + _assert(isinstance(pattern_candidate, dict), f"linear pattern candidate should be derived: {derived_candidates}") + pattern_signature = pattern_candidate.get("geometrySignature") + _assert(isinstance(pattern_signature, dict), f"linear pattern should keep a geometry signature: {pattern_candidate}") + _assert(pattern_signature.get("spacing") == 5.0, f"linear pattern spacing should be preserved: {pattern_signature}") + _assert(pattern_signature.get("instanceCount") == 3, f"linear pattern count should be preserved: {pattern_signature}") + _assert(pattern_signature.get("axis") == [1.0, 0.0, 0.0], f"linear pattern axis should be preserved: {pattern_signature}") + _assert(len(pattern_signature.get("instanceCenters") or []) == 3, f"linear pattern centers should be preserved: {pattern_signature}") + not_productized = diagnostics.get("discovered_not_productized") + _assert(isinstance(not_productized, list) and not_productized, f"round candidate 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", + } + _assert(expected_planned <= planned_keys, f"S7 planned capabilities should be diagnosed but not productized: {planned_keys}") + + raw_file = root / "raw.json" + cache_file = root / "cache.json" + write_json(raw_file, raw) + from_file = map_scdm_raw_features_file(raw_file, cache_file) + _assert(cache_file.is_file(), "cache file should be written") + _assert(from_file.get("objects") == read_json(cache_file).get("objects"), "file mapper should match in-memory mapper") + + print("scdm probe pipeline ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_scdm_result_validator.py b/scripts/verify_scdm_result_validator.py new file mode 100644 index 0000000..b3d08e1 --- /dev/null +++ b/scripts/verify_scdm_result_validator.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import sys +import tempfile +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.scdm_result_validator import ( # noqa: E402 + build_scdm_id_mapping, + check_scdm_summary_delta, + check_scdm_unedited_objects, + check_scdm_target, + match_scdm_object_by_signature, + rewrite_scdm_relation_formula_ids, + validate_scdm_edit_result, +) + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _hole(object_id: str, face_id: int, *, diameter: float, center: tuple[float, float, float]) -> dict[str, object]: + return { + "objectId": object_id, + "objectType": "hole", + "geometrySignature": { + "objectType": "hole", + "faceIds": [face_id], + "surfaceType": "cylinder", + "center": list(center), + "axis": [0.0, 0.0, 1.0], + "diameter": diameter, + }, + "capabilities": [ + {"key": "hole.diameter", "currentValue": diameter}, + {"key": "hole.position", "currentValue": list(center)}, + ], + } + + +def _cache(*objects: dict[str, object]) -> dict[str, object]: + return { + "schemaVersion": 1, + "source": "SCDM", + "objects": list(objects), + "diagnostics": {}, + } + + +def _cache_with_summary(summary: dict[str, int], *objects: dict[str, object]) -> dict[str, object]: + cache = _cache(*objects) + cache["diagnostics"] = {"raw_summary": dict(summary)} + return cache + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_validate_") as temp: + root = Path(temp) + output_step = root / "result.step" + output_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8") + + 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)), + ) + after = _cache( + _hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)), + _hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 9.5)), + ) + before_signature = before["objects"][0]["geometrySignature"] # type: ignore[index] + + match = match_scdm_object_by_signature(before_signature, after, capability_key="hole.diameter") + _assert(match.get("status") == "unique", f"changed diameter should still match by center/axis/type: {match}") + _assert(match.get("object", {}).get("objectId") == "hole:90", f"wrong match: {match}") + + ok = validate_scdm_edit_result( + {"ok": True, "output_step": str(output_step)}, + before_signature=before_signature, + before_cache=before, + after_cache=after, + capability_key="hole.diameter", + expected_target=0.75, + edited_object_id="hole:85", + brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"}, + ) + _assert(ok.get("ok") is True, f"validated edit should pass: {ok}") + _assert(ok.get("targetCheck", {}).get("ok") is True, f"target diameter should be checked: {ok}") + _assert(ok.get("topologyCheck", {}).get("ok") is True, f"unchanged objects should be checked: {ok}") + + drift_after = _cache(_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5))) + drift = validate_scdm_edit_result( + {"ok": True, "output_step": str(output_step)}, + before_signature=before_signature, + before_cache=before, + after_cache=drift_after, + capability_key="hole.diameter", + expected_target=0.75, + edited_object_id="hole:85", + ) + _assert(drift.get("ok") is False and drift.get("reason") == "unexpected-object-drift", f"missing unrelated hole should fail: {drift}") + direct_drift = check_scdm_unedited_objects(before, drift_after, edited_object_id="hole:85", edited_signature=before_signature) + _assert(direct_drift.get("ok") is False and direct_drift.get("checked") == 1, f"direct drift check should inspect one unedited object: {direct_drift}") + + mismatch = validate_scdm_edit_result( + {"ok": True, "output_step": str(output_step)}, + before_signature=before_signature, + after_cache=after, + capability_key="hole.diameter", + expected_target=0.9, + ) + _assert(mismatch.get("ok") is False and mismatch.get("reason") == "target-mismatch", f"wrong target should fail: {mismatch}") + + missing = validate_scdm_edit_result({"ok": True, "output_step": str(root / "missing.step")}) + _assert(missing.get("ok") is False and missing.get("reason") == "missing-output-step", f"missing result STEP should fail: {missing}") + + mapping = build_scdm_id_mapping(before, after, capability_key="hole.diameter") + _assert(mapping.get("faceIdMap") == {85: 90, 87: 91}, f"face IDs should remap through signatures: {mapping}") + rewritten = rewrite_scdm_relation_formula_ids("Face87.直径 = Face85.半径", mapping) + _assert(rewritten == "Face91.直径 = Face90.半径", f"formula IDs should follow SCDM remap: {rewritten}") + + position_after = _cache(_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 6.0))) + position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0]) + _assert(position_check.get("ok") is True, f"position target should pass: {position_check}") + + 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)), + ) + summary_after_ok = _cache_with_summary( + {"bodyCount": 13, "objectCount": 550, "faceCount": 157, "edgeCount": 390}, + _hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)), + ) + summary_ok = check_scdm_summary_delta(summary_before, summary_after_ok, capability_key="hole.diameter") + _assert(summary_ok.get("ok") is True, f"small summary changes should pass: {summary_ok}") + summary_after_bad = _cache_with_summary( + {"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45}, + _hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)), + ) + summary_bad = validate_scdm_edit_result( + {"ok": True, "output_step": str(output_step)}, + before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index] + before_cache=summary_before, + after_cache=summary_after_bad, + capability_key="hole.diameter", + expected_target=0.75, + ) + _assert(summary_bad.get("ok") is False and summary_bad.get("reason") == "summary-drift", f"large summary drift should fail: {summary_bad}") + 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}") + + 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)), + ) + ambiguous = match_scdm_object_by_signature(before_signature, ambiguous_after, capability_key="hole.diameter") + _assert(ambiguous.get("status") == "multiple", f"ambiguous matches should be reported: {ambiguous}") + + print("scdm result validator ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_scdm_status.py b/scripts/verify_scdm_status.py new file mode 100644 index 0000000..d0d78e6 --- /dev/null +++ b/scripts/verify_scdm_status.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import sys +import tempfile +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.scdm_backend import ScdmBackendInfo, save_scdm_backend_cache # noqa: E402 +from step_editor.scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime # noqa: E402 + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> int: + empty = summarize_scdm_runtime(cache_state="empty") + _assert(empty.get("backendReady") is False, f"empty backend should not be ready: {empty}") + _assert("未配置" in str(empty.get("headline")), f"empty headline should be clear: {empty}") + _assert("导入 STEP" in str(empty.get("detail")), f"empty detail should explain next step: {empty}") + + backend = ScdmBackendInfo( + path=Path("D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe"), + source="common:D:/softwaresInstallDir/ANSYS Inc", + version="v222", + verified_at="2026-08-18T00:00:00Z", + run_script_ok=True, + license_ok=True, + ) + feature_cache = { + "objects": [ + {"objectId": "face:1", "capabilities": [{"key": "face.offset"}]}, + {"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]}, + ] + } + ready = summarize_scdm_runtime(backend=backend, cache_state="ready", feature_cache=feature_cache) + _assert(ready.get("backendReady") is True, f"backend should be ready: {ready}") + _assert("已配置 v222" in str(ready.get("headline")), f"version should be visible: {ready}") + _assert("常见安装目录" in str(ready.get("headline")), f"source should be product text: {ready}") + _assert(ready.get("objectCount") == 2 and ready.get("capabilityCount") == 3, f"cache counts should be summarized: {ready}") + _assert("识别缓存已就绪" in str(ready.get("detail")), f"ready detail should be explicit: {ready}") + _assert("/RunScript:可用" in str(ready.get("tooltip")), f"tooltip should include /RunScript status: {ready}") + + running = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="running") + _assert("正在后台识别" in str(running.get("detail")), f"running state should explain background probe: {running}") + + failed = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="failed", cache_message="SpaceClaim.exe was not found.") + _assert("识别未启用" in str(failed.get("detail")), f"failed state should be clear: {failed}") + _assert("已有能力" in str(failed.get("detail")), f"failed state should explain fallback: {failed}") + + disabled = summarize_scdm_runtime(backend={"disabled": True}, cache_state="ready", feature_cache=feature_cache) + _assert(disabled.get("backendReady") is False, f"disabled backend should not be ready: {disabled}") + _assert("已关闭" in str(disabled.get("headline")), f"disabled state should be clear: {disabled}") + _assert("不会启动" in str(disabled.get("detail")), f"disabled detail should explain behavior: {disabled}") + + stale = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="stale", cache_message="模型已重新加载,SCDM cache 已失效。") + _assert("失效" in str(stale.get("detail")), f"stale state should be visible: {stale}") + + progress_cache = { + "objects": [ + { + "objectId": "face:1", + "capabilities": [{"key": "face.offset", "displayName": "偏移"}], + }, + { + "objectId": "hole:1", + "capabilities": [ + {"key": "hole.diameter", "displayName": "直径", "blockReason": "SCDM 当前脚本环境缺少 OffsetFaces 命令。"}, + {"key": "hole.position", "displayName": "位置"}, + {"key": "feature.fill", "displayName": "填孔/删除小特征"}, + ], + }, + ], + "diagnostics": { + "face_adjacency": [ + {"bodyIndex": 0, "faceOrdinals": [1, 2], "edgeCount": 1}, + {"bodyIndex": 0, "faceOrdinals": [2, 3], "edgeCount": 2}, + ], + "edge_geometry_summary": { + "totalEdgeCount": 12, + "edgeKindCounts": {"linear": 8, "circular": 4}, + "circularEdgeCount": 4, + "circularRadiusBuckets": [{"radius": "0.25", "count": 4}], + }, + "feature_inventory": { + "objectTypeCounts": {"face": 4, "hole": 2, "edge": 12, "slot": 1}, + "surfaceTypeCounts": {"plane": 4, "cylinder": 3}, + "operationCounts": {"pull_face_offset": 4, "change_hole_diameter": 2, "change_slot_width": 1}, + }, + "geometry_candidate_hints": [ + { + "capabilityKey": "boss.height", + "displayName": "凸台高度", + "evidenceCount": 3, + "confidence": "low", + }, + { + "capabilityKey": "pattern.spacing", + "displayName": "阵列间距", + "evidenceCount": 2, + "confidence": "low", + }, + ], + "derived_feature_candidates": [ + { + "objectId": "derived:linear_pattern:hole-a|hole-b|hole-c", + "objectType": "linear_pattern", + "geometrySignature": {"spacing": 5.0, "instanceCount": 3}, + } + ], + "planned_not_productized": [ + {"capabilityKey": "slot.width", "objectType": "slot"}, + {"capabilityKey": "slot.width", "objectType": "slot"}, + {"capabilityKey": "round.radius", "objectType": "round"}, + ], + "discovered_not_productized": [ + {"objectType": "mystery_feature"}, + {"objectType": "mystery_feature"}, + ], + }, + } + progress = summarize_scdm_capability_progress( + feature_cache=progress_cache, + execution_ready={"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"}, + ) + 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("discoveredNotProductized") == 2, f"unknown discoveries should be counted: {summary}") + _assert(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}") + _assert( + summary.get("inventoryObjectTypes") == 19 and summary.get("inventoryOperationCandidates") == 7, + f"probe feature inventory should be counted: {summary}", + ) + _assert(summary.get("geometryHints") == 5, f"geometry candidate hints should be counted: {summary}") + _assert(summary.get("derivedFeatureCandidates") == 1, f"derived S7 candidate count should be visible: {summary}") + productized_lines = "\n".join(str(line) for line in progress.get("productizedLines", [])) + planned_lines = "\n".join(str(line) for line in progress.get("plannedLines", [])) + 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("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}") + + _assert("linear_pattern:1" in evidence_lines, f"derived S7 candidate lines should be readable: {evidence_lines}") + + with tempfile.TemporaryDirectory(prefix="step_editor_scdm_status_") as temp: + root = Path(temp) + fake_exe = root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe" + fake_exe.parent.mkdir(parents=True, exist_ok=True) + fake_exe.write_text("fake", encoding="utf-8") + saved_backend = ScdmBackendInfo(path=fake_exe, source="manual", version="v222", run_script_ok=True, license_ok=True) + save_scdm_backend_cache(saved_backend, project_root_override=root) + cached = cached_scdm_backend_payload(root) + _assert(isinstance(cached, dict), f"cached backend should load: {cached}") + _assert(str(cached.get("source")) == "manual", f"cached source should be preserved: {cached}") + + print("scdm status summary ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step_editor/__init__.py b/step_editor/__init__.py index e9950f4..76ff747 100644 --- a/step_editor/__init__.py +++ b/step_editor/__init__.py @@ -1,11 +1,13 @@ from __future__ import annotations -from .model import StepModel - __all__ = ["StepEditorWindow", "StepModel", "main"] def __getattr__(name: str): + if name == "StepModel": + from .model import StepModel + + return StepModel if name in {"StepEditorWindow", "main"}: from .app import StepEditorWindow, main diff --git a/step_editor/app.py b/step_editor/app.py index e862b34..787c065 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -173,7 +173,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.orientation_marker_prop = None self.step_coordinate_axes_actor = None self.hide_edges_during_camera_interaction = False + self.hide_overlays_during_camera_interaction = False self.edge_visibility_before_camera_interaction: int | None = None + self.overlay_visibility_before_camera_interaction: dict[str, int] = {} self.prefer_fxaa_antialiasing = True self.fallback_multi_samples = 2 self.interactive_multi_samples = 0 @@ -184,6 +186,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hover_face_actor = None self.hover_edge_actor = None self.hover_signature: tuple[str, int] | None = None + self.large_model_edge_overlay_skipped = False + self.large_model_hover_disabled = False self.hover_interval_ms = 260 self.hover_move_threshold_px = 10 self.pending_hover_position: tuple[int, int] | None = None @@ -260,6 +264,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.asitus_thread: QThread | None = None self.asitus_worker: ScanWorker | None = None self.pending_asitus_context: dict[str, object] | None = None + self.scdm_thread: QThread | None = None + self.scdm_worker: ScanWorker | None = None + self.pending_scdm_context: dict[str, object] | None = None + self.scdm_backend_status: dict[str, object] | None = None + self.scdm_auto_config_prompt_seen = False + self.scdm_auto_config_prompt_active = False + self.scdm_feature_cache: dict[str, object] | None = None + 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.load_in_progress = False self.load_thread: QThread | None = None self.load_worker: LoadWorker | None = None @@ -505,10 +520,40 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border-color: #bbf7d0; color: #14532d; } + QPushButton#softwareProgressButton { + background: #f0fdf4; + border: 1px solid #86efac; + border-left: 5px solid #16a34a; + border-radius: 7px; + color: #14532d; + font-weight: 800; + min-height: 30px; + padding: 5px 10px; + text-align: left; + } + QPushButton#softwareProgressButton:hover { + background: #dcfce7; + border-color: #22c55e; + } + QPushButton#softwareProgressButton:pressed { + background: #bbf7d0; + border-color: #16a34a; + padding-top: 6px; + padding-bottom: 4px; + } QLabel#capabilityHeadline { color: #14532d; font-weight: 800; } + QLabel#scdmBackendStatus { + color: #166534; + font-size: 11px; + font-weight: 700; + } + QLabel#scdmBackendDetail { + color: #3f6212; + font-size: 11px; + } QLabel#capabilityDetail { color: #166534; font-size: 11px; @@ -1252,6 +1297,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf "特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。", ) self.property_table.itemChanged.connect(self._on_property_table_item_changed) + self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state()) object_edit_layout.addWidget(self.property_table) self.property_command_summary_label = QLabel("未选择可编辑对象") self.property_command_summary_label.setObjectName("propertyCommandSummary") @@ -1361,7 +1407,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.apply_property_button.setObjectName("parametricModelButton") self.apply_property_button.setMinimumHeight(34) self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - help_tip(self.apply_property_button, "应用当前被修改的参数;多个目标值会按表格顺序依次执行,失败时停止后续修改。") + help_tip(self.apply_property_button, "应用当前被修改的数值参数;命令型参数需先选中该行。多个项目会按表格顺序依次执行,失败时停止后续修改。") self.apply_property_button.clicked.connect(self.apply_current_property_edit) self.quick_export_all_button = QPushButton("导出模型") self.quick_export_all_button.setObjectName("quickExportStepButton") @@ -1697,16 +1743,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2) panel_layout.addWidget(self.object_edit_box) - self.current_capability_box = QGroupBox("软件进度") - self.current_capability_box.setObjectName("capabilitySection") - capability_layout = QVBoxLayout(self.current_capability_box) - capability_layout.setContentsMargins(8, 8, 8, 7) - capability_layout.setSpacing(2) - self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体") - self.current_capability_headline.setObjectName("capabilityHeadline") - self.current_capability_headline.setWordWrap(True) - capability_layout.addWidget(self.current_capability_headline) - panel_layout.addWidget(self.current_capability_box) + self.current_capability_button = QPushButton("软件进度") + self.current_capability_button.setObjectName("softwareProgressButton") + self.current_capability_button.setMinimumHeight(32) + self.current_capability_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + help_tip(self.current_capability_button, "点击查看当前参数化能力、SCDM 后端状态和后续实施路线。") + self.current_capability_button.clicked.connect(self.show_software_progress_dialog) + panel_layout.addWidget(self.current_capability_button) + self._update_current_capability_panel() if ENABLE_EXPORT_PANEL: panel_layout.addWidget(export_box) if ENABLE_VIEW_PANEL: diff --git a/step_editor/features.py b/step_editor/features.py index 2670254..6bcf0d2 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -3452,20 +3452,28 @@ class FeatureMixin: plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir)) if plane_axis_alignment < 0.92: continue - try: - axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) - except Exception: - axis_range = { - "v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())), - "v_max": max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())), - } - v_min = float(axis_range["v_min"]) - v_max = float(axis_range["v_max"]) + source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())) + source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())) + v_min = source_v_min + v_max = source_v_max + range_source = "selected-face-v-range-fast" height = max(v_max - v_min, 1e-9) cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point) start_distance = abs(cap_parameter - v_min) end_distance = abs(cap_parameter - v_max) end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + if start_distance > end_tolerance and end_distance > end_tolerance: + try: + axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + range_source = str(axis_range.get("range_source") or "same-domain-cylinder-faces") + height = max(v_max - v_min, 1e-9) + start_distance = abs(cap_parameter - v_min) + end_distance = abs(cap_parameter - v_max) + end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + except Exception: + pass if start_distance <= end_distance and start_distance <= end_tolerance: outward = _neg_tuple(_dir_tuple(axis_dir)) end_label = "start" @@ -3487,6 +3495,7 @@ class FeatureMixin: "cap_axis_parameter": cap_parameter, "cap_axis_start_parameter": v_min, "cap_axis_end_parameter": v_max, + "cap_axis_range_source": range_source, }, ) ) diff --git a/step_editor/model.py b/step_editor/model.py index 8108477..c4d37ed 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -226,6 +226,62 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info.update(_shape_volume_info(self.shape)) return info + 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]] = [] + for face_id, face in enumerate(self.faces): + try: + surf = BRepAdaptor_Surface(face) + surface_type = surf.GetType() + except Exception: + continue + signature: dict[str, object] = { + "faceId": int(face_id), + "logicalFaceId": self.face_logical_id(face_id), + "surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"), + } + try: + if surface_type == GeomAbs_Plane: + plane = surf.Plane() + origin = _point_tuple(plane.Location()) + normal = _dir_tuple(plane.Axis().Direction()) + signature["center"] = origin + signature["axis"] = normal + signature["planeOffset"] = ( + origin[0] * normal[0] + + origin[1] * normal[1] + + origin[2] * normal[2] + ) + elif surface_type == GeomAbs_Cylinder: + cylinder = surf.Cylinder() + axis = cylinder.Axis() + radius = cylinder.Radius() + signature["center"] = _point_tuple(axis.Location()) + signature["axis"] = _dir_tuple(axis.Direction()) + signature["radius"] = radius + signature["diameter"] = radius * 2.0 + elif surface_type == GeomAbs_Cone: + cone = surf.Cone() + signature["center"] = _point_tuple(cone.Location()) + signature["axis"] = _dir_tuple(cone.Axis().Direction()) + signature["radius"] = cone.RefRadius() + elif surface_type == GeomAbs_Sphere: + sphere = surf.Sphere() + radius = sphere.Radius() + signature["center"] = _point_tuple(sphere.Location()) + signature["radius"] = radius + signature["diameter"] = radius * 2.0 + elif surface_type == GeomAbs_Torus: + torus = surf.Torus() + signature["center"] = _point_tuple(torus.Location()) + signature["axis"] = _dir_tuple(torus.Axis().Direction()) + signature["majorRadius"] = torus.MajorRadius() + signature["minorRadius"] = torus.MinorRadius() + except Exception: + pass + signatures.append(signature) + return signatures + def refresh_topology(self) -> None: self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1 self.shape = _compound_from_shapes([p.shape for p in self.display_parts()]) @@ -746,7 +802,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd This intentionally avoids material-side sampling. It only combines directly connected co-cylindrical fragments and uses face orientation as - a hint, so full edit plans still recompute and guard the real feature + a hint. It must not trigger Analysis Situs or the internal recognition + graph; full edit plans still recompute and guard the real feature semantics before changing geometry. """ radius = _float_or_none(info.get("radius")) or 0.0 @@ -765,7 +822,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。" axis_range: dict[str, object] | None = None try: - side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] + side_face_ids = self._connected_cocylindrical_face_ids(face_id) or [face_id] spans: list[float] = [] for side_id in side_face_ids: side_surf = BRepAdaptor_Surface(self.faces[side_id]) @@ -1044,6 +1101,21 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd except Exception: return None + def face_plane_position_along(self, face_id: int, direction: tuple[float, float, float]) -> tuple[float, tuple[float, float, float]] | None: + if face_id < 0 or face_id >= len(self.faces): + return None + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Plane: + return None + plane = surf.Plane() + origin = _point_tuple(plane.Location()) + normal = _dir_tuple(plane.Axis().Direction()) + value = origin[0] * direction[0] + origin[1] * direction[1] + origin[2] * direction[2] + return float(value), normal + except Exception: + return None + def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]: surface = str(info.get("surface") or "") user_priority = feature_recognition_priority(info) @@ -4663,7 +4735,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd return list(self._internal_hole_region_cache.get(face_id, ())) def _can_use_internal_recognition_graph(self) -> bool: - return bool(self.faces) + return bool(self.faces) and len(self.faces) <= 1000 def _load_internal_hole_regions(self) -> None: self._internal_hole_regions_attempted = True diff --git a/step_editor/operations.py b/step_editor/operations.py index fe4f609..c281a2f 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -11298,6 +11298,11 @@ class OperationMixin: return None source_plane = source_surf.Plane() cap_plane_point = source_plane.Location() + try: + if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 2: + return None + except Exception: + pass try: scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id] except Exception: @@ -11342,14 +11347,29 @@ class OperationMixin: continue center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center) - axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) - v_min = float(axis_range["v_min"]) - v_max = float(axis_range["v_max"]) + source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())) + source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())) + axis_range = { + "v_min": source_v_min, + "v_max": source_v_max, + "same_domain_face_ids": (adjacent_id,), + "range_source": "selected-face-v-range-fast", + } + v_min = source_v_min + v_max = source_v_max old_height = max(v_max - v_min, 1e-9) cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point) start_distance = abs(cap_parameter - v_min) end_distance = abs(cap_parameter - v_max) end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + if start_distance > end_tolerance and end_distance > end_tolerance: + axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + old_height = max(v_max - v_min, 1e-9) + start_distance = abs(cap_parameter - v_min) + end_distance = abs(cap_parameter - v_max) + end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) delta_abs = abs(float(distance)) operation = "extend" if distance > 0 else "retract" diff --git a/step_editor/scdm_backend.py b/step_editor/scdm_backend.py new file mode 100644 index 0000000..b709baa --- /dev/null +++ b/step_editor/scdm_backend.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Iterable, Mapping, Sequence + +try: # pragma: no cover - exercised only on Windows hosts with registry access. + import winreg +except ImportError: # pragma: no cover + winreg = None # type: ignore[assignment] + + +SCDM_EXE_NAME = "SpaceClaim.exe" +SCDM_CACHE_RELATIVE_PATH = Path("local") / "scdm_backend.json" +SCDM_PATH_ENV_VARS = ( + "STEP_EDITOR_SCDM_EXE", + "STEP_EDITOR_SPACECLAIM_EXE", + "SPACECLAIM_EXE", +) +SCDM_DISABLE_ENV = "STEP_EDITOR_DISABLE_SCDM" +SCDM_TIMEOUT_ENV = "STEP_EDITOR_SCDM_TIMEOUT" +SCDM_CACHE_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class ScdmBackendInfo: + path: Path + source: str + version: str = "" + verified_at: str = "" + run_script_ok: bool = False + license_ok: bool | None = None + message: str = "" + + def to_cache(self) -> dict[str, object]: + return { + "schemaVersion": SCDM_CACHE_SCHEMA_VERSION, + "path": str(self.path), + "source": self.source, + "version": self.version, + "verifiedAt": self.verified_at, + "runScriptOk": bool(self.run_script_ok), + "licenseOk": self.license_ok, + "message": self.message, + } + + @classmethod + def from_cache(cls, payload: Mapping[str, object]) -> "ScdmBackendInfo | None": + raw_path = str(payload.get("path") or "").strip() + if not raw_path: + return None + path = Path(os.path.expandvars(raw_path)).expanduser() + if not _is_spaceclaim_exe(path): + return None + return cls( + path=path, + source=str(payload.get("source") or "cache"), + version=str(payload.get("version") or _version_from_path(path)), + verified_at=str(payload.get("verifiedAt") or ""), + run_script_ok=bool(payload.get("runScriptOk")), + license_ok=_optional_bool(payload.get("licenseOk")), + message=str(payload.get("message") or ""), + ) + + +def project_root(project_root_override: str | Path | None = None) -> Path: + return Path(project_root_override).expanduser() if project_root_override else Path(__file__).resolve().parent.parent + + +def default_scdm_cache_path(project_root_override: str | Path | None = None) -> Path: + return project_root(project_root_override) / SCDM_CACHE_RELATIVE_PATH + + +def is_scdm_disabled(env: Mapping[str, str] | None = None) -> bool: + value = (env or os.environ).get(SCDM_DISABLE_ENV, "") + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def load_scdm_backend_cache( + *, + project_root_override: str | Path | None = None, + cache_path: str | Path | None = None, +) -> ScdmBackendInfo | None: + path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override) + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + return ScdmBackendInfo.from_cache(payload) + + +def save_scdm_backend_cache( + backend: ScdmBackendInfo, + *, + project_root_override: str | Path | None = None, + cache_path: str | Path | None = None, +) -> Path: + path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(backend.to_cache(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return path + + +def discover_scdm_backend_candidates( + *, + manual_path: str | Path | None = None, + include_env: bool = True, + include_registry: bool = True, + include_common: bool = True, + include_path: bool = True, + common_roots: Iterable[str | Path] | None = None, + env: Mapping[str, str] | None = None, +) -> tuple[ScdmBackendInfo, ...]: + env_map = env or os.environ + candidates: list[ScdmBackendInfo] = [] + + if manual_path: + candidates.extend(_info_for_user_value(manual_path, "manual")) + + if include_env: + for env_name in SCDM_PATH_ENV_VARS: + raw_value = env_map.get(env_name, "").strip() + if raw_value: + candidates.extend(_info_for_user_value(raw_value, f"env:{env_name}")) + + if include_registry: + candidates.extend(_registry_candidates()) + + if include_common: + candidates.extend(_common_install_candidates(common_roots=common_roots, env=env_map)) + + if include_path: + found = shutil.which(SCDM_EXE_NAME) + if found: + candidates.extend(_info_for_user_value(found, "PATH")) + + return _dedupe_candidates(candidates) + + +def resolve_scdm_backend( + *, + project_root_override: str | Path | None = None, + cache_path: str | Path | None = None, + manual_path: str | Path | None = None, + prefer_cache: bool = True, + save_cache: bool = True, + validate: bool = False, + include_env: bool = True, + include_registry: bool = True, + include_common: bool = True, + include_path: bool = True, + common_roots: Iterable[str | Path] | None = None, + env: Mapping[str, str] | None = None, + timeout_seconds: float | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> dict[str, object]: + env_map = env or os.environ + if is_scdm_disabled(env_map): + return {"ok": False, "reason": "disabled", "backend": None, "message": "SCDM backend is disabled by environment."} + + if prefer_cache: + cached = load_scdm_backend_cache(project_root_override=project_root_override, cache_path=cache_path) + if cached is not None: + if not validate or cached.run_script_ok: + return _resolution_payload(cached, reason="cache", message="Using cached SCDM backend.") + checked = verify_scdm_backend(cached, timeout_seconds=timeout_seconds, runner=runner) + if checked.get("ok"): + verified = _verified_backend_from_result(cached, checked) + if save_cache: + save_scdm_backend_cache(verified, project_root_override=project_root_override, cache_path=cache_path) + return _resolution_payload(verified, reason="cache-verified", message="Cached SCDM backend passed smoke test.") + + failures: list[dict[str, object]] = [] + candidates = discover_scdm_backend_candidates( + manual_path=manual_path, + include_env=include_env, + include_registry=include_registry, + include_common=include_common, + include_path=include_path, + common_roots=common_roots, + env=env_map, + ) + for candidate in candidates: + backend = candidate + if validate: + checked = verify_scdm_backend(candidate, timeout_seconds=timeout_seconds, runner=runner) + if not checked.get("ok"): + failures.append( + { + "path": str(candidate.path), + "source": candidate.source, + "reason": checked.get("reason"), + "message": checked.get("message"), + } + ) + continue + backend = _verified_backend_from_result(candidate, checked) + + if save_cache: + save_scdm_backend_cache(backend, project_root_override=project_root_override, cache_path=cache_path) + reason = "discovered-verified" if validate else "discovered" + return _resolution_payload(backend, reason=reason, message=f"SCDM backend resolved from {backend.source}.") + + return { + "ok": False, + "reason": "missing-spaceclaim", + "backend": None, + "candidates": (), + "failures": tuple(failures), + "message": "SpaceClaim.exe was not found. Ask the user to configure the SCDM path manually.", + } + + +def verify_scdm_backend( + backend: ScdmBackendInfo | str | Path, + *, + timeout_seconds: float | None = None, + work_dir: str | Path | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> dict[str, object]: + if isinstance(backend, ScdmBackendInfo): + info = backend + else: + matches = _info_for_user_value(backend, "manual") + if not matches: + return { + "ok": False, + "reason": "missing-exe", + "path": str(Path(str(backend)).expanduser()), + "message": "SpaceClaim.exe does not exist.", + } + info = matches[0] + if not _is_spaceclaim_exe(info.path): + return {"ok": False, "reason": "missing-exe", "path": str(info.path), "message": "SpaceClaim.exe does not exist."} + + timeout = timeout_seconds if timeout_seconds is not None else _timeout_seconds() + temp_context = None + if work_dir is None: + temp_context = tempfile.TemporaryDirectory(prefix="step_editor_scdm_") + work_root = Path(temp_context.name) + else: + work_root = Path(work_dir).expanduser() + work_root.mkdir(parents=True, exist_ok=True) + + try: + script_path = work_root / "scdm_smoke.py" + report_path = work_root / "scdm_smoke_result.json" + script_path.write_text(_smoke_script(report_path), encoding="utf-8") + command = scdm_run_script_command(info.path, script_path) + run = runner or subprocess.run + try: + completed = run( + command, + cwd=str(work_root), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=max(float(timeout), 0.1), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + check=False, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "reason": "timeout", "path": str(info.path), "message": "SCDM smoke test timed out."} + except OSError as exc: + return {"ok": False, "reason": "launch-failed", "path": str(info.path), "message": str(exc)} + + returncode = int(getattr(completed, "returncode", -1)) + stdout = str(getattr(completed, "stdout", "") or "") + stderr = str(getattr(completed, "stderr", "") or "") + if returncode != 0: + return { + "ok": False, + "reason": "run-script-failed", + "path": str(info.path), + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + "message": (stderr or stdout or f"SCDM returned {returncode}.").strip(), + } + if not report_path.is_file(): + return { + "ok": False, + "reason": "missing-report", + "path": str(info.path), + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + "message": "SCDM smoke script finished but did not write a report.", + } + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"ok": False, "reason": "bad-report", "path": str(info.path), "message": str(exc)} + if not isinstance(report, dict) or report.get("ok") is not True: + return {"ok": False, "reason": "negative-report", "path": str(info.path), "message": str(report)} + return { + "ok": True, + "reason": "ok", + "path": str(info.path), + "source": info.source, + "version": str(report.get("version") or info.version or _version_from_path(info.path)), + "verifiedAt": _utc_now(), + "runScriptOk": True, + "licenseOk": True, + "returncode": returncode, + "message": str(report.get("message") or "SCDM /RunScript smoke test passed."), + } + finally: + if temp_context is not None: + temp_context.cleanup() + + +def scdm_run_script_command(spaceclaim_exe: str | Path, script_path: str | Path) -> list[str]: + exe = Path(spaceclaim_exe).expanduser().resolve(strict=False) + script = Path(script_path).expanduser().resolve(strict=False) + return [ + str(exe), + f"/RunScript={script}", + "/Headless=True", + "/ExitAfterScript=True", + ] + + +def _info_for_user_value(value: str | Path, source: str) -> list[ScdmBackendInfo]: + path = _spaceclaim_path_from_value(value) + if path is None: + return [] + return [ScdmBackendInfo(path=path, source=source, version=_version_from_path(path))] + + +def _spaceclaim_path_from_value(value: str | Path) -> Path | None: + text = os.path.expandvars(str(value)).strip().strip('"') + if not text: + return None + path = Path(text).expanduser() + possible = [path] + if path.is_dir(): + possible = [ + path / SCDM_EXE_NAME, + path / "SCDM" / SCDM_EXE_NAME, + ] + for candidate in possible: + if _is_spaceclaim_exe(candidate): + return candidate.resolve(strict=False) + return None + + +def _is_spaceclaim_exe(path: Path) -> bool: + return path.name.lower() == SCDM_EXE_NAME.lower() and path.is_file() + + +def _registry_candidates() -> list[ScdmBackendInfo]: + if os.name != "nt" or winreg is None: + return [] + + candidates: list[ScdmBackendInfo] = [] + app_path_keys = ( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SpaceClaim.exe", + r"SOFTWARE\Classes\Applications\SpaceClaim.exe\shell\open\command", + ) + roots = ((winreg.HKEY_CURRENT_USER, "HKCU"), (winreg.HKEY_LOCAL_MACHINE, "HKLM")) + views = (0, getattr(winreg, "KEY_WOW64_64KEY", 0), getattr(winreg, "KEY_WOW64_32KEY", 0)) + for root, root_label in roots: + for access in views: + for key_path in app_path_keys: + for raw_value in _registry_key_values(root, key_path, access): + for path in _paths_from_registry_value(raw_value): + candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\{key_path}")) + candidates.extend(_uninstall_registry_candidates(root, root_label, access)) + return candidates + + +def _registry_key_values(root: int, key_path: str, access: int) -> list[str]: + values: list[str] = [] + try: + with winreg.OpenKey(root, key_path, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr] + for name in ("", "Path", "InstallPath", "InstallLocation"): + try: + value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr] + except OSError: + continue + if isinstance(value, str) and value.strip(): + values.append(value) + except OSError: + return [] + return values + + +def _uninstall_registry_candidates(root: int, root_label: str, access: int) -> list[ScdmBackendInfo]: + uninstall_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + candidates: list[ScdmBackendInfo] = [] + try: + with winreg.OpenKey(root, uninstall_key, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr] + index = 0 + while True: + try: + subkey_name = winreg.EnumKey(key, index) # type: ignore[union-attr] + except OSError: + break + index += 1 + try: + with winreg.OpenKey(key, subkey_name, 0, winreg.KEY_READ | access) as subkey: # type: ignore[union-attr] + display_name = _registry_string(subkey, "DisplayName") + install_location = _registry_string(subkey, "InstallLocation") + except OSError: + continue + if "spaceclaim" not in display_name.lower() and "ansys" not in display_name.lower(): + continue + for path in _paths_from_registry_value(install_location): + candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\Uninstall")) + except OSError: + return [] + return candidates + + +def _registry_string(key: object, name: str) -> str: + try: + value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr] + except OSError: + return "" + return value if isinstance(value, str) else "" + + +def _paths_from_registry_value(value: str) -> list[str]: + text = value.strip() + if not text: + return [] + exe = _extract_exe_from_command(text) + if exe: + return [exe] + return [ + text, + str(Path(text) / SCDM_EXE_NAME), + str(Path(text) / "SCDM" / SCDM_EXE_NAME), + ] + + +def _extract_exe_from_command(command: str) -> str: + text = command.strip() + if not text: + return "" + if text.startswith('"'): + end = text.find('"', 1) + if end > 1: + first = text[1:end] + return first if first.lower().endswith(".exe") else "" + lowered = text.lower() + index = lowered.find(".exe") + if index >= 0: + return text[: index + 4] + return "" + + +def _common_install_candidates( + *, + common_roots: Iterable[str | Path] | None = None, + env: Mapping[str, str] | None = None, +) -> list[ScdmBackendInfo]: + roots = list(common_roots) if common_roots is not None else _default_common_roots(env or os.environ) + candidates: list[ScdmBackendInfo] = [] + for root in roots: + base = Path(os.path.expandvars(str(root))).expanduser() + if not base.is_dir(): + continue + direct_paths = ( + base / SCDM_EXE_NAME, + base / "SCDM" / SCDM_EXE_NAME, + ) + for path in direct_paths: + candidates.extend(_info_for_user_value(path, f"common:{base}")) + version_dirs = sorted((item for item in base.glob("v*") if item.is_dir()), key=_version_sort_key, reverse=True) + for version_dir in version_dirs: + candidates.extend(_info_for_user_value(version_dir / "SCDM" / SCDM_EXE_NAME, f"common:{base}")) + return candidates + + +def _default_common_roots(env: Mapping[str, str]) -> tuple[Path, ...]: + roots: list[Path] = [] + for env_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"): + raw = env.get(env_name, "") + if raw: + roots.append(Path(raw) / "ANSYS Inc") + for drive in ("C", "D", "E"): + roots.append(Path(f"{drive}:/Program Files/ANSYS Inc")) + roots.append(Path(f"{drive}:/softwaresInstallDir/ANSYS Inc")) + return tuple(_dedupe_paths(roots)) + + +def _dedupe_candidates(candidates: Iterable[ScdmBackendInfo]) -> tuple[ScdmBackendInfo, ...]: + result: list[ScdmBackendInfo] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate.path.resolve(strict=False)).casefold() + if key in seen: + continue + seen.add(key) + result.append(candidate) + return tuple(result) + + +def _dedupe_paths(paths: Iterable[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[str] = set() + for path in paths: + key = str(path.resolve(strict=False)).casefold() + if key in seen: + continue + seen.add(key) + result.append(path) + return result + + +def _version_sort_key(path: Path) -> tuple[int, str]: + match = re.search(r"v(\d+)", path.name, flags=re.IGNORECASE) + return (int(match.group(1)) if match else -1, path.name.lower()) + + +def _version_from_path(path: Path) -> str: + for part in path.parts: + match = re.fullmatch(r"v\d+", part, flags=re.IGNORECASE) + if match: + return part + return "" + + +def _verified_backend_from_result(candidate: ScdmBackendInfo, result: Mapping[str, object]) -> ScdmBackendInfo: + return ScdmBackendInfo( + path=candidate.path, + source=candidate.source, + version=str(result.get("version") or candidate.version), + verified_at=str(result.get("verifiedAt") or _utc_now()), + run_script_ok=bool(result.get("runScriptOk")), + license_ok=_optional_bool(result.get("licenseOk")), + message=str(result.get("message") or candidate.message), + ) + + +def _resolution_payload(backend: ScdmBackendInfo, *, reason: str, message: str) -> dict[str, object]: + return { + "ok": True, + "reason": reason, + "backend": backend, + "path": str(backend.path), + "source": backend.source, + "version": backend.version, + "verifiedAt": backend.verified_at, + "runScriptOk": backend.run_script_ok, + "licenseOk": backend.license_ok, + "message": message, + } + + +def _smoke_script(report_path: Path) -> str: + report_literal = repr(str(report_path)) + return ( + "from __future__ import print_function\n" + f"report_path = {report_literal}\n" + "version = ''\n" + "try:\n" + " version = str(Application.Version)\n" + "except Exception:\n" + " version = ''\n" + "payload = '{\"ok\": true, \"version\": \"' + version.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\", \"message\": \"RunScript reached\"}'\n" + "handle = open(report_path, 'w')\n" + "handle.write(payload)\n" + "handle.close()\n" + ) + + +def _timeout_seconds() -> float: + try: + return max(float(os.environ.get(SCDM_TIMEOUT_ENV, "") or 25.0), 0.1) + except ValueError: + return 25.0 + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _optional_bool(value: object) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return None + + +__all__ = [ + "SCDM_CACHE_RELATIVE_PATH", + "SCDM_DISABLE_ENV", + "SCDM_EXE_NAME", + "SCDM_PATH_ENV_VARS", + "SCDM_TIMEOUT_ENV", + "ScdmBackendInfo", + "default_scdm_cache_path", + "discover_scdm_backend_candidates", + "is_scdm_disabled", + "load_scdm_backend_cache", + "project_root", + "resolve_scdm_backend", + "save_scdm_backend_cache", + "scdm_run_script_command", + "verify_scdm_backend", +] diff --git a/step_editor/scdm_capabilities.py b/step_editor/scdm_capabilities.py new file mode 100644 index 0000000..8f3b811 --- /dev/null +++ b/step_editor/scdm_capabilities.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ScdmCapabilityDefinition: + key: str + display_name: str + object_types: tuple[str, ...] + value_kind: str + current_fields: tuple[str, ...] + default_intent: str + backend_operation: str + post_check: str + required_backend_command_groups: tuple[tuple[str, ...], ...] = () + productized: bool = True + roadmap_stage: str = "S5" + block_reason: str = "" + + def to_payload(self) -> dict[str, object]: + return { + "key": self.key, + "displayName": self.display_name, + "objectTypes": self.object_types, + "valueKind": self.value_kind, + "currentFields": self.current_fields, + "defaultIntent": self.default_intent, + "backendOperation": self.backend_operation, + "postCheck": self.post_check, + "requiredBackendCommandGroups": self.required_backend_command_groups, + "productized": self.productized, + "roadmapStage": self.roadmap_stage, + "blockReason": self.block_reason, + } + + +CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { + "hole.diameter": ScdmCapabilityDefinition( + key="hole.diameter", + display_name="直径", + object_types=("hole", "cylindrical_hole", "cylindrical_face_group"), + value_kind="number", + current_fields=("geometry.diameter", "geometry.radius*2"), + default_intent="修改孔径", + backend_operation="change_hole_diameter", + post_check="target_hole_diameter", + required_backend_command_groups=(("StandardHoles",), ("OffsetFaces",)), + roadmap_stage="S5", + ), + "hole.position": ScdmCapabilityDefinition( + key="hole.position", + display_name="位置", + object_types=("hole", "cylindrical_hole", "cylindrical_face_group"), + value_kind="vector3", + current_fields=("geometry.center", "geometry.axisCenter"), + default_intent="移动孔", + backend_operation="move_hole_axis", + post_check="target_hole_axis_center", + required_backend_command_groups=(("Move",),), + roadmap_stage="S5", + ), + "face.offset": ScdmCapabilityDefinition( + key="face.offset", + display_name="偏移", + object_types=("face", "planar_face"), + value_kind="number", + current_fields=("geometry.offset", "geometry.planeOffset", "0"), + default_intent="推拉平面", + backend_operation="pull_face_offset", + post_check="target_face_offset", + required_backend_command_groups=(("OffsetFaces",),), + roadmap_stage="S5", + ), + "feature.fill": ScdmCapabilityDefinition( + key="feature.fill", + display_name="填孔/删除小特征", + object_types=("hole", "small_feature"), + value_kind="command", + current_fields=("1",), + default_intent="删除并补面", + backend_operation="fill_feature", + post_check="target_feature_removed", + required_backend_command_groups=(("Fill",), ("Delete",)), + roadmap_stage="S5", + ), + "slot.width": ScdmCapabilityDefinition( + key="slot.width", + display_name="槽宽", + object_types=("slot", "obround_slot", "rectangular_slot"), + value_kind="number", + current_fields=("geometry.width",), + default_intent="修改槽宽", + backend_operation="change_slot_width", + post_check="target_slot_width", + productized=False, + 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",), + default_intent="修改槽深", + backend_operation="change_slot_depth", + post_check="target_slot_depth", + productized=False, + roadmap_stage="S7.2", + block_reason="槽深属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), + "slot.position": ScdmCapabilityDefinition( + key="slot.position", + display_name="槽位置", + object_types=("slot", "obround_slot", "rectangular_slot"), + value_kind="vector3", + current_fields=("geometry.center", "geometry.axisCenter"), + default_intent="移动槽", + backend_operation="move_slot", + post_check="target_slot_center", + required_backend_command_groups=(("Move",),), + roadmap_stage="S7.2", + ), + "boss.height": ScdmCapabilityDefinition( + key="boss.height", + display_name="凸台高度", + object_types=("boss", "cylindrical_boss", "rectangular_boss"), + value_kind="number", + current_fields=("geometry.height",), + default_intent="修改凸台高度", + backend_operation="change_boss_height", + post_check="target_boss_height", + productized=False, + roadmap_stage="S7.3", + block_reason="凸台高度属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), + "boss.diameter": ScdmCapabilityDefinition( + key="boss.diameter", + display_name="凸台直径", + object_types=("boss", "cylindrical_boss"), + value_kind="number", + current_fields=("geometry.diameter", "geometry.radius*2"), + default_intent="修改凸台直径", + backend_operation="change_boss_diameter", + post_check="target_boss_diameter", + productized=False, + roadmap_stage="S7.3", + block_reason="凸台直径属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), + "boss.position": ScdmCapabilityDefinition( + key="boss.position", + display_name="凸台位置", + object_types=("boss", "cylindrical_boss", "rectangular_boss"), + value_kind="vector3", + current_fields=("geometry.center", "geometry.axisCenter"), + default_intent="移动凸台", + backend_operation="move_boss", + post_check="target_boss_center", + required_backend_command_groups=(("Move",),), + roadmap_stage="S7.3", + ), + "round.radius": ScdmCapabilityDefinition( + key="round.radius", + display_name="圆角半径", + object_types=("round", "fillet"), + value_kind="number", + current_fields=("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"), + 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", + display_name="删除圆角/倒角", + object_types=("round", "fillet", "chamfer"), + value_kind="command", + current_fields=("1",), + default_intent="删除圆角/倒角并补面", + 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", + display_name="阵列间距", + object_types=("pattern", "linear_pattern"), + value_kind="number", + current_fields=("geometry.spacing", "geometry.pitch"), + default_intent="修改阵列间距", + backend_operation="change_pattern_spacing", + post_check="target_pattern_spacing", + productized=False, + roadmap_stage="S7.5", + block_reason="阵列间距属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), + "pattern.instance_position": ScdmCapabilityDefinition( + key="pattern.instance_position", + display_name="阵列实例位置", + object_types=("pattern", "linear_pattern"), + value_kind="vector3", + current_fields=("geometry.instanceCenter", "geometry.center"), + default_intent="移动阵列实例", + backend_operation="move_pattern_instance", + post_check="target_pattern_instance_center", + productized=False, + roadmap_stage="S7.5", + block_reason="阵列实例位置属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), + "shell.thickness": ScdmCapabilityDefinition( + key="shell.thickness", + display_name="壳体厚度", + object_types=("shell", "thin_wall"), + value_kind="number", + current_fields=("geometry.thickness",), + default_intent="修改壳体厚度", + backend_operation="change_shell_thickness", + post_check="target_shell_thickness", + productized=False, + roadmap_stage="S7.5", + block_reason="壳体厚度属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。", + ), +} + + +def capability_definition(key: str) -> ScdmCapabilityDefinition | None: + return CAPABILITY_DEFINITIONS.get(key) + + +def productized_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + key + for key in capability_keys_for_raw_object(raw_object, include_planned=False) + if (definition := capability_definition(key)) is not None and definition.productized + ) + + +def planned_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + key + for key in capability_keys_for_raw_object(raw_object, include_planned=True) + if (definition := capability_definition(key)) is not None and not definition.productized + ) + + +def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_planned: bool = False) -> tuple[str, ...]: + object_type = str(raw_object.get("objectType") or "").strip().lower() + geometry = _mapping(raw_object.get("geometry")) + commands = tuple(_command_operations(raw_object.get("backendCommandCandidates"))) + keys: list[str] = [] + + if object_type in {"hole", "cylindrical_hole", "cylindrical_face_group"}: + if _has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius")): + keys.append("hole.diameter") + if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")): + keys.append("hole.position") + if _has_command_token(commands, ("fill", "delete", "remove")): + keys.append("feature.fill") + + surface_type = str(geometry.get("surfaceType") or geometry.get("surface") or "").strip().lower() + if object_type in {"face", "planar_face"} and surface_type in {"plane", "planar", ""}: + if _has_command_token(commands, ("pull", "offset", "move_face")) or _has_any(geometry, ("normal", "planeOffset")): + keys.append("face.offset") + + if object_type in {"hole", "small_feature"} and _has_command_token(commands, ("fill", "delete", "remove")): + keys.append("feature.fill") + + if object_type in {"slot", "obround_slot", "rectangular_slot"}: + if _has_any(geometry, ("width",)) or _has_command_token(commands, ("slot_width", "width")): + keys.append("slot.width") + if _has_any(geometry, ("depth",)) or _has_command_token(commands, ("slot_depth", "depth")): + keys.append("slot.depth") + if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")): + keys.append("slot.position") + + if object_type in {"boss", "cylindrical_boss", "rectangular_boss"}: + if _has_any(geometry, ("height",)) or _has_command_token(commands, ("boss_height", "height")): + keys.append("boss.height") + if object_type != "rectangular_boss" and (_has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius"))): + keys.append("boss.diameter") + if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")): + keys.append("boss.position") + + if object_type in {"round", "fillet"}: + if _has_any(geometry, ("radius",)) or _has_command_token(commands, ("round_radius", "fillet_radius", "radius")): + keys.append("round.radius") + if _has_command_token(commands, ("fill", "delete", "remove")): + keys.append("feature.delete_round_or_chamfer") + + if object_type == "chamfer": + if _has_any(geometry, ("distance", "offset")) or _has_command_token(commands, ("chamfer_distance", "distance", "offset")): + keys.append("chamfer.distance") + if _has_command_token(commands, ("fill", "delete", "remove")): + keys.append("feature.delete_round_or_chamfer") + + 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") + if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")): + keys.append("pattern.instance_position") + + if object_type in {"shell", "thin_wall"}: + if _has_any(geometry, ("thickness",)) or _has_command_token(commands, ("shell_thickness", "thickness")): + keys.append("shell.thickness") + + result = [] + for key in keys: + definition = capability_definition(key) + if definition is None: + continue + if definition.productized or include_planned: + result.append(key) + return tuple(dict.fromkeys(result)) + + +def _mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else {} + + +def _has_any(mapping: Mapping[str, object], names: tuple[str, ...]) -> bool: + return any(name in mapping and mapping.get(name) is not None for name in names) + + +def _command_operations(value: object) -> list[str]: + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + if not isinstance(item, Mapping): + continue + enabled = item.get("enabled") + if enabled is False: + continue + text = " ".join( + str(part or "") + for part in ( + item.get("key"), + item.get("operation"), + item.get("command"), + item.get("type"), + ) + ) + result.append(text.lower()) + return result + + +def _has_command_token(commands: tuple[str, ...], tokens: tuple[str, ...]) -> bool: + return any(token in command for command in commands for token in tokens) + + +__all__ = [ + "CAPABILITY_DEFINITIONS", + "ScdmCapabilityDefinition", + "capability_keys_for_raw_object", + "capability_definition", + "planned_capability_keys", + "productized_capability_keys", +] diff --git a/step_editor/scdm_edit_runner.py b/step_editor/scdm_edit_runner.py new file mode 100644 index 0000000..0e98f09 --- /dev/null +++ b/step_editor/scdm_edit_runner.py @@ -0,0 +1,1145 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Mapping +from pathlib import Path +from typing import Callable + +from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command +from .scdm_capabilities import capability_definition +from .scdm_schema import default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json + + +SCDM_EDIT_SCHEMA_VERSION = 1 +SCDM_EDIT_ADAPTER = "spaceclaim-v1" + + +def prepare_scdm_edit_job( + source_step: str | Path, + *, + capability_key: str, + target_value: object, + object_signature: Mapping[str, object] | None = None, + object_id: str = "", + backend_operation: str = "", + output_step: str | Path | None = None, + output_dir: str | Path | None = None, + project_root: str | Path | None = None, + backend: ScdmBackendInfo | None = None, + timeout_seconds: float = 180.0, + rollback_step: str | Path | None = None, + context: Mapping[str, object] | None = None, +) -> dict[str, object]: + source = Path(source_step).expanduser() + if not source.is_file(): + return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"} + + definition = capability_definition(capability_key) + if definition is None: + return { + "ok": False, + "reason": "unsupported-capability", + "message": f"SCDM capability is not productized yet: {capability_key}", + } + if not definition.productized: + return { + "ok": False, + "reason": "capability-not-productized", + "message": definition.block_reason or f"SCDM capability is planned but not executable yet: {capability_key}", + "capability_key": definition.key, + "roadmap_stage": definition.roadmap_stage, + } + + fingerprint = file_fingerprint(source) + work_dir = _edit_work_dir(source, fingerprint=fingerprint, output_dir=output_dir, project_root=project_root).resolve(strict=False) + work_dir.mkdir(parents=True, exist_ok=True) + output_path = Path(output_step).expanduser().resolve(strict=False) if output_step else work_dir / "result.step" + result_path = work_dir / "result.json" + error_path = work_dir / "error.json" + job_path = work_dir / "scdm_edit_job.json" + script_path = work_dir / "scdm_edit.py" + operation = backend_operation or definition.backend_operation + + _clear_stale_outputs((output_path, result_path, error_path)) + + job = { + "schemaVersion": SCDM_EDIT_SCHEMA_VERSION, + "adapter": SCDM_EDIT_ADAPTER, + "createdAt": utc_now(), + "backend": { + "name": "SCDM", + "path": str(backend.path) if backend else "", + "version": backend.version if backend else "", + }, + "model": { + "sourceStep": str(source.resolve(strict=False)), + "rollbackStep": str(Path(rollback_step).expanduser().resolve(strict=False)) if rollback_step else str(source.resolve(strict=False)), + "fingerprint": fingerprint, + }, + "object": { + "objectId": object_id, + "geometrySignature": _json_safe(dict(object_signature or {})), + }, + "target": { + "capabilityKey": definition.key, + "displayName": definition.display_name, + "valueKind": definition.value_kind, + "value": _target_value_for_job(target_value, value_kind=definition.value_kind), + "text": str(target_value), + "backendOperation": operation, + "postCheck": definition.post_check, + }, + "execution": { + "mode": "SpaceClaim.exe /RunScript", + "timeoutSeconds": max(float(timeout_seconds), 0.1), + "isolatedProcess": True, + }, + "outputs": { + "outputStep": str(output_path), + "result": str(result_path), + "error": str(error_path), + }, + "context": _json_safe(dict(context or {})), + } + write_json(job_path, job) + script_path.write_text(generate_scdm_edit_script(job_path), encoding="utf-8") + + return { + "ok": True, + "reason": "ok", + "work_dir": str(work_dir), + "job_path": str(job_path), + "script_path": str(script_path), + "output_step": str(output_path), + "result_path": str(result_path), + "error_path": str(error_path), + "model_fingerprint": fingerprint, + "capability_key": definition.key, + "backend_operation": operation, + } + + +def run_scdm_edit_job( + source_step: str | Path, + *, + capability_key: str, + target_value: object, + object_signature: Mapping[str, object] | None = None, + object_id: str = "", + backend_operation: str = "", + output_step: str | Path | None = None, + output_dir: str | Path | None = None, + project_root: str | Path | None = None, + backend: ScdmBackendInfo | None = None, + timeout_seconds: float = 180.0, + rollback_step: str | Path | None = None, + context: Mapping[str, object] | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> dict[str, object]: + if backend is None: + resolved = resolve_scdm_backend(project_root_override=project_root, validate=False) + if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo): + return { + "ok": False, + "reason": str(resolved.get("reason") or "missing-scdm"), + "message": str(resolved.get("message") or "SCDM backend is not available."), + } + backend = resolved["backend"] # type: ignore[assignment] + + prepared = prepare_scdm_edit_job( + source_step, + capability_key=capability_key, + target_value=target_value, + object_signature=object_signature, + object_id=object_id, + backend_operation=backend_operation, + output_step=output_step, + output_dir=output_dir, + project_root=project_root, + backend=backend, + timeout_seconds=timeout_seconds, + rollback_step=rollback_step, + context=context, + ) + if not prepared.get("ok"): + return prepared + + result = run_prepared_scdm_edit_job(prepared, backend=backend, timeout_seconds=timeout_seconds, runner=runner) + if result.get("ok") is True: + try: + save_scdm_backend_cache(_edit_verified_backend(backend), project_root_override=project_root) + except Exception: + pass + return result + + +def run_prepared_scdm_edit_job( + prepared: Mapping[str, object], + *, + backend: ScdmBackendInfo, + timeout_seconds: float = 180.0, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> dict[str, object]: + script_path = Path(str(prepared.get("script_path") or "")).expanduser() + result_path = Path(str(prepared.get("result_path") or "")).expanduser() + error_path = Path(str(prepared.get("error_path") or "")).expanduser() + output_step = Path(str(prepared.get("output_step") or "")).expanduser() + if not script_path.is_file(): + return _with_prepared( + prepared, + ok=False, + reason="missing-script", + message=f"SCDM edit script not found: {script_path}", + backend=backend.to_cache(), + ) + + command = scdm_run_script_command(backend.path, script_path) + run = runner or subprocess.run + try: + completed = run( + command, + cwd=str(script_path.parent), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=max(float(timeout_seconds), 0.1), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + check=False, + ) + except subprocess.TimeoutExpired: + _write_error( + error_path, + reason="timeout", + message="SCDM edit timed out.", + prepared=prepared, + ) + return _with_prepared(prepared, ok=False, reason="timeout", message="SCDM edit timed out.") + except OSError as exc: + _write_error( + error_path, + reason="launch-failed", + message=str(exc), + prepared=prepared, + ) + return _with_prepared(prepared, ok=False, reason="launch-failed", message=str(exc)) + + return _result_from_completed(completed, prepared=prepared, result_path=result_path, error_path=error_path, output_step=output_step, backend=backend) + + +def generate_scdm_edit_script(job_path: str | Path) -> str: + job_literal = repr(str(Path(job_path).expanduser())) + return _SCDM_EDIT_SCRIPT_TEMPLATE.replace("__STEP_EDITOR_SCDM_EDIT_JOB_PATH__", job_literal) + + +def _edit_work_dir( + source: Path, + *, + fingerprint: str, + output_dir: str | Path | None, + project_root: str | Path | None, +) -> Path: + if output_dir: + return Path(output_dir).expanduser() + return default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint) / "edit" + + +def _clear_stale_outputs(paths: tuple[Path, ...]) -> None: + for path in paths: + try: + if path.exists(): + path.unlink() + except OSError: + pass + + +def _target_value_for_job(value: object, *, value_kind: str) -> object: + if value_kind == "vector3": + parsed = _parse_vector3(value) + return parsed if parsed else _json_safe(value) + if value_kind in {"number", "length", "angle"}: + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return _json_safe(value) + if value_kind == "command": + return True if value in ("", None) else _json_safe(value) + return _json_safe(value) + + +def _parse_vector3(value: object) -> list[float]: + if isinstance(value, (list, tuple)): + items = list(value) + elif isinstance(value, str): + text = value.strip().strip("()[]") + if not text: + return [] + items = text.replace(",", " ").split() + else: + return [] + if len(items) != 3: + return [] + try: + return [float(item) for item in items] + except (TypeError, ValueError): + return [] + + +def _json_safe(value: object) -> object: + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _result_from_completed( + completed: subprocess.CompletedProcess[str], + *, + prepared: Mapping[str, object], + result_path: Path, + error_path: Path, + output_step: Path, + backend: ScdmBackendInfo, +) -> dict[str, object]: + returncode = int(getattr(completed, "returncode", -1)) + stdout = str(getattr(completed, "stdout", "") or "") + stderr = str(getattr(completed, "stderr", "") or "") + + if error_path.is_file(): + error = _read_json_or_message(error_path) + reason = str(error.get("reason") or "scdm-edit-failed") + message = str(error.get("message") or stderr or stdout or "SCDM edit failed.") + return { + **dict(prepared), + "ok": False, + "reason": reason, + "message": message, + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + "error": error, + "backend": backend.to_cache(), + } + + if returncode != 0: + message = (stderr or stdout or f"SCDM returned {returncode}.").strip() + _write_error(error_path, reason="edit-failed", message=message, prepared=prepared, returncode=returncode) + return { + **dict(prepared), + "ok": False, + "reason": "edit-failed", + "message": message, + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + "backend": backend.to_cache(), + } + + if not result_path.is_file(): + message = "SCDM edit finished but did not write result.json." + _write_error(error_path, reason="missing-result", message=message, prepared=prepared, returncode=returncode) + return _with_prepared(prepared, ok=False, reason="missing-result", message=message, returncode=returncode, backend=backend.to_cache()) + + result = _read_json_or_message(result_path) + if result.get("ok") is not True: + reason = str(result.get("reason") or "negative-result") + message = str(result.get("message") or "SCDM edit result was not successful.") + return _with_prepared(prepared, ok=False, reason=reason, message=message, returncode=returncode, result=result, backend=backend.to_cache()) + + if not output_step.is_file(): + message = f"SCDM edit reported success but output STEP was not written: {output_step}" + _write_error(error_path, reason="missing-output-step", message=message, prepared=prepared, returncode=returncode) + return { + **dict(prepared), + "ok": False, + "reason": "missing-output-step", + "message": message, + "returncode": returncode, + "result": result, + "backend": backend.to_cache(), + } + try: + output_size = output_step.stat().st_size + except OSError: + output_size = -1 + if output_size <= 0: + message = f"SCDM edit reported success but output STEP is empty: {output_step}" + _write_error(error_path, reason="empty-output-step", message=message, prepared=prepared, returncode=returncode) + return { + **dict(prepared), + "ok": False, + "reason": "empty-output-step", + "message": message, + "returncode": returncode, + "result": result, + "backend": backend.to_cache(), + } + + return { + **dict(prepared), + "ok": True, + "reason": "ok", + "message": str(result.get("message") or "SCDM edit finished."), + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + "result": result, + "output_step": str(output_step), + "backend": _edit_verified_backend(backend).to_cache(), + } + + +def _edit_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo: + return ScdmBackendInfo( + path=backend.path, + source=backend.source, + version=backend.version, + verified_at=utc_now(), + run_script_ok=True, + license_ok=True, + message="SCDM edit completed.", + ) + + +def _with_prepared(prepared: Mapping[str, object], **payload: object) -> dict[str, object]: + result = dict(prepared) + result.update(payload) + return result + + +def _write_error(path: Path, *, reason: str, message: str, prepared: Mapping[str, object], **extra: object) -> None: + payload = { + "ok": False, + "reason": reason, + "message": message, + "createdAt": utc_now(), + "jobPath": str(prepared.get("job_path") or ""), + "scriptPath": str(prepared.get("script_path") or ""), + "outputStep": str(prepared.get("output_step") or ""), + } + payload.update(extra) + write_json(path, payload) + + +def _read_json_or_message(path: Path) -> dict[str, object]: + try: + return read_json(path) + except Exception as exc: + return {"ok": False, "reason": "bad-json", "message": str(exc)} + + +_SCDM_EDIT_SCRIPT_TEMPLATE = r''' +from __future__ import print_function +import json +import traceback + +JOB_PATH = __STEP_EDITOR_SCDM_EDIT_JOB_PATH__ + + +def _write_json(path, payload): + handle = open(path, 'w') + try: + handle.write(json.dumps(payload, indent=2)) + finally: + handle.close() + + +def _read_job(): + handle = open(JOB_PATH, 'r') + try: + return json.loads(handle.read()) + finally: + handle.close() + + +def _items(collection): + if collection is None: + return [] + try: + return list(collection) + except Exception: + result = [] + try: + count = int(collection.Count) + for index in range(count): + result.append(collection[index]) + except Exception: + pass + return result + + +def _maybe_call(target, name, *args): + try: + func = getattr(target, name) + except Exception: + return None + try: + return func(*args) + except Exception: + return None + + +def _open_step(path): + errors = [] + document_open = globals().get('DocumentOpen') + if document_open is not None: + for args in ((path,),): + try: + return document_open.Execute(*args) + except Exception as exc: + errors.append('DocumentOpen.Execute: ' + str(exc)) + application = globals().get('Application') + if application is not None: + for name in ('OpenDocument', 'OpenFile'): + try: + return getattr(application, name)(path) + except Exception as exc: + errors.append('Application.' + name + ': ' + str(exc)) + raise Exception('open_step_failed: ' + '; '.join(errors)) + + +def _root_part(): + get_root_part = globals().get('GetRootPart') + if get_root_part is not None: + try: + return get_root_part() + except Exception: + pass + application = globals().get('Application') + if application is not None: + for expr in ('ActiveWindow.Document.MainPart', 'ActiveDocument.MainPart', 'Document.MainPart'): + value = application + ok = True + for name in expr.split('.'): + try: + value = getattr(value, name) + except Exception: + ok = False + break + if ok and value is not None: + return value + raise Exception('root_part_not_found') + + +def _bodies(root): + for name in ('Bodies', 'GetAllBodies'): + value = _maybe_call(root, name) + if value is None: + try: + value = getattr(root, name) + except Exception: + value = None + items = _items(value) + if items: + return items + return [] + + +def _faces(body): + for name in ('Faces', 'GetFaces'): + value = _maybe_call(body, name) + if value is None: + try: + value = getattr(body, name) + except Exception: + value = None + items = _items(value) + if items: + return items + return [] + + +def _flat_faces(bodies): + result = [] + for body in bodies: + result.extend(_faces(body)) + return result + + +def _int_or_none(value): + try: + return int(value) + except Exception: + return None + + +def _append_unique_face(result, face): + for existing in result: + try: + if existing == face: + return + except Exception: + pass + try: + if existing is face: + return + except Exception: + pass + result.append(face) + + +def _locate_one_face(bodies, body_index, face_ordinal, global_face_ordinal): + if body_index is not None and face_ordinal is not None and 0 <= body_index < len(bodies): + faces = _faces(bodies[body_index]) + if 0 <= face_ordinal < len(faces): + return faces[face_ordinal] + flat_ordinal = global_face_ordinal if global_face_ordinal is not None else face_ordinal + if flat_ordinal is not None: + flat = _flat_faces(bodies) + if 0 <= flat_ordinal < len(flat): + return flat[flat_ordinal] + return None + + +def _locate_faces(signature): + root = _root_part() + bodies = _bodies(root) + body_index = _int_or_none(signature.get('bodyIndex')) + face_ordinal = _int_or_none(signature.get('faceOrdinal')) + global_face_ordinal = _int_or_none(signature.get('globalFaceOrdinal')) + result = [] + locators = signature.get('scdmFaceLocators') + if isinstance(locators, list): + 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) + face_ordinals = signature.get('faceOrdinals') + if isinstance(face_ordinals, list) and body_index is not None: + for item in face_ordinals: + face = _locate_one_face(bodies, body_index, _int_or_none(item), None) + if face is not None: + _append_unique_face(result, face) + global_face_ordinals = signature.get('globalFaceOrdinals') + if isinstance(global_face_ordinals, list): + for item in global_face_ordinals: + face = _locate_one_face(bodies, None, None, _int_or_none(item)) + if face is not None: + _append_unique_face(result, face) + if result: + return result + face = _locate_one_face(bodies, body_index, face_ordinal, global_face_ordinal) + if face is not None: + return [face] + raise Exception('object_not_found: geometrySignature does not locate a unique SCDM face') + + +def _selection(items): + selection = globals().get('Selection') + if selection is not None: + try: + return selection.Create(items) + except Exception: + pass + if len(items) == 1: + try: + return selection.Create(items[0]) + except Exception: + pass + return items[0] if len(items) == 1 else items + + +def _import_attr(module_name, attr_name): + try: + module = __import__(module_name, fromlist=[attr_name]) + return getattr(module, attr_name) + except Exception: + return None + + +def _api_versions(): + return ('V23', 'V22', 'V21', 'V20', 'V19', 'V18', 'V17', 'V16') + + +def _command_type(name): + value = globals().get(name) + if value is not None: + return value + for version in _api_versions(): + value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Commands', name) + if value is not None: + return value + return None + + +def _command_option_type(name): + value = globals().get(name) + if value is not None: + return value + for version in _api_versions(): + value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Commands.CommandOptions', name) + if value is not None: + return value + return None + + +def _selection_type(): + value = globals().get('Selection') + if value is not None: + return value + for version in _api_versions(): + value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Selection', 'Selection') + if value is not None: + return value + return None + + +def _geometry_type(name): + value = globals().get(name) + if value is not None: + return value + for version in _api_versions(): + value = _import_attr('SpaceClaim.Api.' + version + '.Geometry', name) + if value is not None: + return value + return None + + +def _new_options(name): + cls = _command_option_type(name) + if cls is None: + return None + try: + options = cls() + except Exception: + return None + for attr, value in (('Copy', False), ('DetachFirst', False), ('CreatePatterns', False)): + try: + if hasattr(options, attr): + setattr(options, attr, value) + except Exception: + pass + return options + + +def _empty_selection(): + selection = _selection_type() + if selection is None: + return None + for name in ('Empty', 'Create'): + try: + return getattr(selection, name)() + except Exception: + pass + return None + + +def _fill_mode_three_d(): + fill_mode = _command_type('FillMode') + if fill_mode is None: + return None + for name in ('ThreeD', 'ThreeDMode'): + try: + return getattr(fill_mode, name) + except Exception: + pass + return None + + +def _make_vector(x, y, z): + vector_type = _geometry_type('Vector') + if vector_type is None: + return None + for maker in ('Create', 'CreateVector'): + try: + return getattr(vector_type, maker)(x, y, z) + except Exception: + pass + try: + return vector_type(x, y, z) + except Exception: + return None + + +def _make_direction(x, y, z): + direction_type = _geometry_type('Direction') + if direction_type is None: + return None + for maker in ('Create', 'CreateDirection'): + try: + return getattr(direction_type, maker)(x, y, z) + except Exception: + pass + try: + return direction_type(x, y, z) + except Exception: + return None + + +def _vector_length(values): + return (values[0] * values[0] + values[1] * values[1] + values[2] * values[2]) ** 0.5 + + +def _unit_direction_and_distance(values): + distance = _vector_length(values) + if distance <= 1e-12: + return None, 0.0 + direction = _make_direction(values[0] / distance, values[1] / distance, values[2] / distance) + return direction, distance + + +def _signature_direction(signature): + for key in ('normal', 'axis'): + value = signature.get(key) + if isinstance(value, list) and len(value) == 3: + try: + direction = _make_direction(float(value[0]), float(value[1]), float(value[2])) + if direction is not None: + return direction + except Exception: + pass + return None + + +def _call_variants(label, func, variants): + errors = [] + for args in variants: + if args is None: + continue + try: + result = func(*args) + return {'ok': True, 'result': result, 'signature': label + '(' + str(len(args)) + ' args)'} + except Exception as exc: + errors.append(label + '(' + str(len(args)) + ' args): ' + str(exc)) + return {'ok': False, 'errors': errors} + + +def _offset_faces(selection, distance, signature): + offset_faces = _command_type('OffsetFaces') + if offset_faces is None: + raise Exception('capability_not_implemented: OffsetFaces command not available') + options = _new_options('OffsetFaceOptions') + direction = _signature_direction(signature) + variants = [] + if direction is not None: + variants.append((selection, distance, direction, options, None)) + variants.append((selection, distance, direction, options)) + variants.append((selection, distance, options, None)) + variants.append((selection, distance, options)) + result = _call_variants('OffsetFaces.Execute', offset_faces.Execute, variants) + if result.get('ok'): + return {'command': 'OffsetFaces.Execute', 'distance': distance, 'apiSignature': result.get('signature')} + raise Exception('capability_not_implemented: OffsetFaces.Execute failed; ' + '; '.join(result.get('errors') or [])) + + +def _translate_selection(selection, delta): + move = _command_type('Move') + if move is None: + raise Exception('capability_not_implemented: Move command not available') + options = _new_options('MoveOptions') + vector = _make_vector(delta[0], delta[1], delta[2]) + variants = [] + if vector is not None: + variants.append((selection, vector, options, None)) + variants.append((selection, vector, options)) + direction, distance = _unit_direction_and_distance(delta) + if direction is not None: + variants.append((selection, direction, distance, options, None)) + variants.append((selection, direction, distance, options)) + if vector is not None: + variants.append((selection, vector)) + result = _call_variants('Move.Translate', move.Translate, variants) + if result.get('ok'): + return {'command': 'Move.Translate', 'delta': delta, 'apiSignature': result.get('signature')} + execute = getattr(move, 'Execute', None) + if execute is not None and vector is not None: + fallback = _call_variants('Move.Execute', execute, ((selection, vector, options), (selection, vector))) + if fallback.get('ok'): + return {'command': 'Move.Execute', 'delta': delta, 'apiSignature': fallback.get('signature')} + result['errors'].extend(fallback.get('errors') or []) + raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or [])) + + +def _fill_selection(selection): + fill = _command_type('Fill') + if fill is None: + raise Exception('capability_not_implemented: Fill command not available') + options = _new_options('FillOptions') + for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)): + try: + if options is not None and hasattr(options, attr): + setattr(options, attr, value) + except Exception: + pass + secondary = _empty_selection() + mode = _fill_mode_three_d() + variants = [] + if mode is not None: + variants.append((selection, secondary, options, mode, None)) + variants.append((selection, secondary, options, mode)) + variants.append((selection, None, options, mode, None)) + variants.append((selection, None, options, mode)) + variants.append((selection, secondary, options, None, None)) + variants.append((selection, secondary, options, None)) + variants.append((selection, options, None)) + variants.append((selection, options)) + variants.append((selection, None)) + variants.append((selection,)) + result = _call_variants('Fill.Execute', fill.Execute, variants) + if result.get('ok'): + return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')} + delete = _command_type('Delete') + if delete is not None: + fallback = _call_variants('Delete.Execute', delete.Execute, ((selection,),)) + if fallback.get('ok'): + return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': result.get('errors') or []} + result['errors'].extend(fallback.get('errors') or []) + raise Exception('capability_not_implemented: Fill.Execute failed; ' + '; '.join(result.get('errors') or [])) + + +def _float_value(value): + try: + return float(value) + except Exception: + raise Exception('target_value_not_numeric: ' + str(value)) + + +def _vector3(value): + if not isinstance(value, list) or len(value) != 3: + raise Exception('target_value_not_vector3: ' + str(value)) + return [float(value[0]), float(value[1]), float(value[2])] + + +def _save_step(path): + errors = [] + for name in ('DocumentSave', 'DocumentSaveAs'): + command = globals().get(name) + if command is None: + command = _command_type(name) + if command is None: + continue + try: + command.Execute(path) + return {'command': name + '.Execute'} + except Exception as exc: + errors.append(name + '.Execute: ' + str(exc)) + application = globals().get('Application') + if application is not None: + for expr in ('ActiveWindow.Document', 'ActiveDocument', 'Document'): + document = application + ok = True + for name in expr.split('.'): + try: + document = getattr(document, name) + except Exception: + ok = False + break + if not ok or document is None: + continue + for method in ('SaveAs', 'SaveCopyAs', 'Export'): + try: + getattr(document, method)(path) + return {'command': expr + '.' + method} + except Exception as exc: + errors.append(expr + '.' + method + ': ' + str(exc)) + raise Exception('save_step_failed: ' + '; '.join(errors)) + + +def change_hole_diameter(job): + target = _float_value(job['target']['value']) + if target <= 0: + raise Exception('target_value_illegal: diameter must be positive') + radius = target / 2.0 + signature = job['object'].get('geometrySignature') or {} + faces = _locate_faces(signature) + selection = _selection(faces) + errors = [] + standard_holes = _command_type('StandardHoles') + if standard_holes is not None: + for args in ((selection, radius, None), (selection, radius), (faces, radius, None), (faces, radius)): + try: + standard_holes.ModifyHoleRadius(*args) + return {'command': 'StandardHoles.ModifyHoleRadius', 'targetRadius': radius, 'apiSignature': str(len(args)) + ' args'} + except Exception as exc: + errors.append('StandardHoles.ModifyHoleRadius: ' + str(exc)) + current_radius = None + for key in ('radius',): + try: + current_radius = float(signature.get(key)) + break + except Exception: + pass + if current_radius is None: + try: + current_radius = float(signature.get('diameter')) / 2.0 + except Exception: + current_radius = None + if current_radius is not None: + # For internal cylindrical hole walls, SpaceClaim's positive face offset + # follows the face normal into the void and reduces the measured radius. + radial_delta = current_radius - radius + if abs(radial_delta) <= 1e-12: + return {'command': 'noop', 'targetRadius': radius, 'reason': 'target already reached'} + try: + applied = _offset_faces(selection, radial_delta, signature) + applied['targetRadius'] = radius + applied['radialDelta'] = radial_delta + applied['standardHoleErrors'] = errors + return applied + except Exception as exc: + errors.append('OffsetFaces.Execute: ' + str(exc)) + raise Exception('capability_not_implemented: hole.diameter adapter failed; ' + '; '.join(errors)) + + +def move_hole_axis(job): + target = _vector3(job['target']['value']) + signature = job['object'].get('geometrySignature') or {} + center = signature.get('center') or [] + if not isinstance(center, list) or len(center) != 3: + raise Exception('object_signature_missing_center') + delta = [float(target[index]) - float(center[index]) for index in range(3)] + if _vector_length(delta) <= 1e-12: + return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + return _translate_selection(selection, delta) + except Exception as exc: + raise Exception('capability_not_implemented: hole.position adapter failed; ' + str(exc)) + + +def move_slot(job): + target = _vector3(job['target']['value']) + signature = job['object'].get('geometrySignature') or {} + center = signature.get('center') or signature.get('axisCenter') or [] + if not isinstance(center, list) or len(center) != 3: + raise Exception('object_signature_missing_center') + delta = [float(target[index]) - float(center[index]) for index in range(3)] + if _vector_length(delta) <= 1e-12: + return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _translate_selection(selection, delta) + applied['targetCenter'] = target + return applied + except Exception as exc: + raise Exception('capability_not_implemented: slot.position adapter failed; ' + str(exc)) + + +def move_boss(job): + target = _vector3(job['target']['value']) + signature = job['object'].get('geometrySignature') or {} + center = signature.get('center') or signature.get('axisCenter') or [] + if not isinstance(center, list) or len(center) != 3: + raise Exception('object_signature_missing_center') + delta = [float(target[index]) - float(center[index]) for index in range(3)] + if _vector_length(delta) <= 1e-12: + return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _translate_selection(selection, delta) + applied['targetCenter'] = target + return applied + except Exception as exc: + raise Exception('capability_not_implemented: boss.position adapter failed; ' + str(exc)) + + +def pull_face_offset(job): + target = _float_value(job['target']['value']) + signature = job['object'].get('geometrySignature') or {} + current = None + try: + current = float(signature.get('planeOffset')) + except Exception: + current = None + distance = target - current if current is not None else target + if abs(distance) <= 1e-12: + return {'command': 'noop', 'distance': distance, 'reason': 'target already reached'} + faces = _locate_faces(signature) + selection = _selection(faces) + try: + applied = _offset_faces(selection, distance, signature) + applied['targetPlaneOffset'] = target + return applied + except Exception as exc: + raise Exception('capability_not_implemented: face.offset adapter failed; ' + str(exc)) + + +def fill_feature(job): + faces = _locate_faces(job['object'].get('geometrySignature') or {}) + selection = _selection(faces) + try: + return _fill_selection(selection) + except Exception as exc: + raise Exception('capability_not_implemented: feature.fill adapter failed; ' + str(exc)) + + +def _apply_edit(job): + operation = job['target'].get('backendOperation') or '' + if operation == 'change_hole_diameter': + return change_hole_diameter(job) + if operation == 'move_hole_axis': + return move_hole_axis(job) + if operation == 'move_slot': + return move_slot(job) + if operation == 'move_boss': + return move_boss(job) + if operation == 'pull_face_offset': + return pull_face_offset(job) + if operation == 'fill_feature': + return fill_feature(job) + raise Exception('unsupported_backend_operation: ' + operation) + + +def main(): + job = _read_job() + outputs = job.get('outputs') or {} + error_path = outputs.get('error') or 'error.json' + result_path = outputs.get('result') or 'result.json' + output_step = outputs.get('outputStep') or 'result.step' + try: + _open_step(job['model']['sourceStep']) + applied = _apply_edit(job) + saved = _save_step(output_step) + _write_json(result_path, { + 'ok': True, + 'reason': 'ok', + 'message': 'SCDM edit finished.', + 'capabilityKey': job['target'].get('capabilityKey'), + 'backendOperation': job['target'].get('backendOperation'), + 'objectId': job['object'].get('objectId'), + 'targetValue': job['target'].get('value'), + 'outputStep': output_step, + 'applied': applied, + 'saved': saved, + }) + except Exception as exc: + _write_json(error_path, { + 'ok': False, + 'reason': str(exc).split(':', 1)[0].replace('_', '-'), + 'message': str(exc), + 'traceback': traceback.format_exc(), + 'capabilityKey': (job.get('target') or {}).get('capabilityKey') if 'job' in locals() else '', + 'backendOperation': (job.get('target') or {}).get('backendOperation') if 'job' in locals() else '', + 'objectId': (job.get('object') or {}).get('objectId') if 'job' in locals() else '', + 'outputStep': output_step if 'output_step' in locals() else '', + }) + raise + + +main() +''' + + +__all__ = [ + "SCDM_EDIT_ADAPTER", + "SCDM_EDIT_SCHEMA_VERSION", + "generate_scdm_edit_script", + "prepare_scdm_edit_job", + "run_prepared_scdm_edit_job", + "run_scdm_edit_job", +] diff --git a/step_editor/scdm_feature_mapper.py b/step_editor/scdm_feature_mapper.py new file mode 100644 index 0000000..6ced867 --- /dev/null +++ b/step_editor/scdm_feature_mapper.py @@ -0,0 +1,1137 @@ +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from pathlib import Path + +from .scdm_capabilities import capability_definition, planned_capability_keys, productized_capability_keys +from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json + + +def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]: + objects = [] + diagnostics: dict[str, object] = { + "discovered_not_productized": [], + "planned_not_productized": [], + "blocked": [], + } + raw_diagnostics = _mapping(raw_payload.get("diagnostics")) + available_commands = raw_diagnostics.get("availableCommands") + available_command_names = _available_command_names(available_commands) + if isinstance(available_commands, list): + diagnostics["backend_commands"] = [dict(item) for item in available_commands if isinstance(item, Mapping)] + face_adjacency = raw_diagnostics.get("faceAdjacency") + if isinstance(face_adjacency, list): + diagnostics["face_adjacency"] = [dict(item) for item in face_adjacency if isinstance(item, Mapping)] + edge_geometry_summary = raw_diagnostics.get("edgeGeometrySummary") + if isinstance(edge_geometry_summary, Mapping): + diagnostics["edge_geometry_summary"] = dict(edge_geometry_summary) + feature_inventory = raw_diagnostics.get("featureInventory") + if isinstance(feature_inventory, Mapping): + diagnostics["feature_inventory"] = { + str(key): dict(value) + for key, value in feature_inventory.items() + if isinstance(value, Mapping) + } + raw_summary = raw_payload.get("summary") + if isinstance(raw_summary, Mapping): + diagnostics["raw_summary"] = dict(raw_summary) + raw_objects = raw_payload.get("objects") + if not isinstance(raw_objects, list): + raw_objects = [] + cylindrical_group_objects = _cylindrical_face_group_objects(raw_objects) + derived_objects = _derived_feature_objects([*raw_objects, *cylindrical_group_objects]) + if derived_objects: + diagnostics["derived_feature_candidates"] = [ + { + "objectId": _object_id(item), + "objectType": str(item.get("objectType") or ""), + "geometrySignature": geometry_signature(item), + "rawLimitations": list(item.get("rawLimitations") or []), + } + for item in derived_objects + if isinstance(item, Mapping) + ] + diagnostics["geometry_candidate_hints"] = _geometry_candidate_hints( + [*raw_objects, *cylindrical_group_objects, *derived_objects], + raw_diagnostics, + ) + + for raw_object in [*raw_objects, *derived_objects]: + if not isinstance(raw_object, Mapping): + continue + capability_keys = productized_capability_keys(raw_object) + planned_keys = planned_capability_keys(raw_object) + capabilities = [_capability_payload(key, raw_object, available_command_names=available_command_names) for key in capability_keys] + capabilities = [item for item in capabilities if item is not None] + normalized_object = { + "objectId": _object_id(raw_object), + "objectType": str(raw_object.get("objectType") or ""), + "sourceBackendId": str(raw_object.get("backendId") or ""), + "geometrySignature": geometry_signature(raw_object), + "capabilities": capabilities, + "blockReason": str(raw_object.get("blockReason") or ""), + } + if capabilities: + objects.append(normalized_object) + else: + diagnostics["discovered_not_productized"].append( + { + "objectId": normalized_object["objectId"], + "objectType": normalized_object["objectType"], + "sourceBackendId": normalized_object["sourceBackendId"], + "reason": "no_productized_capability", + } + ) + for key in planned_keys: + definition = capability_definition(key) + diagnostics["planned_not_productized"].append( + { + "objectId": normalized_object["objectId"], + "objectType": normalized_object["objectType"], + "sourceBackendId": normalized_object["sourceBackendId"], + "capabilityKey": key, + "displayName": definition.display_name if definition else key, + "roadmapStage": definition.roadmap_stage if definition else "", + "reason": definition.block_reason if definition else "Planned capability is not productized.", + } + ) + if normalized_object["blockReason"]: + diagnostics["blocked"].append( + { + "objectId": normalized_object["objectId"], + "objectType": normalized_object["objectType"], + "reason": normalized_object["blockReason"], + } + ) + + for raw_object in cylindrical_group_objects: + capability_keys = productized_capability_keys(raw_object) + capabilities = [_capability_payload(key, raw_object, available_command_names=available_command_names) for key in capability_keys] + capabilities = [item for item in capabilities if item is not None] + normalized_object = { + "objectId": _object_id(raw_object), + "objectType": str(raw_object.get("objectType") or ""), + "sourceBackendId": str(raw_object.get("backendId") or ""), + "geometrySignature": geometry_signature(raw_object), + "capabilities": capabilities, + "blockReason": str(raw_object.get("blockReason") or ""), + } + if capabilities: + objects.append(normalized_object) + else: + diagnostics["discovered_not_productized"].append( + { + "objectId": normalized_object["objectId"], + "objectType": normalized_object["objectType"], + "sourceBackendId": normalized_object["sourceBackendId"], + "reason": "no_productized_capability", + } + ) + + return { + "schemaVersion": SCDM_CACHE_SCHEMA_VERSION, + "source": "SCDM", + "createdAt": utc_now(), + "modelFingerprint": payload_model_fingerprint(raw_payload), + "backendVersion": payload_backend_version(raw_payload), + "objects": objects, + "diagnostics": diagnostics, + } + + +def _geometry_candidate_hints( + raw_objects: list[object], + raw_diagnostics: Mapping[str, object], +) -> list[dict[str, object]]: + inventory = _mapping(raw_diagnostics.get("featureInventory")) + object_counts = _normalized_count_dict(inventory.get("objectTypeCounts")) + surface_counts = _normalized_count_dict(inventory.get("surfaceTypeCounts")) + curve_counts = _normalized_count_dict(inventory.get("curveTypeCounts")) + operation_counts = _normalized_count_dict(inventory.get("operationCounts")) + scanned = _scan_raw_feature_counts(raw_objects) + object_counts = _merge_count_dicts(object_counts, scanned["objectTypeCounts"]) + surface_counts = _merge_count_dicts(surface_counts, scanned["surfaceTypeCounts"]) + curve_counts = _merge_count_dicts(curve_counts, scanned["curveTypeCounts"]) + operation_counts = _merge_count_dicts(operation_counts, scanned["operationCounts"]) + + edge_summary = _mapping(raw_diagnostics.get("edgeGeometrySummary")) + edge_kind_counts = _normalized_count_dict(edge_summary.get("edgeKindCounts")) + circular_edges = _count_value(edge_summary.get("circularEdgeCount")) or edge_kind_counts.get("circular", 0) + linear_edges = edge_kind_counts.get("linear", 0) + radius_buckets = edge_summary.get("circularRadiusBuckets") + radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0 + cylinder_faces = surface_counts.get("cylinder", 0) + planar_faces = surface_counts.get("plane", 0) + round_objects = object_counts.get("round", 0) + object_counts.get("fillet", 0) + slot_objects = object_counts.get("slot", 0) + object_counts.get("obround_slot", 0) + object_counts.get("rectangular_slot", 0) + boss_objects = object_counts.get("boss", 0) + object_counts.get("cylindrical_boss", 0) + object_counts.get("rectangular_boss", 0) + chamfer_objects = object_counts.get("chamfer", 0) + pattern_objects = object_counts.get("pattern", 0) + object_counts.get("linear_pattern", 0) + shell_objects = object_counts.get("shell", 0) + object_counts.get("thin_wall", 0) + + hints: list[dict[str, object]] = [] + if slot_objects: + _add_hint_group( + hints, + ("slot.width", "slot.depth", "slot.position"), + slot_objects, + "medium", + "SCDM raw 已给出槽类对象;进入 S7 第二批真实命令和样例回测。", + "scdm-object-type", + ) + elif cylinder_faces and circular_edges and linear_edges: + _add_hint_group( + hints, + ("slot.width", "slot.depth", "slot.position"), + min(cylinder_faces, circular_edges), + "low", + "当前模型含圆柱面、圆边和直边,具备槽/长圆孔分类材料;还需要确认具体槽组和编辑命令。", + "scdm-geometry-summary", + ) + + if boss_objects: + _add_hint_group( + hints, + ("boss.height", "boss.diameter", "boss.position"), + boss_objects, + "medium", + "SCDM raw 已给出凸台类对象;进入 S7 第三批真实命令和样例回测。", + "scdm-object-type", + ) + elif cylinder_faces and planar_faces: + _add_hint_group( + hints, + ("boss.height", "boss.diameter", "boss.position"), + cylinder_faces, + "low", + "当前模型含圆柱面和平面,具备凸台/外圆分类材料;还需要区分孔、外圆、槽和圆角。", + "scdm-geometry-summary", + ) + + round_candidate_count = ( + round_objects + or operation_counts.get("change_round_radius", 0) + or operation_counts.get("delete_round_or_chamfer", 0) + ) + if round_candidate_count: + _add_hint_group( + hints, + ("round.radius", "feature.delete_round_or_chamfer"), + round_candidate_count, + "medium", + "SCDM raw 已给出圆角/倒圆候选命令;执行器和专门质量校验仍待回测。", + "scdm-command-candidate", + ) + elif circular_edges and radius_bucket_count: + _add_hint_group( + hints, + ("round.radius",), + circular_edges, + "low", + "当前模型含圆边和半径分组,具备圆角链分类材料;还需要确认支撑面和真实 Round 命令。", + "scdm-edge-summary", + ) + + if chamfer_objects: + _add_hint_group( + hints, + ("chamfer.distance", "feature.delete_round_or_chamfer"), + chamfer_objects, + "medium", + "SCDM raw 已给出倒角对象;进入 S7 第四批真实命令和样例回测。", + "scdm-object-type", + ) + elif planar_faces and linear_edges and circular_edges: + _add_hint_group( + hints, + ("chamfer.distance",), + min(planar_faces, linear_edges), + "low", + "当前模型含大量平面和直边,具备倒角分类材料;还需要确认倒角斜面和相邻支撑面。", + "scdm-geometry-summary", + ) + + if pattern_objects: + _add_hint_group( + hints, + ("pattern.spacing", "pattern.instance_position"), + pattern_objects, + "medium", + "SCDM raw 已给出阵列对象;进入 S7 第五批真实命令和样例回测。", + "scdm-object-type", + ) + elif radius_bucket_count and circular_edges >= 4: + _add_hint_group( + hints, + ("pattern.spacing", "pattern.instance_position"), + radius_bucket_count, + "low", + "当前模型含重复圆边半径分组,具备孔/圆角阵列识别材料;还需要确认实例分组和间距方向。", + "scdm-edge-summary", + ) + + if shell_objects: + _add_hint_group( + hints, + ("shell.thickness",), + shell_objects, + "medium", + "SCDM raw 已给出壳体/薄壁对象;进入 S7 第五批真实命令和样例回测。", + "scdm-object-type", + ) + elif planar_faces >= 2: + _add_hint_group( + hints, + ("shell.thickness",), + planar_faces, + "low", + "当前模型含多组平面,具备薄壁/相对面厚度分类材料;还需要确认成对壁面和偏移方向。", + "scdm-geometry-summary", + ) + return hints + + +def _add_hint_group( + hints: list[dict[str, object]], + keys: tuple[str, ...], + count: int, + confidence: str, + reason: str, + source: str, +) -> None: + evidence_count = max(0, int(count)) + if evidence_count <= 0: + return + for key in keys: + definition = capability_definition(key) + if definition is None or definition.productized: + continue + hints.append( + { + "capabilityKey": key, + "displayName": definition.display_name, + "roadmapStage": definition.roadmap_stage, + "evidenceCount": evidence_count, + "confidence": confidence, + "source": source, + "reason": reason, + } + ) + + +def _scan_raw_feature_counts(raw_objects: list[object]) -> dict[str, dict[str, int]]: + result = { + "objectTypeCounts": {}, + "surfaceTypeCounts": {}, + "curveTypeCounts": {}, + "operationCounts": {}, + } + for raw_object in raw_objects: + if not isinstance(raw_object, Mapping): + continue + _increment_count(result["objectTypeCounts"], raw_object.get("objectType")) + geometry = _mapping(raw_object.get("geometry")) + if geometry.get("surfaceType") is not None: + _increment_count(result["surfaceTypeCounts"], geometry.get("surfaceType")) + if geometry.get("curveType") is not None: + _increment_count(result["curveTypeCounts"], geometry.get("curveType")) + for command in raw_object.get("backendCommandCandidates", []) or []: + if isinstance(command, Mapping) and command.get("enabled") is not False: + _increment_count(result["operationCounts"], command.get("operation")) + return result + + +def _normalized_count_dict(value: object) -> dict[str, int]: + if not isinstance(value, Mapping): + return {} + result: dict[str, int] = {} + for key, count in value.items(): + normalized = _count_key(key) + number = _count_value(count) + if normalized and number > 0: + result[normalized] = result.get(normalized, 0) + number + return result + + +def _merge_count_dicts(primary: Mapping[str, int], fallback: Mapping[str, int]) -> dict[str, int]: + result = {str(key): int(value) for key, value in primary.items() if int(value) > 0} + for key, value in fallback.items(): + text = str(key) + number = int(value) + if number > result.get(text, 0): + result[text] = number + return result + + +def _increment_count(counts: dict[str, int], key: object) -> None: + normalized = _count_key(key) + if normalized: + counts[normalized] = counts.get(normalized, 0) + 1 + + +def _count_key(value: object) -> str: + return str(value or "").strip().lower() + + +def _count_value(value: object) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def map_scdm_raw_features_file(raw_path: str | Path, cache_path: str | Path | None = None) -> dict[str, object]: + cache = map_scdm_raw_features(read_json(raw_path)) + if cache_path is not None: + write_json(cache_path, cache) + return cache + + +def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str, object]]: + groups: dict[tuple[object, ...], list[Mapping[str, object]]] = {} + for raw_object in raw_objects: + if not isinstance(raw_object, Mapping): + continue + geometry = _mapping(raw_object.get("geometry")) + if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "cylinder": + continue + center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter")) + axis = _canonical_axis(_rounded_vector(geometry.get("axis") or geometry.get("normal"))) + radius = _rounded_number(geometry.get("radius")) + if len(center) != 3 or len(axis) != 3 or radius is None or radius <= 0: + continue + topology = _mapping(raw_object.get("topologyHint")) + body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))) + key = (body_index, tuple(center), tuple(axis), radius) + groups.setdefault(key, []).append(raw_object) + + result: list[dict[str, object]] = [] + for (_body_index, center, axis, radius), members in groups.items(): + if len(members) < 2: + continue + locators = [_locator_from_raw_object(member) for member in members] + locators = [item for item in locators if item] + topology = { + "bodyIndex": _body_index, + "faceOrdinal": _first_int(item.get("faceOrdinal") for item in locators), + "faceOrdinals": [item["faceOrdinal"] for item in locators if item.get("faceOrdinal") is not None], + "globalFaceOrdinal": _first_int(item.get("globalFaceOrdinal") for item in locators), + "globalFaceOrdinals": [item["globalFaceOrdinal"] for item in locators if item.get("globalFaceOrdinal") is not None], + "faceIds": _merge_ints(_mapping(member.get("topologyHint")).get("faceIds") for member in members), + "scdmFaceLocators": locators, + } + source_ids = [str(member.get("backendId") or "") for member in members if str(member.get("backendId") or "")] + result.append( + { + "backendId": "cylindrical_group:" + "|".join(source_ids), + "objectType": "cylindrical_face_group", + "geometry": { + "surfaceType": "cylinder", + "center": list(center), + "axis": list(axis), + "radius": radius, + "diameter": radius * 2.0, + "sourceBackendIds": source_ids, + }, + "topologyHint": topology, + "backendCommandCandidates": [ + {"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": radius * 2.0}}, + {"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": list(center)}}, + {"operation": "fill_feature", "enabled": True, "parameterFields": {}}, + ], + "rawLimitations": [], + } + ) + return result + + +def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]: + return _derived_linear_pattern_objects(source_objects) + + +def _derived_linear_pattern_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 + object_type = str(raw_object.get("objectType") or "").strip().lower() + if object_type not in {"hole", "cylindrical_hole", "cylindrical_face_group"}: + continue + geometry = _mapping(raw_object.get("geometry")) + center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter")) + axis = _canonical_axis(_rounded_vector(geometry.get("axis") or geometry.get("normal"))) + radius = _rounded_number(geometry.get("radius")) + if radius is None: + diameter = _rounded_number(geometry.get("diameter")) + radius = diameter * 0.5 if diameter is not None else None + if len(center) != 3 or len(axis) != 3 or radius is None or radius <= 0: + continue + topology = _mapping(raw_object.get("topologyHint")) + key = ( + _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))), + tuple(axis), + round(float(radius), 6), + ) + groups.setdefault(key, []).append( + { + "objectId": _object_id(raw_object), + "center": center, + "axis": axis, + "radius": float(radius), + "faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")), + "globalFaceOrdinals": _int_list( + topology.get("globalFaceOrdinals") + or topology.get("faceOrdinals") + or [topology.get("globalFaceOrdinal"), topology.get("faceOrdinal")] + ), + } + ) + + result: list[dict[str, object]] = [] + seen: set[tuple[object, ...]] = set() + for (body_index, _axis, radius), instances in groups.items(): + if len(instances) < 3: + continue + candidates = _linear_pattern_candidates(instances) + for candidate in candidates: + member_ids = tuple(str(item.get("objectId") or "") for item in candidate["members"]) + pattern_axis = tuple(round(float(value), 6) for value in candidate["direction"]) + spacing = round(float(candidate["spacing"]), 6) + key = (body_index, radius, pattern_axis, spacing, member_ids) + if key in seen: + continue + seen.add(key) + face_ids: list[int] = [] + global_face_ordinals: list[int] = [] + centers = [] + 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"))) + center = [ + round(sum(float(item[index]) for item in centers) / len(centers), 6) + for index in range(3) + ] + result.append( + { + "backendId": "derived:linear_pattern:" + "|".join(member_ids), + "objectType": "linear_pattern", + "geometry": { + "center": center, + "axis": list(pattern_axis), + "spacing": spacing, + "pitch": spacing, + "instanceCount": len(candidate["members"]), + "instanceCenters": centers, + "radius": radius, + "diameter": radius * 2.0, + "sourceObjectIds": list(member_ids), + }, + "topologyHint": { + "bodyIndex": body_index, + "faceIds": sorted(set(face_ids)), + "globalFaceOrdinals": sorted(set(global_face_ordinals)), + }, + "backendCommandCandidates": [], + "rawLimitations": [ + "Derived from repeated cylindrical feature centers; SCDM edit command is not productized yet.", + ], + } + ) + if len(result) >= 24: + return result + return result + + +def _linear_pattern_candidates(instances: list[dict[str, object]]) -> list[dict[str, object]]: + if len(instances) < 3: + return [] + points = [list(item["center"]) for item in instances] + diagonal = _point_cloud_diagonal(points) + tolerance = max(diagonal * 1.0e-4, max(float(item.get("radius") or 0.0) for item in instances) * 0.02, 1.0e-5) + candidates: list[dict[str, object]] = [] + for left_index in range(len(instances)): + for right_index in range(left_index + 1, len(instances)): + p0 = points[left_index] + p1 = points[right_index] + direction = _unit_vector([p1[index] - p0[index] for index in range(3)]) + if len(direction) != 3: + continue + collinear: list[tuple[float, dict[str, object]]] = [] + for instance in instances: + point = list(instance["center"]) + delta = [point[index] - p0[index] for index in range(3)] + projection = sum(delta[index] * direction[index] for index in range(3)) + nearest = [p0[index] + projection * direction[index] for index in range(3)] + distance = math.sqrt(sum((point[index] - nearest[index]) ** 2 for index in range(3))) + if distance <= tolerance: + collinear.append((projection, instance)) + if len(collinear) < 3: + continue + collinear.sort(key=lambda item: item[0]) + spacings = [ + collinear[index + 1][0] - collinear[index][0] + for index in range(len(collinear) - 1) + if collinear[index + 1][0] - collinear[index][0] > tolerance + ] + if len(spacings) < 2: + continue + spacing = sum(spacings) / len(spacings) + if spacing <= tolerance: + continue + spacing_tolerance = max(abs(spacing) * 0.03, tolerance * 3.0) + if any(abs(value - spacing) > spacing_tolerance for value in spacings): + continue + members = [item[1] for item in collinear] + canonical_direction = _canonical_axis([round(value, 6) for value in direction]) + if not canonical_direction: + continue + member_ids = tuple(str(item.get("objectId") or "") for item in members) + if any(set(member_ids) == set(str(member.get("objectId") or "") for member in existing["members"]) for existing in candidates): + continue + candidates.append({"members": members, "direction": canonical_direction, "spacing": spacing}) + if len(candidates) >= 12: + return candidates + candidates.sort(key=lambda item: (-len(item["members"]), float(item["spacing"]))) + return candidates + + +def _point_cloud_diagonal(points: list[list[float]]) -> float: + if not points: + return 0.0 + mins = [min(float(point[index]) for point in points) for index in range(3)] + maxs = [max(float(point[index]) for point in points) for index in range(3)] + return math.sqrt(sum((maxs[index] - mins[index]) ** 2 for index in range(3))) + + +def _unit_vector(values: list[float]) -> list[float]: + length = math.sqrt(sum(float(item) * float(item) for item in values)) + if length <= 1.0e-12: + return [] + return [float(item) / length for item in values] + + +def geometry_signature(raw_object: Mapping[str, object]) -> dict[str, object]: + geometry = _mapping(raw_object.get("geometry")) + topology = _mapping(raw_object.get("topologyHint")) + return { + "objectType": str(raw_object.get("objectType") 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"))), + "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"))), + "globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))), + "globalFaceOrdinals": _int_list(topology.get("globalFaceOrdinals") or geometry.get("globalFaceOrdinals")), + "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"))), + "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")), + "axis": _rounded_vector(geometry.get("axis") or geometry.get("normal")), + "startPoint": _rounded_vector(geometry.get("startPoint")), + "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")), + "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")), + "sourceObjectIds": _string_list(geometry.get("sourceObjectIds")), + "planeOffset": _rounded_number(geometry.get("planeOffset")), + "scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators")), + } + + +def attach_local_face_ids_to_scdm_cache( + cache: Mapping[str, object], + local_face_signatures: Iterable[Mapping[str, object]], + *, + min_score: float = 5.0, + unique_margin: float = 0.75, +) -> dict[str, object]: + local_signatures = [dict(item) for item in local_face_signatures if isinstance(item, Mapping)] + result = dict(cache) + diagnostics = dict(result.get("diagnostics") if isinstance(result.get("diagnostics"), Mapping) else {}) + mapping_rows: list[dict[str, object]] = [] + objects = [] + for raw_object in result.get("objects", []) if isinstance(result.get("objects"), list) else []: + if not isinstance(raw_object, Mapping): + continue + 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) + 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") + mapping_rows.append( + { + "objectId": item.get("objectId"), + "status": status, + "faceId": match.get("faceId"), + "faceIds": match.get("faceIds"), + "score": match.get("score"), + "message": match.get("message"), + } + ) + item["geometrySignature"] = signature + objects.append(item) + diagnostics["local_face_mapping"] = mapping_rows + result["objects"] = objects + result["diagnostics"] = diagnostics + return result + + +def _match_local_face_signature( + scdm_signature: Mapping[str, object], + local_signatures: list[dict[str, object]], + *, + min_score: float, + unique_margin: float, +) -> dict[str, object]: + scored = [] + for local in local_signatures: + score, scale = _local_signature_score(scdm_signature, local) + if score <= 0: + continue + scored.append({"faceId": local.get("faceId"), "score": score, "unitScale": scale}) + scored.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True) + if not scored or float(scored[0].get("score") or 0.0) < min_score: + return {"status": "none", "message": "No local Face signature matched SCDM object.", "candidates": scored[:5]} + if len(scored) > 1: + top = float(scored[0].get("score") or 0.0) + second = float(scored[1].get("score") or 0.0) + if top - second < unique_margin: + return {"status": "multiple", "message": "Multiple local Faces match the same SCDM object.", "candidates": scored[:5]} + return {"status": "unique", "message": "Matched one local Face.", **scored[0]} + + +def _match_local_face_group_signatures( + scdm_signature: Mapping[str, object], + local_signatures: list[dict[str, object]], + *, + min_score: float, + unique_margin: float, +) -> dict[str, object]: + scored = [] + for local in local_signatures: + score, scale = _local_signature_score(scdm_signature, local) + if score < min_score: + continue + scored.append({"faceId": local.get("faceId"), "score": score, "unitScale": scale}) + scored.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True) + if not scored: + return {"status": "none", "message": "No local cylindrical Face matched SCDM group.", "candidates": []} + top = float(scored[0].get("score") or 0.0) + selected = [item for item in scored if top - float(item.get("score") or 0.0) <= unique_margin] + face_ids = sorted(set(_int_or_none(item.get("faceId")) for item in selected if _int_or_none(item.get("faceId")) is not None)) + if not face_ids: + return {"status": "none", "message": "Local cylindrical group matched no usable Face IDs.", "candidates": scored[:5]} + return { + "status": "group", + "message": "Matched a local cylindrical Face group.", + "faceIds": face_ids, + "score": top, + "unitScale": selected[0].get("unitScale"), + "candidates": scored[:8], + } + + +def _local_signature_score(scdm_signature: Mapping[str, object], local: Mapping[str, object]) -> tuple[float, float]: + scdm_surface = _surface_key(scdm_signature.get("surfaceType")) + local_surface = _surface_key(local.get("surfaceType")) + if scdm_surface and local_surface and scdm_surface != local_surface: + return 0.0, 1.0 + scales = _unit_scale_candidates(scdm_signature, local) + best_score = 0.0 + best_scale = 1.0 + for scale in scales: + score = 2.0 if scdm_surface and scdm_surface == local_surface else 0.0 + score += _axis_match_score(_rounded_vector(scdm_signature.get("axis")), _rounded_vector(local.get("axis"))) + if scdm_surface == "plane": + score += _scaled_number_match_score( + _rounded_number(local.get("planeOffset")), + _rounded_number(scdm_signature.get("planeOffset")), + scale, + ) + elif scdm_surface == "cylinder": + score += _scaled_number_match_score( + _rounded_number(local.get("radius")), + _rounded_number(scdm_signature.get("radius")), + scale, + ) + score += _axis_point_distance_score( + _rounded_vector(local.get("center")), + _rounded_vector(local.get("axis")), + _rounded_vector(scdm_signature.get("center")), + scale, + ) + else: + score += _scaled_vector_match_score( + _rounded_vector(local.get("center")), + _rounded_vector(scdm_signature.get("center")), + scale, + ) + if score > best_score: + best_score = score + best_scale = scale + return best_score, best_scale + + +def _unit_scale_candidates(scdm_signature: Mapping[str, object], local: Mapping[str, object]) -> tuple[float, ...]: + values = [1.0, 0.001, 1000.0] + local_radius = _rounded_number(local.get("radius")) + scdm_radius = _rounded_number(scdm_signature.get("radius")) + if local_radius and scdm_radius: + values.append(float(scdm_radius) / float(local_radius)) + local_offset = _rounded_number(local.get("planeOffset")) + scdm_offset = _rounded_number(scdm_signature.get("planeOffset")) + if local_offset not in {None, 0.0} and scdm_offset is not None: + values.append(float(scdm_offset) / float(local_offset)) + result = [] + for value in values: + if value > 0 and all(abs(value - existing) > max(value, existing) * 1e-9 for existing in result): + result.append(value) + return tuple(result) + + +def _capability_payload( + key: str, + raw_object: Mapping[str, object], + *, + available_command_names: set[str] | None = None, +) -> dict[str, object] | None: + definition = capability_definition(key) + if definition is None: + return None + block_reason = _missing_backend_command_reason(key, available_command_names) + return { + "key": definition.key, + "displayName": definition.display_name, + "valueKind": definition.value_kind, + "currentValue": _current_value(definition.current_fields, raw_object), + "defaultIntent": definition.default_intent, + "backendOperation": definition.backend_operation, + "postCheck": definition.post_check, + "editable": True, + "blockReason": block_reason, + } + + +def _current_value(fields: tuple[str, ...], raw_object: Mapping[str, object]) -> object: + geometry = _mapping(raw_object.get("geometry")) + for field in fields: + if field == "0": + return 0.0 + if field == "1": + return 1 + if field == "geometry.radius*2": + radius = _number(geometry.get("radius")) + if radius is not None: + 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) + return None + + +def _available_command_names(value: object) -> set[str] | None: + if not isinstance(value, list): + return None + result: set[str] = set() + for item in value: + if not isinstance(item, Mapping): + continue + if item.get("available") is not True: + continue + name = str(item.get("name") or "").strip() + if name: + result.add(name) + return result + + +def _missing_backend_command_reason(key: str, available_command_names: set[str] | None) -> str: + if available_command_names is None: + return "" + definition = capability_definition(key) + groups = definition.required_backend_command_groups if definition is not None else () + if not groups: + return "" + if any(all(command in available_command_names for command in group) for group in groups): + return "" + readable = " / ".join(" + ".join(group) for group in groups) + return f"SCDM 当前脚本环境缺少 {readable} 命令,暂不能执行该参数。" + + +def _object_id(raw_object: Mapping[str, object]) -> str: + existing = str(raw_object.get("objectId") or "").strip() + if existing: + return existing + object_type = str(raw_object.get("objectType") or "object").strip() or "object" + backend_id = str(raw_object.get("backendId") or "").strip() + if backend_id: + return f"{object_type}:{backend_id}" + signature = geometry_signature(raw_object) + face_ids = signature.get("faceIds") + if isinstance(face_ids, list) and face_ids: + return f"{object_type}:face:{'-'.join(str(item) for item in face_ids)}" + return object_type + + +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 { + "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"))), + } + + +def _face_locators(value: object) -> list[dict[str, object]]: + if not isinstance(value, (list, tuple)): + return [] + result: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + locator = { + "backendId": str(item.get("backendId") or ""), + "bodyIndex": _int_or_none(item.get("bodyIndex")), + "faceOrdinal": _int_or_none(item.get("faceOrdinal")), + "globalFaceOrdinal": _int_or_none(item.get("globalFaceOrdinal")), + } + if any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")): + result.append(locator) + return result + + +def _first_int(values: Iterable[object]) -> int | None: + for value in values: + number = _int_or_none(value) + if number is not None: + return number + return None + + +def _merge_ints(values: Iterable[object]) -> list[int]: + result: list[int] = [] + for value in values: + result.extend(_int_list(value)) + return sorted(set(result)) + + +def _mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else {} + + +def _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 sorted(set(result)) + + +def _int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _first_present(*values: object) -> object: + for value in values: + if value is not None and value != "": + return value + return None + + +def _surface_key(value: object) -> str: + text = str(value or "").strip().lower() + if "plane" in text: + return "plane" + if "cylinder" in text: + return "cylinder" + return text + + +def _canonical_axis(axis: list[float]) -> list[float]: + if len(axis) != 3: + return [] + length = math.sqrt(sum(item * item for item in axis)) + if length <= 1e-12: + return [] + normalized = [item / length for item in axis] + for value in normalized: + if abs(value) > 1e-12: + if value < 0: + normalized = [-item for item in normalized] + break + return [round(item, 6) for item in normalized] + + +def _axis_match_score(left: list[float], right: list[float]) -> float: + if len(left) != 3 or len(right) != 3: + return 0.0 + left_len = math.sqrt(sum(item * item for item in left)) + right_len = math.sqrt(sum(item * item for item in right)) + if left_len <= 1e-12 or right_len <= 1e-12: + return 0.0 + dot = abs(sum(left[index] * right[index] for index in range(3)) / (left_len * right_len)) + if dot >= 0.999: + return 3.0 + if dot >= 0.99: + return 2.0 + return 0.0 + + +def _scaled_number_match_score(local_value: float | None, scdm_value: float | None, scale: float) -> float: + if local_value is None or scdm_value is None: + return 0.0 + error = abs(float(local_value) * scale - float(scdm_value)) + reference = max(abs(float(scdm_value)), abs(float(local_value) * scale), 1.0e-9) + relative = error / reference + if relative <= 1.0e-5: + return 4.0 + if relative <= 1.0e-3: + return 3.0 + if relative <= 1.0e-2: + return 1.0 + return 0.0 + + +def _scaled_vector_match_score(local_value: list[float], scdm_value: list[float], scale: float) -> float: + if len(local_value) != 3 or len(scdm_value) != 3: + return 0.0 + distance = math.sqrt(sum((local_value[index] * scale - scdm_value[index]) ** 2 for index in range(3))) + reference = max(max(abs(item) for item in scdm_value), 1.0e-9) + relative = distance / reference + if relative <= 1.0e-5: + return 4.0 + if relative <= 1.0e-3: + return 3.0 + if relative <= 1.0e-2: + return 1.0 + return 0.0 + + +def _axis_point_distance_score( + local_point: list[float], + local_axis: list[float], + scdm_point: list[float], + scale: float, +) -> float: + if len(local_point) != 3 or len(local_axis) != 3 or len(scdm_point) != 3: + return 0.0 + point = [local_point[index] * scale for index in range(3)] + axis_len = math.sqrt(sum(item * item for item in local_axis)) + if axis_len <= 1e-12: + return 0.0 + axis = [item / axis_len for item in local_axis] + delta = [scdm_point[index] - point[index] for index in range(3)] + projection = sum(delta[index] * axis[index] for index in range(3)) + nearest = [point[index] + projection * axis[index] for index in range(3)] + distance = math.sqrt(sum((nearest[index] - scdm_point[index]) ** 2 for index in range(3))) + reference = max(max(abs(item) for item in scdm_point), 1.0e-9) + relative = distance / reference + if relative <= 1.0e-5: + return 3.0 + if relative <= 1.0e-3: + return 2.0 + if relative <= 1.0e-2: + return 0.75 + return 0.0 + + +def _rounded_vector(value: object) -> list[float]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + result: list[float] = [] + for item in values[:3]: + number = _number(item) + if number is None: + return [] + result.append(round(number, 6)) + return result if len(result) == 3 else [] + + +def _rounded_vector_list(value: object) -> list[list[float]]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + result: list[list[float]] = [] + for item in values: + rounded = _rounded_vector(item) + if len(rounded) == 3: + result.append(rounded) + return result + + +def _string_list(value: object) -> list[str]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + result: list[str] = [] + for item in values: + text = str(item or "").strip() + if text: + result.append(text) + return result + + +def _rounded_number(value: object) -> float | None: + number = _number(value) + return None if number is None else round(number, 6) + + +def _number(value: object) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +__all__ = [ + "attach_local_face_ids_to_scdm_cache", + "geometry_signature", + "map_scdm_raw_features", + "map_scdm_raw_features_file", +] diff --git a/step_editor/scdm_probe.py b/step_editor/scdm_probe.py new file mode 100644 index 0000000..80c1ae1 --- /dev/null +++ b/step_editor/scdm_probe.py @@ -0,0 +1,680 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Callable + +from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command +from .scdm_schema import ScdmProbeJob, default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json + + +def prepare_scdm_probe_job( + step_path: str | Path, + *, + output_dir: str | Path | None = None, + project_root: str | Path | None = None, + backend: ScdmBackendInfo | None = None, + unit: str = "model", + scan_scope: str = "all", +) -> dict[str, object]: + source = Path(step_path).expanduser() + if not source.is_file(): + return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"} + + fingerprint = file_fingerprint(source) + work_dir = Path(output_dir).expanduser() if output_dir else default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint) + work_dir = work_dir.resolve(strict=False) + work_dir.mkdir(parents=True, exist_ok=True) + job = ScdmProbeJob( + step_path=source.resolve(strict=False), + output_dir=work_dir, + raw_features_path=work_dir / "scdm_raw_features.json", + error_path=work_dir / "error.json", + model_fingerprint=fingerprint, + unit=unit, + scan_scope=scan_scope, + backend_path=str(backend.path) if backend else "", + backend_version=backend.version if backend else "", + ) + job_path = work_dir / "scdm_probe_job.json" + script_path = work_dir / "scdm_probe.py" + write_json(job_path, job.to_payload()) + script_path.write_text(generate_scdm_probe_script(job_path), encoding="utf-8") + return { + "ok": True, + "reason": "ok", + "work_dir": str(work_dir), + "job_path": str(job_path), + "script_path": str(script_path), + "raw_features_path": str(job.raw_features_path), + "error_path": str(job.error_path), + "model_fingerprint": fingerprint, + } + + +def run_scdm_probe( + step_path: str | Path, + *, + backend: ScdmBackendInfo | None = None, + output_dir: str | Path | None = None, + project_root: str | Path | None = None, + timeout_seconds: float = 120.0, + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, +) -> dict[str, object]: + if backend is None: + resolved = resolve_scdm_backend(project_root_override=project_root, validate=False) + if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo): + return { + "ok": False, + "reason": str(resolved.get("reason") or "missing-scdm"), + "message": str(resolved.get("message") or "SCDM backend is not available."), + "backend_resolution": { + "ok": bool(resolved.get("ok")), + "reason": str(resolved.get("reason") or ""), + "message": str(resolved.get("message") or ""), + }, + } + backend = resolved["backend"] # type: ignore[assignment] + + prepared = prepare_scdm_probe_job(step_path, output_dir=output_dir, project_root=project_root, backend=backend) + if not prepared.get("ok"): + return {"backend": backend.to_cache(), **prepared} + + script_path = Path(str(prepared["script_path"])) + raw_path = Path(str(prepared["raw_features_path"])) + error_path = Path(str(prepared["error_path"])) + command = scdm_run_script_command(backend.path, script_path) + run = runner or subprocess.run + try: + completed = run( + command, + cwd=str(script_path.parent), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=max(float(timeout_seconds), 0.1), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + check=False, + ) + except subprocess.TimeoutExpired: + write_json(error_path, {"ok": False, "reason": "timeout", "message": "SCDM probe timed out."}) + return {"ok": False, "reason": "timeout", "message": "SCDM probe timed out.", "backend": backend.to_cache(), **prepared} + except OSError as exc: + write_json(error_path, {"ok": False, "reason": "launch-failed", "message": str(exc)}) + return {"ok": False, "reason": "launch-failed", "message": str(exc), "backend": backend.to_cache(), **prepared} + + returncode = int(getattr(completed, "returncode", -1)) + if returncode != 0: + message = (str(getattr(completed, "stderr", "") or "") or str(getattr(completed, "stdout", "") or "")).strip() + write_json( + error_path, + { + "ok": False, + "reason": "probe-failed", + "returncode": returncode, + "message": message, + }, + ) + return {"ok": False, "reason": "probe-failed", "returncode": returncode, "message": message, "backend": backend.to_cache(), **prepared} + if not raw_path.is_file(): + write_json(error_path, {"ok": False, "reason": "missing-raw-output", "message": "SCDM probe did not write raw features."}) + return { + "ok": False, + "reason": "missing-raw-output", + "message": "SCDM probe did not write raw features.", + "backend": backend.to_cache(), + **prepared, + } + raw = read_json(raw_path) + verified_backend = _probe_verified_backend(backend) + try: + save_scdm_backend_cache(verified_backend, project_root_override=project_root) + except Exception: + pass + return {"ok": True, "reason": "ok", "raw": raw, "backend": verified_backend.to_cache(), **prepared} + + +def _probe_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo: + return ScdmBackendInfo( + path=backend.path, + source=backend.source, + version=backend.version, + verified_at=utc_now(), + run_script_ok=True, + license_ok=True, + message="SCDM probe completed.", + ) + + +def generate_scdm_probe_script(job_path: str | Path) -> str: + job_literal = repr(str(Path(job_path).expanduser())) + return ( + "from __future__ import print_function\n" + "import json\n" + "import traceback\n" + f"JOB_PATH = {job_literal}\n" + "\n" + "def _write_json(path, payload):\n" + " handle = open(path, 'w')\n" + " try:\n" + " handle.write(json.dumps(payload, indent=2))\n" + " finally:\n" + " handle.close()\n" + "\n" + "def _safe_name(value):\n" + " try:\n" + " return type(value).__name__\n" + " except Exception:\n" + " return ''\n" + "\n" + "def _float_attr(value, names):\n" + " for name in names:\n" + " try:\n" + " result = getattr(value, name)\n" + " return float(result)\n" + " except Exception:\n" + " pass\n" + " return None\n" + "\n" + "def _xyz(value):\n" + " if value is None:\n" + " return []\n" + " result = []\n" + " for name in ('X', 'Y', 'Z'):\n" + " try:\n" + " result.append(float(getattr(value, name)))\n" + " except Exception:\n" + " return []\n" + " return result\n" + "\n" + "def _items(collection):\n" + " if collection is None:\n" + " return []\n" + " try:\n" + " return list(collection)\n" + " except Exception:\n" + " items = []\n" + " try:\n" + " count = int(collection.Count)\n" + " for index in range(count):\n" + " items.append(collection[index])\n" + " except Exception:\n" + " pass\n" + " return items\n" + "\n" + "def _geometry_from_face(face):\n" + " geometry = {}\n" + " surface = None\n" + " for expr in ('Shape.Geometry', 'Geometry', 'Surface'):\n" + " try:\n" + " current = face\n" + " for part in expr.split('.'):\n" + " current = getattr(current, part)\n" + " surface = current\n" + " break\n" + " except Exception:\n" + " pass\n" + " surface_name = _safe_name(surface)\n" + " geometry['surfaceType'] = surface_name\n" + " lowered = surface_name.lower()\n" + " radius = _float_attr(surface, ('Radius', 'radius'))\n" + " if radius is not None:\n" + " geometry['radius'] = radius\n" + " geometry['diameter'] = radius * 2.0\n" + " try:\n" + " geometry['center'] = _xyz(surface.Frame.Origin)\n" + " except Exception:\n" + " pass\n" + " try:\n" + " geometry['axis'] = _xyz(surface.Frame.DirZ)\n" + " except Exception:\n" + " pass\n" + " try:\n" + " center = geometry.get('center') or []\n" + " axis = geometry.get('axis') or []\n" + " if len(center) == 3 and len(axis) == 3:\n" + " geometry['planeOffset'] = center[0] * axis[0] + center[1] * axis[1] + center[2] * axis[2]\n" + " except Exception:\n" + " pass\n" + " if 'plane' in lowered:\n" + " geometry['surfaceType'] = 'plane'\n" + " elif 'cylinder' in lowered:\n" + " geometry['surfaceType'] = 'cylinder'\n" + " round_info = _round_info_from_face(face, geometry)\n" + " if round_info:\n" + " geometry['roundInfo'] = round_info\n" + " return geometry\n" + "\n" + "def _round_info_from_face(face, geometry):\n" + " if str(geometry.get('surfaceType', '')).lower() != 'cylinder':\n" + " return {}\n" + " round_info_type = globals().get('RoundInfo')\n" + " if round_info_type is None:\n" + " return {}\n" + " try:\n" + " info = round_info_type.Create(face)\n" + " except Exception:\n" + " return {}\n" + " payload = {'available': True, 'type': _safe_name(info)}\n" + " for attr in ('Radius', 'RoundRadius', 'ConstantRadius'):\n" + " value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n" + " if value is not None:\n" + " payload['radius'] = value\n" + " payload['diameter'] = value * 2.0\n" + " break\n" + " for attr in ('IsConstant', 'IsRound'):\n" + " try:\n" + " payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\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 _path_value(value, expr):\n" + " current = value\n" + " for part in expr.split('.'):\n" + " try:\n" + " current = getattr(current, part)\n" + " except Exception:\n" + " return None\n" + " return current\n" + "\n" + "def _first_path_value(value, exprs):\n" + " for expr in exprs:\n" + " result = _path_value(value, expr)\n" + " if result is not None:\n" + " return result\n" + " return None\n" + "\n" + "def _geometry_from_edge(edge):\n" + " geometry = {}\n" + " shape = getattr(edge, 'Shape', edge)\n" + " curve = _first_path_value(edge, ('Shape.Geometry', 'Geometry', 'Shape.Curve', 'Curve', 'Shape')) or shape\n" + " geometry['curveShapeType'] = _safe_name(shape)\n" + " geometry['curveType'] = _safe_name(curve)\n" + " length = _float_attr(edge, ('Length', 'length'))\n" + " if length is None:\n" + " length = _float_attr(shape, ('Length', 'length'))\n" + " if length is not None:\n" + " geometry['length'] = length\n" + " start = _xyz(_first_path_value(edge, ('StartPoint', 'Shape.StartPoint')))\n" + " end = _xyz(_first_path_value(edge, ('EndPoint', 'Shape.EndPoint')))\n" + " if start:\n" + " geometry['startPoint'] = start\n" + " if end:\n" + " geometry['endPoint'] = end\n" + " if len(start) == 3 and len(end) == 3:\n" + " geometry['midPoint'] = [(start[i] + end[i]) * 0.5 for i in range(3)]\n" + " radius = _float_attr(curve, ('Radius', 'radius'))\n" + " if radius is not None:\n" + " geometry['radius'] = radius\n" + " geometry['diameter'] = radius * 2.0\n" + " center = _xyz(_first_path_value(curve, ('Frame.Origin', 'Circle.Frame.Origin')))\n" + " if center:\n" + " geometry['center'] = center\n" + " axis = _xyz(_first_path_value(curve, ('Frame.DirZ', 'Circle.Frame.DirZ')))\n" + " if axis:\n" + " geometry['axis'] = axis\n" + " return geometry\n" + "\n" + "def _edge_adjacent_face_ordinals(edge, face_ordinals_by_marker):\n" + " faces = []\n" + " for expr in ('Faces', 'Shape.Faces', 'GetFaces'):\n" + " value = _path_value(edge, expr)\n" + " if value is None and expr == 'GetFaces':\n" + " value = _maybe_call(edge, 'GetFaces')\n" + " faces = _items(value)\n" + " if faces:\n" + " break\n" + " ordinals = []\n" + " for face in faces:\n" + " marker = str(id(face))\n" + " if marker in face_ordinals_by_marker:\n" + " ordinals.append(face_ordinals_by_marker[marker])\n" + " return {'adjacentFaceCount': len(faces), 'adjacentFaceOrdinals': ordinals}\n" + "\n" + "def _int_or_none(value):\n" + " try:\n" + " return int(value)\n" + " except Exception:\n" + " return None\n" + "\n" + "def _edge_kind(geometry):\n" + " curve_type = str(geometry.get('curveType', '') or geometry.get('curveShapeType', '')).lower()\n" + " if geometry.get('radius') is not None or 'circle' in curve_type or 'arc' in curve_type:\n" + " return 'circular'\n" + " if 'line' in curve_type or 'segment' in curve_type:\n" + " return 'linear'\n" + " return 'other'\n" + "\n" + "def _add_edge_geometry_summary(summary, geometry):\n" + " summary['totalEdgeCount'] = int(summary.get('totalEdgeCount', 0)) + 1\n" + " kind = _edge_kind(geometry)\n" + " kind_counts = summary.setdefault('edgeKindCounts', {})\n" + " kind_counts[kind] = int(kind_counts.get(kind, 0)) + 1\n" + " radius = geometry.get('radius')\n" + " if radius is not None:\n" + " try:\n" + " radius = float(radius)\n" + " summary['circularEdgeCount'] = int(summary.get('circularEdgeCount', 0)) + 1\n" + " values = summary.setdefault('circularRadii', [])\n" + " if len(values) < 80:\n" + " values.append(radius)\n" + " except Exception:\n" + " pass\n" + " length = geometry.get('length')\n" + " if length is not None:\n" + " try:\n" + " length = float(length)\n" + " summary['minEdgeLength'] = min(float(summary.get('minEdgeLength', length)), length)\n" + " summary['maxEdgeLength'] = max(float(summary.get('maxEdgeLength', length)), length)\n" + " except Exception:\n" + " pass\n" + "\n" + "def _final_edge_geometry_summary(summary):\n" + " result = dict(summary)\n" + " radii = result.get('circularRadii')\n" + " if isinstance(radii, list) and radii:\n" + " buckets = {}\n" + " for value in radii:\n" + " try:\n" + " key = '%.6g' % float(value)\n" + " buckets[key] = int(buckets.get(key, 0)) + 1\n" + " except Exception:\n" + " pass\n" + " result['circularRadiusBuckets'] = [\n" + " {'radius': key, 'count': buckets[key]} for key in sorted(buckets.keys())[:40]\n" + " ]\n" + " result.pop('circularRadii', None)\n" + " return result\n" + "\n" + "def _record_face_adjacency(adjacency_map, body_index, edge_topology, geometry):\n" + " ordinals = []\n" + " for value in edge_topology.get('adjacentFaceOrdinals', []) or []:\n" + " number = _int_or_none(value)\n" + " if number is not None and number not in ordinals:\n" + " ordinals.append(number)\n" + " if len(ordinals) < 2:\n" + " return\n" + " ordinals.sort()\n" + " kind = _edge_kind(geometry)\n" + " for left_index in range(len(ordinals)):\n" + " for right_index in range(left_index + 1, len(ordinals)):\n" + " left = ordinals[left_index]\n" + " right = ordinals[right_index]\n" + " key = (body_index, left, right)\n" + " item = adjacency_map.setdefault(\n" + " key,\n" + " {'bodyIndex': body_index, 'faceOrdinals': [left, right], 'edgeCount': 0, 'edgeKinds': {}, 'edges': []},\n" + " )\n" + " item['edgeCount'] = int(item.get('edgeCount', 0)) + 1\n" + " edge_kinds = item.setdefault('edgeKinds', {})\n" + " edge_kinds[kind] = int(edge_kinds.get(kind, 0)) + 1\n" + " edges = item.setdefault('edges', [])\n" + " if len(edges) < 6:\n" + " edges.append({\n" + " 'edgeOrdinal': edge_topology.get('edgeOrdinal'),\n" + " 'globalEdgeOrdinal': edge_topology.get('globalEdgeOrdinal'),\n" + " 'curveType': geometry.get('curveType'),\n" + " 'kind': kind,\n" + " 'length': geometry.get('length'),\n" + " 'radius': geometry.get('radius'),\n" + " })\n" + "\n" + "def _face_adjacency_rows(adjacency_map):\n" + " rows = list(adjacency_map.values())\n" + " rows.sort(key=lambda item: (int(item.get('bodyIndex') or 0), item.get('faceOrdinals') or []))\n" + " return rows\n" + "\n" + "def _count_key(counts, key):\n" + " key = str(key or '').strip() or 'unknown'\n" + " counts[key] = int(counts.get(key, 0)) + 1\n" + "\n" + "def _feature_inventory(objects):\n" + " result = {'objectTypeCounts': {}, 'surfaceTypeCounts': {}, 'curveTypeCounts': {}, 'operationCounts': {}}\n" + " for item in objects:\n" + " if not isinstance(item, dict):\n" + " continue\n" + " _count_key(result['objectTypeCounts'], item.get('objectType'))\n" + " geometry = item.get('geometry')\n" + " if not isinstance(geometry, dict):\n" + " geometry = {}\n" + " if geometry.get('surfaceType') is not None:\n" + " _count_key(result['surfaceTypeCounts'], geometry.get('surfaceType'))\n" + " if geometry.get('curveType') is not None:\n" + " _count_key(result['curveTypeCounts'], geometry.get('curveType'))\n" + " for command in item.get('backendCommandCandidates', []) or []:\n" + " if isinstance(command, dict):\n" + " _count_key(result['operationCounts'], command.get('operation'))\n" + " return result\n" + "\n" + "def _command_candidates(object_type, geometry):\n" + " surface_type = str(geometry.get('surfaceType', '')).lower()\n" + " result = []\n" + " if object_type == 'face' and surface_type == 'plane':\n" + " result.append({'operation': 'pull_face_offset', 'enabled': True, 'parameterFields': {'distance': 0}})\n" + " if object_type in ('face', 'hole') and surface_type == 'cylinder':\n" + " result.append({'operation': 'change_hole_diameter', 'enabled': True, 'parameterFields': {'diameter': geometry.get('diameter')}})\n" + " 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" + " 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" + " return result\n" + "\n" + "def _open_step(path):\n" + " errors = []\n" + " for opener in ('DocumentOpen.Execute', 'Application.OpenDocument'):\n" + " try:\n" + " current = globals()\n" + " target = None\n" + " for part in opener.split('.'):\n" + " target = current.get(part) if isinstance(current, dict) else getattr(current, part)\n" + " current = target\n" + " target(path)\n" + " return\n" + " except Exception as exc:\n" + " errors.append(str(exc))\n" + " raise Exception('Could not open STEP: ' + '; '.join(errors))\n" + "\n" + "def _root_part():\n" + " try:\n" + " return GetRootPart()\n" + " except Exception:\n" + " pass\n" + " try:\n" + " return Application.ActiveWindow.Document.MainPart\n" + " except Exception:\n" + " return None\n" + "\n" + "def _maybe_call(target, name):\n" + " try:\n" + " value = getattr(target, name)\n" + " except Exception:\n" + " return None\n" + " try:\n" + " return value()\n" + " except Exception:\n" + " return value\n" + "\n" + "def _body_faces(body):\n" + " for name in ('Faces', 'GetFaces'):\n" + " items = _items(_maybe_call(body, name))\n" + " if items:\n" + " return items\n" + " return []\n" + "\n" + "def _body_edges(body):\n" + " for name in ('Edges', 'GetEdges'):\n" + " items = _items(_maybe_call(body, name))\n" + " if items:\n" + " return items\n" + " return []\n" + "\n" + "def _child_parts(part):\n" + " children = []\n" + " for name in ('Components', 'GetAllComponents'):\n" + " for component in _items(_maybe_call(part, name)):\n" + " for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n" + " try:\n" + " value = getattr(component, attr)\n" + " if value is not None:\n" + " children.append(value)\n" + " break\n" + " except Exception:\n" + " pass\n" + " return children\n" + "\n" + "def _all_bodies(root):\n" + " if root is None:\n" + " return []\n" + " for name in ('GetAllBodies', 'Bodies'):\n" + " items = _items(_maybe_call(root, name))\n" + " if items:\n" + " return items\n" + " bodies = []\n" + " queue = [root]\n" + " seen = set()\n" + " while queue:\n" + " part = queue.pop(0)\n" + " marker = str(id(part))\n" + " if marker in seen:\n" + " continue\n" + " seen.add(marker)\n" + " bodies.extend(_items(_maybe_call(part, 'Bodies')))\n" + " queue.extend(_child_parts(part))\n" + " return bodies\n" + "\n" + "def _hole_face_markers(bodies):\n" + " standard_holes = globals().get('StandardHoles')\n" + " if standard_holes is None:\n" + " return set()\n" + " faces = []\n" + " options = None\n" + " options_cls = globals().get('FindStandardHoleOptions')\n" + " if options_cls is not None:\n" + " try:\n" + " options = options_cls()\n" + " except Exception:\n" + " options = None\n" + " identified = []\n" + " find = getattr(standard_holes, 'Find', None)\n" + " if find is not None:\n" + " for args in ((bodies, options, None), (bodies, options), (options, None), (options,), (None,)):\n" + " try:\n" + " identified = _items(find(*args))\n" + " if identified:\n" + " break\n" + " except Exception:\n" + " pass\n" + " if identified:\n" + " try:\n" + " faces = _items(standard_holes.GetHoleFaces(identified))\n" + " except Exception:\n" + " faces = []\n" + " if not faces:\n" + " for hole in identified:\n" + " try:\n" + " faces.extend(_items(getattr(hole, 'Faces')))\n" + " except Exception:\n" + " pass\n" + " if faces:\n" + " return set(str(id(face)) for face in faces)\n" + " for args in ((bodies,), ()):\n" + " try:\n" + " faces = _items(standard_holes.GetHoleFaces(*args))\n" + " if faces:\n" + " break\n" + " except Exception:\n" + " pass\n" + " 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" + " result = []\n" + " for name in names:\n" + " result.append({'name': name, 'available': globals().get(name) is not None})\n" + " return result\n" + "\n" + "def main():\n" + " job = json.load(open(JOB_PATH, 'r'))\n" + " model = job.get('model', {})\n" + " outputs = job.get('outputs', {})\n" + " raw_path = outputs.get('rawFeatures')\n" + " error_path = outputs.get('error')\n" + " try:\n" + " _open_step(model.get('path'))\n" + " root = _root_part()\n" + " bodies = _all_bodies(root)\n" + " hole_face_markers = _hole_face_markers(bodies)\n" + " objects = []\n" + " face_adjacency = {}\n" + " edge_geometry_summary = {}\n" + " face_counter = 0\n" + " edge_counter = 0\n" + " for body_index, body in enumerate(bodies):\n" + " body_faces = _body_faces(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('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n" + " object_type = 'round'\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" + " '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.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" + " objects.append({\n" + " 'backendId': 'body:%d/edge:%d' % (body_index, edge_index),\n" + " 'objectType': 'edge',\n" + " 'geometry': geometry,\n" + " 'topologyHint': edge_topology,\n" + " 'backendCommandCandidates': [],\n" + " 'rawLimitations': [],\n" + " })\n" + " edge_counter += 1\n" + " payload = {\n" + " 'schemaVersion': 1,\n" + " 'backend': job.get('backend', {}),\n" + " 'model': model,\n" + " 'scan': job.get('scan', {}),\n" + " 'objects': objects,\n" + " 'diagnostics': {\n" + " 'availableCommands': _available_commands(),\n" + " 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n" + " 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n" + " 'featureInventory': _feature_inventory(objects),\n" + " },\n" + " 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers)},\n" + " }\n" + " _write_json(raw_path, payload)\n" + " except Exception as exc:\n" + " _write_json(error_path, {'ok': False, 'reason': 'probe-exception', 'message': str(exc), 'traceback': traceback.format_exc()})\n" + " raise\n" + "\n" + "main()\n" + ) + + +__all__ = [ + "generate_scdm_probe_script", + "prepare_scdm_probe_job", + "run_scdm_probe", +] diff --git a/step_editor/scdm_property_specs.py b/step_editor/scdm_property_specs.py new file mode 100644 index 0000000..193fa6f --- /dev/null +++ b/step_editor/scdm_property_specs.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping + + +def property_specs_from_scdm_cache( + cache: Mapping[str, object], + *, + selected_face_ids: Iterable[int] = (), + selected_edge_ids: Iterable[int] = (), + execution_ready: bool | Iterable[str] = False, +) -> list[dict[str, object]]: + face_ids = {int(item) for item in selected_face_ids} + edge_ids = {int(item) for item in selected_edge_ids} + if not face_ids and not edge_ids: + return [] + objects = cache.get("objects") + if not isinstance(objects, list): + return [] + + specs: list[dict[str, object]] = [] + for item in objects: + if not isinstance(item, Mapping) or not _object_matches(item, face_ids=face_ids, edge_ids=edge_ids): + continue + capabilities = item.get("capabilities") + if not isinstance(capabilities, list): + continue + for capability in capabilities: + if isinstance(capability, Mapping): + spec = _capability_spec(item, capability, execution_ready=execution_ready) + if spec is not None: + specs.append(spec) + return specs + + +def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edge_ids: set[int]) -> bool: + signature = raw_object.get("geometrySignature") + if not isinstance(signature, Mapping): + return False + object_faces = set(_int_values(signature.get("faceIds"))) + object_edges = set(_int_values(signature.get("edgeIds"))) + return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids)) + + +def _capability_spec( + raw_object: Mapping[str, object], + capability: Mapping[str, object], + *, + execution_ready: bool | Iterable[str], +) -> dict[str, object] | None: + key = str(capability.get("key") or "").strip() + label = str(capability.get("displayName") or key).strip() + if not key or not label: + return None + value_kind = str(capability.get("valueKind") or "number") + current = capability.get("currentValue") + value_type = _value_type(value_kind, key) + 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) + capability_block = str(capability.get("blockReason") or "").strip() + object_block = str(raw_object.get("blockReason") or "").strip() + block_reason = capability_block or object_block + backend_operation = str(capability.get("backendOperation") or "") + post_check = str(capability.get("postCheck") or "") + can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason) + if can_execute: + disabled_tip = "" + enabled_tip = ( + f"SCDM 已识别“{label}”可由 {backend_operation or '后端命令'} 修改;" + f"执行后会用 {post_check or '结果回测'} 校验。" + ) + elif block_reason: + enabled_tip = "" + disabled_tip = f"SCDM 已识别该对象,但当前能力被阻止:{block_reason}" + else: + enabled_tip = "" + disabled_tip = "SCDM 已识别该参数,但 S5 修改执行器还没有接入;当前只作为后端识别结果缓存,不开放执行。" + return { + "key": f"scdm:{key}", + "label": label, + "current_raw": current if current is not None else "", + "current_text": current_text, + "target_text": target_text, + "editable": True, + "enabled": can_execute, + "status_text": "可修改" if can_execute else "暂未接入", + "scope_text": str(capability.get("defaultIntent") or "SCDM"), + "action": "apply_scdm_property_edit", + "value_type": value_type, + "enabled_tip": enabled_tip, + "disabled_tip": disabled_tip, + "range_hint": "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。", + "min_value": 0.0 if value_type == "positive" else None, + "min_exclusive": True if value_type == "positive" else False, + "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 {}, + } + + +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"): + return "positive" + return "number" + + +def _capability_execution_ready(key: str, execution_ready: bool | Iterable[str]) -> bool: + if isinstance(execution_ready, bool): + return execution_ready + try: + return key in {str(item) for item in execution_ready} + except TypeError: + return False + + +def _format_value(value: object, *, value_type: str) -> str: + if value is None: + return "" + if value_type == "vector3": + values = _float_values(value) + return f"({values[0]:g}, {values[1]:g}, {values[2]:g})" if len(values) == 3 else "" + if isinstance(value, float): + return f"{value:g}" + return str(value) + + +def _float_values(value: object) -> list[float]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + result: list[float] = [] + for item in values[:3]: + try: + result.append(float(item)) + except (TypeError, ValueError): + return [] + return result + + +def _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 + + +__all__ = ["property_specs_from_scdm_cache"] diff --git a/step_editor/scdm_result_validator.py b/step_editor/scdm_result_validator.py new file mode 100644 index 0000000..5bc0b90 --- /dev/null +++ b/step_editor/scdm_result_validator.py @@ -0,0 +1,683 @@ +from __future__ import annotations + +import math +from collections.abc import Callable, Iterable, Mapping, Sequence +from pathlib import Path + +from .relation_formulas import rewrite_relation_formula_ids + + +def validate_scdm_edit_result( + edit_result: Mapping[str, object], + *, + before_signature: Mapping[str, object] | None = None, + before_cache: Mapping[str, object] | None = None, + after_cache: Mapping[str, object] | None = None, + capability_key: str = "", + expected_target: object = None, + edited_object_id: str = "", + tolerance: float = 1.0e-6, + brep_validator: Callable[[Path], Mapping[str, object]] | None = None, +) -> dict[str, object]: + if edit_result.get("ok") is not True: + return { + "ok": False, + "reason": str(edit_result.get("reason") or "edit-failed"), + "message": str(edit_result.get("message") or "SCDM edit did not succeed."), + "editResult": dict(edit_result), + } + + output_step = Path(str(edit_result.get("output_step") or edit_result.get("outputStep") or "")).expanduser() + if not output_step.is_file(): + return { + "ok": False, + "reason": "missing-output-step", + "message": f"SCDM result STEP does not exist: {output_step}", + "editResult": dict(edit_result), + } + + if brep_validator is not None: + brep = dict(brep_validator(output_step)) + if brep.get("ok") is not True: + return { + "ok": False, + "reason": str(brep.get("reason") or "brep-invalid"), + "message": str(brep.get("message") or "OCCT rejected the result STEP."), + "brep": brep, + "editResult": dict(edit_result), + } + else: + brep = {"ok": None, "reason": "not-run", "message": "B-Rep validation callback was not provided."} + + summary_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "SCDM summary check needs the old and new caches."} + topology_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Object drift check needs the old and new SCDM caches."} + if before_cache and after_cache: + summary_check = check_scdm_summary_delta(before_cache, after_cache, capability_key=capability_key) + if summary_check.get("ok") is False: + return { + "ok": False, + "reason": str(summary_check.get("reason") or "summary-drift"), + "message": str(summary_check.get("message") or "SCDM result changed the model summary too much."), + "summaryCheck": summary_check, + "brep": brep, + "editResult": dict(edit_result), + } + topology_check = check_scdm_unedited_objects( + before_cache, + after_cache, + edited_object_id=edited_object_id, + edited_signature=before_signature, + capability_key=capability_key, + ) + if topology_check.get("ok") is not True: + return { + "ok": False, + "reason": str(topology_check.get("reason") or "unexpected-object-drift"), + "message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."), + "topologyCheck": topology_check, + "brep": brep, + "editResult": dict(edit_result), + } + + matched: dict[str, object] | None = None + 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 status != "unique": + return { + "ok": False, + "reason": f"object-match-{status or 'failed'}", + "message": str(match.get("message") or "Edited object could not be uniquely matched in the new SCDM cache."), + "match": match, + "brep": brep, + "editResult": dict(edit_result), + } + candidate = match.get("object") + if isinstance(candidate, Mapping): + matched = dict(candidate) + + if expected_target is not None and matched is not None: + check = check_scdm_target(matched, capability_key=capability_key, expected_target=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 value."), + "targetCheck": check, + "matchedObject": matched, + "brep": brep, + "editResult": dict(edit_result), + } + else: + check = {"ok": None, "reason": "not-run", "message": "Target check needs a matched object and an expected target."} + + return { + "ok": True, + "reason": "ok", + "message": "SCDM edit result passed the available validation checks.", + "output_step": str(output_step), + "matchedObject": matched, + "targetCheck": check, + "summaryCheck": summary_check, + "topologyCheck": topology_check, + "brep": brep, + "editResult": dict(edit_result), + } + + +def match_scdm_object_by_signature( + before_signature: Mapping[str, object], + after_cache: Mapping[str, object], + *, + capability_key: str = "", + min_score: float = 5.0, + unique_margin: float = 0.75, +) -> dict[str, object]: + candidates = [] + objects = after_cache.get("objects") + if not isinstance(objects, list): + return {"status": "none", "message": "New SCDM cache does not contain objects.", "candidates": []} + + for raw_object in objects: + if not isinstance(raw_object, Mapping): + continue + signature = raw_object.get("geometrySignature") + if not isinstance(signature, Mapping): + continue + score = _signature_score(before_signature, signature, capability_key=capability_key) + if score <= 0: + continue + candidates.append({"score": score, "object": dict(raw_object), "geometrySignature": dict(signature)}) + + candidates.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True) + if not candidates or float(candidates[0].get("score") or 0.0) < min_score: + return {"status": "none", "message": "No matching SCDM object reached the confidence threshold.", "candidates": candidates[:5]} + if len(candidates) > 1: + top = float(candidates[0].get("score") or 0.0) + second = float(candidates[1].get("score") or 0.0) + if top - second < unique_margin: + return {"status": "multiple", "message": "More than one SCDM object matches the old signature.", "candidates": candidates[:5]} + best = candidates[0] + return { + "status": "unique", + "message": "Matched one SCDM object.", + "score": best.get("score"), + "object": best.get("object"), + "candidates": candidates[:5], + } + + +def build_scdm_id_mapping( + before_cache: Mapping[str, object], + after_cache: Mapping[str, object], + *, + capability_key: str = "", +) -> dict[str, object]: + face_id_map: dict[int, int] = {} + edge_id_map: dict[int, int] = {} + object_id_map: dict[str, str] = {} + unmatched: list[str] = [] + ambiguous: list[str] = [] + + before_objects = before_cache.get("objects") + if not isinstance(before_objects, list): + before_objects = [] + for raw_object in before_objects: + if not isinstance(raw_object, Mapping): + continue + before_signature = raw_object.get("geometrySignature") + if not isinstance(before_signature, Mapping): + continue + object_id = str(raw_object.get("objectId") or "") + match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key) + status = str(match.get("status") or "") + if status != "unique": + if status == "multiple": + ambiguous.append(object_id) + else: + unmatched.append(object_id) + continue + new_object = match.get("object") + if not isinstance(new_object, Mapping): + unmatched.append(object_id) + continue + new_signature = new_object.get("geometrySignature") + if not isinstance(new_signature, Mapping): + unmatched.append(object_id) + continue + new_object_id = str(new_object.get("objectId") or "") + if object_id and new_object_id: + object_id_map[object_id] = new_object_id + _extend_single_or_zipped_id_map(face_id_map, _int_values(before_signature.get("faceIds")), _int_values(new_signature.get("faceIds"))) + _extend_single_or_zipped_id_map(edge_id_map, _int_values(before_signature.get("edgeIds")), _int_values(new_signature.get("edgeIds"))) + + return { + "ok": not unmatched and not ambiguous, + "objectIdMap": object_id_map, + "faceIdMap": face_id_map, + "edgeIdMap": edge_id_map, + "unmatched": unmatched, + "ambiguous": ambiguous, + } + + +def check_scdm_unedited_objects( + before_cache: Mapping[str, object], + after_cache: Mapping[str, object], + *, + edited_object_id: str = "", + edited_signature: Mapping[str, object] | None = None, + capability_key: str = "", + max_report: int = 5, +) -> dict[str, object]: + before_objects = before_cache.get("objects") + if not isinstance(before_objects, list): + return {"ok": False, "reason": "missing-before-cache", "message": "Old SCDM cache does not contain objects.", "checked": 0} + after_objects = after_cache.get("objects") + if not isinstance(after_objects, list): + return {"ok": False, "reason": "missing-after-cache", "message": "New SCDM cache does not contain objects.", "checked": 0} + + unmatched: list[dict[str, object]] = [] + ambiguous: list[dict[str, object]] = [] + checked = 0 + for raw_object in before_objects: + if not isinstance(raw_object, Mapping): + continue + object_id = str(raw_object.get("objectId") or "") + signature = raw_object.get("geometrySignature") + if not isinstance(signature, Mapping) or not _signature_has_enough_identity(signature): + continue + if object_id and edited_object_id and object_id == edited_object_id: + continue + if edited_signature and _same_signature_subject(signature, edited_signature): + continue + checked += 1 + match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key) + status = str(match.get("status") or "") + if status == "unique": + matched_object = match.get("object") + matched_signature = matched_object.get("geometrySignature") if isinstance(matched_object, Mapping) else None + if isinstance(matched_signature, Mapping) and _unchanged_signature_still_matches(signature, matched_signature): + continue + status = "none" + row = { + "objectId": object_id, + "objectType": raw_object.get("objectType"), + "status": status or "none", + "message": match.get("message"), + } + if status == "multiple": + ambiguous.append(row) + else: + unmatched.append(row) + + if unmatched or ambiguous: + parts = [] + if unmatched: + parts.append(f"{len(unmatched)} recognized object(s) disappeared or changed too much") + if ambiguous: + parts.append(f"{len(ambiguous)} recognized object(s) became ambiguous") + return { + "ok": False, + "reason": "unexpected-object-drift", + "message": "; ".join(parts) + ".", + "checked": checked, + "unmatched": unmatched[:max_report], + "ambiguous": ambiguous[:max_report], + } + return { + "ok": True, + "reason": "ok", + "message": "Unedited recognized objects still match after the SCDM edit.", + "checked": checked, + "unmatched": [], + "ambiguous": [], + } + + +def rewrite_scdm_relation_formula_ids( + text: str, + mapping: Mapping[str, object], +) -> str: + return rewrite_relation_formula_ids( + text, + _int_map(mapping.get("faceIdMap")), + _int_map(mapping.get("edgeIdMap")), + ) + + +def check_scdm_target( + raw_object: Mapping[str, object], + *, + capability_key: str, + expected_target: object, + tolerance: float = 1.0e-6, +) -> dict[str, object]: + if capability_key == "hole.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, "hole.diameter", tolerance) + if capability_key == "hole.position": + 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 == "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."} + return {"ok": None, "reason": "unsupported-post-check", "message": f"No target checker is registered for {capability_key}."} + + +def check_scdm_summary_delta( + before_cache: Mapping[str, object], + after_cache: Mapping[str, object], + *, + capability_key: str = "", + relative_tolerance: float = 0.25, + absolute_tolerance: int = 12, +) -> dict[str, object]: + if capability_key in {"feature.fill", "feature.delete_round_or_chamfer"}: + return {"ok": None, "reason": "skipped-command-feature", "message": "Command features are expected to change Face/Edge counts."} + before_summary = _raw_summary(before_cache) + after_summary = _raw_summary(after_cache) + if not before_summary or not after_summary: + return {"ok": None, "reason": "missing-summary", "message": "SCDM raw summary was not available in both caches."} + + body_before = _int_or_none(before_summary.get("bodyCount")) + body_after = _int_or_none(after_summary.get("bodyCount")) + if body_before is not None and body_after is not None and body_before != body_after: + return { + "ok": False, + "reason": "summary-drift", + "message": f"SCDM result changed body count unexpectedly: {body_before} -> {body_after}.", + "before": dict(before_summary), + "after": dict(after_summary), + } + + for key, label in (("faceCount", "Face"), ("edgeCount", "Edge"), ("objectCount", "对象")): + before_value = _int_or_none(before_summary.get(key)) + after_value = _int_or_none(after_summary.get(key)) + if before_value is None or after_value is None: + continue + delta = abs(after_value - before_value) + limit = max(int(absolute_tolerance), int(math.ceil(abs(before_value) * float(relative_tolerance)))) + if delta > limit: + return { + "ok": False, + "reason": "summary-drift", + "message": f"SCDM result changed {label} count too much: {before_value} -> {after_value}, limit {limit}.", + "before": dict(before_summary), + "after": dict(after_summary), + "metric": key, + "delta": delta, + "limit": limit, + } + + return { + "ok": True, + "reason": "ok", + "message": "SCDM model summary stayed within the allowed range.", + "before": dict(before_summary), + "after": dict(after_summary), + } + + +def _signature_score(before: Mapping[str, object], after: Mapping[str, object], *, capability_key: str) -> float: + score = 0.0 + before_type = str(before.get("objectType") or "").lower() + after_type = str(after.get("objectType") or "").lower() + if before_type and before_type == after_type: + score += 4.0 + elif {before_type, after_type} <= {"hole", "cylindrical_hole", ""}: + score += 2.0 + + before_surface = str(before.get("surfaceType") or "").lower() + after_surface = str(after.get("surfaceType") or "").lower() + if before_surface and before_surface == after_surface: + 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: + overlap = len(before_faces & after_faces) + if overlap: + score += 1.0 + min(overlap, 3) * 0.25 + + before_edges = set(_int_values(before.get("edgeIds"))) + after_edges = set(_int_values(after.get("edgeIds"))) + 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"))) + score += center_score + + axis_score = _axis_score(_vector(before.get("axis")), _vector(after.get("axis"))) + score += axis_score + + if capability_key != "hole.diameter": + score += _number_similarity_score(_diameter_from_signature(before), _diameter_from_signature(after)) + + return score + + +def _signature_has_enough_identity(signature: Mapping[str, object]) -> bool: + if _vector(signature.get("center")) and _vector(signature.get("axis")): + return True + if _vector(signature.get("center")) and str(signature.get("surfaceType") or ""): + return True + if _int_values(signature.get("faceIds")) or _int_values(signature.get("edgeIds")): + return True + return False + + +def _same_signature_subject(left: Mapping[str, object], right: Mapping[str, object]) -> bool: + left_faces = set(_int_values(left.get("faceIds"))) + right_faces = set(_int_values(right.get("faceIds"))) + if left_faces and right_faces and left_faces == right_faces: + return True + left_edges = set(_int_values(left.get("edgeIds"))) + right_edges = set(_int_values(right.get("edgeIds"))) + if left_edges and right_edges and left_edges == right_edges: + return True + left_center = _vector(left.get("center")) + right_center = _vector(right.get("center")) + if len(left_center) == 3 and len(right_center) == 3 and _vector_error(left_center, right_center) <= 1.0e-8: + left_axis = _vector(left.get("axis")) + right_axis = _vector(right.get("axis")) + if len(left_axis) == 3 and len(right_axis) == 3 and _axis_score(left_axis, right_axis) >= 3.0: + return True + return False + + +def _unchanged_signature_still_matches(before: Mapping[str, object], after: Mapping[str, object]) -> bool: + before_center = _vector(before.get("center")) + after_center = _vector(after.get("center")) + if before_center and after_center and _vector_error(before_center, after_center) > _vector_tolerance(before_center, after_center): + return False + + before_axis = _vector(before.get("axis")) + after_axis = _vector(after.get("axis")) + if before_axis and after_axis and _axis_score(before_axis, after_axis) < 3.0: + return False + + before_diameter = _diameter_from_signature(before) + after_diameter = _diameter_from_signature(after) + if before_diameter is not None and after_diameter is not None: + tolerance = max(abs(before_diameter), abs(after_diameter), 1.0) * 1.0e-5 + 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 + return True + + +def _vector_tolerance(left: Sequence[float], right: Sequence[float]) -> float: + scale = 1.0 + values = list(left) + list(right) + if values: + scale = max(scale, max(abs(float(item)) for item in values)) + return max(scale * 1.0e-5, 1.0e-7) + + +def _extend_single_or_zipped_id_map(target: dict[int, int], old_ids: Sequence[int], new_ids: Sequence[int]) -> None: + old_unique = sorted(set(old_ids)) + new_unique = sorted(set(new_ids)) + if len(old_unique) == 1 and len(new_unique) == 1: + target[int(old_unique[0])] = int(new_unique[0]) + elif len(old_unique) == len(new_unique) and len(old_unique) > 1: + for old_id, new_id in zip(old_unique, new_unique): + target[int(old_id)] = int(new_id) + + +def _capability_value(raw_object: Mapping[str, object], capability_key: str) -> object: + capabilities = raw_object.get("capabilities") + if not isinstance(capabilities, list): + return None + for capability in capabilities: + if isinstance(capability, Mapping) and capability.get("key") == capability_key: + return capability.get("currentValue") + return None + + +def _geometry_value(raw_object: Mapping[str, object], key: str) -> object: + signature = raw_object.get("geometrySignature") + if isinstance(signature, Mapping) and key in signature: + return signature.get(key) + geometry = raw_object.get("geometry") + if isinstance(geometry, Mapping) and key in geometry: + return geometry.get(key) + return None + + +def _number_check(actual: float | None, expected: float | None, label: str, tolerance: float) -> dict[str, object]: + if actual is None or expected is None: + return {"ok": False, "reason": "target-value-missing", "message": f"{label} target check does not have comparable values.", "actual": actual, "expected": expected} + error = abs(actual - expected) + return { + "ok": error <= tolerance, + "reason": "ok" if error <= tolerance else "target-mismatch", + "message": "Target value matched." if error <= tolerance else f"{label} actual={actual:g}, expected={expected:g}, error={error:g}.", + "actual": actual, + "expected": expected, + "error": error, + "tolerance": tolerance, + } + + +def _vector_check(actual: Sequence[float], expected: Sequence[float], label: str, tolerance: float) -> dict[str, object]: + if len(actual) != 3 or len(expected) != 3: + return {"ok": False, "reason": "target-value-missing", "message": f"{label} target check does not have comparable vectors.", "actual": list(actual), "expected": list(expected)} + error = math.sqrt(sum((float(actual[index]) - float(expected[index])) ** 2 for index in range(3))) + return { + "ok": error <= tolerance, + "reason": "ok" if error <= tolerance else "target-mismatch", + "message": "Target vector matched." if error <= tolerance else f"{label} vector error={error:g}.", + "actual": list(actual), + "expected": list(expected), + "error": error, + "tolerance": tolerance, + } + + +def _vector_error(actual: Sequence[float], expected: Sequence[float]) -> float: + if len(actual) != 3 or len(expected) != 3: + return math.inf + return math.sqrt(sum((float(actual[index]) - float(expected[index])) ** 2 for index in range(3))) + + +def _vector_distance_score(before: Sequence[float], after: Sequence[float]) -> float: + if len(before) != 3 or len(after) != 3: + return 0.0 + distance = math.sqrt(sum((float(before[index]) - float(after[index])) ** 2 for index in range(3))) + if distance <= 1.0e-5: + return 4.0 + if distance <= 1.0e-3: + return 3.0 + if distance <= 1.0e-1: + return 1.5 + return 0.0 + + +def _axis_score(before: Sequence[float], after: Sequence[float]) -> float: + if len(before) != 3 or len(after) != 3: + return 0.0 + before_len = math.sqrt(sum(float(item) * float(item) for item in before)) + after_len = math.sqrt(sum(float(item) * float(item) for item in after)) + if before_len <= 1.0e-12 or after_len <= 1.0e-12: + return 0.0 + dot = abs(sum(float(before[index]) * float(after[index]) for index in range(3)) / (before_len * after_len)) + if dot >= 0.999: + return 3.0 + if dot >= 0.99: + return 2.0 + return 0.0 + + +def _number_similarity_score(before: float | None, after: float | None) -> float: + if before is None or after is None: + return 0.0 + error = abs(float(before) - float(after)) + scale = max(abs(float(before)), abs(float(after)), 1.0) + relative = error / scale + if relative <= 1.0e-5: + return 3.0 + if relative <= 1.0e-3: + return 2.0 + if relative <= 5.0e-2: + return 0.75 + return 0.0 + + +def _diameter_from_signature(signature: Mapping[str, object]) -> float | None: + diameter = _number(signature.get("diameter")) + if diameter is not None: + return diameter + radius = _number(signature.get("radius")) + return radius * 2.0 if radius is not None else None + + +def _number(value: object) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _vector(value: object) -> list[float]: + if isinstance(value, (str, bytes)) or value is None: + return [] + try: + values = list(value) # type: ignore[arg-type] + except TypeError: + return [] + if len(values) != 3: + return [] + try: + return [float(item) for item in values] + except (TypeError, ValueError): + return [] + + +def _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): + return {} + summary = diagnostics.get("raw_summary") or diagnostics.get("summary") + return summary if isinstance(summary, Mapping) else {} + + +def _int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _int_map(value: object) -> dict[int, int]: + if not isinstance(value, Mapping): + return {} + result: dict[int, int] = {} + for key, item in value.items(): + try: + result[int(key)] = int(item) + except (TypeError, ValueError): + continue + return result + + +__all__ = [ + "build_scdm_id_mapping", + "check_scdm_summary_delta", + "check_scdm_unedited_objects", + "check_scdm_target", + "match_scdm_object_by_signature", + "rewrite_scdm_relation_formula_ids", + "validate_scdm_edit_result", +] diff --git a/step_editor/scdm_schema.py b/step_editor/scdm_schema.py new file mode 100644 index 0000000..b7fdbc1 --- /dev/null +++ b/step_editor/scdm_schema.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +SCDM_RAW_SCHEMA_VERSION = 1 +SCDM_CACHE_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class ScdmProbeJob: + step_path: Path + output_dir: Path + raw_features_path: Path + error_path: Path + model_fingerprint: str + unit: str = "model" + scan_scope: str = "all" + adapter: str = "spaceclaim-v1" + backend_path: str = "" + backend_version: str = "" + created_at: str = "" + + def to_payload(self) -> dict[str, object]: + return { + "schemaVersion": SCDM_RAW_SCHEMA_VERSION, + "adapter": self.adapter, + "createdAt": self.created_at or utc_now(), + "backend": { + "name": "SCDM", + "path": self.backend_path, + "version": self.backend_version, + }, + "model": { + "path": str(self.step_path), + "fingerprint": self.model_fingerprint, + "unit": self.unit, + }, + "scan": { + "scope": self.scan_scope, + }, + "outputs": { + "rawFeatures": str(self.raw_features_path), + "error": str(self.error_path), + }, + } + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def file_fingerprint(path: str | Path) -> str: + source = Path(path) + digest = hashlib.sha256() + stat = source.stat() + digest.update(str(source.resolve(strict=False)).encode("utf-8", errors="replace")) + digest.update(str(stat.st_size).encode("ascii")) + digest.update(str(stat.st_mtime_ns).encode("ascii")) + with source.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_json(path: str | Path) -> dict[str, object]: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"JSON payload must be an object: {path}") + return payload + + +def write_json(path: str | Path, payload: Mapping[str, object]) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(dict(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return target + + +def default_scdm_work_dir( + step_path: str | Path, + *, + project_root: str | Path | None = None, + fingerprint: str | None = None, +) -> Path: + root = Path(project_root).expanduser() if project_root else Path(__file__).resolve().parent.parent + source = Path(step_path) + short = (fingerprint or file_fingerprint(source))[:12] + return root / "local" / "scdm" / f"{source.stem}_{short}" + + +def payload_model_fingerprint(payload: Mapping[str, object]) -> str: + model = payload.get("model") + if not isinstance(model, Mapping): + return "" + return str(model.get("fingerprint") or "") + + +def payload_backend_version(payload: Mapping[str, object]) -> str: + backend = payload.get("backend") + if not isinstance(backend, Mapping): + return "" + return str(backend.get("version") or "") + + +__all__ = [ + "SCDM_CACHE_SCHEMA_VERSION", + "SCDM_RAW_SCHEMA_VERSION", + "ScdmProbeJob", + "default_scdm_work_dir", + "file_fingerprint", + "payload_backend_version", + "payload_model_fingerprint", + "read_json", + "utc_now", + "write_json", +] diff --git a/step_editor/scdm_status.py b/step_editor/scdm_status.py new file mode 100644 index 0000000..e46637d --- /dev/null +++ b/step_editor/scdm_status.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from .scdm_backend import ScdmBackendInfo, is_scdm_disabled, load_scdm_backend_cache +from .scdm_capabilities import CAPABILITY_DEFINITIONS, ScdmCapabilityDefinition + + +def cached_scdm_backend_payload(project_root: str | Path | None = None) -> dict[str, object] | None: + if is_scdm_disabled(): + return {"disabled": True, "reason": "disabled", "message": "SCDM backend is disabled by environment."} + backend = load_scdm_backend_cache(project_root_override=project_root) + return backend.to_cache() if backend is not None else None + + +def summarize_scdm_runtime( + *, + backend: ScdmBackendInfo | Mapping[str, object] | None = None, + cache_state: str = "", + cache_message: str = "", + feature_cache: Mapping[str, object] | None = None, +) -> dict[str, object]: + backend_payload = _backend_payload(backend) + disabled = _backend_disabled(backend) + state = str(cache_state or "empty").strip().lower() + message = _compact(str(cache_message or "").strip(), 120) + count = _feature_cache_counts(feature_cache) + + if disabled: + headline = "SCDM:已关闭,当前使用 OCCT/Analysis Situs 兜底" + path = "" + elif backend_payload: + version = str(backend_payload.get("version") or "").strip() + source = _source_label(str(backend_payload.get("source") or "").strip()) + version_text = f" {version}" if version else "" + headline = f"SCDM:已配置{version_text}({source})" + path = str(backend_payload.get("path") or "").strip() + else: + headline = "SCDM:未配置,当前可用 OCCT/Analysis Situs 兜底" + path = "" + + if disabled: + detail = "检测到 SCDM 禁用开关;本次不会启动 SpaceClaim.exe。" + elif state == "running": + detail = "正在后台识别可修改参数;界面可继续旋转查看模型。" + elif state == "ready": + object_text = f"{count['objects']} 个对象" if count["objects"] else "0 个对象" + capability_text = f"{count['capabilities']} 项能力" if count["capabilities"] else "0 项能力" + detail = f"识别缓存已就绪:{object_text},{capability_text}。" + elif state == "failed": + reason = message or "未拿到 SCDM 识别结果" + detail = f"识别未启用:{reason};当前使用本软件已有能力。" + elif state == "deferred": + detail = message or "大模型已延后 SCDM 全量识别,优先保证查看、旋转和点选流畅。" + elif state == "stale": + detail = message or "缓存已失效,导入、编辑、撤销或重做后会后台重新识别。" + else: + detail = "导入 STEP 后会尝试启动 SCDM 识别;找不到时使用 OCCT/Analysis Situs 兜底。" + + tooltip_lines = [headline, detail] + if path: + tooltip_lines.append(f"路径:{path}") + if backend_payload: + run_script_ok = backend_payload.get("runScriptOk") + license_ok = backend_payload.get("licenseOk") + tooltip_lines.append(f"/RunScript:{_ok_text(run_script_ok)}") + tooltip_lines.append(f"许可证:{_ok_text(license_ok)}") + return { + "headline": headline, + "detail": detail, + "tooltip": "\n".join(line for line in tooltip_lines if line), + "backendReady": bool(backend_payload) and not disabled, + "cacheState": state, + "objectCount": count["objects"], + "capabilityCount": count["capabilities"], + } + + +def summarize_scdm_capability_progress( + *, + feature_cache: Mapping[str, object] | None = None, + execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False, +) -> dict[str, object]: + ready_keys = _execution_ready_keys(execution_ready) + detection_counts = _cache_capability_counts(feature_cache) + blocked_counts = _cache_blocked_capability_counts(feature_cache) + planned_counts = _planned_capability_counts(feature_cache) + hint_counts = _geometry_candidate_hint_counts(feature_cache) + discovered_summary = _discovered_not_productized_summary(feature_cache) + probe_evidence = _probe_evidence_summary(feature_cache) + rows: list[dict[str, object]] = [] + + for key, definition in sorted(CAPABILITY_DEFINITIONS.items(), key=lambda item: (_stage_sort_key(item[1].roadmap_stage), item[0])): + detected = int(detection_counts.get(key, 0)) + blocked = int(blocked_counts.get(key, 0)) + planned_detected = int(planned_counts.get(key, 0)) + hint_detected = int(hint_counts.get(key, 0)) + runner_ready = _capability_runner_ready(key, execution_ready, ready_keys) + status, reason = _capability_progress_status( + key, + definition, + detected=detected, + blocked=blocked, + planned_detected=planned_detected, + hint_detected=hint_detected, + runner_ready=runner_ready, + ) + executable = detected if definition.productized and runner_ready else 0 + if blocked: + executable = max(0, executable - blocked) + rows.append( + { + "key": key, + "displayName": definition.display_name, + "roadmapStage": definition.roadmap_stage, + "productized": definition.productized, + "runnerReady": runner_ready, + "detectedCount": detected, + "plannedDetectedCount": planned_detected, + "hintDetectedCount": hint_detected, + "blockedCount": blocked, + "executableCount": executable, + "status": status, + "reason": reason, + } + ) + + executable_count = sum(int(row["executableCount"]) for row in rows) + productized_count = sum(1 for row in rows if bool(row["productized"])) + runner_ready_count = sum(1 for row in rows if bool(row["productized"]) and bool(row["runnerReady"])) + planned_detected_total = sum(int(row["plannedDetectedCount"]) for row in rows) + hint_detected_total = sum(int(row["hintDetectedCount"]) for row in rows) + blocked_total = sum(int(row["blockedCount"]) for row in rows) + return { + "rows": rows, + "summary": { + "defined": len(rows), + "productized": productized_count, + "runnerReady": runner_ready_count, + "detectedCapabilities": sum(int(row["detectedCount"]) for row in rows), + "executableCapabilities": executable_count, + "plannedDetected": planned_detected_total, + "geometryHints": hint_detected_total, + "backendBlocked": blocked_total, + "discoveredNotProductized": discovered_summary["count"], + "faceAdjacency": probe_evidence["faceAdjacency"], + "circularEdges": probe_evidence["circularEdges"], + "inventoryObjectTypes": probe_evidence["inventoryObjectTypes"], + "inventoryOperationCandidates": probe_evidence["inventoryOperationCandidates"], + "derivedFeatureCandidates": probe_evidence["derivedFeatureCandidates"], + }, + "productizedLines": _capability_progress_lines( + row for row in rows if bool(row["productized"]) + ), + "plannedLines": _capability_progress_lines( + row + for row in rows + if not bool(row["productized"]) + and ( + int(row["plannedDetectedCount"]) > 0 + or int(row["detectedCount"]) > 0 + or int(row["hintDetectedCount"]) > 0 + ) + ), + "roadmapLines": _capability_progress_lines( + row + for row in rows + if not bool(row["productized"]) + and int(row["plannedDetectedCount"]) <= 0 + and int(row["detectedCount"]) <= 0 + and int(row["hintDetectedCount"]) <= 0 + ), + "discoveredNotProductized": discovered_summary, + "probeEvidence": probe_evidence, + } + + +def _backend_payload(backend: ScdmBackendInfo | Mapping[str, object] | None) -> dict[str, object]: + if isinstance(backend, ScdmBackendInfo): + return backend.to_cache() + if not isinstance(backend, Mapping): + return {} + nested = backend.get("backend") + if isinstance(nested, ScdmBackendInfo): + return nested.to_cache() + if isinstance(nested, Mapping): + return _backend_payload(nested) + path = str(backend.get("path") or "").strip() + if not path: + return {} + return { + "path": path, + "source": str(backend.get("source") or ""), + "version": str(backend.get("version") or ""), + "verifiedAt": str(backend.get("verifiedAt") or ""), + "runScriptOk": backend.get("runScriptOk"), + "licenseOk": backend.get("licenseOk"), + "message": str(backend.get("message") or ""), + } + + +def _backend_disabled(backend: ScdmBackendInfo | Mapping[str, object] | None) -> bool: + return isinstance(backend, Mapping) and bool(backend.get("disabled")) + + +def _feature_cache_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]: + if not isinstance(feature_cache, Mapping): + return {"objects": 0, "capabilities": 0} + objects = feature_cache.get("objects") + if not isinstance(objects, list): + return {"objects": 0, "capabilities": 0} + capability_count = 0 + object_count = 0 + for item in objects: + if not isinstance(item, Mapping): + continue + object_count += 1 + capabilities = item.get("capabilities") + if isinstance(capabilities, list): + capability_count += sum(1 for capability in capabilities if isinstance(capability, Mapping)) + return {"objects": object_count, "capabilities": capability_count} + + +def _cache_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]: + result: dict[str, int] = {} + if not isinstance(feature_cache, Mapping): + return result + objects = feature_cache.get("objects") + if not isinstance(objects, list): + return result + for item in objects: + if not isinstance(item, Mapping): + continue + capabilities = item.get("capabilities") + if not isinstance(capabilities, list): + continue + for capability in capabilities: + if not isinstance(capability, Mapping): + continue + key = str(capability.get("key") or "").strip() + if key: + result[key] = result.get(key, 0) + 1 + return result + + +def _cache_blocked_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]: + result: dict[str, int] = {} + if not isinstance(feature_cache, Mapping): + return result + objects = feature_cache.get("objects") + if not isinstance(objects, list): + return result + for item in objects: + if not isinstance(item, Mapping): + continue + object_block = str(item.get("blockReason") or "").strip() + capabilities = item.get("capabilities") + if not isinstance(capabilities, list): + continue + for capability in capabilities: + if not isinstance(capability, Mapping): + continue + key = str(capability.get("key") or "").strip() + if not key: + continue + capability_block = str(capability.get("blockReason") or "").strip() + if object_block or capability_block or capability.get("editable") is False: + result[key] = result.get(key, 0) + 1 + return result + + +def _planned_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]: + diagnostics = _cache_diagnostics(feature_cache) + planned = diagnostics.get("planned_not_productized") + result: dict[str, int] = {} + if not isinstance(planned, list): + return result + for item in planned: + if not isinstance(item, Mapping): + continue + key = str(item.get("capabilityKey") or "").strip() + if key: + result[key] = result.get(key, 0) + 1 + return result + + +def _geometry_candidate_hint_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]: + diagnostics = _cache_diagnostics(feature_cache) + hints = diagnostics.get("geometry_candidate_hints") + result: dict[str, int] = {} + if not isinstance(hints, list): + return result + for item in hints: + if not isinstance(item, Mapping): + continue + key = str(item.get("capabilityKey") or "").strip() + if not key: + continue + count = _int_value(item.get("evidenceCount")) + result[key] = result.get(key, 0) + max(count, 1) + return result + + +def _discovered_not_productized_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]: + diagnostics = _cache_diagnostics(feature_cache) + discovered = diagnostics.get("discovered_not_productized") + by_type: dict[str, int] = {} + if not isinstance(discovered, list): + return {"count": 0, "byObjectType": {}, "lines": []} + for item in discovered: + if not isinstance(item, Mapping): + continue + object_type = str(item.get("objectType") or "object").strip() or "object" + by_type[object_type] = by_type.get(object_type, 0) + 1 + lines = [f"{name}:{count} 个" for name, count in sorted(by_type.items(), key=lambda item: (-item[1], item[0]))[:6]] + return {"count": sum(by_type.values()), "byObjectType": by_type, "lines": lines} + + +def _probe_evidence_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]: + diagnostics = _cache_diagnostics(feature_cache) + face_adjacency = diagnostics.get("face_adjacency") + edge_summary = diagnostics.get("edge_geometry_summary") + feature_inventory = diagnostics.get("feature_inventory") + adjacency_count = len(face_adjacency) if isinstance(face_adjacency, list) else 0 + edge_summary = edge_summary if isinstance(edge_summary, Mapping) else {} + feature_inventory = feature_inventory if isinstance(feature_inventory, Mapping) else {} + edge_kind_counts = edge_summary.get("edgeKindCounts") + edge_kind_counts = edge_kind_counts if isinstance(edge_kind_counts, Mapping) else {} + object_type_counts = _mapping_count_dict(feature_inventory.get("objectTypeCounts")) + surface_type_counts = _mapping_count_dict(feature_inventory.get("surfaceTypeCounts")) + operation_counts = _mapping_count_dict(feature_inventory.get("operationCounts")) + geometry_hints = diagnostics.get("geometry_candidate_hints") + geometry_hint_lines = _geometry_candidate_hint_lines(geometry_hints) + derived_candidates = diagnostics.get("derived_feature_candidates") + derived_candidate_lines = _derived_feature_candidate_lines(derived_candidates) + derived_candidate_count = len(derived_candidates) if isinstance(derived_candidates, list) else 0 + circular_edges = _int_value(edge_summary.get("circularEdgeCount")) + if circular_edges <= 0: + circular_edges = _int_value(edge_kind_counts.get("circular")) + linear_edges = _int_value(edge_kind_counts.get("linear")) + total_edges = _int_value(edge_summary.get("totalEdgeCount")) + radius_buckets = edge_summary.get("circularRadiusBuckets") + radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0 + lines = [] + if adjacency_count: + lines.append(f"Face 邻接 {adjacency_count} 组") + if total_edges: + lines.append(f"Edge {total_edges} 条") + if circular_edges: + lines.append(f"圆边 {circular_edges} 条") + if linear_edges: + lines.append(f"直边 {linear_edges} 条") + if radius_bucket_count: + lines.append(f"圆边半径分组 {radius_bucket_count} 类") + object_lines = _count_summary_lines(object_type_counts, label="对象") + surface_lines = _count_summary_lines(surface_type_counts, label="曲面") + operation_lines = _count_summary_lines(operation_counts, label="命令候选") + lines.extend(object_lines[:2]) + lines.extend(surface_lines[:2]) + lines.extend(operation_lines[:2]) + lines.extend(derived_candidate_lines[:3]) + lines.extend(geometry_hint_lines[:4]) + return { + "faceAdjacency": adjacency_count, + "totalEdges": total_edges, + "circularEdges": circular_edges, + "linearEdges": linear_edges, + "radiusBucketCount": radius_bucket_count, + "inventoryObjectTypes": sum(object_type_counts.values()), + "inventorySurfaceTypes": sum(surface_type_counts.values()), + "inventoryOperationCandidates": sum(operation_counts.values()), + "derivedFeatureCandidates": derived_candidate_count, + "derivedFeatureCandidateLines": derived_candidate_lines, + "objectTypeCounts": object_type_counts, + "surfaceTypeCounts": surface_type_counts, + "operationCounts": operation_counts, + "geometryHintLines": geometry_hint_lines, + "lines": lines, + } + + +def _derived_feature_candidate_lines(value: object, *, limit: int = 4) -> list[str]: + if not isinstance(value, list): + return [] + counts: dict[str, int] = {} + for item in value: + if not isinstance(item, Mapping): + continue + object_type = str(item.get("objectType") or "object").strip() or "object" + counts[object_type] = counts.get(object_type, 0) + 1 + rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))] + return [f"派生候选 {name}:{count}" for name, count in rows] + + +def _geometry_candidate_hint_lines(value: object, *, limit: int = 4) -> list[str]: + if not isinstance(value, list): + return [] + best: dict[str, dict[str, object]] = {} + for item in value: + if not isinstance(item, Mapping): + continue + key = str(item.get("capabilityKey") or "").strip() + if not key: + continue + count = max(_int_value(item.get("evidenceCount")), 1) + existing = best.get(key) + if existing is None or count > int(existing.get("evidenceCount") or 0): + best[key] = { + "displayName": str(item.get("displayName") or key), + "evidenceCount": count, + "confidence": str(item.get("confidence") or ""), + } + rows = sorted(best.items(), key=lambda item: (-int(item[1].get("evidenceCount") or 0), item[0]))[: max(1, int(limit))] + return [ + f"几何候选 {payload['displayName']}:{payload['evidenceCount']}({payload['confidence'] or 'unknown'})" + for _key, payload in rows + ] + + +def _mapping_count_dict(value: object) -> dict[str, int]: + if not isinstance(value, Mapping): + return {} + result: dict[str, int] = {} + for key, count in value.items(): + text = str(key or "").strip() or "unknown" + number = _int_value(count) + if number > 0: + result[text] = number + return result + + +def _count_summary_lines(counts: Mapping[str, int], *, label: str, limit: int = 4) -> list[str]: + if not counts: + return [] + rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))] + summary = ",".join(f"{name}:{count}" for name, count in rows) + return [f"{label}分布 {summary}"] + + +def _int_value(value: object) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _cache_diagnostics(feature_cache: Mapping[str, object] | None) -> Mapping[str, object]: + if not isinstance(feature_cache, Mapping): + return {} + diagnostics = feature_cache.get("diagnostics") + return diagnostics if isinstance(diagnostics, Mapping) else {} + + +def _execution_ready_keys(execution_ready: bool | set[str] | list[str] | tuple[str, ...]) -> set[str]: + if isinstance(execution_ready, bool): + return set() + try: + return {str(item) for item in execution_ready} + except TypeError: + return set() + + +def _capability_runner_ready( + key: str, + execution_ready: bool | set[str] | list[str] | tuple[str, ...], + ready_keys: set[str], +) -> bool: + return bool(execution_ready) if isinstance(execution_ready, bool) else key in ready_keys + + +def _capability_progress_status( + key: str, + definition: ScdmCapabilityDefinition, + *, + detected: int, + blocked: int, + planned_detected: int, + hint_detected: int, + runner_ready: bool, +) -> tuple[str, str]: + if definition.productized and runner_ready and detected > blocked: + return "已开放", "已识别到对象时会显示在特征参数表。" + if definition.productized and runner_ready and blocked: + return "已开放但被后端阻止", "当前模型里识别到该能力,但 SCDM 命令、对象状态或安全守门暂时阻止执行。" + if definition.productized and runner_ready: + return "已开放待识别", "执行链路已接入,当前 cache 还没有识别到可执行对象。" + if definition.productized and detected: + return "已识别待执行器", "能力已进入产品字典,但当前 UI 执行器还未开放。" + if definition.productized: + return "已产品化待对象", "能力已定义,等待 SCDM 在当前模型中识别到对象。" + if planned_detected or detected: + return "已识别待验证", definition.block_reason or "已识别到候选,但还没有完成真实 STEP 回测。" + if hint_detected: + return "几何证据待分类", "SCDM probe 已看到相关曲面/边/命令线索,但还没有确认成可执行特征对象。" + return "路线中待接入", definition.block_reason or f"{key} 还没有接入可执行闭环。" + + +def _capability_progress_lines(rows: object) -> list[str]: + result: list[str] = [] + for row in rows: # type: ignore[assignment] + if not isinstance(row, Mapping): + continue + display = str(row.get("displayName") or row.get("key") or "").strip() + status = str(row.get("status") or "").strip() + detected = int(row.get("detectedCount") or 0) + planned = int(row.get("plannedDetectedCount") or 0) + hinted = int(row.get("hintDetectedCount") or 0) + blocked = int(row.get("blockedCount") or 0) + suffix_parts = [] + if detected: + suffix_parts.append(f"cache {detected}") + if planned: + suffix_parts.append(f"候选 {planned}") + if hinted: + suffix_parts.append(f"证据 {hinted}") + if blocked: + suffix_parts.append(f"阻止 {blocked}") + suffix = f"({','.join(suffix_parts)})" if suffix_parts else "" + result.append(f"- {display}:{status}{suffix}") + return result + + +def _stage_sort_key(stage: str) -> tuple[int, int, str]: + text = str(stage or "") + numbers: list[int] = [] + for part in text.replace("S", "").split("."): + try: + numbers.append(int(part)) + except ValueError: + pass + while len(numbers) < 2: + numbers.append(0) + return numbers[0], numbers[1], text + + +def _source_label(source: str) -> str: + if source.startswith("registry:"): + return "注册表" + if source.startswith("env:"): + return "环境变量" + if source.startswith("common:"): + return "常见安装目录" + if source.lower() == "path": + return "PATH" + if source == "manual": + return "手动配置" + if source == "cache": + return "缓存" + return source or "未知来源" + + +def _ok_text(value: object) -> str: + if value is True: + return "可用" + if value is False: + return "不可用" + return "未验证" + + +def _compact(text: str, limit: int) -> str: + text = " ".join(text.split()) + if len(text) <= limit: + return text + return text[: max(limit - 1, 0)].rstrip() + "…" + + +__all__ = ["cached_scdm_backend_payload", "summarize_scdm_capability_progress", "summarize_scdm_runtime"] diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index cdf41fb..0b2f5a8 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -33,6 +33,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "feature_source_face_id", ], ), + ( + "SCDM", + [ + "scdm_backend_status", + "scdm_runtime_status", + "scdm_selection_status", + "scdm_selection_enabled_capabilities", + "scdm_selection_blocked_capabilities", + "scdm_selection_capability_count", + "scdm_selection_blocked_count", + ], + ), ( "拓扑", [ @@ -529,6 +541,13 @@ INFO_LABELS = { "associated_feature_count": "关联特征数", "associated_feature_face_ids": "关联特征 Face", "feature_context_note": "关联探测", + "scdm_backend_status": "SCDM 后端", + "scdm_runtime_status": "SCDM 运行状态", + "scdm_selection_status": "SCDM 当前选择", + "scdm_selection_enabled_capabilities": "SCDM 可执行能力", + "scdm_selection_blocked_capabilities": "SCDM 未开放能力", + "scdm_selection_capability_count": "SCDM 能力数量", + "scdm_selection_blocked_count": "SCDM 未开放数量", "recognition_summary": "识别摘要", "recognition_candidate": "识别候选", "recognition_confidence": "识别置信度", @@ -1178,6 +1197,39 @@ def _smooth_surface_polydata(polydata): return smoothed +def _large_model_display_deflection( + requested: float, + *, + face_count: int = 0, + edge_count: int = 0, +) -> float: + """Use a coarser display mesh for large STEP interaction only.""" + value = max(float(requested), 1e-9) + if int(face_count or 0) > 1000 or int(edge_count or 0) > 2500: + return max(value, 1.2) + if int(face_count or 0) > 600 or int(edge_count or 0) > 1600: + return max(value, 0.6) + return value + + +def _large_model_display_deflection_for_model(model: object, requested: float) -> float: + faces = getattr(model, "faces", ()) or () + edges = getattr(model, "edges", ()) or () + return _large_model_display_deflection( + requested, + face_count=len(faces), + edge_count=len(edges), + ) + + +def _large_model_display_deflection_for_stats(stats: object, requested: float) -> float: + return _large_model_display_deflection( + requested, + face_count=int(getattr(stats, "faces", 0) or 0), + edge_count=int(getattr(stats, "edges", 0) or 0), + ) + + def _format_percent(value: object) -> str: if value is None or value == "": return "" diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index e88c5ec..4edc12a 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -106,6 +106,7 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str: "validate": "结果校验", "display_faces": "面显示", "display_edges": "边线", + "result_face_mapping": "结果Face定位", "finish_ui": "界面刷新", "total": "总计", } @@ -507,7 +508,14 @@ class WindowActionMixin: if plan["status"] == "blocked": self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止") return - if plan["risk"] != "low": + if keep_relations: + operation_key = "push_pull_face_keep_relations" + isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance]) + else: + operation_key = "push_pull_face" + isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance]) + + if plan["risk"] != "low" and not self._can_skip_edit_confirmation(plan, isolation): warnings = str(plan.get("warnings", "")) warnings_line = f"警告: {warnings}\n\n" if warnings else "" isolation_line = ( @@ -538,10 +546,6 @@ class WindowActionMixin: if result != QMessageBox.StandardButton.Yes: self.statusBar().showMessage("已取消拉伸/切除平面") return - if keep_relations: - isolation = self._isolation_for_plan(plan, "push_pull_face_keep_relations", [face_id, distance]) - else: - isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]) if isolation is None: self._show_push_pull_preview(face_id, distance, plan=plan) else: @@ -629,6 +633,7 @@ class WindowActionMixin: target_kind="face", target_id=face_id, isolation=isolation, + operation_key=operation_key, ) def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: @@ -840,13 +845,31 @@ class WindowActionMixin: if model_face_count < 600: return False - inner_wires = int(quick_plan.get("inner_boundary_wires") or 0) - boundary_wires = int(quick_plan.get("boundary_wires") or 0) + inner_wires = int( + quick_plan.get("inner_boundary_wires") + or quick_plan.get("selected_inner_boundary_wires") + or 0 + ) + boundary_wires = int( + quick_plan.get("boundary_wires") + or quick_plan.get("selected_boundary_wires") + or 0 + ) if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")): return False - # Large STEP + holed planar caps are exactly where a full plan can spend - # seconds scanning topology before the actual isolated edit even starts. + boundary_edges = int( + quick_plan.get("first_level_boundary_edge_count") + or quick_plan.get("selected_boundary_edge_count") + or 0 + ) + if boundary_wires and boundary_wires <= 16 and inner_wires <= 12: + return False + if boundary_edges and boundary_edges <= 120 and inner_wires <= 12: + return False + + # Very large STEP + extremely fragmented holed caps can still spend + # noticeable time scanning topology before the actual edit starts. return abs(float(distance)) > 1e-9 def _deferred_push_pull_model_plan( @@ -1727,13 +1750,66 @@ class WindowActionMixin: return None if risk not in {"low", "medium", "high"}: return None + prefer_smooth_process = self._prefer_isolated_process_for_large_interactive_edit(plan, operation) + if not prefer_smooth_process and self._can_run_inprocess_background_edit(plan, operation): + return None return { "operation": operation, "args": args, "timeout_seconds": timeout_seconds, - "reason": f"{risk}-risk-isolated-occ-edit", + "reason": "large-model-smooth-ui-isolated-occ-edit" if prefer_smooth_process else f"{risk}-risk-isolated-occ-edit", } + def _can_run_inprocess_background_edit(self, plan: dict[str, object], operation: str) -> bool: + if operation != "push_pull_face": + return False + if str(plan.get("planar_cap_extension_method") or "") == "boundary-shell-rebuild": + return True + if str(plan.get("cylindrical_cap_extension_method") or "") == "local-shell-rebuild": + return True + if bool(plan.get("ui_deferred_model_plan")) and int(plan.get("selected_inner_boundary_wires", 0) or 0) > 0: + return True + return False + + def _prefer_isolated_process_for_large_interactive_edit(self, plan: dict[str, object], operation: str) -> bool: + if operation not in {"push_pull_face", "push_pull_face_keep_relations"}: + return False + method = str(plan.get("planar_cap_extension_method") or plan.get("cylindrical_cap_extension_method") or "") + if method not in {"boundary-shell-rebuild", "local-shell-rebuild"}: + return False + if method == "local-shell-rebuild": + return True + if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): + return True + boundary_edges = _int_or_none(plan.get("first_level_boundary_edge_count")) or _int_or_none( + plan.get("planar_cap_boundary_edge_count") + ) or 0 + adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count")) or _int_or_none( + plan.get("planar_cap_adjacent_face_count") + ) or 0 + inner_wires = _int_or_none(plan.get("selected_inner_boundary_wires")) or _int_or_none( + plan.get("planar_cap_inner_boundary_wires") + ) or 0 + return boundary_edges >= 32 or adjacent_faces >= 32 or inner_wires >= 2 + + def _skip_before_quality_check_for_large_edit( + self, + operation_name: str, + operation_key: str | None = None, + ) -> bool: + if operation_key not in {"push_pull_face", "push_pull_face_keep_relations"} and "拉伸/切除" not in str( + operation_name or "" + ): + return False + return bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) + + def _can_skip_edit_confirmation(self, plan: dict[str, object], isolation: dict[str, object] | None) -> bool: + if str(plan.get("status") or "") == "blocked": + return False + if not isinstance(isolation, dict): + return False + return isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit" + def _edit_failure_diagnostics(self, context: dict[str, object]) -> str: parameters = context.get("parameters") if not isinstance(parameters, dict): @@ -7683,6 +7759,8 @@ class WindowActionMixin: @Slot(object) def _finish_scan_task_result(self, result: object) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scan_task_result(result)): + return scan_kind = self.pending_scan_kind context = dict(self.pending_scan_context or {}) if scan_kind == "editable": @@ -7701,6 +7779,8 @@ class WindowActionMixin: @Slot(str) def _fail_scan_task_result(self, message: str) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scan_task_result(message)): + return scan_kind = self.pending_scan_kind if scan_kind == "editable": self._fail_editable_scan(message) @@ -7978,6 +8058,7 @@ class WindowActionMixin: target_kind: str | None = None, target_id: int | None = None, isolation: dict[str, object] | None = None, + operation_key: str | None = None, ) -> None: if self.model is None: return @@ -7985,11 +8066,15 @@ class WindowActionMixin: self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。") return target_logical_id = self._edit_target_logical_id(target_kind, target_id) - result_deflection = float(getattr(self, "edit_result_deflection", 1.6)) + result_deflection = _large_model_display_deflection_for_model( + self.model, + float(getattr(self, "edit_result_deflection", 1.6)), + ) if operation_name == "拉伸/切除平面": result_deflection = max(result_deflection, 0.35) context = { "operation_name": operation_name, + "operation_key": operation_key or "", "target": target, "parameters": parameters, "target_kind": target_kind, @@ -8000,6 +8085,10 @@ class WindowActionMixin: "edit_result_deflection": result_deflection, "defer_edge_polydata": True, "isolation": dict(isolation or {}), + "skip_before_quality_check": self._skip_before_quality_check_for_large_edit( + operation_name, + operation_key, + ), } blocker = self._edit_preflight_blocker(context) if blocker is not None: @@ -8046,7 +8135,11 @@ class WindowActionMixin: target_part_id = self._edit_context_part_id(context) before_stats = self.model.stats() before_part_stats = self._part_stats_or_none(target_part_id) - before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id) + before_quality = ( + None + if bool(context.get("skip_before_quality_check")) + else self._edit_quality_info_or_none(self.model, context, target_part_id) + ) before_geometry = {} timings["snapshot"] = time.perf_counter() - started isolation = context.get("isolation") @@ -8262,7 +8355,9 @@ class WindowActionMixin: except Exception: pass child_message = str(response.get("message") or "隔离子进程编辑完成。") + started = time.perf_counter() self._preserve_isolated_face_logical_id(new_model, context, child_message) + timings["result_face_mapping"] = time.perf_counter() - started started = time.perf_counter() after_snapshot = new_model.snapshot() after_stats = new_model.stats() @@ -8305,7 +8400,7 @@ class WindowActionMixin: timings["total"] = time.perf_counter() - total_started return { - "message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。", + "message": f"{child_message} 已通过独立后台几何进程完成,主界面会保持可响应。", "snapshot": snapshot, "before_stats": before_stats, "before_part_stats": before_part_stats, @@ -8375,9 +8470,6 @@ class WindowActionMixin: logical_id = int(target_logical_id) except (TypeError, ValueError): return - candidate_ids: list[int] = [] - if 0 <= face_id < len(model.faces): - candidate_ids.append(face_id) parameters = context.get("parameters") parameters = parameters if isinstance(parameters, dict) else {} target_position = _float_or_none(parameters.get("target_plane_position")) @@ -8385,6 +8477,29 @@ class WindowActionMixin: _unit_triple_or_none(parameters.get("plane_direction")) or _unit_triple_or_none(parameters.get("outward_direction")) ) + if 0 <= face_id < len(model.faces): + if target_position is not None and plane_direction is not None: + if self._face_target_plane_position_matches( + model, + [face_id], + target_position, + plane_direction, + _float_or_none(parameters.get("bbox_diagonal")), + ): + try: + model.assign_logical_face_region_exclusive(logical_id, [face_id]) + return + except Exception: + pass + elif self._assign_isolated_logical_face_candidate(model, logical_id, face_id, context): + return + + isolation = context.get("isolation") + if isinstance(isolation, dict) and isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit": + return + candidate_ids: list[int] = [] + if 0 <= face_id < len(model.faces): + candidate_ids.append(face_id) if target_position is not None and plane_direction is not None: part_id = self._edit_integrity_int_or_none(parameters.get("part_id")) solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id")) @@ -8422,6 +8537,26 @@ class WindowActionMixin: except Exception: continue + def _assign_isolated_logical_face_candidate( + self, + model: StepModel, + logical_id: int, + face_id: int, + context: dict[str, object], + ) -> bool: + try: + if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) or ( + isinstance(context.get("isolation"), dict) + and context["isolation"].get("reason") == "large-model-smooth-ui-isolated-occ-edit" + ): + face_ids = [int(face_id)] + else: + face_ids = model.face_region_ids(int(face_id)) or [int(face_id)] + model.assign_logical_face_region_exclusive(int(logical_id), face_ids) + return True + except Exception: + return False + def _edit_context_part_id(self, context: dict[str, object]) -> int | None: if self.model is None: return None @@ -9169,10 +9304,16 @@ class WindowActionMixin: pick_position=context["pick_position"], before_snapshot=result["snapshot"], after_snapshot=result["after_snapshot"], + isolation=dict(context.get("isolation") or {}), ) model_polydata = result.get("model_polydata") edge_polydata = result.get("edge_polydata") edge_deferred = bool(result.get("edge_polydata_deferred")) + large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500 + if edge_deferred and large_model: + self.large_model_edge_overlay_skipped = True + elif not edge_deferred: + self.large_model_edge_overlay_skipped = False if model_polydata is None or (edge_polydata is None and not edge_deferred): deflection = float(context.get("edit_result_deflection", 1.6)) model_polydata = self.model.build_face_polydata(deflection=deflection) @@ -9213,7 +9354,8 @@ class WindowActionMixin: self._refresh_history_list() self._end_edit_task(clear_preview=False) timing_text = _edit_timing_summary(result.get("timings")) - if bool(result.get("edge_polydata_deferred")): + edge_deferred = bool(result.get("edge_polydata_deferred")) + if edge_deferred and not bool(getattr(self, "large_model_edge_overlay_skipped", False)): QTimer.singleShot(80, self._rebuild_deferred_edge_display) if result.get("quality_warnings"): self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情") @@ -9221,6 +9363,8 @@ class WindowActionMixin: selection_note = ";已保持当前选择" if self.selected_kind is not None else "" timing_note = f";耗时 {timing_text}" if timing_text else "" edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else "" + if edge_deferred and bool(getattr(self, "large_model_edge_overlay_skipped", False)): + edge_note = ";边线按需生成" self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}") if self.selected_kind is None: timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else "" @@ -9293,6 +9437,84 @@ class WindowActionMixin: self.edit_thread = None self.edit_worker = None + def _operation_parameters_with_recognition_sources( + self, + parameters: dict[str, object], + target_kind: str | None, + target_id: int | None, + ) -> dict[str, object]: + result = dict(parameters or {}) + if result.get("recognition_source") not in {None, ""}: + return result + evidence = [result, getattr(self, "current_info_values", {})] + if ( + target_kind in {"face", "feature"} + and target_id is not None + and getattr(self, "model", None) is not None + ): + try: + evidence.append(self.model.quick_face_info(int(target_id))) + except Exception: + pass + result["recognition_source"] = ( + "Analysis Situs + internal StepModel" + if any(self._operation_has_analysis_situs_evidence(item) for item in evidence) + else "internal StepModel" + ) + return result + + def _operation_backend_log_lines( + self, + parameters: dict[str, object], + result_message: str, + *, + isolation: dict[str, object] | None = None, + ) -> list[str]: + execution = "isolated OCCT subprocess" if isinstance(isolation, dict) and isolation else "Qt background worker" + recognition = str(parameters.get("recognition_source") or "").strip() + if not recognition: + recognition = ( + "Analysis Situs + internal StepModel" + if self._operation_has_analysis_situs_evidence(parameters) + or self._operation_has_analysis_situs_evidence(result_message) + else "internal StepModel" + ) + return [ + "backend: OCCT", + f"execution: {execution}", + f"recognition: {recognition}", + ] + + def _operation_has_analysis_situs_evidence(self, value: object) -> bool: + if self._operation_value_is_empty(value): + return False + if isinstance(value, str): + lowered = value.lower() + return "analysis situs" in lowered or "analysis-situs" in lowered + if isinstance(value, dict): + for key, item in value.items(): + key_text = str(key).lower() + if ( + key_text.startswith("asitus_") + or key_text.startswith("analysis_situs_") + or key_text.startswith("external_recognition_") + ) and not self._operation_value_is_empty(item): + return True + if self._operation_has_analysis_situs_evidence(item): + return True + return False + if isinstance(value, (tuple, list, set)): + return any(self._operation_has_analysis_situs_evidence(item) for item in value) + return False + + @staticmethod + def _operation_value_is_empty(value: object) -> bool: + if value is None or value == "": + return True + if isinstance(value, (tuple, list, set, dict)) and not value: + return True + return False + def _make_operation_record( self, operation_name: str, @@ -9312,7 +9534,9 @@ class WindowActionMixin: pick_position: tuple[float, float, float] | None = None, before_snapshot: dict[int, object] | None = None, after_snapshot: dict[int, object] | None = None, + isolation: dict[str, object] | None = None, ) -> OperationRecord: + parameters = self._operation_parameters_with_recognition_sources(parameters, target_kind, target_id) target_summary = target if target_kind in {"face", "feature"} and target_logical_id is not None: target_summary = f"{target_kind} logical {target_logical_id}" @@ -9377,6 +9601,7 @@ class WindowActionMixin: f"target: {target}", f"target_kind: {target_kind or ''}", f"target_id: {target_id if target_id is not None else ''}", + *self._operation_backend_log_lines(parameters, result_message, isolation=isolation), "parameters:", ] if target_logical_id is not None: diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 9f164ea..446c7b2 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -21,6 +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_probe import run_scdm_probe +from .scdm_schema import write_json from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker @@ -145,6 +148,12 @@ class WindowCoreMixin: if hasattr(self, "ui_task_requested"): self.ui_task_requested.emit(callback) + def _reroute_to_ui_thread(self, callback) -> bool: + if self._is_ui_thread(): + return False + self._invoke_on_ui_thread(callback) + return True + def eventFilter(self, watched, event): if event.type() == QEvent.Type.ToolTip and self._should_suppress_transient_tooltip(watched): QToolTip.hideText() @@ -194,11 +203,16 @@ class WindowCoreMixin: event.accept() return True elif watched is getattr(self, "relation_formula_input", None): + if event.type() == QEvent.Type.FocusIn: + if hasattr(self, "_update_relation_formula_completions"): + QTimer.singleShot(0, self._update_relation_formula_completions) if event.type() == QEvent.Type.MouseButtonPress: if hasattr(self, "_hide_relation_formula_completion_popup"): self._hide_relation_formula_completion_popup() if hasattr(watched, "setFocus"): watched.setFocus(Qt.FocusReason.MouseFocusReason) + if hasattr(self, "_update_relation_formula_completions"): + QTimer.singleShot(0, self._update_relation_formula_completions) if event.type() in {QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress} and event.key() == Qt.Key.Key_Tab: if bool(getattr(self, "_relation_formula_tab_completion_accepted", False)): self._relation_formula_tab_completion_accepted = False @@ -940,6 +954,21 @@ class WindowCoreMixin: changed = True except Exception: self.edge_visibility_before_camera_interaction = None + if bool(getattr(self, "hide_overlays_during_camera_interaction", False)): + overlay_visibility: dict[str, int] = {} + for attr_name in ("highlight_actor", "edge_highlight_actor", "pick_marker_actor"): + actor = getattr(self, attr_name, None) + if actor is None: + continue + try: + visibility = int(actor.GetVisibility()) + overlay_visibility[attr_name] = visibility + if visibility: + actor.VisibilityOff() + changed = True + except Exception: + continue + self.overlay_visibility_before_camera_interaction = overlay_visibility changed = self._set_render_window_multisamples( int(getattr(self, "interactive_multi_samples", 0)) ) or changed @@ -955,6 +984,19 @@ class WindowCoreMixin: except Exception: pass self.edge_visibility_before_camera_interaction = None + overlay_visibility = getattr(self, "overlay_visibility_before_camera_interaction", {}) or {} + if isinstance(overlay_visibility, dict): + for attr_name, previous_visibility in overlay_visibility.items(): + actor = getattr(self, str(attr_name), None) + if actor is None: + continue + try: + if int(actor.GetVisibility()) != int(previous_visibility): + actor.SetVisibility(int(previous_visibility)) + changed = True + except Exception: + continue + self.overlay_visibility_before_camera_interaction = {} changed = self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", 4))) or changed changed = bool(getattr(self, "camera_interaction_visual_changed", False)) or changed self.camera_interaction_visual_changed = False @@ -1005,6 +1047,11 @@ class WindowCoreMixin: self.statusBar().showMessage("孔组识别正在后台预热,请稍后再关闭窗口。") event.ignore() return + scdm_thread_running = bool(self.scdm_thread is not None and self.scdm_thread.isRunning()) + if scdm_thread_running: + self.statusBar().showMessage("SCDM 可修改参数正在后台识别,请稍后再关闭窗口。") + event.ignore() + return super().closeEvent(event) def _request_thread_quit(self, thread: QThread | None) -> None: @@ -1031,23 +1078,25 @@ class WindowCoreMixin: started = time.perf_counter() stats = new_model.stats() timings["stats"] = time.perf_counter() - started + display_deflection = _large_model_display_deflection_for_stats(stats, deflection) result = { "path": path, "model": new_model, "stats": stats, - "deflection": deflection, + "deflection": display_deflection, + "requested_deflection": deflection, "show_internal_edges": show_internal_edges, "timings": timings, } if build_polydata: started = time.perf_counter() - face_polydata = new_model.build_face_polydata(deflection=deflection) + face_polydata = new_model.build_face_polydata(deflection=display_deflection) result["model_polydata"] = _smooth_surface_polydata(face_polydata) timings["display_faces"] = time.perf_counter() - started if build_edges: started = time.perf_counter() result["edge_polydata"] = new_model.build_edge_polydata( - deflection=deflection, + deflection=display_deflection, show_same_domain_internal_edges=show_internal_edges, ) timings["display_edges"] = time.perf_counter() - started @@ -1077,7 +1126,30 @@ class WindowCoreMixin: self.statusBar().showMessage(f"正在读取 STEP 可视化网格:{new_path.name}...") self._clear_hover(render=True) self._update_action_states() - QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path)) + deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35) + show_internal_edges = self._show_same_domain_internal_edges() + load_step_result = type(self)._load_step_result + + def action(path=new_path, deflection=deflection, show_internal_edges=show_internal_edges, load_step_result=load_step_result): + return load_step_result( + path, + deflection=deflection, + show_internal_edges=show_internal_edges, + build_edges=False, + ) + + thread = QThread(self) + worker = LoadWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_initial_load, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_initial_load, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_load_thread) + self.load_thread = thread + self.load_worker = worker + thread.start() @Slot(object) def _run_packaged_initial_load(self, expected_path: Path) -> None: @@ -1169,6 +1241,7 @@ class WindowCoreMixin: stats = result["stats"] self.model = result["model"] self.step_path = new_path + self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。") self._clear_history() if hasattr(self, "measure_text"): self.clear_measurement() @@ -1178,6 +1251,12 @@ class WindowCoreMixin: self.path_label.setCursorPosition(0) self._populate_part_tree() self._reset_selection() + large_interaction_model = self._large_model_interaction_mode(stats=stats) + self.hide_edges_during_camera_interaction = large_interaction_model + self.hide_overlays_during_camera_interaction = large_interaction_model + self.hover_after_camera_cooldown_ms = 700 if large_interaction_model else 420 + self.large_model_edge_overlay_skipped = False + self.large_model_hover_disabled = large_interaction_model model_polydata = result.get("model_polydata") edge_polydata = result.get("edge_polydata") edge_deferred = bool(result.get("edge_polydata_deferred")) @@ -1203,7 +1282,11 @@ class WindowCoreMixin: timings["apply_loaded"] = time.perf_counter() - apply_started display_state = result.get("display", "quick preview" if self.load_in_progress else "ready") if edge_deferred: - display_state = f"{display_state}; edge display pending" + if large_interaction_model: + self.large_model_edge_overlay_skipped = True + display_state = f"{display_state}; edge display skipped for large model" + else: + display_state = f"{display_state}; edge display pending" info_payload = { "file": str(self.step_path), "parts": stats.parts, @@ -1218,13 +1301,228 @@ class WindowCoreMixin: info_payload["load_performance"] = timing_text self.set_info(info_payload) self._update_action_states() - if edge_deferred: + if edge_deferred and not large_interaction_model: QTimer.singleShot(80, self._rebuild_deferred_edge_display) - QTimer.singleShot(160, self._start_asitus_hole_recognition_preload) + elif edge_deferred and large_interaction_model: + self.statusBar().showMessage( + 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: + 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)) - def _start_asitus_hole_recognition_preload(self) -> None: + def _large_model_interaction_mode(self, stats: object | None = None) -> bool: + if stats is not None: + try: + return int(getattr(stats, "faces", 0) or 0) > 1000 or int(getattr(stats, "edges", 0) or 0) > 2500 + except Exception: + return False + if self.model is None: + return False + try: + return len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500 + except Exception: + return False + + def _defer_large_model_recognition_preloads(self) -> None: + self.scdm_feature_cache_state = "deferred" + self.scdm_feature_cache_message = ( + "大模型已延后 SCDM/Analysis Situs 全量识别,优先保证查看、旋转和点选流畅;" + "本地 OCCT 快路径仍可直接用于已稳定验证的参数。" + ) + if self.model is not None: + try: + self.model.fail_asitus_hole_region_load("大模型已延后 Analysis Situs 全量孔组识别。") + except Exception: + pass + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + + def _invalidate_scdm_feature_cache(self, message: str = "") -> None: + self.scdm_feature_cache = None + self.scdm_feature_cache_state = "stale" + self.scdm_feature_cache_message = message + self.scdm_feature_cache_path = "" + if self.model is not None: + try: + self.model.scdm_feature_cache = None + except Exception: + pass + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + + 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._large_model_interaction_mode(): + self._defer_large_model_recognition_preloads() + return + if self.scdm_thread is not None and self.scdm_thread.isRunning(): + QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force)) + return + step_path = Path(self.step_path) + context = { + "path": step_path, + "model_id": id(self.model), + } + model = self.model + self.pending_scdm_context = dict(context) + self.scdm_feature_cache_state = "running" + self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。" + self.statusBar().showMessage("正在后台识别 SCDM 可修改参数,可继续旋转查看模型。") + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + + def action(path=step_path, model=model): + probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0) + if not isinstance(probe, dict) or probe.get("ok") is not True: + return { + "ok": False, + "reason": str(probe.get("reason") if isinstance(probe, dict) else "probe-failed"), + "message": str(probe.get("message") if isinstance(probe, dict) else "SCDM probe failed."), + "probe": probe, + } + raw = probe.get("raw") + if not isinstance(raw, dict): + return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe} + face_signatures = [] + builder = getattr(model, "scdm_local_face_signatures", None) + if callable(builder): + try: + face_signatures = [dict(item) for item in builder() if isinstance(item, dict)] + except Exception: + face_signatures = [] + cache = attach_local_face_ids_to_scdm_cache( + map_scdm_raw_features(raw), + face_signatures, + ) + cache_path = Path(str(probe.get("raw_features_path") or path)).with_name("scdm_feature_cache.json") + write_json(cache_path, cache) + return { + "ok": True, + "reason": "ok", + "cache": cache, + "cache_path": str(cache_path), + "probe": probe, + } + + thread = QThread(self) + worker = ScanWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_scdm_probe_preload, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_scdm_probe_preload, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_scdm_thread) + self.scdm_thread = thread + self.scdm_worker = worker + try: + thread.start(QThread.Priority.LowPriority) + except TypeError: + thread.start() + + def _scdm_local_face_signatures(self) -> list[dict[str, object]]: + if self.model is None: + return [] + builder = getattr(self.model, "scdm_local_face_signatures", None) + if callable(builder): + try: + return [dict(item) for item in builder() if isinstance(item, dict)] + except Exception: + return [] + return [] + + @Slot(object) + def _finish_scdm_probe_preload(self, result: object) -> None: + if self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_probe_preload(result)): + return + try: + context = dict(self.pending_scdm_context or {}) + if self.model is None or id(self.model) != context.get("model_id"): + return + if self.step_path is None or Path(context.get("path", "")) != Path(self.step_path): + return + if not isinstance(result, dict) or result.get("ok") is not True: + reason = str(result.get("reason") if isinstance(result, dict) else "probe-failed") + message = str(result.get("message") if isinstance(result, dict) else "SCDM probe failed.") + if isinstance(result, dict) and isinstance(result.get("backend"), dict): + self.scdm_backend_status = dict(result["backend"]) + self.scdm_feature_cache = None + self.scdm_feature_cache_state = "failed" + self.scdm_feature_cache_message = message + self.statusBar().showMessage(f"SCDM 可修改参数识别未启用:{reason}") + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + if hasattr(self, "maybe_prompt_missing_scdm_backend"): + self.maybe_prompt_missing_scdm_backend(reason=reason, message=message) + if hasattr(self, "_finish_pending_scdm_edit_reload"): + self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{reason}") + return + cache = result.get("cache") + if not isinstance(cache, dict): + if isinstance(result.get("backend"), dict): + self.scdm_backend_status = dict(result["backend"]) + self.scdm_feature_cache_state = "failed" + self.scdm_feature_cache_message = "SCDM cache 格式无效。" + self.statusBar().showMessage("SCDM 可修改参数识别失败:cache 格式无效。") + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + if hasattr(self, "_finish_pending_scdm_edit_reload"): + self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 刷新失败:格式无效") + 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 + 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: + self._request_thread_quit(self.scdm_thread) + + @Slot(str) + def _fail_scdm_probe_preload(self, message: str) -> None: + if self._reroute_to_ui_thread(lambda message=message: self._fail_scdm_probe_preload(message)): + return + try: + self.scdm_feature_cache = None + self.scdm_feature_cache_state = "failed" + self.scdm_feature_cache_message = message + self.statusBar().showMessage("SCDM 可修改参数识别失败,不影响当前模型查看。") + if hasattr(self, "_update_current_capability_panel"): + self._update_current_capability_panel() + if hasattr(self, "_finish_pending_scdm_edit_reload"): + self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{message}") + finally: + self._request_thread_quit(self.scdm_thread) + + @Slot() + def _forget_scdm_thread(self) -> None: + self.scdm_thread = None + self.scdm_worker = None + self.pending_scdm_context = None + + def _start_asitus_hole_recognition_preload(self, *, force: bool = False) -> None: + if self.model is None or self.step_path is None: + return + if not force and self._large_model_interaction_mode(): + return if self.asitus_thread is not None and self.asitus_thread.isRunning(): return if not hasattr(self.model, "begin_asitus_hole_region_load"): @@ -1259,6 +1557,8 @@ class WindowCoreMixin: @Slot(object) def _finish_asitus_hole_recognition(self, result: object) -> None: + if self._reroute_to_ui_thread(lambda result=result: self._finish_asitus_hole_recognition(result)): + return try: context = dict(self.pending_asitus_context or {}) if self.model is None or id(self.model) != context.get("model_id"): @@ -1273,6 +1573,8 @@ class WindowCoreMixin: @Slot(str) def _fail_asitus_hole_recognition(self, message: str) -> None: + if self._reroute_to_ui_thread(lambda message=message: self._fail_asitus_hole_recognition(message)): + return try: context = dict(self.pending_asitus_context or {}) if self.model is not None and id(self.model) == context.get("model_id"): @@ -1302,6 +1604,8 @@ class WindowCoreMixin: @Slot(object) def _finish_initial_load(self, result: object) -> None: + if self._reroute_to_ui_thread(lambda result=result: self._finish_initial_load(result)): + return try: if not isinstance(result, dict): raise RuntimeError("Load task returned an unexpected result.") @@ -1328,15 +1632,21 @@ class WindowCoreMixin: @Slot(str) def _fail_initial_load(self, message: str) -> None: + if self._reroute_to_ui_thread(lambda message=message: self._fail_initial_load(message)): + return self._end_load_task() QMessageBox.critical(self, "Load failed", message) self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + if hasattr(self, "_fail_pending_scdm_edit_reload"): + self._fail_pending_scdm_edit_reload(message) def _start_load_refine(self, initial_result: dict[str, object]) -> None: self.statusBar().showMessage(f"Loaded {self.step_path.name}") @Slot(object) def _finish_load_refine(self, result: object) -> None: + if self._reroute_to_ui_thread(lambda result=result: self._finish_load_refine(result)): + return try: if ( isinstance(result, dict) @@ -1368,6 +1678,8 @@ class WindowCoreMixin: @Slot(str) def _fail_load_refine(self, message: str) -> None: + if self._reroute_to_ui_thread(lambda message=message: self._fail_load_refine(message)): + return self._end_load_task() self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}") @@ -1651,13 +1963,20 @@ class WindowCoreMixin: return "实体" return f"面 {info.get('faces', 0)} 个 | 边 {info.get('edges', 0)} 条" - def _rebuild_scene(self, reset_camera: bool = False) -> None: + def _rebuild_scene(self, reset_camera: bool = False, build_edges: bool | None = None) -> None: if self.model is None: return model_polydata = self.model.build_face_polydata() - edge_polydata = self.model.build_edge_polydata( - show_same_domain_internal_edges=self._show_same_domain_internal_edges() - ) + large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500 + should_build_edges = (not large_model) if build_edges is None else bool(build_edges) + if should_build_edges: + edge_polydata = self.model.build_edge_polydata( + show_same_domain_internal_edges=self._show_same_domain_internal_edges() + ) + self.large_model_edge_overlay_skipped = False + else: + edge_polydata = _empty_edge_polydata() + self.large_model_edge_overlay_skipped = large_model self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera) def _show_same_domain_internal_edges(self) -> bool: @@ -1669,7 +1988,7 @@ class WindowCoreMixin: if self.scene_isolated and self.selected_kind is not None: self.isolate_selected() else: - self._rebuild_scene(reset_camera=False) + self._rebuild_scene(reset_camera=False, build_edges=True) self._refresh_selection_highlight() state = "显示" if checked else "隐藏" self.statusBar().showMessage(f"已{state}同域内部拓扑边") @@ -1892,20 +2211,67 @@ class WindowCoreMixin: def _rebuild_deferred_edge_display(self) -> None: if self.model is None or self.operation_in_progress or self.load_in_progress: return + if self.load_refine_thread is not None and self.load_refine_thread.isRunning(): + return started = time.perf_counter() self.statusBar().showMessage("模型已显示,正在补充边线...") - QApplication.processEvents() - try: - edge_polydata = self.model.build_edge_polydata( - deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35), - show_same_domain_internal_edges=self._show_same_domain_internal_edges(), + + model = self.model + path = Path(self.step_path) if self.step_path is not None else None + deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35) + show_internal_edges = self._show_same_domain_internal_edges() + context = {"path": path, "model_id": id(model), "started": started} + + def action(model=model, context=context, deflection=deflection, show_internal_edges=show_internal_edges): + edge_polydata = model.build_edge_polydata( + deflection=deflection, + show_same_domain_internal_edges=show_internal_edges, ) - self._install_edge_polydata(edge_polydata, render=True) - except Exception as exc: - self.statusBar().showMessage(f"模型已显示;边线补充失败:{exc}") + return {**context, "edge_polydata": edge_polydata, "elapsed": time.perf_counter() - started} + + thread = QThread(self) + worker = LoadWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_deferred_edge_display, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_deferred_edge_display, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_load_refine_thread) + self.load_refine_thread = thread + self.load_refine_worker = worker + try: + thread.start(QThread.Priority.LowPriority) + except TypeError: + thread.start() + + @Slot(object) + def _finish_deferred_edge_display(self, result: object) -> None: + if self._reroute_to_ui_thread(lambda result=result: self._finish_deferred_edge_display(result)): return - elapsed = time.perf_counter() - started - self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s") + try: + if not isinstance(result, dict): + return + if self.model is None or id(self.model) != result.get("model_id"): + return + if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path): + return + edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata")) + self._install_edge_polydata(edge_polydata, render=True) + self.large_model_edge_overlay_skipped = False + elapsed = float(result.get("elapsed") or 0.0) + self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s") + finally: + self._request_thread_quit(self.load_refine_thread) + + @Slot(str) + def _fail_deferred_edge_display(self, message: str) -> None: + if self._reroute_to_ui_thread(lambda message=message: self._fail_deferred_edge_display(message)): + return + try: + self.statusBar().showMessage(f"模型已显示;边线补充失败:{message}") + finally: + self._request_thread_quit(self.load_refine_thread) def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object: if len(cache) >= self.overlay_cache_limit: @@ -2054,6 +2420,8 @@ class WindowCoreMixin: def _on_mode_changed(self, mode: str) -> None: self._clear_hover(render=True) + if str(mode) == "Edge" and bool(getattr(self, "large_model_edge_overlay_skipped", False)): + QTimer.singleShot(0, self._rebuild_deferred_edge_display) if hasattr(self, "_update_id_select_title"): self._update_id_select_title(mode) @@ -2082,6 +2450,7 @@ class WindowCoreMixin: self.left_button_press_position = (int(x), int(y)) self.left_button_dragged = False self.left_button_press_camera_state = self._camera_state_signature() + self.left_button_press_target_unknown = False self.left_button_press_target = self._selection_target_at_position(x, y) self.pending_hover_position = None self.last_hover_pick_position = None @@ -2107,17 +2476,21 @@ class WindowCoreMixin: camera_changed = self._left_button_camera_changed() self.pointer_button_down = False press_target = getattr(self, "left_button_press_target", None) + press_target_unknown = bool(getattr(self, "left_button_press_target_unknown", False)) self.left_button_press_position = None self.left_button_dragged = False self.left_button_press_camera_state = None self.left_button_press_target = None + self.left_button_press_target_unknown = False self.pending_hover_position = None self.last_hover_pick_position = None if getattr(self, "camera_interaction_active", False): self._end_camera_interaction() - if was_dragged or camera_changed or self._selection_target_signature(press_target) is None: + if was_dragged or camera_changed: return - self._handle_left_click(x, y, required_press_target=press_target) + if not press_target_unknown and self._selection_target_signature(press_target) is None: + return + self._handle_left_click(x, y, required_press_target=None if press_target_unknown else press_target) def _camera_state_signature(self) -> tuple[float, ...] | None: renderer = getattr(self, "renderer", None) @@ -2206,7 +2579,10 @@ class WindowCoreMixin: if self.model is None: return mode = self._current_selection_mode() - target = self._pick_selection_target(mode, x, y) + if required_press_target is not None and not bool(self._large_model_interaction_mode()): + target = self._pick_selection_target(mode, x, y) + else: + target = required_press_target if required_press_target is not None else self._pick_selection_target(mode, x, y) if required_press_target is not None: press_signature = self._selection_target_signature(required_press_target) release_signature = self._selection_target_signature(target) @@ -2397,6 +2773,7 @@ class WindowCoreMixin: if ( getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False) + or getattr(self, "large_model_hover_disabled", False) or self._hover_suppressed_after_camera() ): return @@ -2425,6 +2802,7 @@ class WindowCoreMixin: or self.model is None or self.model_actor is None or self.pending_hover_position is None + or getattr(self, "large_model_hover_disabled", False) or self._hover_suppressed_after_camera() ): self._clear_hover(render=True) @@ -2863,10 +3241,11 @@ class WindowCoreMixin: info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征") highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id] else: - highlight_face_ids = self._selection_same_domain_face_ids(face_id) or [face_id] + highlight_face_ids = self._selection_same_domain_face_ids(face_id, info) or [face_id] selection_fields = self._selection_identity_fields( face_id, "特征来源 Face" if feature_mode else "Face", + region_face_ids=highlight_face_ids, ) logical_id = int(selection_fields["selection_display_id"]) input_info = dict(info) @@ -2882,9 +3261,16 @@ class WindowCoreMixin: message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}" self.statusBar().showMessage(self._selection_status(message, pick_position)) - def _selection_same_domain_face_ids(self, face_id: int) -> list[int]: + def _selection_same_domain_face_ids(self, face_id: int, fallback_info: dict[str, object] | None = None) -> list[int]: if self.model is None: return [face_id] + fallback_ids = _int_values((fallback_info or {}).get("feature_highlight_face_ids")) or _int_values( + (fallback_info or {}).get("same_domain_face_ids") + ) + if fallback_ids: + return sorted({int(item) for item in fallback_ids if 0 <= int(item) < len(self.model.faces)}) or [face_id] + if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): + return [face_id] try: face_ids = self.model.face_region_ids(face_id) except Exception: @@ -2904,7 +3290,11 @@ class WindowCoreMixin: return info = self._feature_context_info(face_id) info["kind"] = "feature" - selection_fields = self._selection_identity_fields(face_id, "特征来源 Face") + selection_fields = self._selection_identity_fields( + face_id, + "特征来源 Face", + region_face_ids=_int_values(info.get("feature_highlight_face_ids")) or [face_id], + ) logical_id = int(selection_fields["selection_display_id"]) info.update(selection_fields) self._reset_selection(clear_highlight=False, clear_info=False) diff --git a/step_editor/window_state.py b/step_editor/window_state.py index c2f4b1e..a9809cf 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -11,6 +11,7 @@ from PySide6.QtWidgets import ( QApplication, QCheckBox, QCompleter, + QDialog, QFileDialog, QFrame, QHBoxLayout, @@ -21,6 +22,7 @@ from PySide6.QtWidgets import ( QPushButton, QSizePolicy, QTableWidgetItem, + QTextEdit, QTreeWidgetItem, QToolTip, QVBoxLayout, @@ -41,7 +43,12 @@ from .relation_formulas import ( relation_value_to_text, rewrite_relation_formula_ids, ) +from .scdm_backend import resolve_scdm_backend +from .scdm_edit_runner import run_scdm_edit_job from .records import OperationRecord +from .scdm_property_specs import property_specs_from_scdm_cache +from .scdm_result_validator import build_scdm_id_mapping, rewrite_scdm_relation_formula_ids, validate_scdm_edit_result +from .scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime from .ui_helpers import * # noqa: F403 from .widgets import NoWheelComboBox from .workers import EditWorker, LoadWorker, ScanWorker @@ -487,16 +494,42 @@ class WindowStateMixin: enriched["pick_position"] = pick_position return enriched - def _selection_identity_fields(self, face_id: int, title_prefix: str = "Face") -> dict[str, object]: + def _selection_identity_fields( + self, + face_id: int, + title_prefix: str = "Face", + region_face_ids: Iterable[int] | None = None, + ) -> dict[str, object]: logical_id = int(face_id) + region_ids = sorted( + { + int(item) + for item in _int_values(region_face_ids) + if self.model is not None and 0 <= int(item) < len(self.model.faces) + } + ) if self.model is not None: - try: - logical_id = int(self.model.face_region_logical_id(face_id)) - except Exception: + if region_ids: + logical_candidates: list[int] = [] + for item in region_ids: + try: + logical_candidates.append(int(self.model.face_logical_id(item))) + except Exception: + logical_candidates.append(int(item)) + logical_id = min(logical_candidates) if logical_candidates else int(face_id) + elif bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): try: logical_id = int(self.model.face_logical_id(face_id)) except Exception: logical_id = int(face_id) + else: + try: + logical_id = int(self.model.face_region_logical_id(face_id)) + except Exception: + try: + logical_id = int(self.model.face_logical_id(face_id)) + except Exception: + logical_id = int(face_id) title = f"{title_prefix} {logical_id}" result = { "selection_title": title, @@ -504,10 +537,11 @@ class WindowStateMixin: "selection_topological_face_id": int(face_id), } if self.model is not None: - try: - region_ids = self.model.face_region_ids(face_id) - except Exception: - region_ids = [] + if not region_ids and not bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): + try: + region_ids = self.model.face_region_ids(face_id) + except Exception: + region_ids = [] region_ids = sorted({int(item) for item in region_ids if 0 <= int(item) < len(self.model.faces)}) if len(region_ids) > 1: result.update( @@ -540,6 +574,94 @@ class WindowStateMixin: "first_level_fact_summary": f"当前 Face 的一级事实图暂时无法生成:{exc}", } + def _should_defer_selection_first_level_topology(self, face_id: int) -> bool: + if self.model is None: + return False + try: + if face_id in getattr(self.model, "_face_first_level_topology_cache", {}): + return False + except Exception: + pass + try: + face_count = len(getattr(self.model, "faces", []) or []) + edge_count = len(getattr(self.model, "edges", []) or []) + except Exception: + return False + if face_count <= 1000 and edge_count <= 2500: + return False + return True + + def _selection_feature_detection_level(self) -> str: + level = self._current_feature_detection_level() + if level != "current-only" and bool(getattr(self, "_large_model_interaction_mode", lambda: False)()): + return "current-only" + return level + + def _quick_face_first_level_selection_fields( + self, + face_id: int, + fallback_info: dict[str, object] | None = None, + ) -> dict[str, object]: + if self.model is None: + return {} + fallback = dict(fallback_info or {}) + try: + boundary_edge_ids = tuple(self.model.face_boundary_edge_ids(face_id)) + except Exception: + boundary_edge_ids = () + adjacent_face_ids: set[int] = set() + for edge_id in boundary_edge_ids: + try: + for adjacent_id in self.model._edge_adjacent_face_ids(edge_id): + adjacent_id = int(adjacent_id) + if adjacent_id != face_id: + adjacent_face_ids.add(adjacent_id) + except Exception: + continue + boundary_vertex_count = 0 + try: + vertex_points: set[tuple[float, float, float]] = set() + for edge_id in boundary_edge_ids: + for point in self.model.edge_vertex_points(int(edge_id)): + vertex_points.add(tuple(float(item) for item in point)) + boundary_vertex_count = len(vertex_points) + except Exception: + boundary_vertex_count = 0 + return { + "topology_relation_depth": 1, + "topology_relation_model": "STEP/B-Rep shared-edge first-level", + "topology_relation_scope": "quick selected Face boundary", + "topology_relation_boundary": "shared-edge", + "topology_relation_status": "deferred", + "topology_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "topology_ignored_relation_note": "Large model selection keeps first-level relation expansion deferred until edit planning.", + "same_domain_face_ids": tuple(_int_values(fallback.get("same_domain_face_ids")) or [face_id]), + "same_domain_face_count": int(fallback.get("same_domain_face_count") or 1), + "same_domain_region_kind": str(fallback.get("same_domain_region_kind") or "single-face"), + "first_level_boundary_edge_ids": boundary_edge_ids, + "first_level_boundary_edge_count": len(boundary_edge_ids), + "first_level_boundary_vertex_count": boundary_vertex_count, + "first_level_adjacent_face_ids": tuple(sorted(adjacent_face_ids)), + "first_level_adjacent_face_count": len(adjacent_face_ids), + "first_level_face_ids": tuple(sorted({face_id, *adjacent_face_ids})), + "first_level_face_count": 1 + len(adjacent_face_ids), + "first_level_fact_model": "STEP/B-Rep first-level fact graph", + "first_level_fact_status": "deferred", + "first_level_fact_relation_depth": 1, + "first_level_fact_scope": "face", + "first_level_fact_subject_face_ids": (face_id,), + "first_level_fact_subject_face_count": 1, + "first_level_fact_boundary_edge_count": len(boundary_edge_ids), + "first_level_fact_boundary_vertex_count": boundary_vertex_count, + "first_level_fact_adjacent_face_count": len(adjacent_face_ids), + "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), + "first_level_fact_summary": "Large model: full first-level fact graph is deferred until edit planning.", + "first_level_topology_note": ( + "Large model selection uses a quick first-level boundary summary; " + "the complete relation graph is recalculated in the background edit plan." + ), + } + def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]: if self.model is None: return {} @@ -705,7 +827,7 @@ class WindowStateMixin: surface = str(fallback_info.get("surface", "") or "") if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES: return dict(fallback_info) - allow_cached_info = self._current_feature_detection_level() != "current-only" + allow_cached_info = self._selection_feature_detection_level() != "current-only" if allow_cached_info: try: cached_info = self.model.cached_feature_info(face_id) @@ -727,11 +849,14 @@ class WindowStateMixin: info.setdefault("feature_type", "可拉伸/切除平面候选") info.setdefault("feature_edit_actions", "拉伸/切除平面") elif surface == "cylinder": - same_domain_ids = [] - try: - same_domain_ids = self.model.connected_same_domain_face_ids(face_id) or [face_id] - except Exception: - same_domain_ids = [face_id] + same_domain_ids = _int_values(info.get("same_domain_face_ids")) or _int_values( + info.get("feature_highlight_face_ids") + ) + if not same_domain_ids: + try: + same_domain_ids = self.model._connected_cocylindrical_face_ids(face_id) or [face_id] + except Exception: + same_domain_ids = [face_id] same_domain_ids = sorted({int(item) for item in same_domain_ids if 0 <= int(item) < len(self.model.faces)}) if same_domain_ids: info["same_domain_face_ids"] = tuple(same_domain_ids) @@ -782,16 +907,28 @@ class WindowStateMixin: if key in fallback_info: info.setdefault(key, fallback_info[key]) if surface == "plane": - info.update(self._face_first_level_selection_fields(face_id)) + if self._should_defer_selection_first_level_topology(face_id): + info.update(self._quick_face_first_level_selection_fields(face_id, fallback_info)) + else: + info.update(self._face_first_level_selection_fields(face_id)) return info def _feature_context_info(self, face_id: int) -> dict[str, object]: if self.model is None: return {} - detection_level = self._current_feature_detection_level() + requested_detection_level = self._current_feature_detection_level() + detection_level = self._selection_feature_detection_level() root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id)) + if detection_level != requested_detection_level: + root_info.setdefault( + "feature_detection_large_model_note", + "当前模型较大,点击选择只做快速当前特征识别;相邻/二级识别请使用显式扫描或后台识别。", + ) if str(root_info.get("surface", "") or "") == "plane" and "topology_relation_depth" not in root_info: - root_info.update(self._face_first_level_selection_fields(face_id)) + if self._should_defer_selection_first_level_topology(face_id): + root_info.update(self._quick_face_first_level_selection_fields(face_id, root_info)) + else: + root_info.update(self._face_first_level_selection_fields(face_id)) associated: list[dict[str, object]] = [] if detection_level in {"associated-only", "secondary"}: try: @@ -851,7 +988,13 @@ class WindowStateMixin: ), } ) - info.update(self._selection_identity_fields(face_id, "特征来源 Face")) + info.update( + self._selection_identity_fields( + face_id, + "特征来源 Face", + region_face_ids=_int_values(info.get("feature_highlight_face_ids")) or [face_id], + ) + ) try: info.update(self.model._recognition_summary_fields(info)) except Exception: @@ -1673,6 +1816,8 @@ class WindowStateMixin: return text def _clear_property_editor(self) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._clear_property_editor): + return if not hasattr(self, "property_table"): return self.property_editor_specs = [] @@ -1688,7 +1833,7 @@ class WindowStateMixin: self._clear_property_cards() self._clear_property_command_bar() self._update_current_capability_panel() - self._update_relation_formula_completions() + self._update_relation_formula_completions_if_active() self._refresh_relation_formula_list() self._resize_property_table_height() self._update_property_apply_state(False) @@ -1762,25 +1907,258 @@ class WindowStateMixin: return summary, "\n\n".join(part for part in tooltip_parts if part) def _current_capability_text(self) -> tuple[str, str, str]: - headline = "当前支持:Face、孔、槽、凸台、圆角/倒角、壳体、Edge" + headline = "当前支持:常见 Face、孔、槽、凸台、圆角/倒角、壳体、Edge" detail = "" tooltip = ( - "Face:面内长度/宽度、偏移、壳体厚度。\n" - "孔/槽:孔径、位置、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n" - "凸台:圆柱凸台直径/高度/位置;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n" - "圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n" - "Edge/解析曲面:直线 Edge 长度/端点、圆/椭圆 Edge、简单圆锥/球/环面。\n" - "一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n" - "受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。" + "能改:Face 偏移,孔径/位置,槽位置,凸台位置/尺寸,部分槽宽/槽深,简单圆角/倒角,Edge 长度等。\n" + "能识别:基础拓扑、曲面类型、一级关系、孔组、槽、凸台、圆角/倒角候选。\n" + "暂不能:原 CAD 历史树、复杂装配约束、二级/三级拓扑自动传播。" ) return headline, detail, tooltip + def configure_scdm_backend(self, *, automatic: bool = False, reason: str = "", message: str = "") -> bool: + start_dir = Path("D:/softwaresInstallDir/ANSYS Inc") if Path("D:/softwaresInstallDir/ANSYS Inc").exists() else Path.home() + title = "找不到 SCDM,请选择 SpaceClaim.exe" if automatic else "选择 SpaceClaim.exe" + path, _ = QFileDialog.getOpenFileName( + self, + title, + str(start_dir), + "SpaceClaim.exe (SpaceClaim.exe);;可执行文件 (*.exe);;所有文件 (*.*)", + ) + if not path: + if automatic: + detail = self._compact_status_message(message or reason or "未选择 SpaceClaim.exe") + self.statusBar().showMessage(f"未配置 SCDM,当前使用 OCCT/Analysis Situs 兜底:{detail}") + self._update_current_capability_panel() + return False + button = getattr(self, "configure_scdm_button", None) + if isinstance(button, QPushButton): + button.setEnabled(False) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + try: + result = resolve_scdm_backend( + project_root_override=Path(__file__).resolve().parent.parent, + manual_path=path, + prefer_cache=False, + save_cache=True, + validate=True, + ) + finally: + QApplication.restoreOverrideCursor() + if isinstance(button, QPushButton): + button.setEnabled(True) + if result.get("ok") is not True: + reason = str(result.get("reason") or "missing-spaceclaim") + message = str(result.get("message") or "无法使用这个 SCDM/SpaceClaim 路径。") + if not automatic: + QMessageBox.information(self, "SCDM 配置失败", f"不能启用 SCDM。\n类型:{reason}\n原因:{message}") + self.statusBar().showMessage(f"SCDM 配置失败,当前使用 OCCT/Analysis Situs 兜底:{reason}") + self._update_current_capability_panel() + return False + backend = result.get("backend") + backend_path = str(getattr(backend, "path", "") or path) + if hasattr(backend, "to_cache"): + try: + self.scdm_backend_status = backend.to_cache() + except Exception: + self.scdm_backend_status = {"path": backend_path, "source": "manual"} + else: + self.scdm_backend_status = {"path": backend_path, "source": "manual"} + self.statusBar().showMessage(f"SCDM 已配置:{backend_path}") + self._update_current_capability_panel() + if not automatic: + QMessageBox.information(self, "SCDM 配置完成", f"已缓存 SpaceClaim.exe:\n{backend_path}") + if self.model is not None and self.step_path is not None: + self._invalidate_scdm_feature_cache("SCDM 后端已重新配置,cache 已失效。") + self._start_scdm_probe_preload() + return True + + def maybe_prompt_missing_scdm_backend(self, *, reason: str = "", message: str = "") -> None: + reason_key = str(reason or "").strip() + if reason_key not in {"missing-spaceclaim", "missing-scdm"}: + return + if bool(getattr(self, "scdm_auto_config_prompt_seen", False)): + return + if bool(getattr(self, "scdm_auto_config_prompt_active", False)): + return + self.scdm_auto_config_prompt_seen = True + self.scdm_auto_config_prompt_active = True + + def prompt() -> None: + try: + self.configure_scdm_backend(automatic=True, reason=reason_key, message=message) + finally: + self.scdm_auto_config_prompt_active = False + + QTimer.singleShot(0, prompt) + def _update_current_capability_panel(self) -> None: - if not hasattr(self, "current_capability_headline"): + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._update_current_capability_panel): return headline, _detail, tooltip = self._current_capability_text() - self.current_capability_headline.setText(headline) - self.current_capability_headline.setToolTip(tooltip or headline) + progress_tooltip = "\n".join( + part + for part in ( + "点击查看软件能力清单。", + headline, + tooltip, + ) + if part + ) + button = getattr(self, "current_capability_button", None) + if isinstance(button, QPushButton): + button.setText("软件进度") + button.setToolTip(progress_tooltip) + headline_widget = getattr(self, "current_capability_headline", None) + if isinstance(headline_widget, QLabel): + headline_widget.setText(headline) + headline_widget.setToolTip(tooltip or headline) + if hasattr(self, "scdm_backend_status_label"): + self.scdm_backend_status_label.setText("") + self.scdm_backend_status_label.setToolTip("") + if hasattr(self, "scdm_backend_detail_label"): + self.scdm_backend_detail_label.setText("") + self.scdm_backend_detail_label.setToolTip("") + + def show_software_progress_dialog(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("软件进度") + dialog.resize(680, 560) + layout = QVBoxLayout(dialog) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(8) + + title = QLabel("软件进度") + title.setStyleSheet("font-size: 16px; font-weight: 800; color: #14532d;") + layout.addWidget(title) + + body = QTextEdit(dialog) + body.setReadOnly(True) + detail_text = self._software_progress_detail_text() + if hasattr(body, "setMarkdown"): + body.setMarkdown(detail_text) + else: + body.setPlainText(detail_text) + body.setStyleSheet( + "QTextEdit { background: #f8fafc; border: 1px solid #cbd5e1; " + "border-radius: 6px; color: #172033; padding: 8px; }" + ) + layout.addWidget(body, stretch=1) + + button_row = QHBoxLayout() + button_row.addStretch(1) + close_button = QPushButton("关闭") + close_button.setMinimumWidth(88) + close_button.clicked.connect(dialog.accept) + button_row.addWidget(close_button) + layout.addLayout(button_row) + dialog.exec() + + def _software_progress_detail_text(self) -> str: + return "\n".join( + [ + "# 软件进度", + "", + "## 已能修改", + "- **Face**:偏移/推拉,部分稳定矩形平面的长度、宽度,局部壳体厚度。", + "- **孔**:直径、半径、位置、盲孔深度、封堵;拆成多片的圆柱孔会按同一孔组处理。", + "- **槽**:简单槽/长圆槽的位置、槽宽、槽深、弧长、总长、中心距。", + "- **凸台**:圆柱凸台位置、直径、高度;部分矩形凸台/口袋的长度、宽度、高度或深度。", + "- **圆角/倒角**:简单已有圆角半径、已有等距倒角,直线 Edge 新增圆角/倒角。", + "- **Edge**:直线 Edge 长度/端点,圆或椭圆 Edge 的半径类参数。", + "- **关系式/参数导出**:`FaceID.参数` 公式、输入参数 JSON、组件 `main.py` 导出。", + "", + "## 已能识别", + "- **基础拓扑**:Part、Solid、Face、Edge、Vertex。", + "- **曲面类型**:平面、圆柱、圆锥、球面、环面、自由曲面。", + "- **一级关系**:当前对象边界、顶点、共享边相邻面、共面/同域碎片、同轴圆柱。", + "- **常见特征**:孔组、槽/长圆槽、圆柱/矩形凸台、圆角/倒角候选、壳体候选。", + "- **辅助识别**:可读取外部后端发现的高频可改对象,并缓存到本软件能力表。", + "", + "## 暂不能修改", + "- 任意复杂自由曲面的直接变形。", + "- 二级/三级拓扑自动传播。", + "- 阵列间距联动、跨特征强约束、复杂公式组求解。", + "- 复杂圆角链/倒角链批量重建。", + "- 无法通过 B-Rep 校验或目标回测的修改会被阻止并回滚。", + "", + "## 暂不能稳定识别", + "- 原 CAD 历史树、草图尺寸、真实建模顺序。", + "- CAD 原始关系式、装配约束、阵列约束。", + "- 语义不明确的破面、碎小面、导入质量较差的 STEP。", + "- 任意厂商私有参数或私有命名特征。", + ] + ) + + def _current_scdm_backend_payload(self) -> dict[str, object] | None: + cached = getattr(self, "scdm_backend_status", None) + if isinstance(cached, dict) and str(cached.get("path") or "").strip(): + return dict(cached) + try: + return cached_scdm_backend_payload(Path(__file__).resolve().parent.parent) + except Exception: + return None + + def _sync_scdm_selection_diagnostics(self) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._sync_scdm_selection_diagnostics): + return + if not isinstance(getattr(self, "current_info_values", None), dict): + return + info = dict(getattr(self, "current_info_values", {}) or {}) + runtime = summarize_scdm_runtime( + backend=self._current_scdm_backend_payload(), + cache_state=str(getattr(self, "scdm_feature_cache_state", "") or ""), + cache_message=str(getattr(self, "scdm_feature_cache_message", "") or ""), + feature_cache=self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None, + ) + info["scdm_backend_status"] = str(runtime.get("headline") or "") + info["scdm_runtime_status"] = str(runtime.get("detail") or "") + selection_status = str(getattr(self, "scdm_selection_status_message", "") or "").strip() + if selection_status: + info["scdm_selection_status"] = selection_status + scdm_specs = self._scdm_property_specs_for_selection() if hasattr(self, "_scdm_property_specs_for_selection") else [] + enabled_labels = [ + str(spec.get("label") or spec.get("scdm_capability_key") or "").strip() + for spec in scdm_specs + if bool(spec.get("enabled")) + ] + blocked_items: list[str] = [] + for spec in scdm_specs: + if bool(spec.get("enabled")): + continue + label = str(spec.get("label") or spec.get("scdm_capability_key") or "").strip() + reason = str(spec.get("disabled_tip") or spec.get("status_text") or "").strip() + if label: + blocked_items.append(f"{label}:{self._compact_status_message(reason, 64)}" if reason else label) + if enabled_labels: + info["scdm_selection_enabled_capabilities"] = "、".join(enabled_labels[:8]) + else: + info.pop("scdm_selection_enabled_capabilities", None) + if blocked_items: + info["scdm_selection_blocked_capabilities"] = ";".join(blocked_items[:6]) + else: + info.pop("scdm_selection_blocked_capabilities", None) + info["scdm_selection_capability_count"] = len(scdm_specs) + info["scdm_selection_blocked_count"] = len(blocked_items) + self.current_info_values = info + try: + self.current_info_text = _info_to_text(info) + if hasattr(self, "info_text"): + self.info_text.setPlainText(self.current_info_text) + except Exception: + pass + try: + diagnostic_content = getattr(self, "diagnostic_info_content", None) + diagnostics_visible = bool( + diagnostic_content is None + or not hasattr(diagnostic_content, "isVisible") + or diagnostic_content.isVisible() + ) + if diagnostics_visible and hasattr(self, "_populate_info_tree"): + self._populate_info_tree(info) + except Exception: + pass def _rebuild_property_command_bar(self) -> None: layout = getattr(self, "property_command_layout", None) @@ -2160,6 +2538,8 @@ class WindowStateMixin: return combo def _refresh_property_editor(self) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._refresh_property_editor): + return if not hasattr(self, "property_table"): return has_selection = any( @@ -2181,6 +2561,7 @@ class WindowStateMixin: specs = self._sort_property_specs_for_display(self._property_editor_specs(info, action_info)) self.property_editor_specs = specs + self._sync_scdm_selection_diagnostics() self.property_table_expanded = False self.property_editor_selected_row = None self.property_command_active_key = "" @@ -2228,7 +2609,7 @@ class WindowStateMixin: self._clear_property_command_bar() self._clear_property_cards() self._update_current_capability_panel() - self._update_relation_formula_completions() + self._update_relation_formula_completions_if_active() self._refresh_relation_formula_list() self._resize_property_table_height() finally: @@ -2236,12 +2617,24 @@ class WindowStateMixin: self.property_editor_updating = False self._resize_property_table_columns() self._update_property_apply_state() + scdm_message = str(getattr(self, "scdm_selection_status_message", "") or "").strip() + if scdm_message: + self.statusBar().showMessage(scdm_message) def _on_relation_formula_input_changed(self, _text: str = "") -> None: self._update_relation_formula_completions() self._update_relation_formula_buttons() + def _update_relation_formula_completions_if_active(self) -> None: + editor = getattr(self, "relation_formula_input", None) + if not isinstance(editor, QLineEdit) or not editor.hasFocus(): + self._hide_relation_formula_completion_popup() + return + self._update_relation_formula_completions() + def _update_relation_formula_completions(self) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._update_relation_formula_completions): + return completer = getattr(self, "relation_formula_completer", None) if not isinstance(completer, QCompleter): return @@ -4116,8 +4509,24 @@ class WindowStateMixin: info: dict[str, object], action_info: dict[str, object], ) -> list[dict[str, object]]: + self.scdm_selection_status_message = "" if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes": return self._multi_hole_property_specs(action_info) + scdm_specs = self._scdm_property_specs_for_selection() + if self._prefer_local_face_offset_over_scdm(scdm_specs, action_info): + scdm_specs = [ + spec + for spec in scdm_specs + if str(spec.get("scdm_capability_key") or "") != "face.offset" + ] + self.scdm_selection_status_message = ( + "SCDM 已识别当前偏移参数;当前大模型多内孔平面优先使用本软件已验证的快速一级边界重建," + "避免 SCDM 长时间直接建模计算。" + ) + else: + self.scdm_selection_status_message = self._scdm_selection_status_message(scdm_specs) + if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs): + return [spec for spec in scdm_specs if bool(spec.get("enabled"))] editable_specs, _used_keys = self._editable_property_specs(action_info) if self.selected_kind == "feature": return self._feature_context_property_specs(editable_specs, action_info) @@ -4131,6 +4540,124 @@ class WindowStateMixin: and _property_parameter_is_visible(spec) ] + def _prefer_local_face_offset_over_scdm( + self, + scdm_specs: list[dict[str, object]], + action_info: dict[str, object], + ) -> bool: + if self.selected_kind not in {"feature", "face"} or self.model is None: + return False + if not any( + bool(spec.get("enabled")) and str(spec.get("scdm_capability_key") or "") == "face.offset" + for spec in scdm_specs + ): + return False + if str(action_info.get("surface") or "").lower() != "plane": + return False + face_count = len(getattr(self.model, "faces", ()) or ()) + edge_count = len(getattr(self.model, "edges", ()) or ()) + if face_count <= 1000 and edge_count <= 2500: + return False + inner_wires = _int_or_none(action_info.get("inner_boundary_wires")) or 0 + boundary_edges = _int_or_none(action_info.get("boundary_edges")) or _int_or_none( + action_info.get("first_level_boundary_edge_count") + ) or 0 + 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"}: + return "" + if self.model is None: + return "" + if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs): + return "SCDM 已识别当前对象,参数表优先显示 SCDM 可执行能力。" + if scdm_specs: + reasons = [ + str(spec.get("disabled_tip") or "").strip() + for spec in scdm_specs + if not bool(spec.get("enabled")) and str(spec.get("disabled_tip") or "").strip() + ] + if reasons: + return f"SCDM 已识别当前对象,但当前能力未开放:{self._compact_status_message(reasons[0])}" + return "SCDM 已识别当前对象,但当前能力还没有完成执行验证。" + state = str(getattr(self, "scdm_feature_cache_state", "") or "") + cache_message = str(getattr(self, "scdm_feature_cache_message", "") or "").strip() + if state == "running": + return "SCDM 正在后台识别可修改参数,当前先显示本软件已有能力。" + if state == "failed": + return f"SCDM 识别失败,当前使用本软件已有能力:{self._compact_status_message(cache_message)}" + if state in {"empty", "stale", ""}: + return "SCDM 识别缓存未就绪,当前先显示本软件已有能力。" + return "SCDM 未在当前对象上识别到已产品化参数,当前使用本软件已有能力。" + + @staticmethod + def _compact_status_message(text: str, limit: int = 96) -> str: + value = " ".join(str(text or "").split()) + if len(value) <= limit: + return value + return f"{value[: max(0, limit - 1)]}..." + + def _scdm_property_specs_for_selection(self) -> list[dict[str, object]]: + cache = self._current_scdm_feature_cache() + if not isinstance(cache, dict): + return [] + face_ids: set[int] = set() + edge_ids: set[int] = set() + if self.selected_kind in {"feature", "face"} and self.selected_face_id is not None: + face_ids.update(self._scdm_selection_face_match_ids(int(self.selected_face_id))) + if self.selected_kind == "edge" and self.selected_edge_id is not None: + edge_ids.add(int(self.selected_edge_id)) + if not face_ids and not edge_ids: + return [] + return property_specs_from_scdm_cache( + cache, + selected_face_ids=face_ids, + selected_edge_ids=edge_ids, + execution_ready=getattr(self, "scdm_edit_runner_ready", False), + ) + + def _current_scdm_feature_cache(self) -> dict[str, object] | None: + for owner in (self, self.model): + if owner is None: + continue + for attr in ("scdm_feature_cache", "_scdm_feature_cache"): + value = getattr(owner, attr, None) + if callable(value): + try: + value = value() + except Exception: + value = None + if isinstance(value, dict): + return dict(value) + return None + + def _scdm_selection_face_match_ids(self, face_id: int) -> set[int]: + ids = {int(face_id)} + if self.model is None: + return ids + for method_name in ("face_logical_id", "face_region_logical_id"): + method = getattr(self.model, method_name, None) + if callable(method): + try: + ids.add(int(method(face_id))) + except Exception: + pass + region_method = getattr(self.model, "face_region_ids", None) + if callable(region_method): + try: + for region_face_id in region_method(face_id): + ids.add(int(region_face_id)) + for method_name in ("face_logical_id", "face_region_logical_id"): + method = getattr(self.model, method_name, None) + if callable(method): + try: + ids.add(int(method(int(region_face_id)))) + except Exception: + pass + except Exception: + pass + return ids + def _multi_hole_property_specs(self, action_info: dict[str, object]) -> list[dict[str, object]]: entries = list(getattr(self, "multi_selected_hole_entries", []) or []) count = len(entries) @@ -7579,6 +8106,8 @@ class WindowStateMixin: self._update_property_apply_state() def _update_property_apply_state(self, has_model: bool | None = None) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda: self._update_property_apply_state(has_model)): + return if not hasattr(self, "property_table"): return if has_model is None: @@ -7690,17 +8219,24 @@ class WindowStateMixin: setattr(widget, "_geom_param_button_state", button_state) if hasattr(self, "apply_property_button"): changed = self._changed_property_rows() - enabled = bool(has_model and changed) - disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。关系式会在点击“添加公式”后立即执行。" - if changed: - count = len(changed) + commands = self._selected_command_property_rows() + pending = changed + commands + enabled = bool(has_model and pending) + disabled_tip = "请先选择对象,并在属性表中修改目标值;命令型行需要先选中该行。关系式会在点击“添加公式”后立即执行。" + if pending: + count = len(pending) + command_count = len(commands) enabled_tip = ( "应用当前被修改的 1 个参数。" - if count == 1 - else f"按表格顺序依次应用当前被修改的 {count} 个参数;失败时会停止后续修改。" + if count == 1 and command_count == 0 + else ( + "执行当前选中的命令型参数。" + if count == 1 and command_count == 1 + else f"按表格顺序依次应用当前 {count} 个参数/命令;失败时会停止后续修改。" + ) ) else: - enabled_tip = "应用当前被修改的参数。" + enabled_tip = "应用当前被修改的参数,或执行当前选中的命令型参数。" self._set_control_state( self.apply_property_button, enabled, @@ -7816,6 +8352,35 @@ class WindowStateMixin: changed.append((row, effective_spec, text)) return changed + def _selected_command_property_rows(self) -> list[tuple[int, dict[str, object], str]]: + if not hasattr(self, "property_table"): + return [] + selection_model = self.property_table.selectionModel() + selected_rows = ( + { + int(index.row()) + for index in selection_model.selectedRows() + if index is not None and index.row() >= 0 + } + if selection_model is not None + else set() + ) + current_row = int(self.property_table.currentRow()) + if current_row >= 0: + selected_rows.add(current_row) + commands: list[tuple[int, dict[str, object], str]] = [] + specs = getattr(self, "property_editor_specs", []) + for row in sorted(selected_rows): + if row < 0 or row >= len(specs): + continue + effective_spec = self._effective_property_spec(specs[row], row=row) + if str(effective_spec.get("value_type", "number")) != "command": + continue + if not effective_spec.get("action") or not effective_spec.get("enabled"): + continue + commands.append((row, effective_spec, self._property_target_text(row))) + return commands + def _property_batch_item_from_row(self, row: int, spec: dict[str, object], text: str) -> dict[str, object]: return { "key": str(spec.get("key", "")), @@ -8052,19 +8617,25 @@ class WindowStateMixin: return abs(value - current) > PROPERTY_VALUE_TOLERANCE def apply_current_property_edit(self) -> None: - changed = self._changed_property_rows() - if not changed: - self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值;关系式会在添加后自动执行。") + pending = self._pending_property_edit_rows() + if not pending: + self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值,或选中一个命令型参数行。") return if self.selected_kind == "multi_feature" and hasattr(self, "apply_multi_selected_hole_property_edit"): - self.apply_multi_selected_hole_property_edit(changed) + self.apply_multi_selected_hole_property_edit(pending) return - if len(changed) > 1: - self._start_property_batch_edit(changed) + if len(pending) > 1: + self._start_property_batch_edit(pending) return - row, _spec, _text = changed[0] + row, _spec, _text = pending[0] self.apply_property_row_edit(row) + def _pending_property_edit_rows(self) -> list[tuple[int, dict[str, object], str]]: + rows: dict[int, tuple[int, dict[str, object], str]] = {} + for row, spec, text in self._changed_property_rows() + self._selected_command_property_rows(): + rows[int(row)] = (int(row), spec, text) + return [rows[row] for row in sorted(rows)] + def _start_property_batch_edit(self, changed: list[tuple[int, dict[str, object], str]]) -> None: if self._edit_busy("请等待当前编辑完成后再执行批量参数化建模。"): return @@ -8205,19 +8776,22 @@ class WindowStateMixin: if validation_error: QMessageBox.information(self, "目标值无效", validation_error) return + action_name = str(spec.get("action", "")) try: self._activate_property_source_feature(spec) - if not is_command: + if not is_command and action_name != "apply_scdm_property_edit": self._sync_property_edit_target(spec, text) self._sync_property_preselects(spec) except ValueError as exc: QMessageBox.information(self, "目标值无效", str(exc)) return - action_name = str(spec.get("action", "")) action = getattr(self, action_name, None) if action is None: QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}") return + if action_name == "apply_scdm_property_edit": + self.apply_scdm_property_edit(spec, text) + return self._run_parametric_property_action_directly(action) def _run_parametric_property_action_directly(self, action) -> None: @@ -8232,6 +8806,401 @@ class WindowStateMixin: finally: QMessageBox.question = original_question + def apply_scdm_property_edit(self) -> None: + QMessageBox.information( + self, + "SCDM 修改暂未接入", + "SCDM 已能把这个参数识别到产品化缓存里,但 S5 修改执行器还没有接入。\n\n" + "当前不会启动长时间计算,也不会改动模型。下一步需要生成 scdm_edit_job.json," + "再调用 SpaceClaim.exe /RunScript 后台执行并做 OCCT 回测。", + ) + self._after_property_edit_finished(success=False) + + def apply_scdm_property_edit(self, spec: dict[str, object] | None = None, target_text: str | None = None) -> None: + if spec is None: + spec = {} + capability_key = str(spec.get("scdm_capability_key") or "").strip() + if not capability_key: + self.statusBar().showMessage("SCDM 参数缺少能力 key,无法执行。") + self._after_property_edit_finished(success=False) + return + ready = getattr(self, "scdm_edit_runner_ready", False) + if isinstance(ready, bool): + capability_ready = ready + else: + try: + capability_ready = capability_key in {str(item) for item in ready} + except TypeError: + capability_ready = False + if not capability_ready: + QMessageBox.information(self, "暂未开放", f"{spec.get('label', capability_key)} 的 SCDM 修改命令还没有完成真机验证。") + self._after_property_edit_finished(success=False) + return + if self._edit_busy("请等待当前编辑完成后再执行 SCDM 参数化建模。"): + self._after_property_edit_finished(success=False) + return + source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None + if source_step is None or not source_step.is_file(): + QMessageBox.information(self, "缺少 STEP", "当前模型缺少可交给 SCDM 修改的 STEP 文件,请重新导入模型。") + self._after_property_edit_finished(success=False) + return + try: + target_value = self._scdm_property_target_value(spec, str(target_text or "")) + except ValueError as exc: + QMessageBox.information(self, "目标值无效", str(exc)) + self._after_property_edit_finished(success=False) + return + + operation_name = f"SCDM {spec.get('label', capability_key)}" + selected_kind = self.selected_kind + selected_face_id = self.selected_face_id + selected_edge_id = self.selected_edge_id + selected_pick_position = self.selected_pick_position + target_id = selected_face_id if selected_kind in {"face", "feature"} else selected_edge_id + object_id = str(spec.get("scdm_object_id") or "") + backend_operation = str(spec.get("scdm_backend_operation") or "") + signature = spec.get("scdm_geometry_signature") + object_signature = dict(signature) if isinstance(signature, dict) else {} + context = { + "operation_name": operation_name, + "target": str(spec.get("label") or capability_key), + "parameters": { + "backend": "SCDM", + "capability_key": capability_key, + "target_value": target_value, + "object_id": object_id, + "object_signature": object_signature, + }, + "target_kind": selected_kind, + "target_id": target_id, + "target_logical_id": self._edit_target_logical_id(selected_kind, target_id) if hasattr(self, "_edit_target_logical_id") else None, + "pick_position": selected_pick_position, + } + self._begin_edit_task(operation_name) + self.pending_edit_context = context + thread = QThread(self) + + def action() -> dict[str, object]: + return run_scdm_edit_job( + source_step, + capability_key=capability_key, + target_value=target_value, + object_signature=object_signature, + object_id=object_id, + backend_operation=backend_operation, + project_root=Path(__file__).resolve().parent.parent, + timeout_seconds=180.0, + context={ + "selectedKind": selected_kind, + "selectedFaceId": selected_face_id, + "selectedEdgeId": selected_edge_id, + "source": "property_table", + }, + ) + + worker = EditWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_scdm_edit_action, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_scdm_edit_action, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_edit_thread) + self.edit_thread = thread + self.edit_worker = worker + try: + thread.start(QThread.Priority.LowPriority) + except TypeError: + thread.start() + + def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object: + value_type = str(spec.get("value_type") or "number") + if value_type == "vector3": + return list(self._parse_property_vector3(text)) + if value_type in {"number", "positive"}: + try: + value = float(text) + except (TypeError, ValueError) as exc: + raise ValueError("请输入数字形式的目标值。") from exc + if value_type == "positive" and value <= 0: + raise ValueError("请输入大于 0 的目标值。") + return value + if value_type == "command": + return True + return text + + @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)): + return + if not isinstance(result, dict): + self._end_edit_task(clear_preview=True) + QMessageBox.information(self, "SCDM 修改失败", "SCDM 后台返回了无法识别的结果。") + self._after_property_edit_finished(success=False) + return + if isinstance(result.get("backend"), dict): + self.scdm_backend_status = dict(result["backend"]) + self._update_current_capability_panel() + if result.get("ok") is not True: + self._end_edit_task(clear_preview=True) + reason = str(result.get("reason") or "unknown") + message = str(result.get("message") or "SCDM 修改失败。") + QMessageBox.information(self, "SCDM 修改失败", f"不能修改。\n类型:{reason}\n原因:{message}\n模型未修改。") + self._after_property_edit_finished(success=False) + return + output_step = Path(str(result.get("output_step") or "")) + if not output_step.is_file(): + self._end_edit_task(clear_preview=True) + QMessageBox.information(self, "SCDM 修改失败", "SCDM 报告成功,但没有生成结果 STEP。模型未修改。") + self._after_property_edit_finished(success=False) + return + source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None + before_snapshot = None + if self.model is not None: + try: + before_snapshot = self.model.snapshot() + except Exception: + before_snapshot = None + before_cache = self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None + pending_context = dict(self.pending_edit_context or {}) + context_parameters = pending_context.get("parameters") + if not isinstance(context_parameters, dict): + context_parameters = {} + self.pending_scdm_edit_reload = { + "edit_result": dict(result), + "edit_context": pending_context, + "before_cache": dict(before_cache) if isinstance(before_cache, dict) else {}, + "before_snapshot": before_snapshot, + "source_step": str(source_step) if source_step is not None else "", + "object_id": str(result.get("object_id") or context_parameters.get("object_id") or ""), + "capability_key": str(result.get("capability_key") or context_parameters.get("capability_key") or ""), + "target_value": context_parameters.get("target_value"), + "object_signature": context_parameters.get("object_signature"), + } + self._end_edit_task(clear_preview=True) + self.statusBar().showMessage("SCDM 修改完成,正在重新读取结果模型...") + self.load_step(output_step, background=True) + + @Slot(str) + def _fail_scdm_edit_action(self, message: str) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scdm_edit_action(message)): + return + self._end_edit_task(clear_preview=True) + QMessageBox.information(self, "SCDM 修改失败", f"SCDM 后台执行失败:{message}") + self._after_property_edit_finished(success=False) + + def _finish_pending_scdm_edit_reload(self, *, cache_ready: bool, message: str = "") -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread( + lambda cache_ready=cache_ready, message=message: self._finish_pending_scdm_edit_reload(cache_ready=cache_ready, message=message) + ): + return + pending = getattr(self, "pending_scdm_edit_reload", None) + if not isinstance(pending, dict): + return + self.pending_scdm_edit_reload = None + notes: list[str] = [] + before_cache = pending.get("before_cache") + after_cache = self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None + if not cache_ready: + self._reject_scdm_result_after_reload( + pending, + { + "ok": False, + "reason": "cache-not-ready", + "message": message or "SCDM 修改后没有拿到新的可修改参数 cache,结果无法验证。", + }, + ) + return + if isinstance(before_cache, dict) and isinstance(after_cache, dict): + signature = pending.get("object_signature") + validation = validate_scdm_edit_result( + pending.get("edit_result") if isinstance(pending.get("edit_result"), dict) else {}, + before_signature=signature if isinstance(signature, dict) else None, + before_cache=before_cache, + after_cache=after_cache, + capability_key=str(pending.get("capability_key") or ""), + expected_target=pending.get("target_value"), + edited_object_id=str(pending.get("object_id") or ""), + brep_validator=self._validate_current_scdm_result_brep, + ) + if validation.get("ok") is not True: + self._reject_scdm_result_after_reload(pending, validation) + return + mapping = build_scdm_id_mapping( + before_cache, + after_cache, + capability_key=str(pending.get("capability_key") or ""), + ) + note = self._rewrite_relation_formulas_from_scdm_mapping(mapping) + if note: + notes.append(note) + self._record_successful_scdm_edit(pending, validation) + if hasattr(self, "_refresh_relation_formulas_after_model_edit"): + note = self._refresh_relation_formulas_after_model_edit( + context=pending.get("edit_context") if isinstance(pending.get("edit_context"), dict) else None + ) + if note: + notes.append(note) + if message: + notes.append(str(message)) + if notes: + self.statusBar().showMessage(";".join(notes)) + self._after_property_edit_finished(success=True) + + def _validate_current_scdm_result_brep(self, _path: Path) -> dict[str, object]: + if self.model is None: + return {"ok": False, "reason": "model-not-loaded", "message": "结果 STEP 还没有成功加载到当前模型。"} + try: + info = dict(self.model.export_quality_info("all", None)) + except Exception as exc: + return {"ok": False, "reason": "brep-check-failed", "message": str(exc)} + if info.get("brep_valid") is not True: + warning = str(info.get("quality_warnings") or "").strip() + return { + "ok": False, + "reason": "brep-invalid", + "message": warning or "OCCT B-Rep 校验未通过。", + "quality": info, + } + try: + face_count = int(info.get("faces") or 0) + except (TypeError, ValueError): + face_count = 0 + if face_count <= 0: + return { + "ok": False, + "reason": "empty-topology", + "message": "结果模型没有可用 Face。", + "quality": info, + } + return {"ok": True, "reason": "ok", "message": "OCCT B-Rep 校验通过。", "quality": info} + + def _reject_scdm_result_after_reload(self, pending: dict[str, object], validation: dict[str, object]) -> None: + reason = str(validation.get("reason") or "result-check-failed") + message = str(validation.get("message") or "SCDM 修改结果没有通过校验。") + rollback_message = self._restore_scdm_before_snapshot(pending) + QMessageBox.information( + self, + "SCDM 修改结果已回滚", + f"不能修改。\n类型:{reason}\n原因:{message}\n{rollback_message}\n模型未修改。", + ) + self.statusBar().showMessage(f"SCDM 修改结果未通过校验,已回滚:{reason}") + self._after_property_edit_finished(success=False) + + def _restore_scdm_before_snapshot(self, pending: dict[str, object]) -> str: + source_step_text = str(pending.get("source_step") or "").strip() + source_step = Path(source_step_text) if source_step_text else None + snapshot = pending.get("before_snapshot") + if source_step is not None and source_step.is_file(): + self.step_path = source_step + if hasattr(self, "path_label"): + path_text = str(source_step) + self.path_label.setText(path_text) + self.path_label.setToolTip(path_text) + self.path_label.setCursorPosition(0) + if isinstance(snapshot, dict) and self.model is not None: + try: + if hasattr(self, "_restore_snapshot"): + self._restore_snapshot(snapshot) + self._invalidate_scdm_feature_cache("SCDM 修改结果未通过校验,已恢复到修改前,cache 已失效。") + QTimer.singleShot(350, self._start_scdm_probe_preload) + return "已恢复到修改前的模型快照。" + except Exception as exc: + if source_step is not None and source_step.is_file(): + QTimer.singleShot(0, lambda path=source_step: self.load_step(path, background=True)) + return f"快照恢复失败:{exc}。正在重新加载修改前 STEP。" + return f"快照恢复失败:{exc}。请重新导入修改前 STEP。" + if source_step is not None and source_step.is_file(): + QTimer.singleShot(0, lambda path=source_step: self.load_step(path, background=True)) + return "未找到可用快照,正在重新加载修改前 STEP。" + return "未找到可用快照或修改前 STEP,请重新导入模型确认状态。" + + def _record_successful_scdm_edit(self, pending: dict[str, object], validation: dict[str, object]) -> None: + if self.model is None: + return + before_snapshot = pending.get("before_snapshot") + if not isinstance(before_snapshot, dict): + return + edit_context = pending.get("edit_context") if isinstance(pending.get("edit_context"), dict) else {} + parameters = dict(edit_context.get("parameters") if isinstance(edit_context.get("parameters"), dict) else {}) + target_value = pending.get("target_value") + capability_key = str(pending.get("capability_key") or parameters.get("capability_key") or "") + operation_name = str(edit_context.get("operation_name") or f"SCDM {capability_key}") + target = str(edit_context.get("target") or capability_key) + after_snapshot = self.model.snapshot() + summary = f"{operation_name}: {target} -> {_format_value(target_value)}" + detail_lines = [ + summary, + "backend: SCDM", + f"capability: {capability_key}", + f"target: {_format_value(target_value)}", + f"result: {validation.get('message') or 'SCDM edit result passed validation.'}", + ] + topology_check = validation.get("topologyCheck") + if isinstance(topology_check, dict): + detail_lines.append(f"unchanged objects checked: {topology_check.get('checked', 0)}") + record = OperationRecord( + summary=summary, + detail="\n".join(detail_lines), + operation_name=operation_name, + target=target, + parameters=parameters, + result_message=str(validation.get("message") or "SCDM edit result passed validation."), + target_kind=edit_context.get("target_kind") if isinstance(edit_context.get("target_kind"), str) else None, + target_id=_int_or_none(edit_context.get("target_id")), + target_logical_id=_int_or_none(edit_context.get("target_logical_id")), + pick_position=edit_context.get("pick_position") if isinstance(edit_context.get("pick_position"), tuple) else None, + before_snapshot=before_snapshot, + after_snapshot=after_snapshot, + ) + self.undo_stack.append(before_snapshot) + self.redo_stack.clear() + self.operation_history.append(record) + self.redo_history.clear() + if hasattr(self, "_refresh_history_list"): + self._refresh_history_list() + self._update_action_states() + + def _fail_pending_scdm_edit_reload(self, message: str) -> None: + if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_pending_scdm_edit_reload(message)): + return + if not isinstance(getattr(self, "pending_scdm_edit_reload", None), dict): + return + self.pending_scdm_edit_reload = None + QMessageBox.information(self, "SCDM 结果加载失败", message) + self._after_property_edit_finished(success=False) + + def _rewrite_relation_formulas_from_scdm_mapping(self, mapping: dict[str, object]) -> str: + if not getattr(self, "relation_formula_items", None): + return "" + if not isinstance(mapping, dict): + return "" + if not mapping.get("faceIdMap") and not mapping.get("edgeIdMap"): + return "" + updated_items: list[dict[str, object]] = [] + changed_count = 0 + for raw_item in getattr(self, "relation_formula_items", []) or []: + item = dict(raw_item) + old_text = str(item.get("text") or "") + new_text = rewrite_scdm_relation_formula_ids(old_text, mapping) + if new_text != old_text: + item["text"] = new_text + changed_count += 1 + try: + item["signatures"] = self._relation_signatures_for_formula(parse_relation_formula(new_text)) + if item.get("status") == "invalid": + item["status"] = "ready" + item["message"] = "SCDM 已重新定位关系式对象。" + except RelationFormulaError as exc: + item["status"] = "invalid" + item["message"] = str(exc) + updated_items.append(item) + self.relation_formula_items = updated_items + self._refresh_relation_formula_list() + if not changed_count: + return "" + return f"SCDM 已更新 {changed_count} 条关系式里的 Face/Edge ID" + def set_manual_hole_bottom_face(self) -> None: self._update_action_states() self._refresh_property_editor() @@ -8478,7 +9447,7 @@ class WindowStateMixin: self, action_info: dict[str, object], ) -> tuple[tuple[float, float, float] | None, tuple[float, float, float] | None]: - plane_origin = _triple_or_none(action_info.get("plane_origin")) + plane_origin = _triple_or_none(action_info.get("plane_origin")) or _triple_or_none(action_info.get("origin")) plane_direction = _triple_or_none(action_info.get("push_pull_outward_direction")) selected_face_id = _int_or_none(action_info.get("feature_source_face_id")) if selected_face_id is None: @@ -8486,12 +9455,14 @@ class WindowStateMixin: if selected_face_id is None: selected_face_id = _int_or_none(action_info.get("face_id")) model_faces = getattr(self.model, "faces", None) if self.model is not None else None + large_model_fast_display = bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) + needs_precise_direction = plane_direction is None and not large_model_fast_display if ( self.model is not None and model_faces is not None and selected_face_id is not None and 0 <= selected_face_id < len(model_faces) - and (plane_origin is None or plane_direction is None) + and (plane_origin is None or needs_precise_direction) ): try: full_info = self.model.face_info(selected_face_id) @@ -8503,10 +9474,10 @@ class WindowStateMixin: plane_direction = _triple_or_none(full_info.get("push_pull_outward_direction")) if plane_direction is None: plane_direction = _triple_or_none(full_info.get("oriented_normal")) - if plane_direction is None: - plane_direction = _triple_or_none(action_info.get("oriented_normal")) if plane_direction is None: plane_direction = _triple_or_none(action_info.get("normal")) + if plane_direction is None: + plane_direction = _triple_or_none(action_info.get("oriented_normal")) return plane_origin, plane_direction def _transform_property_vector3_target( @@ -8671,11 +9642,17 @@ class WindowStateMixin: if record.target_kind in {"face", "feature"} and record.target_id is not None: resolved_face_id = self._resolve_record_face_id(record) if resolved_face_id is not None: - info = ( - self.model.feature_info(resolved_face_id) - if record.target_kind == "feature" - else self._feature_info_for_selected_face(resolved_face_id, self.model.face_info(resolved_face_id)) - ) + if self._large_model_fast_record_locator(): + base_info = self.model.quick_face_info(resolved_face_id) + info = self._feature_info_for_selected_face(resolved_face_id, base_info) + if record.target_kind == "feature": + info["kind"] = "feature" + else: + info = ( + self.model.feature_info(resolved_face_id) + if record.target_kind == "feature" + else self._feature_info_for_selected_face(resolved_face_id, self.model.face_info(resolved_face_id)) + ) info.update( self._selection_identity_fields( resolved_face_id, @@ -8693,10 +9670,10 @@ class WindowStateMixin: face_ids = ( _int_values(info.get("feature_highlight_face_ids")) if record.target_kind == "feature" - else self.model.face_region_ids(resolved_face_id) + else self._record_locator_face_ids(resolved_face_id, info) ) if not face_ids: - face_ids = self.model.face_region_ids(resolved_face_id) + face_ids = self._record_locator_face_ids(resolved_face_id, info) self._highlight_faces(face_ids=face_ids) self.set_info(self._with_pick_info(info, record.pick_position)) located = True @@ -8789,6 +9766,32 @@ class WindowStateMixin: self.statusBar().showMessage(locator_note.splitlines()[0]) return locator_note + def _large_model_fast_record_locator(self) -> bool: + if self.model is None: + return False + mode = getattr(self, "_large_model_interaction_mode", None) + if callable(mode): + try: + return bool(mode()) + except Exception: + pass + try: + return len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500 + except Exception: + return False + + def _record_locator_face_ids(self, face_id: int, info: dict[str, object]) -> list[int]: + selector = getattr(self, "_selection_same_domain_face_ids", None) + if callable(selector): + try: + return list(selector(face_id, info)) + except Exception: + pass + try: + return list(self.model.face_region_ids(face_id)) if self.model is not None else [face_id] + except Exception: + return [face_id] + def _resolve_record_face_id(self, record: OperationRecord) -> int | None: if self.model is None: return None @@ -8822,16 +9825,19 @@ class WindowStateMixin: def _record_verified_face_id(self, record: OperationRecord) -> int | None: if self.model is None: return None - value = _record_message_field(record.result_message, "verified_face") - if value is None: - return None - try: - face_id = int(value) - except (TypeError, ValueError): - return None - if not (0 <= face_id < len(self.model.faces)): - return None - return face_id if self._record_face_semantic_matches(record, face_id) else None + for key in ("verified_face", "nearest_face", "matched_face", "retained_face"): + value = _record_message_field(record.result_message, key) + if value is None: + continue + try: + face_id = int(value) + except (TypeError, ValueError): + continue + if not (0 <= face_id < len(self.model.faces)): + continue + if self._record_face_semantic_matches(record, face_id): + return face_id + return None def _record_moved_face_id(self, record: OperationRecord) -> int | None: if self.model is None or record.pick_position is None: @@ -8974,19 +9980,12 @@ class WindowStateMixin: for face_id in self._record_candidate_face_ids(record): if not (0 <= int(face_id) < len(self.model.faces)): continue - try: - info = self.model.face_info(int(face_id)) - except Exception: + plane_position = self.model.face_plane_position_along(int(face_id), unit) + if plane_position is None: continue - if info.get("surface") != "plane": + position, normal = plane_position + if abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85: continue - normal = _triple_or_none(info.get("normal")) or _triple_or_none(info.get("oriented_normal")) - if normal is not None and abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85: - continue - origin = _triple_or_none(info.get("plane_origin")) - if origin is None: - continue - position = origin[0] * unit[0] + origin[1] * unit[1] + origin[2] * unit[2] error = abs(position - target_position) if error < best_error: best_error = error @@ -9102,6 +10101,7 @@ class WindowStateMixin: self.operation_history.pop() self.redo_stack.append(current) self.redo_history.append(undone) + self._refresh_scdm_cache_after_snapshot_restore("撤销后模型已恢复,SCDM cache 已失效。") self._refresh_history_list() self._update_action_states() self.statusBar().showMessage(f"已撤销:{undone.summary}") @@ -9129,10 +10129,17 @@ class WindowStateMixin: self.redo_history.pop() self.undo_stack.append(current) self.operation_history.append(redone) + self._refresh_scdm_cache_after_snapshot_restore("重做后模型已恢复,SCDM cache 已失效。") self._refresh_history_list() self._update_action_states() self.statusBar().showMessage(f"已重做:{redone.summary}") + def _refresh_scdm_cache_after_snapshot_restore(self, message: str) -> None: + if not hasattr(self, "_invalidate_scdm_feature_cache") or not hasattr(self, "_start_scdm_probe_preload"): + return + self._invalidate_scdm_feature_cache(message) + QTimer.singleShot(350, self._start_scdm_probe_preload) + def _restore_snapshot(self, snapshot: dict[int, object]) -> None: if self.model is None: return