feat: 推进SCDM-first后端接入和大模型编辑优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- 主入口是 `python main.py`;无参数启动会快速进入空场景,不默认读取 STEP。常用测试模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。
|
||||
- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出模型;部分辅助面板暂时收起,后续需要时再放回。
|
||||
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
|
||||
- 后续过渡到 `SCDM-first` 后端:能由 SpaceClaim/SCDM 识别和修改的特征,优先调用 SCDM 脚本接口;本软件不再重复造完整特征识别和直接建模内核,只负责 UI、缓存、脚本生成、校验、回滚和结果映射。
|
||||
- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点。
|
||||
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
|
||||
|
||||
@@ -47,6 +48,21 @@ python-occt
|
||||
│ ├── asitus_bridge.py
|
||||
│ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号
|
||||
│ │
|
||||
│ ├── scdm_backend.py
|
||||
│ │ └── SCDM 后端发现和缓存底座,负责自动发现并缓存 SpaceClaim.exe,生成 /RunScript 命令并做最小烟测
|
||||
│ │
|
||||
│ ├── scdm_probe.py
|
||||
│ │ └── SCDM probe 任务生成和脚本生成,负责写 scdm_probe_job.json、临时 RunScript 脚本和 raw 识别输出约定
|
||||
│ │
|
||||
│ ├── scdm_edit_runner.py
|
||||
│ │ └── SCDM 修改任务执行器,负责写 scdm_edit_job.json、临时修改脚本、result.step、result.json 和 error.json
|
||||
│ │
|
||||
│ ├── scdm_result_validator.py
|
||||
│ │ └── SCDM 结果校验和 ID 续接工具,负责输出文件检查、目标值回测、旧对象到新对象的唯一匹配和关系式 ID 重写
|
||||
│ │
|
||||
│ ├── scdm_schema.py / scdm_capabilities.py / scdm_feature_mapper.py / scdm_property_specs.py
|
||||
│ │ └── SCDM raw 结果、产品能力字典、scdm_feature_cache.json 映射和参数表 spec 转换层,避免把 SCDM 原始技术对象直接暴露给 UI
|
||||
│ │
|
||||
│ ├── operations.py
|
||||
│ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角
|
||||
│ │
|
||||
@@ -81,6 +97,110 @@ python-occt
|
||||
└── 仓库自带测试 STEP 模型
|
||||
```
|
||||
|
||||
## SCDM 后端过渡路线
|
||||
|
||||
本阶段目标:把 SpaceClaim/SCDM 接成优先几何后端。SCDM 负责识别和执行它能稳定处理的直接建模能力;本软件负责显示、选择、能力映射、任务生成、异步执行、结果校验、回滚、ID 续接和参数导出。SCDM 不接管本软件 UI,也不把 SCDM 原始界面文字直接展示给客户。
|
||||
|
||||
交付判断不是“调用了 SCDM”,而是下面这条闭环能稳定跑通:
|
||||
|
||||
```text
|
||||
导入 STEP
|
||||
-> 自动找到可用的 SpaceClaim.exe,并缓存路径、来源、版本和验证结果
|
||||
-> 本软件显示模型并建立当前 Face / Edge / Solid 基础索引
|
||||
-> 生成 scdm_probe_job.json,说明要让 SCDM 扫描哪个 STEP、输出到哪里、使用哪个适配器
|
||||
-> SCDM probe 打开 STEP,读取结构化几何对象、可执行命令候选、失败/限制原因
|
||||
-> 写出 scdm_raw_features.json,保留 SCDM 原始结构化结果,不依赖 UI 显示名称
|
||||
-> 本软件把 raw 结果映射到能力字典,生成 scdm_feature_cache.json
|
||||
-> 用户点击对象时,参数表只显示 cache 中已产品化、可执行、可校验的参数
|
||||
-> 用户修改目标值后,生成 scdm_edit_job.json 和对应 SCDM 修改脚本
|
||||
-> SpaceClaim.exe /RunScript 在后台隔离执行,成功输出 result.step,失败输出 error.json
|
||||
-> 本软件用 OCCT 重新读 result.step,做 B-Rep 校验、目标值回测和非预期变形检查
|
||||
-> 校验通过后替换当前模型,重新跑 probe 刷新 scdm_feature_cache.json
|
||||
-> 用新缓存更新 Face / Edge / 特征 ID 映射和关系式引用
|
||||
```
|
||||
|
||||
核心实现拆分如下:
|
||||
|
||||
```text
|
||||
SCDM-first 实施路线
|
||||
├── S0. 后端发现与缓存
|
||||
│ ├── [x] 新建 step_editor/scdm_backend.py
|
||||
│ ├── [x] 读取 local/scdm_backend.json,缓存可用时直接复用 SpaceClaim.exe
|
||||
│ ├── [x] 缓存失效时自动发现:Windows 注册表 -> 环境变量 -> ANSYS 常见安装目录 -> PATH
|
||||
│ ├── [x] 自动发现失败时,后端返回需要用户配置;导入模型后的 SCDM probe 会自动弹出路径选择框,用户选择 SpaceClaim.exe 后写回缓存
|
||||
│ ├── [x] 找到后可运行最小 /RunScript 烟测,确认能启动、能写报告、许可证可用
|
||||
│ ├── [x] 写回 local/scdm_backend.json:path、source、version、verifiedAt、runScriptOk、licenseOk
|
||||
│ └── [x] 验收:scripts/verify_scdm_backend.py 覆盖缓存、发现、禁用开关和 /RunScript 命令;SCDM 不可用时主程序仍可走 OCCT 降级模式
|
||||
│
|
||||
├── S1. Probe 任务与原始识别输出
|
||||
│ ├── [x] 新建 step_editor/scdm_probe.py,负责生成 scdm_probe_job.json 和临时 SCDM probe 脚本
|
||||
│ ├── [x] Probe 输入:STEP 路径、单位、模型指纹、输出目录、SCDM 版本、扫描范围
|
||||
│ ├── [~] Probe 动作:当前脚本已防御式遍历 Body / Face / Edge,Edge raw 已输出长度、起终点、圆弧半径和相邻 Face 摘要,并会先尝试 `StandardHoles.Find` / `GetHoleFaces`,失败或为空再回退到圆柱碎片聚合;圆柱面会尝试 `RoundInfo.Create` 生成圆角诊断摘要,Chamfer / Fill 的深度 API 识别仍待继续补
|
||||
│ ├── [x] Probe 输出:scdm_raw_features.json,包含 geometry、topologyHint、backendCommandCandidates、rawLimitations、availableCommands、Face 邻接、Edge 几何摘要和 featureInventory
|
||||
│ ├── [x] 不依赖 SCDM UI 文案;只记录 API 类型、几何属性、命令对象、参数字段和返回状态
|
||||
│ └── [~] 验收:scripts/verify_scdm_probe_pipeline.py 已覆盖 job/script/raw->cache 假数据闭环、`StandardHoles.Find`、`RoundInfo` 脚本入口和 availableCommands 映射;本机 SCDM v222 已验证 ICEPAK 可打开并遍历 13 Body / 158 Face / 396 Edge,且 396 条 Edge 均可回传长度、起终点和相邻 Face 数,脚本环境可见 `StandardHoles` / `OffsetFaces` / `Move` / `Fill` / `Delete` / `Chamfer` / `ConstantRound` / `RoundInfo`;该样例的标准孔和圆角 API 识别结果均为 0,仍需继续拿更多样例适配
|
||||
│
|
||||
├── S2. 统一 Schema 与能力字典
|
||||
│ ├── [x] 新建 step_editor/scdm_schema.py,定义 raw feature、normalized feature、capability、edit job 的字段
|
||||
│ ├── [x] 新建 step_editor/scdm_capabilities.py,定义产品能力 key、中文名、单位、输入类型、默认建模意图、SCDM 命令、所需后端命令组和校验器
|
||||
│ ├── [x] 第一批能力:hole.diameter、hole.position、face.offset、feature.fill
|
||||
│ ├── [x] S7 后续能力先以 productized=false 进入能力字典和诊断缓存,不直接显示成客户可执行参数
|
||||
│ ├── [x] 未进入能力字典的 SCDM 结果只进入诊断和日志,状态为 discovered_not_productized
|
||||
│ ├── [x] 每个能力都必须声明 postCheck,例如目标孔径、目标轴心、目标偏移或目标特征消失
|
||||
│ └── [x] 验收:假 raw JSON 经过映射后,只产出已定义能力;未定义能力不会出现在产品化 cache
|
||||
│
|
||||
├── S3. SCDM 缓存生成与失效策略
|
||||
│ ├── [x] 新建 step_editor/scdm_feature_mapper.py,把 scdm_raw_features.json 映射为 scdm_feature_cache.json
|
||||
│ ├── [x] cache 记录:modelFingerprint、backendVersion、objects、capabilities、geometrySignature、blockReason
|
||||
│ ├── [x] SCDM raw 没有 Python Face ID 时,用本软件当前 StepModel 的轻量几何签名补回 faceIds,支持米/mm 单位比例匹配
|
||||
│ ├── [x] cache 会额外保留 geometry_candidate_hints:当 SCDM 只给出平面/圆柱面/圆边/直边这些底层证据,而没有明确 slot/boss/round 对象时,先把 S7 候选记为“几何证据待分类”,不进入客户参数表
|
||||
│ ├── [x] 导入模型、重新加载、SCDM 参数化修改成功、撤销和重做后都会标记 cache 失效并后台刷新
|
||||
│ ├── [x] cache 可用时点击对象只查窗口/model 上的 scdm_feature_cache,不重新启动 SCDM
|
||||
│ ├── [x] cache 正在生成时,UI 状态栏显示“正在后台识别 SCDM 可修改参数”,不阻塞旋转查看模型
|
||||
│ └── [~] 验收:raw->cache 映射已由 scripts/verify_scdm_probe_pipeline.py 覆盖;window_core.py 已接后台预热,真实 SCDM 运行耗时和失败提示待样例机验证
|
||||
│
|
||||
├── S4. 参数表接入
|
||||
│ ├── [x] 修改 window_state.py 的参数表数据源:存在 SCDM cache 时优先转成参数表 spec,现有 OCCT/Analysis Situs 能力作为兜底;对大模型多内孔平面位置调整这类本地已验证快路径,参数表会优先走 OCCT 专用一级边界重建,避免 SCDM 直接建模长时间计算
|
||||
│ ├── [x] 选中对象后,用当前 Face / Edge 索引、逻辑 ID、整孔 Face 组和 SCDM->Python faceIds 映射匹配 cache 中的 geometrySignature
|
||||
│ ├── [~] 匹配成功且 capability 已接入执行器时展示能力字典里的中文参数、当前值、建模意图、目标值和输入参数勾选框;参数表只展示 enabled 能力,未开放能力只进状态提示/诊断;当前按能力开放 `face.offset`、`hole.diameter`、`hole.position`、`slot.position`、`boss.position`
|
||||
│ ├── [x] 匹配失败或能力未开放时给出短原因:后台识别中、SCDM 未识别、能力未产品化、脚本命令不可用、后端失败;当前状态栏和下方诊断信息都会显示 SCDM 后端状态、当前选择状态、可执行能力和未开放原因
|
||||
│ ├── [x] 保留现有关系式、参数导出和统一“参数化建模”按钮,不新增一套 SCDM 专用 UI;数值型 SCDM 能力改目标值后执行,命令型能力在选中该行后由同一个按钮执行
|
||||
│ └── [~] 验收:scripts/verify_scdm_probe_pipeline.py 已验证 Face / Hole 参数 spec 转换、能力级启用开关和圆柱碎片组到本地 Face ID 的回填;scripts/verify_property_card_editor_ui.py 已覆盖 SCDM 状态摘要和选择诊断同步;真实 UI 长流程回归待继续
|
||||
│
|
||||
├── S5. 修改任务执行闭环
|
||||
│ ├── [x] 新建 step_editor/scdm_edit_runner.py,生成 scdm_edit_job.json 和临时 SCDM 修改脚本
|
||||
│ ├── [x] edit job 记录:模型路径、对象签名、capabilityKey、目标值、输出 STEP、超时、回滚文件
|
||||
│ ├── [~] 第一批执行:face.offset、孔径、孔位置已在本机 SCDM v222 + ICEPAK 样例真机验证;S7.2 的槽位置和 S7.3 的凸台位置已接入 `Move` 脚本、edit job、cache 映射和 UI gate,真实槽/凸台样例回测待补;命令型能力的统一按钮承载已补,填孔/删除小特征已补 `Fill.Execute` / `Delete.Execute` 脚本适配但仍待真实 STEP 验证后再开放
|
||||
│ ├── [~] SpaceClaim.exe /RunScript 已接入 UI 后台任务,成功后加载 result.step,重新触发 SCDM probe,并在 cache 刷新后再通知关系式/批量执行继续;校验通过后会写入撤销历史,撤销/重做会触发 SCDM cache 失效和后台刷新
|
||||
│ ├── [x] 成功写 result.step / result.json;失败写 error.json,包含 SCDM 命令、对象、参数和失败原因
|
||||
│ └── [~] 验收:scripts/verify_scdm_edit_runner.py 已覆盖 job/script/result/error、缺失输出、空 STEP、`Fill.Execute` / `Delete.Execute` 脚本路径和假 runner 闭环;真实 SCDM 已验证 `OffsetFaces.Execute`、`Move.Translate`、`DocumentSave.Execute`,UI 撤销历史和撤销/重做 cache 失效已接入,真实长流程回归待继续
|
||||
│
|
||||
├── S6. 结果校验、ID 续接和关系式刷新
|
||||
│ ├── [~] 用 OCCT 读取 result.step,校验 B-Rep 有效性、Solid 数量、目标参数、关键特征是否仍存在;当前 UI 已拒绝缺失/空输出 STEP,并在重新 probe 后调用 OCCT B-Rep 检查、目标值回测、未编辑对象漂移检查和 raw summary 全局规模漂移检查,圆角链/孔面数量等更专门几何质量规则待继续
|
||||
│ ├── [x] 修改前后保存 geometrySignature,用新 cache 做旧对象到新对象的唯一匹配
|
||||
│ ├── [~] 唯一匹配成功时更新 Face / Edge / 特征 ID,并同步更新关系式文本;SCDM 修改后的 cache 刷新阶段已接入关系式 ID 重写,歧义场景仍需更多真实样例回归
|
||||
│ ├── [x] 找不到或匹配多个时,返回 none / multiple 状态,供 UI 将关系式标记为失效并提示用户重新选择对象
|
||||
│ ├── [~] 对 SCDM 返回成功但几何变形的结果必须回滚;当前已能在 cache 层拦截其它已识别对象丢失、漂移、歧义匹配,以及 Face/Edge/Object 总量异常大幅变化,并由 UI 恢复编辑前快照;圆角/孔面数量等更专门质量规则待补
|
||||
│ └── [~] 验收:scripts/verify_scdm_result_validator.py 已覆盖输出文件、目标值、raw summary 全局规模漂移、未编辑对象漂移、ID 映射、关系式重写和歧义匹配;ICEPAK 真实 SCDM 已回读验证 face.offset、孔径和孔位置输出 STEP
|
||||
│
|
||||
├── S7. 能力扩展顺序
|
||||
│ ├── [~] 第二批:slot.position 已接入能力字典、参数表、`move_slot` 执行脚本和 fake runner 回归,真实槽 STEP 回测待补;slot.width、slot.depth 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补
|
||||
│ ├── [~] 第三批:boss.position 已接入能力字典、参数表、`move_boss` 执行脚本和 fake runner 回归,真实凸台 STEP 回测待补;boss.height、boss.diameter 仍只进入 planned_not_productized 诊断,真实 SCDM 命令和样例回测待补
|
||||
│ ├── [~] 第四批:round.radius、chamfer.distance、feature.delete_round_or_chamfer 已进入能力字典和 planned_not_productized 诊断;probe 已补 `RoundInfo` 摘要入口,真实 SCDM 命令和样例回测待补
|
||||
│ ├── [~] 第五批:pattern.spacing、pattern.instance_position、shell.thickness 已进入能力字典和 planned_not_productized 诊断;重复圆柱孔中心已能派生 linear_pattern 候选并写入 derived_feature_candidates,真实 SCDM 命令和样例回测待补
|
||||
│ ├── [x] 内部能力进度报告:`scdm_status.summarize_scdm_capability_progress` 会统计能力字典、执行器开放名单、当前 cache 已识别能力、后端命令阻止项、planned_not_productized、discovered_not_productized、geometry_candidate_hints、derived_feature_candidates,以及 SCDM probe 的对象/曲面/命令候选分布,供开发诊断和内部日志解释当前进度;客户 UI 只展示能识别/不能识别、能修改/不能修改
|
||||
│ └── [~] 每新增一个 capability,都必须同时补 probe 映射、edit runner、postCheck、UI 假 cache 测试和真实 STEP 样例;当前已补能力字典、probe 映射、Face 邻接、Edge raw 几何摘要、featureInventory、geometry_candidate_hints、假 cache 测试和能力进度报告,slot.position、boss.position 已补 edit runner 协议,其它 S7 能力的 edit runner 与真实 STEP 样例待补
|
||||
│
|
||||
└── S8. 打包与交付策略
|
||||
├── [ ] 不把商业 SCDM 打包进本软件,只检测客户本机安装和许可证
|
||||
├── [~] 打包产物带默认发现逻辑、自动路径选择和 SCDM 不可用说明;当前“软件进度”已改为紧凑按钮,点击后弹出 Markdown 风格能力清单,按“已能修改、已能识别、暂不能修改、暂不能稳定识别”组织;SCDM 找不到时再自动弹出 SpaceClaim.exe 路径选择
|
||||
├── [~] SCDM-only 能力在后端命令不可用时禁用并显示原因;当前已根据 availableCommands 阻止缺少 `OffsetFaces` / `Move` / `Fill` / `Delete` 等命令的能力,隐藏策略待打包态细化
|
||||
├── [x] 日志保留本次使用的后端:SCDM / OCCT / Analysis Situs,方便给客户解释结果来源;当前 SCDM probe/edit job、result.json、SCDM 操作历史、普通 OCCT 操作历史和左侧状态摘要都会记录后端来源,普通 OCCT 历史会额外写 execution=Qt worker/isolated subprocess,以及 recognition=internal StepModel 或 Analysis Situs + internal StepModel
|
||||
└── [ ] 验收:无 SCDM 机器可启动和查看 STEP;有 SCDM 机器可完成第一批 SCDM 修改闭环
|
||||
```
|
||||
|
||||
短期开发只做第一批能力,不追求把 SCDM 识别到的所有候选都立即开放。`scdm_raw_features.json` 保留 SCDM 发现的全部结构化候选,供后续扩展;`scdm_feature_cache.json` 只保存已经映射到能力字典、能执行、能校验、能解释失败原因的产品化能力。`geometry_candidate_hints` 只表示“有几何证据值得继续分类”,不能当作可编辑参数展示给客户。`软件进度` 弹窗只展示客户关心的能力边界:哪些能识别、哪些不能稳定识别、哪些能修改、哪些暂不能修改;cache 数量、probe 证据、Runner 开放数等后台细节留在日志和开发诊断里。这样既能利用 SCDM 的专业识别和直接建模能力,也不会把未验证的内部候选暴露给客户。
|
||||
|
||||
## Analysis Situs 接入状态
|
||||
|
||||
一句话状态:还没有全量接完;当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”这一步,能把 Analysis Situs 识别出的拆面圆柱孔组用于整孔高亮、参数表和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py` 的 `external_*` 关系证据;但还没有把它的完整 AAG/特征分析能力接成通用识别引擎。
|
||||
@@ -186,7 +306,7 @@ python scripts\verify_first_level_edit_suites.py
|
||||
python scripts\verify_first_level_edit_suites.py --quick
|
||||
```
|
||||
|
||||
`--quick` 会检查 smoke test、属性表规格、参数表 UI、参数导出组件生成、ICEPAK 真实 STEP 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。
|
||||
`--quick` 会检查 smoke test、属性表规格、参数表 UI、参数导出组件生成、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。
|
||||
|
||||
只验证某个阶段时,例如 Edge:
|
||||
|
||||
@@ -554,9 +674,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
|
||||
|
||||
当前已经实现:
|
||||
|
||||
- 默认加载 `assets/models/geom_extract.step`。
|
||||
- 启动后默认进入空场景,不自动读取 `assets/models/geom_extract.step`;用户点击“导入几何模型”后才加载模型,避免客户打开软件时被大 STEP 阻塞。
|
||||
- 支持打开其他 `.step` / `.stp` 文件。
|
||||
- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。
|
||||
- 打开 STEP 时会在 `LoadWorker` 后台线程里完成读取和首屏显示网格生成;失败时当前模型保持不变。大模型不会再把 STEP 读取、B-Rep 网格生成和 VTK polydata 构建直接压在 UI 主线程上,并且默认只先显示面片,几千条 Edge 边线会在切到 Edge 选择或显式请求时再后台生成。
|
||||
- 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名。
|
||||
- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。
|
||||
- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。
|
||||
@@ -570,8 +690,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
|
||||
- Edge
|
||||
- 特征
|
||||
- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。
|
||||
- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;真正执行编辑计划、扫描候选或历史定位时才做更深识别。
|
||||
- 普通 Face / 特征点选优先走轻量选择:只高亮命中的对象并显示基础参数,避免旋转、悬停、点选时触发同域面、端盖、底面等重计算;大模型会关闭鼠标悬停拾取高亮,只保留点击选择;真正执行编辑计划、扫描候选或历史定位时才做更深识别。
|
||||
- 大模型的 VTK 显示网格带有预算保护:即使编辑后请求较细的显示 deflection,也不会对上千个 Face 的 STEP 无限制生成百万级圆柱三角面;B-Rep 几何编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。只显示少量 Face 或隔离选中对象时仍可局部细化这些 Face,不会触发全模型超细重网格。
|
||||
- SCDM 识别 cache 的本地 Face 映射现在使用轻量几何签名,只读取曲面类型、平面法向/偏移、圆柱轴线/半径等必要信息;不再为每个 Face 调完整 `quick_face_info()`,且签名生成放在后台 worker 内执行,不会在启动 SCDM probe 前卡住 UI。在 `geom_extract.step` 上,这一步从约 83 秒降到约 0.06 秒。
|
||||
- 拉伸/切除后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回拉伸/切除前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。
|
||||
- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。
|
||||
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。
|
||||
@@ -789,7 +910,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
|
||||
- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。
|
||||
- 首次加载默认使用中等清晰度显示网格,避免长期停留在粗网格预览;B-Rep 编辑和结果校验仍使用真实拓扑,显示网格只负责渲染和拾取。
|
||||
- 旋转/平移/缩放相机时保持边线和静态画质一致;渲染优先使用较轻的 FXAA 抗锯齿,环境不支持时回退到 2x MSAA,减少复杂 STEP 查看时的帧率抖动。
|
||||
- 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。
|
||||
- 鼠标悬停高亮做了节流和移动阈值;大模型会直接关闭悬停拾取高亮,避免鼠标路过复杂面片时触发 VTK CellPicker 卡顿。
|
||||
- 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。
|
||||
- 可编辑对象和圆柱面候选扫描已经在 `StepModel` 层做参数化缓存;同一模型、同一扫描范围重复打开候选列表时会直接复用结果,编辑、撤销/重做或重新加载模型后缓存会随拓扑刷新清空。
|
||||
- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。
|
||||
@@ -884,6 +1005,8 @@ vertices: 3262
|
||||
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。
|
||||
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。
|
||||
- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算和一级验收文档一致性。
|
||||
- 2026-08-18,新增 `python scripts\verify_scdm_backend.py`、`python scripts\verify_scdm_status.py`、`python scripts\verify_scdm_probe_pipeline.py`、`python scripts\verify_scdm_edit_runner.py` 和 `python scripts\verify_scdm_result_validator.py`,覆盖 SCDM 后端发现、缓存、禁用开关、/RunScript 命令生成、左侧状态摘要、probe job/script 生成、raw->cache 映射、edit job/script/result/error 执行协议,以及结果文件检查、目标值回测、ID 映射和关系式重写;已接入 `--quick`。
|
||||
- 2026-08-18,本机 `D:\softwaresInstallDir\ANSYS Inc\v222\SCDM\SpaceClaim.exe` 已通过真实 `/RunScript` smoke;对 `assets/models/ICEPAK-NATURAL.stp` 的真实 probe 可打开模型并输出 13 个 Body、158 个 Face、396 条 Edge,396 条 Edge 均可回传长度、起终点和相邻 Face 数,其中 160 条圆弧/圆边可回传半径和圆心。当前 `StandardHoles.GetHoleFaces` 对该 STEP 返回 0 个标准孔面,因此已改为按同中心/同轴/同半径聚合圆柱碎片;真实 SCDM 已验证 face.offset、孔径和孔位置,输出 STEP 均可被 SCDM probe 回读。
|
||||
|
||||
Face 阶段的当前验收口径(R1 已收口):
|
||||
|
||||
@@ -1361,6 +1484,11 @@ pythonocc-step-editor/
|
||||
verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征
|
||||
verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数
|
||||
verify_parametric_component_export.py # 验证导出参数会生成嵌入参数列表的组件 main.py,组件目录不额外写 data.json
|
||||
verify_scdm_backend.py # 验证 SCDM 后端发现、缓存、禁用开关和 /RunScript 烟测命令生成
|
||||
verify_scdm_status.py # 验证 SCDM 左侧状态摘要、后端来源、识别缓存计数和 OCCT/Analysis Situs 兜底说明
|
||||
verify_scdm_probe_pipeline.py # 验证 SCDM probe job/script 生成和 raw 识别结果到产品化 cache 的映射
|
||||
verify_scdm_edit_runner.py # 验证 SCDM edit job/script/result/error 执行协议
|
||||
verify_scdm_result_validator.py # 验证 SCDM 结果文件检查、目标值回测、全局摘要漂移、ID 映射和关系式重写
|
||||
verify_asitus_hole_bridge.py # 验证 Analysis Situs 孔组识别结果会映射回 Python Face 编号和 UI 整孔选择
|
||||
verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证
|
||||
verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改
|
||||
@@ -1477,7 +1605,12 @@ git diff --check
|
||||
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
|
||||
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。
|
||||
- `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。
|
||||
- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、ICEPAK 真实 STEP 同域圆柱孔、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。
|
||||
- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、SCDM 后端发现底座、SCDM 状态摘要、SCDM probe/cache 假数据管线、SCDM edit job/runner 协议、SCDM result validator、ICEPAK 真实 STEP 同域圆柱孔、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。
|
||||
- `scripts/verify_scdm_backend.py`:纯 Python 检查 SCDM 后端发现和缓存逻辑,不依赖 OCC;覆盖环境变量、常见安装目录、`local/scdm_backend.json`、禁用开关和 `/RunScript` 命令生成。
|
||||
- `scripts/verify_scdm_status.py`:纯 Python 检查 SCDM 运行状态摘要,不依赖 OCC;覆盖未配置、已配置、后台识别中、识别成功、识别失败、cache 失效和缓存来源显示。
|
||||
- `scripts/verify_scdm_probe_pipeline.py`:纯 Python 检查 SCDM probe job、RunScript 脚本生成、raw 识别结果到产品化 cache 的映射;覆盖第一批 `hole.diameter`、`hole.position` 和 `face.offset` 能力。
|
||||
- `scripts/verify_scdm_edit_runner.py`:纯 Python 检查 SCDM edit job、临时修改脚本、成功 result.step/result.json、失败 error.json 和缺输出回滚判定;不依赖 OCC,也不需要真实 SCDM。
|
||||
- `scripts/verify_scdm_result_validator.py`:纯 Python 检查 SCDM 修改结果的输出 STEP 存在性、目标值回测、raw summary 全局规模漂移、旧对象到新对象的唯一匹配、Face ID 映射、关系式 ID 重写和歧义匹配拒绝。
|
||||
- `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。
|
||||
- `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。
|
||||
- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。
|
||||
@@ -1521,9 +1654,9 @@ git diff --check
|
||||
- `scripts/verify_asitus_hole_bridge.py`:验证 Analysis Situs `recognize_holes` 输出的 1-based AAG Face 组会先映射成 Python/Qt 里的 Face 编号,再作为整孔区域参与高亮、参数表和一级关系计划;本地 C++ CLI 不存在时只验证解析和映射逻辑。
|
||||
- `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。
|
||||
- `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。
|
||||
- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。
|
||||
- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划不会退回通用慢路径,避免按钮点击阶段长时间卡住主界面。
|
||||
- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示面内长度、面内宽度、中心和偏移,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 编辑;同时验证面积不会作为特征参数表驱动变量出现,目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的额外 `移动量` 行。
|
||||
- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表。
|
||||
- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表;同时守住普通 OCCT 操作历史里的 backend / execution / recognition 来源记录。
|
||||
- `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表;`关联探测` 本身保留为诊断信息,不再混入参数表。同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。
|
||||
- `scripts/verify_shell_edit_suite.py`:集中运行壳体厚度阶段验证,串起壳体厚度局部拉伸/切除、缩放特征、Face 一级事实图、结果校验和属性表分组。
|
||||
- `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。脚本还会生成开口薄壁盒,验证完整抽壳/开口面编辑会被列为受限能力,而局部壳体厚度仍保留可用计划。
|
||||
@@ -1639,6 +1772,15 @@ Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过
|
||||
- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。
|
||||
- 工作区可能有用户改动,不能随意 `git checkout --`、`git reset --hard` 或回滚不相关文件。
|
||||
|
||||
### 2026-08-18 geom_extract.step 性能记录
|
||||
|
||||
- `ICEPAK-NATURAL.stp` 速度快不是因为有原 CAD 建模历史;它仍是 STEP/B-Rep 结果几何。`geom_extract.step` 慢的主要原因是模型规模更大,约 1800 个 Face、近 5000 条 Edge,且 Face594 是带多个内孔的大平面端盖,一级边界和相邻面更多。
|
||||
- 大模型点选阶段必须保持轻量:当前 Face / Feature 选择只生成快速一级边界摘要,完整一级关系事实图放到编辑计划或后台任务中计算,避免点一个 Face 就同步深扫全模型。圆柱面 quick 选择只合并直接相接的同圆柱碎面,不触发 Analysis Situs 或内部孔组识别图;超过 1000 个 Face 的模型默认不在 UI 选择路径同步构建内部全模型识别图。即使用户把“特征探测级别”切到相邻/二级,大模型点击选择也会先按“只识别当前特征”处理,深层识别必须走显式扫描或后台任务。
|
||||
- Face594 `偏移 57.5 -> 70` 的基线:`face_info` 从约 10.5s 降到约 0.9s,`push_pull_plan` 从约 9.5s 降到约 1.5s,内存 `push_pull_face` 约 3.5s-5.5s;即使 SCDM cache 识别出 `face.offset`,这类大模型多内孔平面位置调整也会优先使用本软件已验证的本地 OCCT 一级边界重建,避免退回 100s 级别的外部后端/导出/启动/回读慢路径。2026-08-18 复测:Face594 核心 UI job 约 9.1s,其中快照/统计约 2.0s、几何计算约 3.7s、校验约 2.2s、面显示约 1.2s;独立后台几何进程单次约 12.6s,但旋转查看更不容易被 Python/OCCT worker 抢占。
|
||||
- 大模型编辑后的逻辑 Face ID 保持必须走结果消息快路径:如果子进程返回 `nearest_face` / `verified_face` 且该 Face 通过目标位置回测,直接把旧逻辑 ID 绑定到这个结果 Face;不要再同步扫描全模型寻找同平面候选。2026-08-18 发现 Face594 isolated UI job 曾有约 75s 花在结果 Face 映射兜底扫描,修复后同一路径总耗时约 11.8s,`result_face_mapping` 约 0.013s。
|
||||
- 大模型显示阶段:先后台读取主面片,默认跳过全量 Edge overlay;超过 1000 个 Face 或 2500 条 Edge 的模型首屏和编辑后显示会使用交互网格预算,`geom_extract.step` 主显示三角面从约 11 万降到约 4.7 万,旋转和点选优先流畅,局部几何校验仍使用 OCCT B-Rep 而不是显示网格。编辑完成后的操作历史定位优先读取结果消息中的 `verified_face` / `nearest_face`,fallback 只做轻量平面位置查询,不再在 UI 主线程逐个 Face 调完整 `face_info()`;Face594 定位从 120s 级超时降到 0s 级。普通导入大模型后会延后 SCDM/Analysis Situs 全模型预热,优先保证旋转和点选;SCDM 修改结果回读校验仍会强制刷新 cache。旋转相机时仍会临时隐藏已有边线 overlay,停止后恢复,保证画质不降的同时减少交互卡顿。
|
||||
- 大模型交互阶段不再把所有 UI 辅助工作绑到点选上:关系式补全只在公式输入框获得焦点或正在输入时刷新,避免每次点 Face 都重建数千个 Face/Edge 候选;左键按下会记录按下位置的对象,背景开始拖动/旋转后即使松手落在模型上也不会误选;旋转期间会临时隐藏选择高亮和拾取点覆盖层,松手后恢复,减少透明 overlay 对帧率的影响。
|
||||
|
||||
### 2026-08-03 工程师返还代码合并记录
|
||||
|
||||
- 已合并特征参数策略:Feature 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+1063
-56
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user