feat: 推进SCDM-first后端接入和大模型编辑优化

This commit is contained in:
2026-08-19 10:28:09 +08:00
parent 722256a41f
commit a3eb7e2476
28 changed files with 9666 additions and 168 deletions
+7
View File
@@ -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
+150 -8
View File
@@ -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.jsonpath、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 / EdgeEdge 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 条 Edge396 条 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 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。
@@ -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",)),
+79 -1
View File
@@ -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}"
)
+544 -6
View File
@@ -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:
+157
View File
@@ -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())
+351
View File
@@ -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())
+550
View File
@@ -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())
+169
View File
@@ -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())
+175
View File
@@ -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())
+4 -2
View File
@@ -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
+55 -11
View File
@@ -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:
+18 -9
View File
@@ -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,
},
)
)
+75 -3
View File
@@ -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
+21 -1
View File
@@ -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,11 +11347,26 @@ class OperationMixin:
continue
center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center)
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)
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)
+623
View File
@@ -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",
]
+380
View File
@@ -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",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+680
View File
@@ -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",
]
+167
View File
@@ -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"]
+683
View File
@@ -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",
]
+122
View File
@@ -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",
]
+568
View File
@@ -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"]
+52
View File
@@ -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 ""
+242 -17
View File
@@ -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:
+412 -22
View File
@@ -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,6 +1282,10 @@ 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:
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),
@@ -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)
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()
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
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()
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)
File diff suppressed because it is too large Load Diff