From a633b5a338435c3ddc1af2422979f8e8a68c1a8a Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Fri, 14 Aug 2026 18:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20STEP=20=E4=B8=80?= =?UTF-8?q?=E7=BA=A7=E5=8F=82=E6=95=B0=E5=8C=96=E7=BC=96=E8=BE=91=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E4=B8=8E=E5=85=B3=E7=B3=BB=E5=BC=8F=E5=BB=BA=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 71 +- scripts/build_asitus_probe.ps1 | 19 + scripts/verify_asitus_hole_bridge.py | 207 ++++ scripts/verify_face_ui_isolation_contract.py | 4 + scripts/verify_feature_recognition_summary.py | 145 +++ scripts/verify_first_level_edit_suites.py | 1 + ...ify_icepak_cylindrical_region_selection.py | 145 +++ scripts/verify_property_card_editor_ui.py | 55 +- scripts/verify_property_editor_specs.py | 22 +- step_editor/app.py | 103 +- step_editor/asitus_bridge.py | 308 +++++ step_editor/features.py | 96 +- step_editor/isolated_edit_worker.py | 15 +- step_editor/model.py | 634 +++++++++- step_editor/operations.py | 163 +++ step_editor/parametric_component.py | 39 + step_editor/recognition_graph.py | 539 +++++++++ step_editor/recognition_priority.py | 78 +- step_editor/relation_formulas.py | 244 ++++ step_editor/ui_helpers.py | 15 + step_editor/window_actions.py | 215 +++- step_editor/window_core.py | 201 +++- step_editor/window_state.py | 1047 ++++++++++++++++- tools/asitus_probe/CMakeLists.txt | 47 + tools/asitus_probe/recognize_holes.cpp | 339 ++++++ 26 files changed, 4676 insertions(+), 77 deletions(-) create mode 100644 scripts/build_asitus_probe.ps1 create mode 100644 scripts/verify_asitus_hole_bridge.py create mode 100644 step_editor/asitus_bridge.py create mode 100644 step_editor/recognition_graph.py create mode 100644 step_editor/relation_formulas.py create mode 100644 tools/asitus_probe/CMakeLists.txt create mode 100644 tools/asitus_probe/recognize_holes.cpp diff --git a/.gitignore b/.gitignore index 5f54434..6786ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ tmp.md data.json nodes/ Analysis-Component/ +occt_feature_editor/ third_party/ # Local reference docs; keep them on disk, never commit them. diff --git a/README.md b/README.md index a5d3976..56359af 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ python-occt │ ├── features.py │ │ └── 特征识别和编辑计划,比如孔、槽、凸台、圆角、壳体、解析曲面 │ │ +│ ├── recognition_graph.py +│ │ └── 内部 Face 几何图和通孔拆面识别缓存,记录平面/圆柱、邻接、共面、平行、垂直、同轴等基础关系 +│ │ +│ ├── asitus_bridge.py +│ │ └── Analysis Situs 孔组识别 CLI 桥接,把外部识别到的孔 Face 组映射回 Python/Qt Face 编号 +│ │ │ ├── operations.py │ │ └── 真正的几何编辑实现,比如拉伸/切除、孔径、孔深、边长、圆角、倒角 │ │ @@ -75,6 +81,44 @@ python-occt └── 仓库自带测试 STEP 模型 ``` +## Analysis Situs 接入状态 + +一句话状态:还没有全量接完;当前已经完成“孔组识别桥接 + AAG 轻量关系摘要 + 几何关系摘要”这一步,能把 Analysis Situs 识别出的拆面圆柱孔组用于整孔高亮、参数表和一级关系计划,也能把外部 AAG 的 Face、邻接、角度类型、共面、同轴、平行、垂直和相切摘要缓存到 `StepModel`,并作为 `recognition_graph.py` 的 `external_*` 关系证据;但还没有把它的完整 AAG/特征分析能力接成通用识别引擎。 + +当前已完成: + +- `[x]` 本地 CLI 桥接:`step_editor/asitus_bridge.py` 会优先查找 `third_party/asitus_probe_tools_build/*/recognize_holes.exe`,再兜底查找旧的 `third_party/asitus_probe_build/*/recognize_holes.exe`,也支持通过 `STEP_EDITOR_ASITUS_RECOGNIZE_HOLES` 指定路径;找不到时不会影响主程序,会退回内部识别。 +- `[x]` 可提交 probe 源码:`tools/asitus_probe/` 是当前 Analysis Situs probe 的受控源码,构建输出仍放在被忽略的 `third_party/asitus_probe_tools_build/`。 +- `[x]` JSON 解析:支持读取 Analysis Situs 输出的 `holes[].faceIds` / `holeFaceIds`,并去重成孔组。 +- `[x]` Face 编号映射:Analysis Situs AAG Face 通常是 1-based,程序会映射回 Python/Qt 里使用的 0-based Face 编号。 +- `[x]` UI/模型使用:加载 STEP 后会后台预识别孔组;识别完成后,点击半片圆柱 Face 可以按完整孔组高亮和生成参数。 +- `[x]` 缓存与回退:`StepModel` 会缓存外部孔组结果;如果外部 CLI 不存在或失败,会使用 `recognition_graph.py` 的内部 Face 几何图做通孔拆面识别兜底。 +- `[x]` AAG 轻量关系摘要:新 probe 会输出 `faces`、`adjacency`、`surfaceSummary` 和 `angleSummary`;Python 会缓存每个 Face 的外部邻接 Face、曲面类型和邻接角度类型,作为识别证据和后续关系增强入口。 +- `[x]` 几何关系摘要:新 probe 会输出 `geometricRelations`、`geometricRelationSummary` 和 `geometricRelationMode`,当前覆盖 `coplanar`、`parallel`、`perpendicular`、`coaxial`、`parallel_axis` 和基于 AAG smooth 角度的 `tangent`;Python 会映射回本软件 Face 编号,并在 `recognition_graph.py` 中作为 `external_*` 关系参与统计、拆分圆柱合组和通孔置信度加权。为避免大 STEP 生成巨大 JSON,超过当前阈值时会从全量 pair 扫描降级为只保留 AAG smooth/tangent 关系。 +- `[x]` 候选置信度和排序增强:`recognition_priority.py` 会按 `coaxial/tangent/coplanar/parallel/perpendicular/parallel_axis` 给外部关系加权;候选扫描会把 Analysis Situs 关系摘要带到候选行里,同一类特征内部优先显示外部关系证据更强的候选。 +- `[x]` 槽 / 凸台 / 圆角辅助识别:`StepModel` 会把 AAG 角度和几何关系汇总成 `analysis_situs_feature_hint_*`,用于提示外部关系更支持槽、凸台还是圆角;这只影响识别摘要、候选置信度和排序,不直接开放新的编辑执行路径。 +- `[x]` 回归守门:`scripts/verify_asitus_hole_bridge.py` 已加入 `--quick`,验证孔组 JSON 解析、Face 编号映射、异步安装结果和整孔选择。 + +本地重新编译 probe: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\build_asitus_probe.ps1 +``` + +当前未完成: + +- `[ ]` 还没有把 Analysis Situs 的完整 AAG 关系图作为主识别数据源;现在已经消费孔组、AAG 邻接/角度摘要和基础几何关系摘要,并用它们增强孔、槽、凸台、圆角候选的置信度和排序,但编辑计划的主判断仍以本项目现有 `StepModel` / `recognition_graph.py` 为准。 +- `[ ]` 还没有用 Analysis Situs 直接识别倒角、壳体、阵列孔或装配约束;槽、凸台和圆角当前只是辅助识别提示,不等同于外部特征引擎直接给出的可编辑特征。 +- `[ ]` 还没有让 Analysis Situs 的共面、同轴、平行、垂直、相切关系直接驱动一级/二级编辑计划;目前它们只作为外部证据增强识别图和置信度,真正能不能改仍由现有 `StepModel`、`recognition_graph.py`、编辑计划和结果守门共同决定。 +- `[ ]` 还没有把 Analysis Situs CLI 编译产物纳入打包流程;本地没有 `recognize_holes.exe` 时,程序只走内部识别,不会报错退出。 +- `[ ]` 还没有做 pybind11 直连;当前是 Python 调外部 CLI,优点是隔离稳定,缺点是启动和 JSON 交换有额外开销。 + +下一步接入顺序: + +1. 把 `tools/asitus_probe` 的构建步骤写入 Windows 打包流程,让交付包能自带可用的 `recognize_holes.exe` 或明确跳过外部识别。 +2. 继续扩展倒角、壳体、阵列孔等候选的辅助识别提示,仍然只参与“识别和排序”,不直接绕过一级编辑守门。 +3. 最后再评估是否需要 pybind11 直连;在稳定性没证明之前,CLI 隔离比直接把 C++ 库塞进主进程更安全。 + ## 怎么运行 你第一次配置或运行这个项目时,可以按下面步骤来。命令默认在 Windows PowerShell 里执行。 @@ -284,8 +328,12 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 孔壁消失,孔口补面有效,结果保持单 Solid。 │ ├── [x] [锥孔/沉孔 -> 局部重切或提前阻止] │ │ └── 简单圆锥解析重建,嵌入式锥孔/沉孔优先局部重切;复杂浅锥、螺纹孔和组合孔提前解释为受限能力。 -│ └── [x] [孔编辑范围 -> 单个稳定孔特征] -│ └── 当前只承诺单个孔/锥孔/沉孔的一级关系修改;孔组、阵列和同尺寸孔联动放到第 8 阶段。 +│ ├── [x] [孔编辑范围 -> 单个稳定孔特征] +│ │ └── 单孔、锥孔、沉孔的一级关系修改已经作为孔阶段基线。 +│ ├── [~] [Ctrl 多选完整孔 -> 批量孔径 / 位置偏移] +│ │ └── 第一版支持多个完整圆柱孔统一改孔径或按同一偏移移动;等距、阵列、同尺寸联动关系式仍放到第 8 阶段。 +│ └── [~] [关系式 -> FaceID.参数 = 表达式] +│ └── 第一版先把关系式作为“目标值生成器”:用户输入 `Face87.直径 = Face85.直径` 或 `Face87.位置 = Face85.位置 + (0, 0, -3.5)`,程序先计算并回填当前参数表目标值,再复用现有一级编辑执行;跨对象批量队列、等距/阵列/同尺寸联动和持久约束求解后续再扩展。 │ ├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征 │ ├── [x] [槽宽 -> 改槽宽] @@ -398,6 +446,19 @@ STEP/B-Rep 参数化编辑主线 └── 当二级/三级关系互相冲突时,列出冲突原因,允许只执行一级或扩展传播,不强行硬改。 ``` +## 关系式实现流程 + +当前关系式先按“可见公式 + 目标值生成器”推进,不把第一版伪装成完整 CAD 约束求解器。 + +1. `[~]` 公式输入与显示:左侧 `特征参数` 下方增加 `关系式` 区域,只提供 `添加公式` 和已有公式列表。公式面向用户显示为 `Face87.直径 = Face85.直径`、`Face87.位置 = Face85.位置 + (0, 0, -3.5)` 这类通用 `对象ID.参数` 形式,不使用“孔1/孔2”这类临时命名。 +2. `[~]` 公式补全:输入 `Face87.` 后,按当前选中对象、当前多选对象和可见参数表提供参数候选;第一版优先覆盖 `直径`、`半径`、`位置`、`位置X/Y/Z`、`偏移`、`面内长度`、`面内宽度`、`长度` 等已经能稳定显示或计算的参数。 +3. `[~]` 公式计算:点击 `参数化建模` 时,先计算启用的关系式,把结果回填到当前可见参数表目标值;随后继续走现有单参数或多参数批量建模执行链路。第一版不直接跨多个未显示对象开新编辑任务。 +4. `[~]` 简单 Face ID 追踪:公式保存时记录引用 Face 的逻辑 ID 和轻量几何签名;编辑成功后,如果旧 `Face87` 在新模型中能唯一映射到新 `Face90`,就自动把公式显示更新为 `Face90.参数`。如果找不到、找到多个、Face 被拆分/合并或参数不再存在,则把公式标为失效,提示用户重新选择。 +5. `[ ]` 跨对象批量执行:后续把多个公式目标拆成执行队列,例如先算 `Face87.直径`、`Face92.直径`,再按对象逐个切换选择并执行已有建模动作;失败时停在明确对象和原因。 +6. `[ ]` 循环依赖和冲突处理:后续检查 `A = B`、`B = A`、同一目标被多个公式覆盖、同一参数既手工改又被公式驱动等冲突,给出可读提示。 +7. `[ ]` 持久化:后续把公式、对象签名、目标参数、建模意图和失效状态保存到项目配置或组件配置里,重新打开模型后能尝试恢复。 +8. `[ ]` 复杂拓扑追踪:后续再处理 `原 Face -> 多个新 Face`、`多个原 Face -> 一个新 Face`、特征删除、阵列孔重编号、装配 occurrence 等情况。 + ## 几何修改语义原则 所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 @@ -1248,6 +1309,8 @@ pythonocc-step-editor/ main.py # 程序入口,保持 python main.py 启动 data.json # 导出参数生成的本地流程参数文件,默认不提交 nodes/ # 导出参数生成的本地组件目录,组件参数会嵌入 main.py,默认不提交 + tools/ + asitus_probe/ # 可提交的 Analysis Situs probe 源码,构建输出放到 third_party/asitus_probe_tools_build assets/ models/ geom_extract.step # 当前默认测试模型 @@ -1298,6 +1361,7 @@ 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_asitus_hole_bridge.py # 验证 Analysis Situs 孔组识别结果会映射回 Python Face 编号和 UI 整孔选择 verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 @@ -1453,7 +1517,8 @@ git diff --check - `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖拉伸/切除并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`缩放特征` 的轴向缩放会按轴向跨度校验结果。 - `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面浅范围向内收缩也走 `boundary-shell-rebuild`,深向内切除会快速阻止且说明材料厚度或二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 - `scripts/verify_hole_slot_edit_suite.py`:集中运行当前孔/槽阶段验证,串起孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker 执行、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。后续孔/槽能力新增或重构时优先跑这个套件,而不是只跑单个孔径或槽宽脚本。 -- `scripts/verify_icepak_cylindrical_region_selection.py`:加载本地 `assets/models/ICEPAK-NATURAL.stp` 时,验证 Face87/94 这类被 STEP 拆成两片的同域圆柱孔会按整孔高亮、按整孔生成孔径计划,并能通过隔离 worker 完成 `0.5 -> 0.3` 的孔径缩小;模型文件不存在时自动跳过。 +- `scripts/verify_icepak_cylindrical_region_selection.py`:加载本地 `assets/models/ICEPAK-NATURAL.stp` 时,验证 Face87/94 这类被 STEP 拆成两片的同域圆柱孔会按整孔高亮、按整孔生成孔径计划,Face85/Face87 可以进入 Ctrl 多选孔参数面板,并能通过隔离 worker 完成 `0.5 -> 0.3` 的孔径缩小;模型文件不存在时自动跳过。 +- `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 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 diff --git a/scripts/build_asitus_probe.ps1 b/scripts/build_asitus_probe.ps1 new file mode 100644 index 0000000..6308c81 --- /dev/null +++ b/scripts/build_asitus_probe.ps1 @@ -0,0 +1,19 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$sourceDir = Join-Path $repoRoot "tools\asitus_probe" +$buildDir = Join-Path $repoRoot "third_party\asitus_probe_tools_build" + +cmake -S $sourceDir -B $buildDir -A x64 +cmake --build $buildDir --config $Configuration + +$exePath = Join-Path $buildDir "$Configuration\recognize_holes.exe" +if (-not (Test-Path $exePath)) { + throw "Analysis Situs probe build finished but recognize_holes.exe was not found: $exePath" +} + +Write-Host "Analysis Situs probe built: $exePath" diff --git a/scripts/verify_asitus_hole_bridge.py b/scripts/verify_asitus_hole_bridge.py new file mode 100644 index 0000000..dc09ed8 --- /dev/null +++ b/scripts/verify_asitus_hole_bridge.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from pathlib import Path +import sys + + +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.asitus_bridge import ( + default_asitus_recognize_holes_path, + parse_asitus_hole_groups, + parse_asitus_probe_payload, + run_asitus_hole_recognition, +) +from step_editor.model import StepModel +from step_editor.recognition_graph import recognition_summary + + +MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp" + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def _install_external_regions(model: StepModel, groups: list[tuple[int, ...]]) -> None: + model._asitus_hole_regions_attempted = True # noqa: SLF001 + model._asitus_hole_region_cache.clear() # noqa: SLF001 + for group in groups: + for face_id in group: + model._asitus_hole_region_cache[int(face_id)] = list(group) # noqa: SLF001 + model._same_domain_face_ids_cache.clear() # noqa: SLF001 + + +def main() -> int: + payload = { + "validBreP": True, + "faceCount": 158, + "aagNodeCount": 158, + "holeFaceIds": [85, 86, 87, 88, 95, 96, 97, 98], + "holeCount": 4, + "holes": [ + {"index": 1, "faceIds": [85, 98]}, + {"index": 2, "faceIds": [86, 97]}, + {"index": 3, "faceIds": [87, 96]}, + {"index": 4, "faceIds": [88, 95]}, + ], + "surfaceSummary": {"plane": 106, "cylinder": 52}, + "angleSummary": {"smooth": 4, "convex": 8}, + "geometricRelationMode": "all-pairs", + "geometricRelationSummary": { + "coaxial": 1, + "coplanar": 1, + "parallel": 1, + "parallel_axis": 1, + "perpendicular": 1, + "tangent": 1, + }, + "faces": [ + {"id": 88, "surface": "cylinder", "neighbors": [12, 95]}, + {"id": 95, "surface": "cylinder", "neighbors": [88, 42]}, + ], + "adjacency": [ + {"faceIds": [88, 95], "angleType": "smooth", "angleRad": 0.0, "edgeIds": [501, 502]}, + ], + "geometricRelations": [ + {"faceIds": [88, 95], "type": "coaxial", "residual": 0.0, "source": "analysis-situs-probe"}, + {"faceIds": [88, 95], "type": "tangent", "residual": 0.0, "source": "analysis-situs-aag-angle"}, + {"faceIds": [88, 95], "type": "coplanar", "residual": 0.0, "source": "analysis-situs-probe"}, + {"faceIds": [88, 95], "type": "parallel", "residual": 2.0, "source": "analysis-situs-probe"}, + {"faceIds": [88, 95], "type": "perpendicular", "residual": 0.0, "source": "analysis-situs-probe"}, + {"faceIds": [88, 95], "type": "parallel_axis", "residual": 0.0, "source": "analysis-situs-probe"}, + ], + } + raw_groups = parse_asitus_hole_groups(payload) + _assert(raw_groups == [(85, 98), (86, 97), (87, 96), (88, 95)], f"unexpected parsed groups: {raw_groups}") + parsed = parse_asitus_probe_payload(payload) + _assert(parsed.get("surface_summary") == {"plane": 106, "cylinder": 52}, f"bad surface summary: {parsed}") + _assert(parsed.get("geometric_relation_mode") == "all-pairs", f"bad geometric relation mode: {parsed}") + expected_geometric_summary = { + "coaxial": 1, + "coplanar": 1, + "parallel": 1, + "parallel_axis": 1, + "perpendicular": 1, + "tangent": 1, + } + _assert( + parsed.get("geometric_relation_summary") == expected_geometric_summary, + f"bad geometric summary: {parsed}", + ) + _assert(len(tuple(parsed.get("faces", ()))) == 2, f"bad face summary parse: {parsed}") + _assert(len(tuple(parsed.get("adjacency", ()))) == 1, f"bad adjacency parse: {parsed}") + _assert(len(tuple(parsed.get("geometric_relations", ()))) == 6, f"bad geometric relation parse: {parsed}") + + if not MODEL_PATH.exists(): + print("asitus hole bridge mapping skipped: local ICEPAK-NATURAL.stp is not present") + return 0 + + model = StepModel.load(MODEL_PATH) + mapped = model._map_asitus_hole_groups(raw_groups) # noqa: SLF001 + expected = [(84, 97), (85, 96), (86, 95), (87, 94)] + _assert(mapped == expected, f"Analysis Situs 1-based AAG face ids should map to Python face ids: {mapped}") + + async_model = StepModel.load(MODEL_PATH) + _assert(async_model.begin_asitus_hole_region_load() is True, "background preload should be accepted once") + _assert( + async_model._asitus_hole_region_ids(87) == [], # noqa: SLF001 + "selection should not synchronously run Analysis Situs while background preload is pending", + ) + async_mapped = async_model.install_asitus_hole_recognition_result({"ok": True, "reason": "ok", "groups": raw_groups}) + _assert(async_mapped == expected, f"background result should install mapped groups: {async_mapped}") + _assert(async_model.face_region_ids(87) == [87, 94], "installed background result should drive whole-hole selection") + + relation_model = StepModel.load(MODEL_PATH) + relation_result = { + "ok": True, + "reason": "ok", + "groups": raw_groups, + "faces": parsed.get("faces", ()), + "adjacency": parsed.get("adjacency", ()), + "geometric_relations": parsed.get("geometric_relations", ()), + "surface_summary": parsed.get("surface_summary", {}), + "angle_summary": parsed.get("angle_summary", {}), + "geometric_relation_summary": parsed.get("geometric_relation_summary", {}), + } + relation_mapped = relation_model.install_asitus_hole_recognition_result(relation_result) + _assert(relation_mapped == expected, f"relation install should preserve hole mapping: {relation_mapped}") + relation_info = relation_model.quick_face_info(87) + _assert(relation_info.get("asitus_relation_status") == "ready", f"missing AAG relation fields: {relation_info}") + _assert( + relation_info.get("asitus_adjacent_face_ids") == (11, 94), + f"Analysis Situs 1-based neighbors should map to Python face ids: {relation_info}", + ) + _assert( + "smooth:1" in str(relation_info.get("asitus_adjacent_relation_summary") or ""), + f"missing AAG angle relation summary: {relation_info}", + ) + _assert( + "coaxial:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""), + f"missing AAG geometric relation summary: {relation_info}", + ) + _assert( + "tangent:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""), + f"missing AAG tangent relation summary: {relation_info}", + ) + graph_summary = recognition_summary(relation_model) + relation_counts = dict(graph_summary.get("relation_counts", {}) or {}) + _assert( + relation_counts.get("external_coaxial", 0) >= 1, + f"recognition graph should include external coaxial evidence: {graph_summary}", + ) + for relation_type in ( + "external_coplanar", + "external_parallel", + "external_parallel_axis", + "external_perpendicular", + "external_tangent", + ): + _assert( + relation_counts.get(relation_type, 0) >= 1, + f"recognition graph should include {relation_type} evidence: {graph_summary}", + ) + _assert( + int(relation_info.get("recognition_external_relation_score_bonus") or 0) > 0, + f"feature summary should expose an external relation confidence bonus: {relation_info}", + ) + candidates = relation_model.editable_feature_candidates(limit=160, detailed=False, max_scan_faces=120) + related_candidates = [ + item + for item in candidates + if int(item.get("face_id", -1) or -1) == 87 + or int(item.get("target_id", -1) or -1) == 87 + ] + _assert(related_candidates, f"editable scan should include the Analysis Situs-backed Face 87 candidate: {candidates}") + _assert( + any(int(item.get("recognition_external_relation_score_bonus") or 0) > 0 for item in related_candidates), + f"editable candidates should carry external relation sorting support: {related_candidates}", + ) + _assert( + any("coaxial" in str(item.get("external_recognition_relation_summary") or "") for item in related_candidates), + f"editable candidates should expose external relation summary: {related_candidates}", + ) + + _install_external_regions(model, mapped) + _assert(model.face_region_ids(87) == [87, 94], "Face 87 should select the complete external hole group") + _assert(model.face_region_ids(94) == [87, 94], "Face 94 should select the complete external hole group") + info = model.quick_face_info(87) + _assert(list(info.get("feature_highlight_face_ids") or []) == [87, 94], "quick UI highlight should use external hole group") + + cli = default_asitus_recognize_holes_path(PROJECT_ROOT) + if cli is not None: + result = run_asitus_hole_recognition(MODEL_PATH, project_root=PROJECT_ROOT, timeout_seconds=6.0) + _assert(result.get("ok") is True, f"local Analysis Situs CLI should recognize ICEPAK holes: {result}") + external_mapped = model._map_asitus_hole_groups(result.get("groups", ())) # noqa: SLF001 + for group in expected: + _assert(group in external_mapped, f"local Analysis Situs CLI mapping missed {group}: {external_mapped}") + + print("asitus hole bridge ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_face_ui_isolation_contract.py b/scripts/verify_face_ui_isolation_contract.py index e56a768..57ec937 100644 --- a/scripts/verify_face_ui_isolation_contract.py +++ b/scripts/verify_face_ui_isolation_contract.py @@ -39,6 +39,10 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = { EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS = { "resize_cylindrical_hole", + "edit_cylindrical_holes_by_refs", + "resize_cylindrical_holes_by_refs", + "move_cylindrical_holes_by_offset", + "suppress_cylindrical_holes_by_refs", "resize_cylindrical_owning_scale", "move_cylindrical_hole_axis", "suppress_cylindrical_hole", diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index 1d80d3f..0a076fa 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -22,6 +22,7 @@ from verify_boss_resize import _first_boss_face, _write_boss_model # noqa: E402 from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402 from verify_edge_round_chamfer import ( # noqa: E402 _first_existing_fillet_face, + _write_filleted_box_model, _write_mixed_radius_filleted_box_model, ) from verify_shell_thickness_resize import _first_open_shell_wall_face, _write_open_thin_wall_box_model # noqa: E402 @@ -64,6 +65,66 @@ def _first_surface_face(model: StepModel, surface: str) -> int: raise AssertionError(f"no {surface} Face was found") +def _first_adjacent_face(model: StepModel, face_id: int) -> int: + edge_ids = model._face_boundary_edge_ids(face_id) # noqa: SLF001 + adjacent_ids = sorted(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001 + for adjacent_id in adjacent_ids: + if 0 <= int(adjacent_id) < len(model.faces): + return int(adjacent_id) + raise AssertionError(f"Face {face_id} has no adjacent Face") + + +def _install_synthetic_asitus_support( + model: StepModel, + face_id: int, + *, + relation_type: str, + angle_type: str, +) -> int: + adjacent_id = _first_adjacent_face(model, face_id) + face_info = model.quick_face_info(face_id) + adjacent_info = model.quick_face_info(adjacent_id) + result = { + "ok": True, + "reason": "ok", + "groups": (), + "faces": ( + { + "id": face_id + 1, + "surface": str(face_info.get("surface") or ""), + "neighbor_ids": (adjacent_id + 1,), + }, + { + "id": adjacent_id + 1, + "surface": str(adjacent_info.get("surface") or ""), + "neighbor_ids": (face_id + 1,), + }, + ), + "adjacency": ( + { + "face_ids": (face_id + 1, adjacent_id + 1), + "angle_type": angle_type, + "angle_rad": 0.0, + "edge_ids": (), + }, + ), + "geometric_relations": ( + { + "face_ids": (face_id + 1, adjacent_id + 1), + "relation_type": relation_type, + "residual": 0.0, + "source": "synthetic-analysis-situs-test", + }, + ), + "surface_summary": {}, + "angle_summary": {angle_type: 1}, + "geometric_relation_summary": {relation_type: 1}, + "geometric_relation_mode": "synthetic-test", + } + model.install_asitus_hole_recognition_result(result) + return adjacent_id + + def _first_quick_candidate( model: StepModel, *, @@ -245,6 +306,79 @@ def _verify_boss_summary(root: Path) -> None: _assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}") +def _assert_asitus_hint( + info: dict[str, object], + *, + preferred: str, + label: str, +) -> None: + _assert( + info.get("analysis_situs_feature_hint_preferred") == preferred, + f"{label}: Analysis Situs hint should prefer {preferred}: {info}", + ) + _assert( + int(info.get("analysis_situs_feature_hint_score") or 0) > 0, + f"{label}: Analysis Situs hint score is missing: {info}", + ) + _assert( + "analysis_situs_feature_hint" in set(info.get("recognition_evidence_keys") or ()), + f"{label}: recognition evidence should mention Analysis Situs feature hint: {info}", + ) + + +def _verify_asitus_slot_boss_fillet_hints(root: Path) -> None: + slot_path = root / "asitus_slot_hint.step" + _write_half_round_slot_model(slot_path) + slot_model = StepModel.load(slot_path) + slot_face_id = _first_slot_face(slot_model) + _install_synthetic_asitus_support(slot_model, slot_face_id, relation_type="tangent", angle_type="smooth") + slot_info = slot_model.feature_info(slot_face_id) + _assert_asitus_hint(slot_info, preferred="slot", label="slot hint") + slot_candidates = [ + item + for item in slot_model.editable_feature_candidates(limit=80, detailed=False) + if int(item.get("face_id", -1) or -1) == slot_face_id + ] + _assert( + any(int(item.get("analysis_situs_slot_hint_score") or 0) > 0 for item in slot_candidates), + f"slot candidates should carry Analysis Situs slot support: {slot_candidates}", + ) + + boss_path = root / "asitus_boss_hint.step" + _write_boss_model(boss_path) + boss_model = StepModel.load(boss_path) + boss_face_id = _first_boss_face(boss_model) + _install_synthetic_asitus_support(boss_model, boss_face_id, relation_type="parallel", angle_type="convex") + boss_info = boss_model.feature_info(boss_face_id) + _assert_asitus_hint(boss_info, preferred="boss", label="boss hint") + boss_candidates = [ + item + for item in boss_model.editable_feature_candidates(limit=80, detailed=False) + if int(item.get("face_id", -1) or -1) == boss_face_id + ] + _assert( + any(int(item.get("analysis_situs_boss_hint_score") or 0) > 0 for item in boss_candidates), + f"boss candidates should carry Analysis Situs boss support: {boss_candidates}", + ) + + fillet_path = root / "asitus_fillet_hint.step" + _write_filleted_box_model(fillet_path, 1.0) + fillet_model = StepModel.load(fillet_path) + fillet_face_id = _first_existing_fillet_face(fillet_model, 1.0, 2e-4) + _install_synthetic_asitus_support(fillet_model, fillet_face_id, relation_type="tangent", angle_type="smooth") + fillet_info = fillet_model.feature_info(fillet_face_id) + _assert_asitus_hint(fillet_info, preferred="fillet", label="fillet hint") + fillet_candidates = [ + item + for item in fillet_model.editable_feature_candidates(limit=80, detailed=False) + if int(item.get("face_id", -1) or -1) == fillet_face_id + ] + _assert( + any(int(item.get("analysis_situs_fillet_hint_score") or 0) > 0 for item in fillet_candidates), + f"fillet candidates should carry Analysis Situs fillet support: {fillet_candidates}", + ) + + def _verify_torus_summary(root: Path) -> None: path = root / "torus.step" _write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path) @@ -416,6 +550,16 @@ def main() -> int: "recognition_user_priority_label", "recognition_user_priority_reason", "recognition_evidence", + "recognition_external_relation_score_bonus", + "analysis_situs_feature_hint_status", + "analysis_situs_feature_hint_preferred", + "analysis_situs_feature_hint_label", + "analysis_situs_feature_hint_score", + "analysis_situs_feature_hint_summary", + "analysis_situs_feature_hint_related_face_ids", + "analysis_situs_slot_hint_score", + "analysis_situs_boss_hint_score", + "analysis_situs_fillet_hint_score", "recognition_ready_actions", "recognition_limited_actions", "recognition_blockers", @@ -431,6 +575,7 @@ def main() -> int: _verify_hole_summary(root) _verify_slot_summary(root) _verify_boss_summary(root) + _verify_asitus_slot_boss_fillet_hints(root) _verify_torus_summary(root) _verify_user_priority_scan_order(root) _verify_candidate_scan_cache(root) diff --git a/scripts/verify_first_level_edit_suites.py b/scripts/verify_first_level_edit_suites.py index 01d0357..7b57b96 100644 --- a/scripts/verify_first_level_edit_suites.py +++ b/scripts/verify_first_level_edit_suites.py @@ -71,6 +71,7 @@ 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",)), + ("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",)), ("First-level fact graph", ("verify_first_level_fact_graph.py",)), diff --git a/scripts/verify_icepak_cylindrical_region_selection.py b/scripts/verify_icepak_cylindrical_region_selection.py index 49ab81d..dd724e3 100644 --- a/scripts/verify_icepak_cylindrical_region_selection.py +++ b/scripts/verify_icepak_cylindrical_region_selection.py @@ -32,6 +32,10 @@ class _SelectionHarness(WindowCoreMixin, WindowStateMixin): self.selected_solid_id = None self.manual_bottom_face_id = None self.manual_slot_pair_face_id = None + self.multi_selected_feature_face_ids = [] + self.multi_selected_hole_entries = [] + self.multi_selection_active = False + self.current_info_values = {} def _current_feature_detection_level(self) -> str: return str(self.feature_detection_level) @@ -76,6 +80,38 @@ def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root: return str(response.get("message") or "") +def _isolated_hole_axis_move( + model: StepModel, + face_id: int, + target_center: tuple[float, float, float], + root: Path, +) -> str: + input_path = root / "face85_axis_input.brep" + output_path = root / "face85_axis_output.brep" + request_path = root / "face85_axis_request.json" + model.export_internal_brep(input_path) + request_path.write_text( + json.dumps( + { + "input_path": str(input_path), + "output_path": str(output_path), + "input_format": "brep", + "output_format": "brep", + "operation": "move_cylindrical_hole_axis", + "args": [face_id, list(target_center)], + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + code = run_request(request_path) + response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8")) + _assert(code == 0 and response.get("ok") is True, f"isolated Face {face_id} axis move should pass: {response}") + _assert(output_path.exists(), f"isolated Face {face_id} axis move should write output BREP") + return str(response.get("message") or "") + + def _edge_ids_from_polydata(polydata) -> set[int]: edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None if edge_arr is None: @@ -137,6 +173,98 @@ def _assert_rectangular_face_parameters(model: StepModel, harness: _SelectionHar _assert(not missing, f"ICEPAK Face 9 feature parameters should expose length, width and offset: {missing}") +def _assert_face85_hole_axis_move(model: StepModel, harness: _SelectionHarness) -> tuple[float, float, float]: + face_id = 85 + info = model.quick_face_info(face_id) + _assert(info.get("surface") == "cylinder", "ICEPAK Face 85 should be cylindrical") + _assert( + list(info.get("feature_highlight_face_ids") or []) == [85, 96], + f"ICEPAK Face 85 should highlight the complete split hole region: {info.get('feature_highlight_face_ids')}", + ) + + harness.select_feature_face(face_id) + feature = model.feature_info(face_id) + specs = harness._property_editor_specs(feature, feature) + editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))} + _assert("hole_axis_center" in editable_keys, "ICEPAK Face 85 should expose the hole axis-center move parameter") + + current_center = info.get("axis_center") or feature.get("axis_center") + if not (isinstance(current_center, (list, tuple)) and len(current_center) == 3): + axis_point = feature.get("axis_point") or info.get("axis_point") + axis_direction = feature.get("axis") or info.get("axis") + axis_range = feature.get("same_domain_v_range") or info.get("same_domain_v_range") or feature.get("v_range") + if ( + isinstance(axis_point, (list, tuple)) + and len(axis_point) == 3 + and isinstance(axis_direction, (list, tuple)) + and len(axis_direction) == 3 + and isinstance(axis_range, (list, tuple)) + and len(axis_range) >= 2 + ): + axis_mid = (float(axis_range[0]) + float(axis_range[1])) * 0.5 + current_center = tuple(float(axis_point[index]) + float(axis_direction[index]) * axis_mid for index in range(3)) + _assert(isinstance(current_center, (list, tuple)) and len(current_center) == 3, "Face 85 should have an axis center") + near_target_center = (float(current_center[0]) + 0.1, float(current_center[1]), float(current_center[2])) + plan = model.cylindrical_axis_move_plan(face_id, near_target_center) + _assert(plan.get("status") != "blocked", f"Face 85 axis move should not be blocked as a half cylinder: {plan}") + _assert(list(plan.get("same_domain_face_ids") or []) == [85, 96], "Face 85 axis move should use both side fragments") + _assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 85 axis move should use full same-domain span") + _assert( + abs(float(plan.get("selected_angular_span") or 0.0) - 3.141592653589793) <= 1e-6, + "Face 85 selected fragment span should still be recorded separately", + ) + target_center = (float(current_center[0]), float(current_center[1]), 6.0) + high_risk_plan = model.cylindrical_axis_move_plan(face_id, target_center) + _assert( + high_risk_plan.get("status") != "blocked", + f"Face 85 axis move to Z=6 should be high-risk but still plannable: {high_risk_plan}", + ) + _assert(high_risk_plan.get("risk") == "high", f"Face 85 far axis move should be classified high risk: {high_risk_plan}") + _assert( + bool(high_risk_plan.get("supports_isolation")), + f"Face 85 high-risk axis move should be routed to isolated execution: {high_risk_plan}", + ) + _assert( + tuple(round(float(item), 7) for item in high_risk_plan.get("target_axis_center", ())) == (0.5, 1.0, 6.0), + f"Face 85 high-risk axis move target should match the requested location: {high_risk_plan}", + ) + return target_center + + +def _assert_face85_face87_multi_hole_specs(model: StepModel, harness: _SelectionHarness) -> None: + entry85 = harness._hole_multi_select_entry(85) + entry87 = harness._hole_multi_select_entry(87) + _assert(entry85 is not None, "Face 85 should be eligible for multi-hole selection") + _assert(entry87 is not None, "Face 87 should be eligible for multi-hole selection") + _assert(entry85["logical_id"] != entry87["logical_id"], "Face 85 and Face 87 should be separate hole groups") + + harness.multi_selected_hole_entries = [entry85, entry87] + harness.multi_selected_feature_face_ids = [85, 87] + harness.multi_selection_active = True + harness.selected_kind = "multi_feature" + harness.selected_face_id = 87 + info = harness._selected_action_info() + _assert(info.get("multi_selection_kind") == "holes", f"multi selection info should describe holes: {info}") + _assert(int(info.get("multi_selected_count") or 0) == 2, f"multi selection should include two holes: {info}") + _assert( + set(info.get("feature_highlight_face_ids") or ()) == {85, 96, 87, 94}, + f"multi-hole highlight should include both split-cylinder regions: {info.get('feature_highlight_face_ids')}", + ) + + specs = harness._property_editor_specs(info, info) + keys = [str(spec.get("key") or "") for spec in specs] + _assert( + keys == ["multi_hole_diameter", "multi_hole_radius", "multi_hole_position_delta", "multi_hole_suppress"], + f"multi-hole specs should expose batch hole dimensions and commands: {keys}", + ) + actions = {str(spec.get("action") or "") for spec in specs} + _assert( + actions + == {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset", "suppress_multi_selected_holes"}, + f"multi-hole specs should expose batch edit actions: {actions}", + ) + + def main() -> int: if not MODEL_PATH.exists(): print("icepak cylindrical region selection skipped: local ICEPAK-NATURAL.stp is not present") @@ -146,7 +274,11 @@ def main() -> int: _assert_default_edges_hide_same_domain_internal_edges(model, "ICEPAK before edit") harness = _SelectionHarness(model) _assert_rectangular_face_parameters(model, harness) + _assert_face85_hole_axis_move(model, harness) + _assert_face85_face87_multi_hole_specs(model, harness) expected_regions = { + 85: [85, 96], + 96: [85, 96], 87: [87, 94], 94: [87, 94], 84: [84, 97], @@ -176,6 +308,15 @@ def main() -> int: ) _assert(bool(feature.get("is_full_cylinder")), f"Face {face_id} should be treated as a full cylinder") + axis_move_model = StepModel.load(MODEL_PATH) + axis_move_target = _assert_face85_hole_axis_move(axis_move_model, _SelectionHarness(axis_move_model)) + axis_move_result = axis_move_model.move_cylindrical_hole_axis(85, axis_move_target) + _assert( + "Cylindrical hole axis move completed" in axis_move_result, + f"Face 85 axis move should complete: {axis_move_result}", + ) + _assert_default_edges_hide_same_domain_internal_edges(axis_move_model, "ICEPAK after Face 85 axis move") + resize_model = StepModel.load(MODEL_PATH) plan = resize_model.cylindrical_resize_plan(87, 0.3) _assert(plan.get("feature_type") == "圆柱孔候选", "Face 87 should plan as a cylindrical hole") @@ -191,6 +332,10 @@ def main() -> int: worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir)) _assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete") + with tempfile.TemporaryDirectory(prefix="icepak_face85_axis_isolated_") as temp_dir: + worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir)) + _assert("Cylindrical hole axis move completed" in worker_message, "Face 85 isolated axis move should complete") + print("icepak cylindrical region selection ok") return 0 diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 925e757..8a6e603 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -9,14 +9,16 @@ import tempfile os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PySide6.QtCore import QEvent, QObject +from PySide6.QtCore import QEvent, QObject, QStringListModel from PySide6.QtWidgets import ( QApplication, QCheckBox, + QCompleter, QFrame, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QPushButton, QScrollArea, QTableWidget, @@ -62,6 +64,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.property_command_active_key = "" self.property_command_buttons = {} self.property_editor_specs = [] + self.relation_formula_items = [] + self.relation_formula_next_id = 1 self.selected_kind = "feature" self.selected_part_id = None self.selected_solid_id = None @@ -93,6 +97,13 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.current_capability_headline = QLabel() self.apply_property_button = QPushButton() self.export_parameters_button = QPushButton() + self.relation_formula_input = QLineEdit() + self.relation_formula_completer_model = QStringListModel(self) + self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self) + self.relation_formula_input.setCompleter(self.relation_formula_completer) + self.add_relation_formula_button = QPushButton() + self.remove_relation_formula_button = QPushButton() + self.relation_formula_list = QListWidget() self.face_width_input = QLineEdit() self.face_height_input = QLineEdit() @@ -446,6 +457,34 @@ def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) _assert(diagnostic_label not in table_labels, f"{diagnostic_label} should not be shown as a feature parameter") +def _assert_relation_formula_editor() -> None: + probe = _PropertyTableProbe() + probe._refresh_property_editor() + 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}") + probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2") + probe.add_relation_formula() + _assert(len(probe.relation_formula_items) == 1, f"formula was not stored: {probe.relation_formula_items}") + _assert(probe.relation_formula_list.count() == 1, "formula list should display the stored formula") + probe._update_property_apply_state() + _assert(probe.apply_property_button.isEnabled(), "formula targeting current table should enable parametric modeling") + probe.apply_current_property_edit() + for _index in range(6): + QApplication.processEvents() + if not getattr(probe, "property_batch_active", False): + break + _assert( + probe.executed_property_actions == [("resize_face_height_local", "12")], + f"formula should fill target value and execute the existing row action: {probe.executed_property_actions}", + ) + width_row = _row_by_label(probe, "面内宽度") + width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN) + _assert(isinstance(width_widget, QLineEdit), "formula target row should still have a target editor") + _assert(width_widget.text().strip() == "12", "formula result should be written back to the target value cell") + _assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be marked as applied") + + def _assert_mouse_selection_guards() -> None: mouse_probe = _MouseSelectionProbe() mouse_probe._handle_left_button_press(20, 20) @@ -502,6 +541,7 @@ def _assert_quick_blind_depth_spec() -> None: "radius": 2.0, "axis_point": (0.0, 0.0, 0.0), "axis": (0.0, 0.0, 1.0), + "axis_center": (0.0, 0.0, 3.0), "angular_span": math.tau, "feature_guess": "hole/groove candidate", "feature_type": "圆柱孔候选", @@ -513,6 +553,18 @@ def _assert_quick_blind_depth_spec() -> None: } blind_specs, _blind_used = blind_probe._editable_property_specs(quick_blind_info) blind_feature_specs = blind_probe._feature_property_specs(blind_specs, quick_blind_info) + blind_axis_specs = [ + spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_axis_center" + ] + _assert(blind_axis_specs, "quick blind hole axis center should be visible in feature parameters") + blind_axis_spec = blind_axis_specs[0] + _assert(str(blind_axis_spec.get("value_type", "")) == "vector3", "hole axis center should accept X/Y/Z") + blind_axis_effective = blind_probe._effective_property_spec(blind_axis_spec) + _assert(bool(blind_axis_effective.get("enabled")), "quick blind hole axis center should be editable") + _assert( + str(blind_axis_effective.get("action", "")) == "move_cylindrical_hole_axis", + f"quick blind hole axis center should move the hole itself: {blind_axis_effective}", + ) blind_depth_specs = [ spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_depth_estimate" ] @@ -671,6 +723,7 @@ def main() -> int: ) _assert_diagnostics_stay_out_of_parameter_table(probe) + _assert_relation_formula_editor() _assert_mouse_selection_guards() _assert_quick_blind_depth_spec() _assert_user_facing_failure_messages() diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index faef02d..f9651e5 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -740,7 +740,15 @@ def main() -> int: cylinder_specs = _specs(cylinder_info) cylinder_keys = {str(spec.get("key", "")) for spec in cylinder_specs} _assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature") - _assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature") + _assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "cylindrical hole feature") + hole_axis_spec = _spec(cylinder_specs, "hole_axis_center") + hole_axis_local = _scope_mode(cylinder_specs, "hole_axis_center", "local") + if str(hole_axis_spec.get("label") or "") != "位置": + raise SystemExit(f"hole axis center should be shown to users as position: {hole_axis_spec}") + if str(hole_axis_spec.get("value_type") or "") != "vector3": + raise SystemExit(f"hole axis center should use an X/Y/Z vector target: {hole_axis_spec}") + if str(hole_axis_local.get("action") or "") != "move_cylindrical_hole_axis": + raise SystemExit(f"hole axis center should move the hole itself by default: {hole_axis_local}") _assert_current_text_contains( cylinder_specs, "cad_modeling_form", @@ -750,7 +758,7 @@ def main() -> int: _assert_current_text_contains( cylinder_specs, "cad_recommended_operation", - ("孔径", "盲孔", "轴心"), + ("孔径", "盲孔", "位置"), "cylindrical hole feature", ) @@ -789,6 +797,12 @@ def main() -> int: cylinder_feature_probe = _PropertySpecProbe() cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info) cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info) + cylinder_feature_keys = {str(spec.get("key", "")) for spec in cylinder_feature_rows} + _assert_contains( + cylinder_feature_keys, + {"diameter", "hole_axis_center"}, + "cylindrical feature mode display specs", + ) _assert_keys_absent( cylinder_feature_rows, ( @@ -876,7 +890,7 @@ def main() -> int: "feature_bottom_face_ids": (), } ) - _assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole") + _assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "split full cylindrical hole") if "slot_chord_width_estimate" in split_full_hole_keys: raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}") @@ -915,7 +929,7 @@ def main() -> int: _assert_current_text_contains( slot_specs, "cad_recommended_operation", - ("槽宽", "槽深", "轴心"), + ("槽宽", "槽深", "位置"), "slot/half-hole feature", ) blocked_slot_specs = _display_specs( diff --git a/step_editor/app.py b/step_editor/app.py index 95e77bd..96b5a23 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -12,13 +12,14 @@ import vtkmodules.vtkInteractionWidgets # noqa: F401 import vtkmodules.vtkInteractionStyle # noqa: F401 import vtkmodules.vtkRenderingFreeType # noqa: F401 import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 -from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot +from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot, QStringListModel from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, + QCompleter, QFileDialog, QFrame, QGridLayout, @@ -161,6 +162,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.selected_face_id: int | None = None self.selected_edge_id: int | None = None self.selected_pick_position: tuple[float, float, float] | None = None + self.multi_selected_feature_face_ids: list[int] = [] + self.multi_selected_hole_entries: list[dict[str, object]] = [] + self.multi_selection_active = False self.model_actor = None self.edge_actor = None @@ -253,6 +257,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.pending_scan_kind: str | None = None self.pending_scan_context: dict[str, object] | None = None self.scan_wait_cursor_active = False + self.asitus_thread: QThread | None = None + self.asitus_worker: ScanWorker | None = None + self.pending_asitus_context: dict[str, object] | None = None self.load_in_progress = False self.load_thread: QThread | None = None self.load_worker: LoadWorker | None = None @@ -278,6 +285,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.property_editor_selected_row: int | None = None self.property_command_active_key = "" self.property_command_buttons: dict[str, QPushButton] = {} + self.relation_formula_items: list[dict[str, object]] = [] + self.relation_formula_next_id = 1 self._build_ui() self._build_vtk() @@ -879,6 +888,47 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf QLineEdit#propertyCardTargetEditor:focus { border-color: #2563eb; } + QGroupBox#relationFormulaBox { + margin-top: 2px; + } + QLineEdit#relationFormulaInput { + background: #ffffff; + border: 1px solid #b7c6d9; + border-radius: 5px; + color: #172033; + min-height: 24px; + padding: 3px 6px; + } + QLineEdit#relationFormulaInput:focus { + border: 1px solid #2563eb; + } + QPushButton#relationFormulaAddButton, + QPushButton#relationFormulaRemoveButton { + background: #f8fafc; + border: 1px solid #94a3b8; + border-radius: 5px; + color: #1f2937; + font-weight: 700; + min-height: 26px; + padding: 3px 8px; + } + QPushButton#relationFormulaAddButton:hover, + QPushButton#relationFormulaRemoveButton:hover { + background: #eef6ff; + border-color: #2563eb; + color: #1e3a8a; + } + QPushButton#relationFormulaAddButton:disabled, + QPushButton#relationFormulaRemoveButton:disabled { + background: #eef2f6; + border: 1px dashed #bcc7d4; + color: #8f99a8; + } + QListWidget#relationFormulaList { + background: #ffffff; + border: 1px solid #d8e0eb; + border-radius: 5px; + } QTabWidget::pane { border: 1px solid #d8e0eb; border-radius: 6px; @@ -1237,6 +1287,57 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.property_expand_button.setMaximumHeight(22) self.property_expand_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) object_edit_layout.addWidget(self.property_expand_button) + + self.relation_formula_box = QGroupBox("关系式") + self.relation_formula_box.setObjectName("relationFormulaBox") + help_tip( + self.relation_formula_box, + "用 FaceID.参数 = 表达式 的形式建立关系式。第一版会先计算公式并回填当前参数表目标值,再执行参数化建模。", + ) + relation_layout = QVBoxLayout(self.relation_formula_box) + relation_layout.setContentsMargins(6, 8, 6, 6) + relation_layout.setSpacing(5) + relation_input_row = QHBoxLayout() + relation_input_row.setContentsMargins(0, 0, 0, 0) + relation_input_row.setSpacing(5) + self.relation_formula_input = QLineEdit("") + self.relation_formula_input.setObjectName("relationFormulaInput") + self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径") + help_tip( + self.relation_formula_input, + "示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示当前可用参数。", + ) + self.relation_formula_completer_model = QStringListModel(self) + self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self) + self.relation_formula_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.relation_formula_input.setCompleter(self.relation_formula_completer) + self.relation_formula_input.installEventFilter(self) + self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed) + self.relation_formula_input.returnPressed.connect(self.add_relation_formula) + self.add_relation_formula_button = QPushButton("添加公式") + self.add_relation_formula_button.setObjectName("relationFormulaAddButton") + self.add_relation_formula_button.clicked.connect(self.add_relation_formula) + relation_input_row.addWidget(self.relation_formula_input, stretch=1) + relation_input_row.addWidget(self.add_relation_formula_button) + relation_layout.addLayout(relation_input_row) + self.relation_formula_list = QListWidget() + self.relation_formula_list.setObjectName("relationFormulaList") + self.relation_formula_list.setMinimumHeight(54) + self.relation_formula_list.setMaximumHeight(86) + self.relation_formula_list.itemSelectionChanged.connect(self._update_relation_formula_buttons) + help_tip(self.relation_formula_list, "已建立的关系式。失效或暂不能映射的公式会在这里标出原因。") + relation_layout.addWidget(self.relation_formula_list) + relation_button_row = QHBoxLayout() + relation_button_row.setContentsMargins(0, 0, 0, 0) + relation_button_row.addStretch(1) + self.remove_relation_formula_button = QPushButton("删除公式") + self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton") + self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula) + relation_button_row.addWidget(self.remove_relation_formula_button) + relation_layout.addLayout(relation_button_row) + object_edit_layout.addWidget(self.relation_formula_box) + property_action_row = QHBoxLayout() property_action_row.setContentsMargins(0, 0, 0, 0) self.apply_property_button = QPushButton("参数化建模") diff --git a/step_editor/asitus_bridge.py b/step_editor/asitus_bridge.py new file mode 100644 index 0000000..dfec6de --- /dev/null +++ b/step_editor/asitus_bridge.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Iterable + + +ASITUS_RECOGNIZE_HOLES_ENV = "STEP_EDITOR_ASITUS_RECOGNIZE_HOLES" +ASITUS_DISABLE_ENV = "STEP_EDITOR_DISABLE_ASITUS" +ASITUS_TIMEOUT_ENV = "STEP_EDITOR_ASITUS_TIMEOUT" + + +def _project_root(project_root: Path | None = None) -> Path: + return project_root or Path(__file__).resolve().parent.parent + + +def default_asitus_recognize_holes_path(project_root: Path | None = None) -> Path | None: + env_path = os.environ.get(ASITUS_RECOGNIZE_HOLES_ENV, "").strip() + if env_path: + path = Path(env_path).expanduser() + return path if path.is_file() else None + if os.environ.get(ASITUS_DISABLE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}: + return None + + root = _project_root(project_root) + candidates = ( + root / "third_party" / "asitus_probe_tools_build" / "Release" / "recognize_holes.exe", + root / "third_party" / "asitus_probe_tools_build" / "RelWithDebInfo" / "recognize_holes.exe", + root / "third_party" / "asitus_probe_tools_build" / "Debug" / "recognize_holes.exe", + root / "third_party" / "asitus_probe_build" / "Release" / "recognize_holes.exe", + root / "third_party" / "asitus_probe_build" / "RelWithDebInfo" / "recognize_holes.exe", + root / "third_party" / "asitus_probe_build" / "Debug" / "recognize_holes.exe", + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def asitus_runtime_path_entries(project_root: Path | None = None) -> list[Path]: + root = _project_root(project_root) + third_party = root / "third_party" / "3rdparty" + candidates = ( + root / "third_party" / "AnalysisSitus_build_algo_occt77" / "win64" / "vc14" / "bin", + third_party / "OCCT" / "win64" / "vc14" / "bin", + third_party / "freeimage-3.17.0-vc14-64" / "bin", + third_party / "freetype-2.5.5-vc14-64" / "bin", + third_party / "tbb_2021.5-vc14-64" / "bin", + third_party / "tcltk-86-64" / "bin", + third_party / "ffmpeg-3.3.4-64" / "bin", + third_party / "openvr-1.14.15-64" / "bin" / "win64", + third_party / "3rdparty-vc14-64" / "freeimage-3.18.0-x64" / "bin", + third_party / "3rdparty-vc14-64" / "freetype-2.13.3-x64" / "bin", + third_party / "3rdparty-vc14-64" / "tbb-2021.13.0-x64" / "bin", + third_party / "3rdparty-vc14-64" / "tcltk-8.6.15-x64" / "bin", + third_party / "3rdparty-vc14-64" / "ffmpeg-3.3.4-64" / "bin", + third_party / "3rdparty-vc14-64" / "openvr-1.14.15-64" / "bin" / "win64", + ) + return [path for path in candidates if path.is_dir()] + + +def parse_asitus_hole_groups(payload: object) -> list[tuple[int, ...]]: + payload = _json_payload(payload) + if not isinstance(payload, dict): + return [] + + groups: list[tuple[int, ...]] = [] + holes = payload.get("holes") + if isinstance(holes, list): + for item in holes: + if not isinstance(item, dict): + continue + group = _int_tuple(item.get("faceIds")) + if group: + groups.append(group) + if groups: + return _dedupe_groups(groups) + + flat_ids = _int_tuple(payload.get("holeFaceIds")) + return [flat_ids] if flat_ids else [] + + +def parse_asitus_probe_payload(payload: object) -> dict[str, object]: + data = _json_payload(payload) + if not isinstance(data, dict): + return { + "groups": (), + "faces": (), + "adjacency": (), + "geometric_relations": (), + "surface_summary": {}, + "angle_summary": {}, + "geometric_relation_summary": {}, + "geometric_relation_mode": "", + } + + faces: list[dict[str, object]] = [] + raw_faces = data.get("faces") + if isinstance(raw_faces, list): + for item in raw_faces: + if not isinstance(item, dict): + continue + face_id = _int_or_none(item.get("id")) + if face_id is None: + continue + faces.append( + { + "id": face_id, + "surface": str(item.get("surface") or ""), + "neighbor_ids": _int_tuple(item.get("neighbors")), + } + ) + + adjacency: list[dict[str, object]] = [] + raw_adjacency = data.get("adjacency") + if isinstance(raw_adjacency, list): + for item in raw_adjacency: + if not isinstance(item, dict): + continue + face_ids = _int_tuple(item.get("faceIds")) + if len(face_ids) != 2: + continue + adjacency.append( + { + "face_ids": face_ids, + "angle_type": str(item.get("angleType") or item.get("type") or ""), + "angle_rad": _float_or_none(item.get("angleRad")), + "edge_ids": _int_tuple(item.get("edgeIds")), + } + ) + + geometric_relations: list[dict[str, object]] = [] + raw_geometric_relations = data.get("geometricRelations") + if isinstance(raw_geometric_relations, list): + for item in raw_geometric_relations: + if not isinstance(item, dict): + continue + face_ids = _int_tuple(item.get("faceIds")) + if len(face_ids) != 2: + continue + geometric_relations.append( + { + "face_ids": face_ids, + "relation_type": str(item.get("type") or item.get("relationType") or ""), + "residual": _float_or_none(item.get("residual")), + "source": str(item.get("source") or "analysis-situs-probe"), + } + ) + + return { + "groups": tuple(parse_asitus_hole_groups(data)), + "valid_brep": data.get("validBreP"), + "face_count": _int_or_none(data.get("faceCount")), + "aag_node_count": _int_or_none(data.get("aagNodeCount")), + "faces": tuple(faces), + "adjacency": tuple(adjacency), + "geometric_relations": tuple(geometric_relations), + "surface_summary": _str_int_dict(data.get("surfaceSummary")), + "angle_summary": _str_int_dict(data.get("angleSummary")), + "geometric_relation_summary": _str_int_dict(data.get("geometricRelationSummary")), + "geometric_relation_mode": str(data.get("geometricRelationMode") or ""), + } + + +def _json_payload(payload: object) -> object: + if not isinstance(payload, str): + return payload + text = payload.strip() + json_start = text.find("{") + if json_start > 0: + text = text[json_start:] + return json.loads(text) + + +def _int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _float_or_none(value: object) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _str_int_dict(value: object) -> dict[str, int]: + if not isinstance(value, dict): + return {} + result: dict[str, int] = {} + for key, item in value.items(): + try: + result[str(key)] = int(item) + except (TypeError, ValueError): + continue + return result + + +def _int_tuple(values: object) -> tuple[int, ...]: + if values is None: + return () + if isinstance(values, (str, bytes)): + return () + try: + items = list(values) # type: ignore[arg-type] + except TypeError: + return () + result: list[int] = [] + for item in items: + try: + result.append(int(item)) + except (TypeError, ValueError): + continue + return tuple(sorted(set(result))) + + +def _dedupe_groups(groups: Iterable[tuple[int, ...]]) -> list[tuple[int, ...]]: + result: list[tuple[int, ...]] = [] + seen: set[tuple[int, ...]] = set() + for group in groups: + if not group or group in seen: + continue + seen.add(group) + result.append(group) + return result + + +def run_asitus_hole_recognition( + step_path: str | Path, + *, + cli_path: str | Path | None = None, + project_root: Path | None = None, + timeout_seconds: float | None = None, +) -> dict[str, object]: + source = Path(step_path).expanduser() + if not source.is_file(): + return {"ok": False, "reason": "missing-step", "groups": (), "message": f"STEP file not found: {source}"} + + cli = Path(cli_path).expanduser() if cli_path else default_asitus_recognize_holes_path(project_root) + if cli is None or not cli.is_file(): + return {"ok": False, "reason": "missing-cli", "groups": (), "message": "Analysis Situs recognize_holes CLI is not available."} + + if timeout_seconds is None: + try: + timeout_seconds = float(os.environ.get(ASITUS_TIMEOUT_ENV, "") or 3.0) + except ValueError: + timeout_seconds = 3.0 + + env = os.environ.copy() + path_entries = [str(path) for path in asitus_runtime_path_entries(project_root)] + env["PATH"] = os.pathsep.join([*path_entries, env.get("PATH", "")]) + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + completed = subprocess.run( + [str(cli), str(source)], + cwd=str(_project_root(project_root)), + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=max(float(timeout_seconds), 0.1), + creationflags=creationflags, + check=False, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "reason": "timeout", "groups": (), "message": "Analysis Situs hole recognition timed out."} + except OSError as exc: + return {"ok": False, "reason": "launch-failed", "groups": (), "message": str(exc)} + + if completed.returncode != 0: + message = (completed.stderr or completed.stdout or "").strip() + return { + "ok": False, + "reason": "recognizer-failed", + "returncode": completed.returncode, + "groups": (), + "message": message, + } + + try: + parsed = parse_asitus_probe_payload(completed.stdout) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + return {"ok": False, "reason": "bad-json", "groups": (), "message": str(exc), "stdout": completed.stdout} + groups = tuple(parsed.get("groups", ())) + + return { + "ok": True, + "reason": "ok", + "groups": groups, + "hole_count": len(groups), + "valid_brep": parsed.get("valid_brep"), + "face_count": parsed.get("face_count"), + "aag_node_count": parsed.get("aag_node_count"), + "faces": tuple(parsed.get("faces", ())), + "adjacency": tuple(parsed.get("adjacency", ())), + "geometric_relations": tuple(parsed.get("geometric_relations", ())), + "surface_summary": dict(parsed.get("surface_summary", {}) or {}), + "angle_summary": dict(parsed.get("angle_summary", {}) or {}), + "geometric_relation_summary": dict(parsed.get("geometric_relation_summary", {}) or {}), + "geometric_relation_mode": str(parsed.get("geometric_relation_mode") or ""), + "cli": str(cli), + "message": f"Analysis Situs recognized {len(groups)} hole groups.", + } diff --git a/step_editor/features.py b/step_editor/features.py index aa7ff56..2670254 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -61,10 +61,78 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .geometry_utils import * # noqa: F403 -from .recognition_priority import feature_recognition_sort_key +from .recognition_priority import external_relation_score_bonus, feature_recognition_sort_key class FeatureMixin: + def _external_candidate_relation_fields(self, candidate: dict[str, object]) -> dict[str, object]: + face_ids = ( + _int_values(candidate.get("feature_highlight_face_ids")) + or _int_values(candidate.get("feature_face_ids")) + or _int_values(candidate.get("face_region_ids")) + or _int_values(candidate.get("face_id")) + ) + if not face_ids and str(candidate.get("target_kind") or "") == "face": + face_ids = _int_values(candidate.get("target_id")) + relation_summary_by_type: dict[str, int] = {} + relation_face_count = 0 + summary_getter = getattr(self, "_asitus_face_summary_fields", None) + if not callable(summary_getter): + return {} + for face_id in sorted(set(face_ids)): + try: + fields = summary_getter(face_id) + except Exception: + continue + if not isinstance(fields, dict): + continue + relation_count = int(fields.get("asitus_geometric_relation_count") or 0) + if relation_count <= 0: + continue + relation_face_count += 1 + for relation_type in fields.get("asitus_geometric_relation_types", ()) or (): + relation_key = str(relation_type or "").strip() + if relation_key: + relation_summary_by_type[relation_key] = relation_summary_by_type.get(relation_key, 0) + 1 + if not relation_summary_by_type: + return {} + relation_summary = ", ".join( + f"{key}:{value}" for key, value in sorted(relation_summary_by_type.items()) + ) + result = { + "external_recognition_relation_source": "analysis-situs", + "external_recognition_relation_summary": relation_summary, + "external_recognition_relation_types": tuple(sorted(relation_summary_by_type)), + "external_recognition_relation_count": sum(relation_summary_by_type.values()), + "external_recognition_relation_face_count": relation_face_count, + } + hint_getter = getattr(self, "_asitus_cylindrical_feature_hint_fields", None) + if callable(hint_getter) and len(set(face_ids)) == 1: + try: + hint_face_id = int(face_ids[0]) + hint_context = {**candidate, **result} + cached_feature_getter = getattr(self, "cached_feature_info", None) + cached_feature = cached_feature_getter(hint_face_id) if callable(cached_feature_getter) else None + if isinstance(cached_feature, dict): + hint_context = {**cached_feature, **hint_context} + result.update(hint_getter(hint_face_id, hint_context)) + except Exception: + pass + result["recognition_external_relation_score_bonus"] = external_relation_score_bonus(result) + return result + + def _with_external_candidate_relation_support(self, candidate: dict[str, object]) -> dict[str, object]: + result = dict(candidate) + fields = self._external_candidate_relation_fields(result) + if not fields: + return result + result.update(fields) + bonus = int(result.get("recognition_external_relation_score_bonus") or 0) + confidence = str(result.get("confidence") or "") + if bonus >= 8 and confidence in {"", "pending", "unchecked", "none", "low"}: + result["confidence"] = "medium" + return result + def _first_level_fact_plan_fields(self, face_id: int, scope: str) -> dict[str, object]: try: return self.face_first_level_facts(face_id, scope=scope) @@ -1005,6 +1073,7 @@ class FeatureMixin: ) ellipse_edge_minor_radius_count += 1 + candidates = [self._with_external_candidate_relation_support(candidate) for candidate in candidates] for candidate in candidates: candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0] candidates.sort(key=feature_recognition_sort_key) @@ -1094,12 +1163,21 @@ class FeatureMixin: "boss_resize_note", "recognition_risk", "recognition_blockers", + "analysis_situs_feature_hint_status", + "analysis_situs_feature_hint_preferred", + "analysis_situs_feature_hint_label", + "analysis_situs_feature_hint_score", + "analysis_situs_feature_hint_summary", + "analysis_situs_feature_hint_related_face_ids", + "analysis_situs_slot_hint_score", + "analysis_situs_boss_hint_score", + "analysis_situs_fillet_hint_score", ): if feature.get(key) not in {None, ""}: candidate[key] = feature.get(key) except Exception: pass - candidates.append(candidate) + candidates.append(self._with_external_candidate_relation_support(candidate)) if len(candidates) >= limit: break result = [dict(item) for item in candidates] @@ -1248,7 +1326,13 @@ class FeatureMixin: blockers.append("Target cylinder axis center must be three numeric coordinates.") current_diameter = _float_or_none(info.get("diameter")) - angular_span = _float_or_none(info.get("angular_span")) + selected_angular_span = _float_or_none(info.get("angular_span")) + angular_span = ( + _float_or_none(feature.get("same_domain_angular_span")) + or _float_or_none(info.get("same_domain_angular_span")) + or _float_or_none(feature.get("angular_span")) + or selected_angular_span + ) feature_guess = str(info.get("feature_guess", "")) confidence = str(info.get("confidence", "low")) surf = BRepAdaptor_Surface(self.faces[face_id]) @@ -1357,13 +1441,17 @@ class FeatureMixin: "axis_move_radial_distance": radial_distance, "axis": axis_direction, "angular_span": angular_span, + "selected_angular_span": selected_angular_span, + "same_domain_angular_span": feature.get("same_domain_angular_span") or info.get("same_domain_angular_span"), + "is_full_cylinder": feature.get("is_full_cylinder", info.get("is_full_cylinder")), "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), "same_domain_face_count": axis_range.get("same_domain_face_count", 0), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), + "supports_isolation": True, "resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder", "edit_strategy_label": "填旧孔并切新孔", - "edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。", + "edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标位置切出新孔;这会改变孔的位置,不会整体平移零件。", } def cylindrical_slot_resize_plan( diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index 94fd95c..420ef42 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -15,7 +15,10 @@ def _optional_int(value: object) -> int | None: def _point3(value: object, operation: str) -> tuple[float, float, float]: - point = list(value) if isinstance(value, (list, tuple)) else [] + if isinstance(value, str): + point = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()] + else: + point = list(value) if isinstance(value, (list, tuple)) else [] if len(point) != 3: raise ValueError(f"{operation} requires a 3D target center.") return (float(point[0]), float(point[1]), float(point[2])) @@ -78,6 +81,16 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str: return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2])) if operation == "resize_cylindrical_hole": return model.resize_cylindrical_hole(int(args[0]), float(args[1])) + if operation == "edit_cylindrical_holes_by_refs": + offset = _point3(args[2], operation) if len(args) > 2 and args[2] is not None and args[2] != "" else None + diameter = None if len(args) <= 1 or args[1] in {None, ""} else float(args[1]) + return model.edit_cylindrical_holes_by_refs(list(args[0]), target_diameter=diameter, offset=offset) + if operation == "resize_cylindrical_holes_by_refs": + return model.resize_cylindrical_holes_by_refs(list(args[0]), float(args[1])) + if operation == "move_cylindrical_holes_by_offset": + return model.move_cylindrical_holes_by_offset(list(args[0]), _point3(args[1], operation)) + if operation == "suppress_cylindrical_holes_by_refs": + return model.suppress_cylindrical_holes_by_refs(list(args[0])) if operation == "resize_cylindrical_owning_scale": return model.resize_cylindrical_owning_scale(int(args[0]), float(args[1])) if operation == "move_cylindrical_hole_axis": diff --git a/step_editor/model.py b/step_editor/model.py index 5489d2c..ce39923 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import Counter import math from pathlib import Path import time @@ -78,9 +79,12 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge from .constants import CURVE_TYPES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .export import ExportMixin from .features import FeatureMixin +from .asitus_bridge import run_asitus_hole_recognition from .operations import OperationMixin from .polydata import PolydataMixin +from .recognition_graph import recognize_through_hole_regions from .recognition_priority import ( + external_relation_score_bonus, feature_recognition_priority, feature_recognition_priority_label, feature_recognition_priority_reason, @@ -119,6 +123,19 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._face_edge_ids_cache: dict[int, list[int]] = {} self._edge_face_ids_cache: dict[int, list[int]] = {} self._same_domain_face_ids_cache: dict[int, list[int]] = {} + self._asitus_hole_regions_attempted = False + self._asitus_hole_regions_loading = False + self._asitus_hole_region_cache: dict[int, list[int]] = {} + self._asitus_hole_recognition_info: dict[str, object] = {} + self._asitus_face_relation_cache: dict[int, dict[str, object]] = {} + self._asitus_adjacency_relation_cache: dict[tuple[int, int], dict[str, object]] = {} + self._asitus_geometric_relation_cache: dict[tuple[int, int], list[dict[str, object]]] = {} + self._internal_hole_regions_attempted = False + self._internal_hole_region_cache: dict[int, list[int]] = {} + self._internal_recognition_info: dict[str, object] = {} + self._recognition_graph_cache: dict[int, object] = {} + self._through_hole_regions_cache: dict[tuple[object, ...], list[object]] = {} + self._topology_refresh_generation = 0 self._face_first_level_topology_cache: dict[int, dict[str, object]] = {} self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {} self._face_first_level_fact_cache: dict[tuple[int, str], dict[str, object]] = {} @@ -210,6 +227,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd return info 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()]) self.faces.clear() self.face_logical_ids.clear() @@ -226,6 +244,18 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._face_edge_ids_cache.clear() self._edge_face_ids_cache.clear() self._same_domain_face_ids_cache.clear() + self._asitus_hole_regions_attempted = False + self._asitus_hole_regions_loading = False + self._asitus_hole_region_cache.clear() + self._asitus_hole_recognition_info.clear() + self._asitus_face_relation_cache.clear() + self._asitus_adjacency_relation_cache.clear() + self._asitus_geometric_relation_cache.clear() + self._internal_hole_regions_attempted = False + self._internal_hole_region_cache.clear() + self._internal_recognition_info.clear() + self._recognition_graph_cache.clear() + self._through_hole_regions_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() self._face_first_level_fact_cache.clear() @@ -359,8 +389,34 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd full_info = self._face_info_cache.get(face_id) if full_info is not None: info = dict(full_info) - if str(info.get("surface") or "") == "cylinder" and not str(info.get("feature_type") or ""): - info.update(self._cylindrical_feature_label_fields(info)) + info.update(self._asitus_face_summary_fields(face_id)) + if str(info.get("surface") or "") == "cylinder": + try: + hint = self._quick_cylindrical_feature_hint( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + info, + ) + for key in ( + "same_domain_face_ids", + "same_domain_face_count", + "feature_highlight_face_ids", + "same_domain_angular_span", + "same_domain_note", + "same_domain_v_range", + "same_domain_height_estimate", + "same_domain_range_source", + ): + if key in hint: + info[key] = hint[key] + if bool(hint.get("is_full_cylinder")): + info["is_full_cylinder"] = True + if hint.get("angular_span") is not None: + info["angular_span"] = hint["angular_span"] + except Exception: + info.setdefault("feature_highlight_face_ids", (face_id,)) + if not str(info.get("feature_type") or ""): + info.update(self._cylindrical_feature_label_fields(info)) info.update(self._recognition_summary_fields(info)) return info cached = self._quick_face_info_cache.get(face_id) @@ -514,6 +570,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd elif surface_label in FREEFORM_FACE_SURFACES: info.update(self._freeform_face_limit_fields(surface_label)) + info.update(self._asitus_face_summary_fields(face_id)) + if surface_type == GeomAbs_Cylinder: + info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info)) info.update(self._recognition_summary_fields(info)) self._quick_face_info_cache[face_id] = dict(info) return dict(info) @@ -558,6 +617,125 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "feature_edit_actions": "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认。", } + def _asitus_cylindrical_feature_hint_fields(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + return {} + if not self._asitus_adjacency_relation_cache and not self._asitus_geometric_relation_cache: + return {} + + angle_types: Counter[str] = Counter() + relation_types: Counter[str] = Counter() + related_face_ids: set[int] = set() + for pair, relation in self._asitus_adjacency_relation_cache.items(): + if int(face_id) not in pair: + continue + related_face_ids.update(int(item) for item in pair if int(item) != int(face_id)) + angle_type = str(relation.get("angle_type") or "adjacent").strip().lower() + if angle_type: + angle_types[angle_type] += 1 + for pair, relations in self._asitus_geometric_relation_cache.items(): + if int(face_id) not in pair: + continue + related_face_ids.update(int(item) for item in pair if int(item) != int(face_id)) + for relation in relations: + relation_type = str(relation.get("relation_type") or "").strip().lower() + if relation_type: + relation_types[relation_type] += 1 + if not angle_types and not relation_types: + return {} + + guess = str(info.get("feature_guess") or "") + orientation = str(info.get("orientation") or "") + angular_span = _float_or_none(info.get("same_domain_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) or 0.0 + is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92 + is_partial = 1.0e-6 < angular_span < math.tau * 0.92 + radius = _float_or_none(info.get("radius")) or 0.0 + boundary_edges = int(info.get("boundary_edges") or 0) + solid_id = self.face_solid_ids[face_id] if 0 <= face_id < len(self.face_solid_ids) else -1 + solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape + solid_diagonal = max(_shape_diagonal(solid_shape), 1.0) + is_small_radius = radius > 0 and radius <= solid_diagonal * 0.04 + is_fillet_radius = radius > 0 and radius <= solid_diagonal * 0.12 + smooth_count = sum(value for key, value in angle_types.items() if "smooth" in key) + convex_count = sum(value for key, value in angle_types.items() if "convex" in key) + concave_count = sum(value for key, value in angle_types.items() if "concave" in key) + tangent_count = int(relation_types.get("tangent", 0)) + coaxial_count = int(relation_types.get("coaxial", 0)) + parallel_axis_count = int(relation_types.get("parallel_axis", 0)) + planar_relation_count = sum( + int(relation_types.get(key, 0)) + for key in ("coplanar", "parallel", "perpendicular") + ) + + slot_score = 0 + if is_partial and guess == "hole/groove candidate": + slot_score += 4 + slot_score += min((smooth_count + tangent_count) * 3, 6) + slot_score += min(concave_count * 2, 4) + slot_score += min(planar_relation_count, 3) + + fillet_score = 0 + if is_partial and ( + guess == "round/fillet candidate" + or (is_fillet_radius and guess not in {"hole/groove candidate", "boss/outer-round candidate"}) + ): + fillet_score += 3 + if is_small_radius: + fillet_score += 4 + fillet_score += min((smooth_count + tangent_count) * 4, 8) + if boundary_edges >= 4: + fillet_score += 2 + + boss_score = 0 + is_boss_like = ( + guess == "boss/outer-round candidate" + or ( + str(info.get("material_toward_axis") or "") == "inside" + and "outside" in str(info.get("material_away_axis") or "") + ) + or (is_full and orientation == "forward") + ) + if is_full and is_boss_like: + boss_score += 5 + boss_score += min(convex_count * 2, 4) + boss_score += min(coaxial_count + parallel_axis_count, 4) + boss_score += min(planar_relation_count, 3) + + scores = { + "slot": slot_score, + "boss": boss_score, + "fillet": fillet_score, + } + preferred, hint_score = max(scores.items(), key=lambda item: item[1]) + if hint_score <= 0: + return {} + + labels = { + "slot": "槽 / 半孔", + "boss": "凸台 / 外圆", + "fillet": "圆角 / 倒圆", + } + relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items())) + angle_summary = ", ".join(f"{key}:{value}" for key, value in sorted(angle_types.items())) + summary_parts = [f"{labels[preferred]} +{hint_score}"] + if relation_summary: + summary_parts.append(f"几何关系 {relation_summary}") + if angle_summary: + summary_parts.append(f"AAG角度 {angle_summary}") + return { + "analysis_situs_feature_hint_status": "ready", + "analysis_situs_feature_hint_preferred": preferred, + "analysis_situs_feature_hint_label": labels[preferred], + "analysis_situs_feature_hint_score": hint_score, + "analysis_situs_feature_hint_summary": ";".join(summary_parts), + "analysis_situs_feature_hint_related_face_ids": tuple(sorted(related_face_ids)), + "analysis_situs_slot_hint_score": slot_score, + "analysis_situs_boss_hint_score": boss_score, + "analysis_situs_fillet_hint_score": fillet_score, + } + def _quick_cylindrical_feature_hint( self, face_id: int, @@ -728,6 +906,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "v_range": (surf.FirstVParameter(), surf.LastVParameter()), "boundary_edges": boundary_edges, } + info.update(self._asitus_face_summary_fields(face_id)) info.update(_shape_bounds_info(face)) info.update(self._face_boundary_wire_info(face)) if surface_type == GeomAbs_Plane: @@ -807,6 +986,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["minor_radius"] = torus.MinorRadius() elif str(info.get("surface") or "") in FREEFORM_FACE_SURFACES: info.update(self._freeform_face_limit_fields(str(info.get("surface") or ""))) + info.update(self._asitus_face_summary_fields(face_id)) + if surface_type == GeomAbs_Cylinder: + info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info)) info.update(self._recognition_summary_fields(info)) self._face_info_cache[face_id] = dict(info) return dict(info) @@ -893,6 +1075,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd confidence = "high" if surface in FREEFORM_FACE_SURFACES: confidence = "low" + external_relation_bonus = external_relation_score_bonus(info) + if ( + external_relation_bonus >= 8 + and surface not in FREEFORM_FACE_SURFACES + and confidence in {"low", "unchecked", "none"} + ): + confidence = "medium" risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} capability_specs = ( @@ -1024,6 +1213,21 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add("first_level_topology", f"一级相邻Face={adjacent_count}") if info.get("first_level_fact_summary") not in {None, ""}: add("first_level_fact_graph", f"一级事实={info.get('first_level_fact_summary')}") + if info.get("asitus_adjacent_face_count") not in {None, ""}: + add( + "analysis_situs_aag", + f"Analysis Situs AAG adjacent Face={info.get('asitus_adjacent_face_count')}", + ) + if info.get("asitus_geometric_relation_summary") not in {None, ""}: + add( + "analysis_situs_geometry", + f"Analysis Situs geometry={info.get('asitus_geometric_relation_summary')}", + ) + if info.get("analysis_situs_feature_hint_summary") not in {None, ""}: + add( + "analysis_situs_feature_hint", + f"Analysis Situs hint={info.get('analysis_situs_feature_hint_summary')}", + ) if info.get("material_vote_summary") not in {None, ""}: add("material_votes", f"材料采样={info.get('material_vote_summary')}") if info.get("feature_end_face_ids") not in {None, ""}: @@ -1127,6 +1331,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd risk_penalty = {"low": 0, "medium": 14, "high": 30, "blocked": 72} score = confidence_points.get(confidence, 22) score += min(len(evidence_keys) * 5, 24) + score += external_relation_bonus score -= risk_penalty.get(risk, 14) if blockers: score -= 35 @@ -1183,6 +1388,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "recognition_user_priority_reason": user_priority_reason, "recognition_evidence": ";".join(evidence), "recognition_evidence_keys": tuple(evidence_keys), + "recognition_external_relation_score_bonus": external_relation_bonus, "recognition_ready_actions": ";".join(ready_actions), "recognition_limited_actions": ";".join(limited_actions), "recognition_blockers": ";".join(blockers), @@ -2625,6 +2831,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd **fillet_info, } ) + result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result)) if angular_span >= math.tau * 0.92: result.update( { @@ -4423,7 +4630,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if surface_type == GeomAbs_Plane: face_ids = self._connected_coplanar_planar_face_ids(face_id) elif surface_type == GeomAbs_Cylinder: - face_ids = self._connected_cocylindrical_face_ids(face_id) + face_ids = ( + self._asitus_hole_region_ids(face_id) + or self._internal_hole_region_ids(face_id) + or self._connected_cocylindrical_face_ids(face_id) + ) else: face_ids = [face_id] face_ids = sorted(set(face_ids or [face_id])) @@ -4431,6 +4642,423 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._same_domain_face_ids_cache[item] = list(face_ids) return list(face_ids) + def _asitus_hole_region_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + if not self._can_use_asitus_hole_recognition(): + return [] + if self._asitus_hole_regions_loading: + return [] + if not self._asitus_hole_regions_attempted: + self._load_asitus_hole_regions() + return list(self._asitus_hole_region_cache.get(face_id, ())) + + def _internal_hole_region_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + if not self._can_use_internal_recognition_graph(): + return [] + if not self._internal_hole_regions_attempted: + self._load_internal_hole_regions() + return list(self._internal_hole_region_cache.get(face_id, ())) + + def _can_use_internal_recognition_graph(self) -> bool: + return bool(self.faces) + + def _load_internal_hole_regions(self) -> None: + self._internal_hole_regions_attempted = True + self._internal_hole_region_cache.clear() + self._internal_recognition_info.clear() + try: + regions = recognize_through_hole_regions(self) + mapped_groups: set[tuple[int, ...]] = set() + for region in regions: + group = tuple(sorted({int(item) for item in region.face_ids if 0 <= int(item) < len(self.faces)})) + if len(group) < 2: + continue + mapped_groups.add(group) + for face_id in group: + self._internal_hole_region_cache[face_id] = list(group) + self._internal_recognition_info.update( + { + "ok": True, + "source": "internal-recognition-graph", + "mapped_hole_count": len(mapped_groups), + "mapped_hole_groups": tuple(sorted(mapped_groups)), + "through_hole_region_count": len(regions), + } + ) + except Exception as exc: + self._internal_hole_region_cache.clear() + self._internal_recognition_info.update( + { + "ok": False, + "source": "internal-recognition-graph", + "reason": "recognition-failed", + "message": str(exc), + } + ) + + def _can_use_asitus_hole_recognition(self) -> bool: + if int(getattr(self, "_topology_refresh_generation", 0) or 0) > 1: + return False + suffix = self.filename.suffix.lower() + return suffix in {".step", ".stp"} and self.filename.is_file() + + def _load_asitus_hole_regions(self) -> None: + self.install_asitus_hole_recognition_result(run_asitus_hole_recognition(self.filename)) + + def begin_asitus_hole_region_load(self) -> bool: + if not self._can_use_asitus_hole_recognition(): + return False + if self._asitus_hole_regions_attempted or self._asitus_hole_regions_loading: + return False + self._asitus_hole_regions_loading = True + self._asitus_hole_recognition_info.update( + { + "ok": False, + "reason": "pending", + "message": "Analysis Situs hole recognition is running in the background.", + } + ) + return True + + def install_asitus_hole_recognition_result(self, result: object) -> list[tuple[int, ...]]: + self._asitus_hole_regions_loading = False + self._asitus_hole_regions_attempted = True + self._asitus_hole_region_cache.clear() + self._asitus_hole_recognition_info.clear() + self._asitus_face_relation_cache.clear() + self._asitus_adjacency_relation_cache.clear() + self._asitus_geometric_relation_cache.clear() + if not self._can_use_asitus_hole_recognition(): + self._asitus_hole_recognition_info.update( + { + "ok": False, + "reason": "stale-topology", + "message": "Analysis Situs result ignored because the model topology has changed.", + } + ) + return [] + if not isinstance(result, dict): + result = {"ok": False, "reason": "bad-result", "message": "Analysis Situs returned an unexpected result."} + self._asitus_hole_recognition_info.update( + { + "ok": bool(result.get("ok")), + "reason": result.get("reason", ""), + "message": result.get("message", ""), + "cli": result.get("cli", ""), + } + ) + if not result.get("ok"): + self._clear_same_domain_dependent_caches() + return [] + self._install_asitus_relation_summary(result) + raw_groups = result.get("groups", ()) + if not isinstance(raw_groups, (tuple, list)): + self._clear_same_domain_dependent_caches() + return [] + mapped_groups = self._map_asitus_hole_groups(raw_groups) + if not mapped_groups: + self._clear_same_domain_dependent_caches() + return [] + for group in mapped_groups: + for item in group: + self._asitus_hole_region_cache[int(item)] = list(group) + self._asitus_hole_recognition_info.update( + { + "mapped_hole_count": len(mapped_groups), + "mapped_hole_groups": tuple(tuple(group) for group in mapped_groups), + "source_face_id_base": "analysis-situs-aag-1-based", + } + ) + self._clear_same_domain_dependent_caches() + return mapped_groups + + def fail_asitus_hole_region_load(self, message: str) -> None: + self._asitus_hole_regions_loading = False + self._asitus_hole_regions_attempted = True + self._asitus_hole_region_cache.clear() + self._asitus_hole_recognition_info.clear() + self._asitus_face_relation_cache.clear() + self._asitus_adjacency_relation_cache.clear() + self._asitus_geometric_relation_cache.clear() + self._asitus_hole_recognition_info.update( + { + "ok": False, + "reason": "worker-failed", + "message": str(message or "Analysis Situs hole recognition failed."), + } + ) + self._clear_same_domain_dependent_caches() + + def _clear_same_domain_dependent_caches(self) -> None: + self._same_domain_face_ids_cache.clear() + self._internal_hole_regions_attempted = False + self._internal_hole_region_cache.clear() + self._internal_recognition_info.clear() + self._recognition_graph_cache.clear() + self._through_hole_regions_cache.clear() + self._quick_face_info_cache.clear() + self._face_info_cache.clear() + self._feature_info_cache.clear() + self._face_first_level_topology_cache.clear() + self._cylindrical_first_level_topology_cache.clear() + self._face_first_level_fact_cache.clear() + self._editable_feature_candidates_cache.clear() + self._cylindrical_feature_candidates_cache.clear() + + def _install_asitus_relation_summary(self, result: dict[str, object]) -> None: + faces = result.get("faces", ()) + if isinstance(faces, (tuple, list)): + for item in faces: + if not isinstance(item, dict): + continue + raw_face_id = self._coerce_int(item.get("id")) + face_id = self._map_asitus_single_face_id(raw_face_id) + if face_id is None: + continue + neighbor_ids = tuple( + sorted( + { + mapped + for raw_neighbor in self._iter_int_values(item.get("neighbor_ids")) + for mapped in (self._map_asitus_single_face_id(raw_neighbor),) + if mapped is not None and mapped != face_id + } + ) + ) + self._asitus_face_relation_cache[face_id] = { + "source": "analysis-situs-aag", + "asitus_face_id": raw_face_id, + "surface": str(item.get("surface") or ""), + "neighbor_face_ids": neighbor_ids, + "neighbor_face_count": len(neighbor_ids), + } + + adjacency = result.get("adjacency", ()) + if isinstance(adjacency, (tuple, list)): + for item in adjacency: + if not isinstance(item, dict): + continue + mapped_pair = tuple( + sorted( + { + mapped + for raw_face_id in self._iter_int_values(item.get("face_ids")) + for mapped in (self._map_asitus_single_face_id(raw_face_id),) + if mapped is not None + } + ) + ) + if len(mapped_pair) != 2: + continue + self._asitus_adjacency_relation_cache[mapped_pair] = { + "source": "analysis-situs-aag", + "face_ids": mapped_pair, + "angle_type": str(item.get("angle_type") or ""), + "angle_rad": item.get("angle_rad"), + "edge_ids": tuple(self._iter_int_values(item.get("edge_ids"))), + } + + geometric_relations = result.get("geometric_relations", ()) + if isinstance(geometric_relations, (tuple, list)): + for item in geometric_relations: + if not isinstance(item, dict): + continue + mapped_pair = tuple( + sorted( + { + mapped + for raw_face_id in self._iter_int_values(item.get("face_ids")) + for mapped in (self._map_asitus_single_face_id(raw_face_id),) + if mapped is not None + } + ) + ) + if len(mapped_pair) != 2: + continue + relation = { + "source": str(item.get("source") or "analysis-situs-probe"), + "face_ids": mapped_pair, + "relation_type": str(item.get("relation_type") or ""), + "residual": item.get("residual"), + } + self._asitus_geometric_relation_cache.setdefault(mapped_pair, []).append(relation) + + if ( + self._asitus_face_relation_cache + or self._asitus_adjacency_relation_cache + or self._asitus_geometric_relation_cache + ): + self._asitus_hole_recognition_info.update( + { + "aag_face_relation_count": len(self._asitus_face_relation_cache), + "aag_adjacency_relation_count": len(self._asitus_adjacency_relation_cache), + "aag_geometric_relation_pair_count": len(self._asitus_geometric_relation_cache), + "aag_geometric_relation_count": sum( + len(items) for items in self._asitus_geometric_relation_cache.values() + ), + "aag_surface_summary": dict(result.get("surface_summary", {}) or {}), + "aag_angle_summary": dict(result.get("angle_summary", {}) or {}), + "aag_geometric_relation_summary": dict(result.get("geometric_relation_summary", {}) or {}), + "aag_geometric_relation_mode": str(result.get("geometric_relation_mode") or ""), + "aag_relation_source": "analysis-situs-aag", + } + ) + + def _asitus_face_summary_fields(self, face_id: int) -> dict[str, object]: + entry = self._asitus_face_relation_cache.get(int(face_id)) + if not entry: + return {} + neighbor_ids = tuple(int(item) for item in entry.get("neighbor_face_ids", ()) or ()) + relation_types: Counter[str] = Counter() + for neighbor_id in neighbor_ids: + pair = tuple(sorted((int(face_id), int(neighbor_id)))) + relation = self._asitus_adjacency_relation_cache.get(pair) + if not relation: + continue + relation_type = str(relation.get("angle_type") or "adjacent") + relation_types[relation_type] += 1 + relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items())) + if not relation_summary: + relation_summary = f"adjacent:{len(neighbor_ids)}" + geometric_relation_types: Counter[str] = Counter() + for pair, relations in self._asitus_geometric_relation_cache.items(): + if int(face_id) not in pair: + continue + for relation in relations: + relation_type = str(relation.get("relation_type") or "") + if relation_type: + geometric_relation_types[relation_type] += 1 + geometric_relation_summary = ", ".join( + f"{key}:{value}" for key, value in sorted(geometric_relation_types.items()) + ) + return { + "asitus_relation_status": "ready", + "asitus_relation_source": entry.get("source", "analysis-situs-aag"), + "asitus_face_id": entry.get("asitus_face_id"), + "asitus_surface": entry.get("surface", ""), + "asitus_adjacent_face_ids": neighbor_ids, + "asitus_adjacent_face_count": len(neighbor_ids), + "asitus_adjacent_relation_summary": relation_summary, + "asitus_geometric_relation_count": sum(geometric_relation_types.values()), + "asitus_geometric_relation_summary": geometric_relation_summary, + "asitus_geometric_relation_types": tuple(sorted(geometric_relation_types)), + } + + def _map_asitus_single_face_id(self, raw_face_id: object) -> int | None: + value = self._coerce_int(raw_face_id) + if value is None: + return None + if 1 <= value <= len(self.faces): + return value - 1 + if 0 <= value < len(self.faces): + return value + return None + + @staticmethod + def _coerce_int(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _iter_int_values(values: object) -> tuple[int, ...]: + if values is None or isinstance(values, (str, bytes)): + return () + try: + iterator = iter(values) # type: ignore[arg-type] + except TypeError: + coerced = StepModel._coerce_int(values) + return () if coerced is None else (coerced,) + result: list[int] = [] + for item in iterator: + coerced = StepModel._coerce_int(item) + if coerced is not None: + result.append(coerced) + return tuple(result) + + def _map_asitus_hole_groups(self, groups: Iterable[object]) -> list[tuple[int, ...]]: + mapped: list[tuple[int, ...]] = [] + seen: set[tuple[int, ...]] = set() + for raw_group in groups: + try: + values = tuple(sorted({int(item) for item in raw_group})) # type: ignore[arg-type] + except (TypeError, ValueError): + continue + group = self._map_asitus_hole_group(values) + if not group or group in seen: + continue + seen.add(group) + mapped.append(group) + return mapped + + def _map_asitus_hole_group(self, raw_group: tuple[int, ...]) -> tuple[int, ...]: + best_group: tuple[int, ...] = () + best_score = -1.0 + for offset in (-1, 0): + candidate = tuple(sorted({item + offset for item in raw_group})) + score, cylinder_group = self._score_asitus_hole_group(candidate) + if score > best_score: + best_score = score + best_group = cylinder_group + return best_group if best_score >= 35.0 else () + + def _score_asitus_hole_group(self, candidate: tuple[int, ...]) -> tuple[float, tuple[int, ...]]: + if not candidate: + return -1.0, () + if any(face_id < 0 or face_id >= len(self.faces) for face_id in candidate): + return -1.0, () + + cylinder_face_ids: list[int] = [] + cylinder_surfaces: list[BRepAdaptor_Surface] = [] + for face_id in candidate: + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + except Exception: + continue + if surf.GetType() != GeomAbs_Cylinder: + continue + cylinder_face_ids.append(face_id) + cylinder_surfaces.append(surf) + if not cylinder_face_ids: + return -1.0, () + + score = 20.0 * len(cylinder_face_ids) + if len(cylinder_face_ids) < len(candidate): + score -= 5.0 * (len(candidate) - len(cylinder_face_ids)) + + part_ids = {self.face_part_ids[item] for item in cylinder_face_ids} + solid_ids = {self.face_solid_ids[item] for item in cylinder_face_ids} + if len(part_ids) == 1: + score += 10.0 + if len(solid_ids) == 1: + score += 10.0 + + if len(cylinder_surfaces) > 1: + diagonal = _shape_diagonal(self.shape) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + source = cylinder_surfaces[0] + if all(_surfaces_are_cocylindrical(source, other, tolerance) for other in cylinder_surfaces[1:]): + score += 40.0 + else: + score -= 45.0 + + angular_span = 0.0 + for surf in cylinder_surfaces: + try: + angular_span += abs(float(surf.LastUParameter()) - float(surf.FirstUParameter())) + except Exception: + pass + if angular_span >= math.tau * 0.85: + score += 25.0 + elif angular_span >= math.pi * 0.9: + score += 8.0 + + return score, tuple(sorted(set(cylinder_face_ids))) + def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]: if face_id < 0 or face_id >= len(self.faces): return [] diff --git a/step_editor/operations.py b/step_editor/operations.py index 0fbf2c0..fe4f609 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -13506,6 +13506,169 @@ class OperationMixin: f"verified_face={verification.get('face_id', '')}." ) + def _cylindrical_hole_batch_entry(self, face_id: int) -> dict[str, object] | None: + if face_id < 0 or face_id >= len(self.faces): + return None + try: + feature = self.feature_info(face_id) + except Exception: + return None + if feature.get("surface") != "cylinder" or str(feature.get("feature_guess") or "") != "hole/groove candidate": + return None + if not _is_effectively_full_cylinder(feature): + return None + diameter = _float_or_none(feature.get("diameter")) + if diameter is None or diameter <= 1e-9: + return None + axis_data = self._cylindrical_face_axis_mid_center(face_id, feature) + center = _tuple_or_none((axis_data or {}).get("current_axis_center")) + if center is None: + center = _tuple_or_none(feature.get("axis_center")) + if center is None: + return None + try: + logical_id = self.face_region_logical_id(face_id) + except Exception: + logical_id = face_id + return { + "face_id": int(face_id), + "logical_id": int(logical_id), + "diameter": float(diameter), + "axis_center": center, + "same_domain_face_ids": tuple(_int_values(feature.get("same_domain_face_ids")) or [face_id]), + } + + def _resolve_cylindrical_hole_batch_ref(self, ref: dict[str, object]) -> int | None: + reference_center = _tuple_or_none(ref.get("axis_center") or ref.get("center")) + reference_diameter = _float_or_none(ref.get("diameter")) + if reference_center is None: + return None + + preferred_face_id = _int_or_none(ref.get("face_id")) + candidates: list[tuple[float, int]] = [] + seen_logical_ids: set[int] = set() + face_order: list[int] = [] + if preferred_face_id is not None and 0 <= preferred_face_id < len(self.faces): + face_order.append(preferred_face_id) + face_order.extend(face_id for face_id in range(len(self.faces)) if face_id != preferred_face_id) + + for face_id in face_order: + entry = self._cylindrical_hole_batch_entry(face_id) + if entry is None: + continue + logical_id = int(entry.get("logical_id", face_id)) + if logical_id in seen_logical_ids: + continue + seen_logical_ids.add(logical_id) + center = _tuple_or_none(entry.get("axis_center")) + diameter = _float_or_none(entry.get("diameter")) + if center is None: + continue + center_distance = _vector_length(_tuple_sub(center, reference_center)) + diameter_delta = 0.0 + if reference_diameter is not None and diameter is not None: + diameter_delta = abs(diameter - reference_diameter) + distance_limit = max(float(reference_diameter or diameter or 1.0) * 3.0, 1e-3) + if center_distance > distance_limit: + continue + score = center_distance + diameter_delta * 0.1 + (0.0 if face_id == preferred_face_id else 1e-6) + candidates.append((score, int(entry["face_id"]))) + + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + + def edit_cylindrical_holes_by_refs( + self, + refs: Iterable[dict[str, object]], + target_diameter: float | None = None, + offset: tuple[float, float, float] | None = None, + ) -> str: + entries = [dict(item) for item in refs if isinstance(item, dict)] + if len(entries) < 2: + raise ValueError("Batch hole edit requires at least two cylindrical hole references.") + diameter = _float_or_none(target_diameter) + move_offset = _tuple_or_none(offset) + if diameter is None and move_offset is None: + raise ValueError("Batch hole edit requires a target diameter or a position offset.") + if diameter is not None and diameter <= 0: + raise ValueError("Target diameter must be greater than 0.") + if move_offset is not None and _vector_length(move_offset) <= 1e-9: + move_offset = None + if diameter is None and move_offset is None: + raise ValueError("Position offset is zero; no batch hole edit is required.") + + snapshot = self.snapshot() + resized = 0 + moved = 0 + try: + if diameter is not None: + for entry in entries: + face_id = self._resolve_cylindrical_hole_batch_ref(entry) + if face_id is None: + raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.") + self.resize_cylindrical_hole(face_id, diameter) + resized += 1 + if move_offset is not None: + for entry in entries: + face_id = self._resolve_cylindrical_hole_batch_ref(entry) + if face_id is None: + raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.") + current = self._cylindrical_hole_batch_entry(face_id) + center = _tuple_or_none((current or {}).get("axis_center")) + if center is None: + raise RuntimeError(f"Could not read current axis center for cylindrical hole face {face_id}.") + self.move_cylindrical_hole_axis(face_id, _tuple_add(center, move_offset)) + moved += 1 + except Exception: + self.restore_snapshot(snapshot) + raise + + summary_parts: list[str] = [] + if resized: + summary_parts.append(f"diameter -> {diameter:g} on {resized} holes") + if moved and move_offset is not None: + summary_parts.append(f"offset {move_offset} on {moved} holes") + return "Multi-hole edit completed: " + "; ".join(summary_parts) + "." + + def resize_cylindrical_holes_by_refs( + self, + refs: Iterable[dict[str, object]], + target_diameter: float, + ) -> str: + return self.edit_cylindrical_holes_by_refs(refs, target_diameter=target_diameter) + + def move_cylindrical_holes_by_offset( + self, + refs: Iterable[dict[str, object]], + offset: tuple[float, float, float], + ) -> str: + return self.edit_cylindrical_holes_by_refs(refs, offset=offset) + + def suppress_cylindrical_holes_by_refs( + self, + refs: Iterable[dict[str, object]], + ) -> str: + entries = [dict(item) for item in refs if isinstance(item, dict)] + if len(entries) < 2: + raise ValueError("Batch hole suppress requires at least two cylindrical hole references.") + + snapshot = self.snapshot() + suppressed = 0 + try: + for entry in entries: + face_id = self._resolve_cylindrical_hole_batch_ref(entry) + if face_id is None: + raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.") + self.suppress_cylindrical_hole(face_id) + suppressed += 1 + except Exception: + self.restore_snapshot(snapshot) + raise + + return f"Multi-hole suppress completed: {suppressed} holes." + def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": diff --git a/step_editor/parametric_component.py b/step_editor/parametric_component.py index db0f8d2..2881f5e 100644 --- a/step_editor/parametric_component.py +++ b/step_editor/parametric_component.py @@ -29,6 +29,9 @@ _ACTION_OPERATION_MAP = { "resize_shell_thickness": "resize_shell_thickness", "resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale", "resize_hole": "resize_cylindrical_hole", + "resize_multi_selected_holes": "resize_cylindrical_holes_by_refs", + "move_multi_selected_holes_by_offset": "move_cylindrical_holes_by_offset", + "suppress_multi_selected_holes": "suppress_cylindrical_holes_by_refs", "resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale", "resize_hole_depth": "resize_cylindrical_depth", "resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale", @@ -203,6 +206,42 @@ def component_edit_config_from_spec( ) -> dict[str, object] | None: action = str(spec.get("action") or "") operation = operation_for_action(action) + if action in {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset"}: + refs = [dict(item) for item in (spec.get("multi_hole_refs") or []) if isinstance(item, dict)] + if not operation or len(refs) < 2: + return None + value_type = str(spec.get("value_type", "number")) + default_value = parameter_row.get("default", "") + target_value: object + if value_type == "vector3": + target_value = _as_list3(default_value) or _as_list3(spec.get("current_raw")) or default_value + elif value_type in {"number", "positive", "integer", "integer_or_empty"}: + target_value = _float_or_text(default_value) + else: + target_value = default_value + target_arg: object = {"param": parameter_row["name"]} + transform = str(spec.get("target_transform") or "") + if transform: + target_arg = { + "param": parameter_row["name"], + "transform": transform, + "context": spec.get("transform_context", {}), + } + return { + "parameter": parameter_row["name"], + "displayName": parameter_row.get("displayName", parameter_row["name"]), + "targetKind": "multi_feature", + "targetId": -1, + "uiAction": action, + "operation": operation, + "args": [refs, target_arg], + "default": target_value, + "valueType": value_type, + "scope": spec.get("scope_key", spec.get("scope_default", "")), + "scopeLabel": spec.get("scope_label", spec.get("scope_text", "")), + "sourceStep": str(step_path or ""), + "parameterKey": spec.get("key", ""), + } target_id = _target_object_id(spec, selected_kind, selected_face_id, selected_edge_id) if not operation or target_id is None: return None diff --git a/step_editor/recognition_graph.py b/step_editor/recognition_graph.py new file mode 100644 index 0000000..e9caf55 --- /dev/null +++ b/step_editor/recognition_graph.py @@ -0,0 +1,539 @@ +from __future__ import annotations + +import math +from collections import Counter +from dataclasses import dataclass +from typing import Iterable + +from OCC.Core.BRepAdaptor import BRepAdaptor_Surface +from OCC.Core.BRepGProp import brepgprop +from OCC.Core.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane +from OCC.Core.GProp import GProp_GProps +from OCC.Core.TopAbs import TopAbs_EDGE +from OCC.Core.TopExp import topexp +from OCC.Core.TopTools import TopTools_IndexedMapOfShape +from OCC.Core.TopoDS import TopoDS_Shape + +from .geometry_utils import ( + _axis_parameter, + _direction_dot, + _point_axis_distance, + _shape_axis_interval, + _shape_diagonal, + _surface_center, +) + + +ANGULAR_TOLERANCE = 1.0e-7 +COVERAGE_TOLERANCE = 0.82 +EXTERNAL_COAXIAL_CONFIDENCE_BOOST = 0.08 +EXTERNAL_TANGENT_CONFIDENCE_BOOST = 0.03 +EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST = 0.04 + + +@dataclass(frozen=True) +class RecognitionFace: + face_id: int + solid_id: int + surface_type: str + area: float + centroid: tuple[float, float, float] + boundary_edge_ids: tuple[int, ...] + adjacent_face_ids: tuple[int, ...] + axis_point: object | None = None + axis_direction: object | None = None + radius: float | None = None + axis_interval: tuple[float, float] | None = None + angular_span: float | None = None + plane_parameter: float | None = None + + +@dataclass(frozen=True) +class RecognitionRelation: + relation_type: str + face_ids: tuple[int, ...] + residual: float + + +@dataclass(frozen=True) +class RecognitionGraph: + solid_id: int + face_ids: tuple[int, ...] + faces: tuple[RecognitionFace, ...] + relation_counts: dict[str, int] + relations: tuple[RecognitionRelation, ...] + + def face(self, face_id: int) -> RecognitionFace | None: + for item in self.faces: + if item.face_id == int(face_id): + return item + return None + + +@dataclass(frozen=True) +class ThroughHoleRegion: + face_ids: tuple[int, ...] + solid_id: int + diameter: float + axis_interval: tuple[float, float] + angular_coverage: float + opening_face_ids: tuple[int, ...] + confidence: float + + +def build_recognition_graph(model: object, solid_id: int) -> RecognitionGraph: + face_ids = tuple( + face_id + for face_id, item in enumerate(getattr(model, "face_solid_ids", ())) + if int(item) == int(solid_id) + ) + faces: list[RecognitionFace] = [] + for face_id in face_ids: + face = getattr(model, "faces")[face_id] + boundary_edge_ids = tuple(_face_boundary_edge_ids(model, face_id)) + adjacent_face_ids = tuple(sorted(_adjacent_face_ids(model, boundary_edge_ids, face_id))) + surf = BRepAdaptor_Surface(face) + surface_type = "other" + axis_point = None + axis_direction = None + radius: float | None = None + axis_interval: tuple[float, float] | None = None + angular_span: float | None = None + plane_parameter: float | None = None + if surf.GetType() == GeomAbs_Cylinder: + surface_type = "cylinder" + cylinder = surf.Cylinder() + axis = cylinder.Axis() + axis_point = axis.Location() + axis_direction = axis.Direction() + radius = float(cylinder.Radius()) + axis_interval = _shape_axis_interval(face, axis_point, axis_direction) + angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter())) + elif surf.GetType() == GeomAbs_Plane: + surface_type = "plane" + plane = surf.Plane() + axis_point = plane.Location() + axis_direction = plane.Axis().Direction() + plane_parameter = _axis_parameter(axis_point, axis_direction, plane.Location()) + area, centroid = _surface_metrics(face) + faces.append( + RecognitionFace( + face_id=face_id, + solid_id=int(solid_id), + surface_type=surface_type, + area=area, + centroid=centroid, + boundary_edge_ids=boundary_edge_ids, + adjacent_face_ids=adjacent_face_ids, + axis_point=axis_point, + axis_direction=axis_direction, + radius=radius, + axis_interval=axis_interval, + angular_span=angular_span, + plane_parameter=plane_parameter, + ) + ) + relations = infer_recognition_relations(faces, _recognition_tolerance(model)) + relations.extend(_external_recognition_relations(model, face_ids)) + relation_counts = dict(Counter(item.relation_type for item in relations)) + return RecognitionGraph( + solid_id=int(solid_id), + face_ids=face_ids, + faces=tuple(faces), + relation_counts=relation_counts, + relations=tuple(relations), + ) + + +def infer_recognition_relations( + faces: Iterable[RecognitionFace], + tolerance: float, +) -> list[RecognitionRelation]: + items = list(faces) + relations: list[RecognitionRelation] = [] + for face in items: + for adjacent_id in face.adjacent_face_ids: + if face.face_id < adjacent_id: + relations.append(RecognitionRelation("adjacent", (face.face_id, adjacent_id), 0.0)) + + for index, left in enumerate(items): + for right in items[index + 1 :]: + if left.surface_type == "plane" and right.surface_type == "plane": + relation = _plane_relation(left, right, tolerance) + if relation is not None: + relations.append(relation) + if left.surface_type == "cylinder" and right.surface_type == "cylinder": + relation = _cylinder_relation(left, right, tolerance) + if relation is not None: + relations.append(relation) + return relations + + +def recognize_through_hole_regions(model: object, solid_id: int | None = None) -> list[ThroughHoleRegion]: + solid_ids = _solid_ids(model, solid_id) + cache_key = ("all", solid_ids) if solid_id is None else ("solid", int(solid_id)) + cache = getattr(model, "_through_hole_regions_cache", None) + if isinstance(cache, dict) and cache_key in cache: + return list(cache[cache_key]) + + regions: list[ThroughHoleRegion] = [] + for current_solid_id in solid_ids: + graph = _cached_recognition_graph(model, current_solid_id) + regions.extend(_recognize_graph_through_hole_regions(model, graph)) + result = _dedupe_regions(regions) + if isinstance(cache, dict): + cache[cache_key] = list(result) + return result + + +def recognition_summary(model: object) -> dict[str, object]: + solid_ids = _solid_ids(model, None) + relation_counts: Counter[str] = Counter() + hole_count = 0 + face_count = 0 + for solid_id in solid_ids: + graph = _cached_recognition_graph(model, solid_id) + relation_counts.update(graph.relation_counts) + face_count += len(graph.face_ids) + hole_count += len(recognize_through_hole_regions(model, solid_id)) + return { + "source": "internal-recognition-graph", + "solid_count": len(solid_ids), + "face_count": face_count, + "relation_counts": dict(relation_counts), + "through_hole_region_count": hole_count, + } + + +def _cached_recognition_graph(model: object, solid_id: int) -> RecognitionGraph: + cache = getattr(model, "_recognition_graph_cache", None) + if isinstance(cache, dict) and int(solid_id) in cache: + return cache[int(solid_id)] + graph = build_recognition_graph(model, int(solid_id)) + if isinstance(cache, dict): + cache[int(solid_id)] = graph + return graph + + +def _recognize_graph_through_hole_regions(model: object, graph: RecognitionGraph) -> list[ThroughHoleRegion]: + cylinders = [face for face in graph.faces if face.surface_type == "cylinder" and face.radius and face.radius > 0] + if not cylinders: + return [] + tolerance = _recognition_tolerance(model) + visited: set[int] = set() + regions: list[ThroughHoleRegion] = [] + for source in cylinders: + if source.face_id in visited: + continue + group = _cocylindrical_interval_group(model, cylinders, source, tolerance) + visited.update(face.face_id for face in group) + if not group: + continue + coverage = sum(min(abs(float(face.angular_span or 0.0)), math.tau) for face in group) + if coverage < math.tau * COVERAGE_TOLERANCE: + continue + intervals = [face.axis_interval for face in group if face.axis_interval is not None] + if not intervals: + continue + v_min = min(float(item[0]) for item in intervals) + v_max = max(float(item[1]) for item in intervals) + opening_face_ids = _opening_plane_face_ids(graph, group, tolerance) + confidence = 0.72 + if coverage >= math.tau * 0.98: + confidence += 0.12 + if len(opening_face_ids) >= 2: + confidence += 0.12 + if len(group) > 1: + confidence += 0.04 + if _has_external_relation(model, (face.face_id for face in group), {"coaxial"}): + confidence += EXTERNAL_COAXIAL_CONFIDENCE_BOOST + if _has_external_relation(model, (face.face_id for face in group), {"tangent"}): + confidence += EXTERNAL_TANGENT_CONFIDENCE_BOOST + if len(opening_face_ids) >= 2 and _has_external_relation( + model, + opening_face_ids, + {"coplanar", "parallel"}, + ): + confidence += EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST + regions.append( + ThroughHoleRegion( + face_ids=tuple(sorted(face.face_id for face in group)), + solid_id=graph.solid_id, + diameter=float(group[0].radius or 0.0) * 2.0, + axis_interval=(v_min, v_max), + angular_coverage=coverage, + opening_face_ids=tuple(sorted(opening_face_ids)), + confidence=min(confidence, 0.99), + ) + ) + return regions + + +def _external_recognition_relations(model: object, face_ids: Iterable[int]) -> list[RecognitionRelation]: + cache = getattr(model, "_asitus_geometric_relation_cache", None) + if not isinstance(cache, dict): + return [] + valid_face_ids = {int(item) for item in face_ids} + relations: list[RecognitionRelation] = [] + for pair, items in cache.items(): + try: + face_pair = tuple(sorted(int(item) for item in pair)) + except (TypeError, ValueError): + continue + if len(face_pair) != 2 or face_pair[0] not in valid_face_ids or face_pair[1] not in valid_face_ids: + continue + if not isinstance(items, (tuple, list)): + continue + for item in items: + if not isinstance(item, dict): + continue + relation_type = str(item.get("relation_type") or "").strip() + if not relation_type: + continue + relations.append( + RecognitionRelation( + f"external_{relation_type}", + face_pair, + _float_or_zero(item.get("residual")), + ) + ) + return relations + + +def _has_external_relation(model: object, face_ids: Iterable[int], relation_types: set[str]) -> bool: + return _external_relation(model, face_ids, relation_types) is not None + + +def _external_relation( + model: object, + face_ids: Iterable[int], + relation_types: set[str], +) -> dict[str, object] | None: + cache = getattr(model, "_asitus_geometric_relation_cache", None) + if not isinstance(cache, dict): + return None + face_id_set = {int(item) for item in face_ids} + if len(face_id_set) < 2: + return None + for pair, items in cache.items(): + try: + face_pair = tuple(sorted(int(item) for item in pair)) + except (TypeError, ValueError): + continue + if len(face_pair) != 2 or face_pair[0] not in face_id_set or face_pair[1] not in face_id_set: + continue + if not isinstance(items, (tuple, list)): + continue + for item in items: + if isinstance(item, dict) and str(item.get("relation_type") or "").strip() in relation_types: + return item + return None + + +def _float_or_zero(value: object) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _cocylindrical_interval_group( + model: object, + cylinders: list[RecognitionFace], + source: RecognitionFace, + tolerance: float, +) -> list[RecognitionFace]: + pending = [source] + visited = {source.face_id} + result: list[RecognitionFace] = [] + while pending: + current = pending.pop(0) + result.append(current) + for candidate in cylinders: + if candidate.face_id in visited: + continue + if candidate.solid_id != source.solid_id: + continue + if not _recognition_faces_are_cocylindrical(source, candidate, tolerance) and not ( + _external_cocylindrical_hint(model, source, candidate, tolerance) + ): + continue + if not _intervals_overlap_or_touch(current.axis_interval, candidate.axis_interval, tolerance * 50.0): + continue + visited.add(candidate.face_id) + pending.append(candidate) + return result + + +def _external_cocylindrical_hint( + model: object, + left: RecognitionFace, + right: RecognitionFace, + tolerance: float, +) -> bool: + relation = _external_relation(model, (left.face_id, right.face_id), {"coaxial"}) + if relation is None: + return False + if left.radius is None or right.radius is None: + return False + radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6) + radius_delta = abs(float(left.radius) - float(right.radius)) + residual = _float_or_zero(relation.get("residual")) + return radius_delta <= radius_tolerance or residual <= radius_tolerance + + +def _recognition_faces_are_cocylindrical(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> bool: + if left.axis_point is None or left.axis_direction is None or right.axis_point is None or right.axis_direction is None: + return False + if left.radius is None or right.radius is None: + return False + radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6) + if abs(left.radius - right.radius) > radius_tolerance: + return False + if abs(_direction_dot(left.axis_direction, right.axis_direction)) < 1.0 - 1e-6: + return False + return _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point) <= max(tolerance, radius_tolerance) + + +def _opening_plane_face_ids( + graph: RecognitionGraph, + group: list[RecognitionFace], + tolerance: float, +) -> set[int]: + if not group or group[0].axis_point is None or group[0].axis_direction is None: + return set() + axis_point = group[0].axis_point + axis_direction = group[0].axis_direction + intervals = [face.axis_interval for face in group if face.axis_interval is not None] + if not intervals: + return set() + v_min = min(float(item[0]) for item in intervals) + v_max = max(float(item[1]) for item in intervals) + end_tolerance = max(tolerance * 80.0, abs(v_max - v_min) * 1e-4, 1e-4) + side_ids = {face.face_id for face in group} + adjacent_ids: set[int] = set() + for face in group: + adjacent_ids.update(face.adjacent_face_ids) + openings: set[int] = set() + by_id = {face.face_id: face for face in graph.faces} + for adjacent_id in adjacent_ids - side_ids: + adjacent = by_id.get(adjacent_id) + if adjacent is None or adjacent.surface_type != "plane" or adjacent.axis_direction is None: + continue + if abs(_direction_dot(adjacent.axis_direction, axis_direction)) < 1.0 - ANGULAR_TOLERANCE: + continue + try: + parameter = _axis_parameter(axis_point, axis_direction, _gp_point(adjacent.centroid)) + except Exception: + continue + if abs(parameter - v_min) <= end_tolerance or abs(parameter - v_max) <= end_tolerance: + openings.add(adjacent_id) + return openings + + +def _plane_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None: + if left.axis_direction is None or right.axis_direction is None: + return None + dot = abs(_direction_dot(left.axis_direction, right.axis_direction)) + if dot >= 1.0 - ANGULAR_TOLERANCE: + residual = abs(_plane_offset(left, right)) + if residual <= tolerance: + return RecognitionRelation("coplanar", (left.face_id, right.face_id), residual) + return RecognitionRelation("parallel", (left.face_id, right.face_id), residual) + if dot <= ANGULAR_TOLERANCE: + return RecognitionRelation("perpendicular", (left.face_id, right.face_id), dot) + return None + + +def _cylinder_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None: + if not _recognition_faces_are_cocylindrical(left, right, tolerance): + if left.axis_point is not None and left.axis_direction is not None and right.axis_direction is not None: + if abs(_direction_dot(left.axis_direction, right.axis_direction)) >= 1.0 - ANGULAR_TOLERANCE: + return RecognitionRelation("parallel_axis", (left.face_id, right.face_id), 0.0) + return None + residual = 0.0 + if left.axis_point is not None and left.axis_direction is not None and right.axis_point is not None: + residual = _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point) + return RecognitionRelation("coaxial", (left.face_id, right.face_id), residual) + + +def _plane_offset(left: RecognitionFace, right: RecognitionFace) -> float: + if left.axis_point is None or left.axis_direction is None or right.axis_point is None: + return math.inf + return float(_axis_parameter(left.axis_point, left.axis_direction, right.axis_point)) + + +def _surface_metrics(shape: TopoDS_Shape) -> tuple[float, tuple[float, float, float]]: + props = GProp_GProps() + try: + brepgprop.SurfaceProperties(shape, props) + center = props.CentreOfMass() + return float(props.Mass()), (float(center.X()), float(center.Y()), float(center.Z())) + except Exception: + center = _surface_center(shape) + return 0.0, (float(center.X()), float(center.Y()), float(center.Z())) + + +def _face_boundary_edge_ids(model: object, face_id: int) -> list[int]: + if hasattr(model, "_face_boundary_edge_ids"): + return list(model._face_boundary_edge_ids(face_id)) # noqa: SLF001 + edges = TopTools_IndexedMapOfShape() + topexp.MapShapes(getattr(model, "faces")[face_id], TopAbs_EDGE, edges) + return list(range(edges.Size())) + + +def _adjacent_face_ids(model: object, edge_ids: Iterable[int], face_id: int) -> set[int]: + adjacent: set[int] = set() + if hasattr(model, "_adjacent_face_ids_for_edges"): + adjacent.update(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001 + else: + edge_face_ids = getattr(model, "_edge_face_ids_cache", {}) + for edge_id in edge_ids: + adjacent.update(int(item) for item in edge_face_ids.get(int(edge_id), ()) if int(item) != int(face_id)) + return adjacent + + +def _recognition_tolerance(model: object) -> float: + try: + diagonal = _shape_diagonal(getattr(model, "shape")) + except Exception: + diagonal = 1.0 + return min(max(float(diagonal) * 1e-7, 1e-6), 1e-3) + + +def _solid_ids(model: object, solid_id: int | None) -> tuple[int, ...]: + if solid_id is not None: + return (int(solid_id),) + face_solid_ids = sorted({int(item) for item in getattr(model, "face_solid_ids", ()) if int(item) >= 0}) + if face_solid_ids: + return tuple(face_solid_ids) + return tuple(range(len(getattr(model, "solids", ()) or ()))) + + +def _intervals_overlap_or_touch( + left: tuple[float, float] | None, + right: tuple[float, float] | None, + tolerance: float, +) -> bool: + if left is None or right is None: + return True + left_min, left_max = min(left), max(left) + right_min, right_max = min(right), max(right) + return max(left_min, right_min) <= min(left_max, right_max) + max(tolerance, 0.0) + + +def _dedupe_regions(regions: Iterable[ThroughHoleRegion]) -> list[ThroughHoleRegion]: + result: list[ThroughHoleRegion] = [] + seen: set[tuple[int, ...]] = set() + for region in sorted(regions, key=lambda item: (item.solid_id, item.face_ids)): + if region.face_ids in seen: + continue + seen.add(region.face_ids) + result.append(region) + return result + + +def _gp_point(values: tuple[float, float, float]): + from OCC.Core.gp import gp_Pnt + + return gp_Pnt(float(values[0]), float(values[1]), float(values[2])) diff --git a/step_editor/recognition_priority.py b/step_editor/recognition_priority.py index a0a4677..de37e4b 100644 --- a/step_editor/recognition_priority.py +++ b/step_editor/recognition_priority.py @@ -38,6 +38,17 @@ USER_PRIORITY_BUCKETS: tuple[tuple[int, str, str], ...] = ( (90, "只读/诊断", "暂未稳定归类为可修改特征。"), ) +EXTERNAL_RELATION_SCORE_WEIGHTS: dict[str, int] = { + "coaxial": 8, + "tangent": 5, + "coplanar": 4, + "parallel": 3, + "perpendicular": 3, + "parallel_axis": 3, +} +EXTERNAL_RELATION_SCORE_LIMIT = 18 +EXTERNAL_FEATURE_HINT_SCORE_LIMIT = 12 + def _text(value: object) -> str: return str(value or "").strip() @@ -50,6 +61,69 @@ def _float_or_none(value: object) -> float | None: return None +def _int_or_zero(value: object) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _text_values(value: object) -> tuple[str, ...]: + if value is None or value == "": + return () + if isinstance(value, str): + return (value.strip(),) if value.strip() else () + if isinstance(value, (list, tuple, set)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return () + + +def _relation_types_from_summary(value: object) -> tuple[str, ...]: + text = _text(value) + if not text: + return () + result: list[str] = [] + for chunk in text.replace(";", ",").split(","): + relation_type = chunk.split(":", 1)[0].strip() + if relation_type: + result.append(relation_type) + return tuple(result) + + +def external_relation_score_bonus(info: Mapping[str, object]) -> int: + relation_types = _text_values(info.get("external_recognition_relation_types")) or _text_values( + info.get("asitus_geometric_relation_types") + ) + if not relation_types: + relation_types = _relation_types_from_summary( + info.get("external_recognition_relation_summary") + or info.get("asitus_geometric_relation_summary") + ) + relation_count = _int_or_zero( + info.get("external_recognition_relation_count") + or info.get("asitus_geometric_relation_count") + ) + score = 0 + for relation_type in relation_types: + score += EXTERNAL_RELATION_SCORE_WEIGHTS.get(relation_type, 1) + if relation_count and not relation_types: + score = min(relation_count * 2, EXTERNAL_RELATION_SCORE_LIMIT) + score = max(0, min(score, EXTERNAL_RELATION_SCORE_LIMIT)) + hint_score = min(_int_or_zero(info.get("analysis_situs_feature_hint_score")), EXTERNAL_FEATURE_HINT_SCORE_LIMIT) + return max(0, min(score + hint_score, EXTERNAL_RELATION_SCORE_LIMIT + EXTERNAL_FEATURE_HINT_SCORE_LIMIT)) + + +def _confidence_sort_rank(value: object) -> int: + return { + "high": 0, + "medium": 1, + "low": 2, + "pending": 3, + "unchecked": 3, + "none": 4, + }.get(_text(value), 5) + + def _is_effectively_full_cylinder(info: Mapping[str, object]) -> bool: if bool(info.get("is_full_cylinder")): return True @@ -146,7 +220,7 @@ def feature_recognition_priority_reason(info: Mapping[str, object]) -> str: return reason -def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int]: +def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int, int, int]: status_order = {"ready": 0, "candidate": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} target_id = info.get("target_id", info.get("face_id", info.get("edge_id", -1))) @@ -158,5 +232,7 @@ def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, feature_recognition_priority(info), status_order.get(_text(info.get("status")), 9), risk_order.get(_text(info.get("risk")), 9), + _confidence_sort_rank(info.get("confidence") or info.get("recognition_confidence")), + -external_relation_score_bonus(info), numeric_target, ) diff --git a/step_editor/relation_formulas.py b/step_editor/relation_formulas.py new file mode 100644 index 0000000..8c6c18d --- /dev/null +++ b/step_editor/relation_formulas.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from dataclasses import dataclass +import ast +import math +import re +from typing import Callable, Iterable + + +RELATION_REF_PATTERN = re.compile( + r"\b(?PFace|Edge)(?P\d+)\.(?P[A-Za-z0-9_\u4e00-\u9fff]+)\b" +) + + +class RelationFormulaError(ValueError): + pass + + +@dataclass(frozen=True) +class ObjectParameterRef: + kind: str + object_id: int + parameter: str + + @property + def token(self) -> str: + return f"{self.kind}{self.object_id}.{self.parameter}" + + +@dataclass(frozen=True) +class RelationFormula: + text: str + target: ObjectParameterRef + expression: str + safe_expression: str + references: tuple[ObjectParameterRef, ...] + + +class Vector3: + __slots__ = ("values",) + + def __init__(self, values: Iterable[object]) -> None: + items = tuple(values) + if len(items) != 3: + raise TypeError("Vector expression must contain exactly 3 values.") + try: + self.values = (float(items[0]), float(items[1]), float(items[2])) + except (TypeError, ValueError) as exc: + raise TypeError("Vector expression values must be numbers.") from exc + + def __iter__(self): + return iter(self.values) + + def __len__(self) -> int: + return 3 + + def __getitem__(self, index: int) -> float: + return self.values[index] + + def __repr__(self) -> str: + return f"Vector3({self.values!r})" + + def __add__(self, other: object) -> "Vector3": + right = _coerce_vector(other) + return Vector3((self.values[0] + right[0], self.values[1] + right[1], self.values[2] + right[2])) + + def __radd__(self, other: object) -> "Vector3": + return self.__add__(other) + + def __sub__(self, other: object) -> "Vector3": + right = _coerce_vector(other) + return Vector3((self.values[0] - right[0], self.values[1] - right[1], self.values[2] - right[2])) + + def __rsub__(self, other: object) -> "Vector3": + left = _coerce_vector(other) + return Vector3((left[0] - self.values[0], left[1] - self.values[1], left[2] - self.values[2])) + + def __mul__(self, other: object) -> "Vector3": + scalar = _coerce_number(other) + return Vector3((self.values[0] * scalar, self.values[1] * scalar, self.values[2] * scalar)) + + def __rmul__(self, other: object) -> "Vector3": + return self.__mul__(other) + + def __truediv__(self, other: object) -> "Vector3": + scalar = _coerce_number(other) + if abs(scalar) <= 1e-15: + raise ZeroDivisionError("Vector division by zero.") + return Vector3((self.values[0] / scalar, self.values[1] / scalar, self.values[2] / scalar)) + + def __neg__(self) -> "Vector3": + return Vector3((-self.values[0], -self.values[1], -self.values[2])) + + +def parse_relation_formula(text: str) -> RelationFormula: + normalized = " ".join(str(text or "").strip().split()) + if not normalized: + raise RelationFormulaError("请输入关系式。") + if normalized.count("=") != 1: + raise RelationFormulaError("关系式必须且只能包含一个等号,例如 Face87.直径 = Face85.直径。") + left, expression = (part.strip() for part in normalized.split("=", 1)) + if not left or not expression: + raise RelationFormulaError("关系式左侧和右侧都不能为空。") + target_match = RELATION_REF_PATTERN.fullmatch(left) + if target_match is None: + raise RelationFormulaError("关系式左侧必须是 FaceID.参数 或 EdgeID.参数,例如 Face87.直径。") + target = _ref_from_match(target_match) + + references: list[ObjectParameterRef] = [] + + def replace_ref(match: re.Match[str]) -> str: + references.append(_ref_from_match(match)) + return f"__ref{len(references) - 1}" + + safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression) + try: + tree = ast.parse(safe_expression, mode="eval") + except SyntaxError as exc: + raise RelationFormulaError(f"关系式右侧语法错误:{exc.msg}") from exc + _validate_expression_tree(tree, len(references)) + return RelationFormula( + text=f"{target.token} = {expression}", + target=target, + expression=expression, + safe_expression=safe_expression, + references=tuple(references), + ) + + +def evaluate_relation_formula( + formula: RelationFormula, + value_resolver: Callable[[ObjectParameterRef], object], +) -> float | Vector3: + namespace: dict[str, object] = {} + for index, ref in enumerate(formula.references): + namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref)) + code = compile(formula.safe_expression, "", "eval") + try: + value = eval(code, {"__builtins__": {}}, namespace) + except ZeroDivisionError as exc: + raise RelationFormulaError("关系式中出现除以 0。") from exc + except Exception as exc: + raise RelationFormulaError(f"关系式计算失败:{exc}") from exc + return _coerce_formula_value(value) + + +def relation_value_to_text(value: object) -> str: + value = _coerce_formula_value(value) + if isinstance(value, Vector3): + return ", ".join(_format_number(item) for item in value.values) + return _format_number(float(value)) + + +def rewrite_relation_formula_ids(text: str, face_id_map: dict[int, int], edge_id_map: dict[int, int] | None = None) -> str: + edge_id_map = dict(edge_id_map or {}) + + def replace(match: re.Match[str]) -> str: + kind = str(match.group("kind")) + object_id = int(match.group("object_id")) + parameter = str(match.group("parameter")) + if kind == "Face" and object_id in face_id_map: + object_id = int(face_id_map[object_id]) + elif kind == "Edge" and object_id in edge_id_map: + object_id = int(edge_id_map[object_id]) + return f"{kind}{object_id}.{parameter}" + + return RELATION_REF_PATTERN.sub(replace, text) + + +def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef: + return ObjectParameterRef( + kind=str(match.group("kind")), + object_id=int(match.group("object_id")), + parameter=str(match.group("parameter")), + ) + + +def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None: + allowed = ( + ast.Expression, + ast.BinOp, + ast.UnaryOp, + ast.Name, + ast.Load, + ast.Constant, + ast.Tuple, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.UAdd, + ast.USub, + ) + for node in ast.walk(tree): + if not isinstance(node, allowed): + raise RelationFormulaError("关系式只支持数字、对象参数、括号、向量和 + - * / 运算。") + if isinstance(node, ast.Name): + if not re.fullmatch(r"__ref\d+", node.id): + raise RelationFormulaError(f"未知参数引用:{node.id}") + index = int(node.id.replace("__ref", "")) + if index < 0 or index >= ref_count: + raise RelationFormulaError(f"未知参数引用:{node.id}") + elif isinstance(node, ast.Constant): + if not isinstance(node.value, (int, float)): + raise RelationFormulaError("关系式常量只支持数字。") + if isinstance(node.value, float) and not math.isfinite(node.value): + raise RelationFormulaError("关系式数字不能是 NaN 或无穷大。") + elif isinstance(node, ast.Tuple): + if len(node.elts) != 3: + raise RelationFormulaError("向量必须是 3 个数字,例如 (0, 0, -3.5)。") + + +def _coerce_formula_value(value: object) -> float | Vector3: + if isinstance(value, Vector3): + return value + if isinstance(value, (tuple, list)): + return Vector3(value) + return _coerce_number(value) + + +def _coerce_vector(value: object) -> tuple[float, float, float]: + if isinstance(value, Vector3): + return value.values + if isinstance(value, (tuple, list)): + return Vector3(value).values + raise TypeError("Vector operation requires another 3D vector.") + + +def _coerce_number(value: object) -> float: + if isinstance(value, bool): + raise TypeError("Boolean is not a valid numeric formula value.") + if isinstance(value, (int, float)): + number = float(value) + else: + raise TypeError(f"{value!r} is not a valid numeric formula value.") + if not math.isfinite(number): + raise TypeError("Formula value must be finite.") + return number + + +def _format_number(value: float) -> str: + if abs(value) < 5e-13: + value = 0.0 + return f"{value:.12g}" diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 73ac628..cdf41fb 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -540,6 +540,21 @@ INFO_LABELS = { "recognition_user_priority_reason": "优先级说明", "recognition_evidence": "识别依据", "recognition_evidence_keys": "识别依据项", + "recognition_external_relation_score_bonus": "Analysis Situs 关系加权", + "external_recognition_relation_summary": "Analysis Situs 关系摘要", + "external_recognition_relation_types": "Analysis Situs 关系类型", + "external_recognition_relation_count": "Analysis Situs 关系数", + "external_recognition_relation_face_count": "Analysis Situs 关系 Face 数", + "external_recognition_relation_source": "Analysis Situs 关系来源", + "analysis_situs_feature_hint_status": "Analysis Situs 特征提示状态", + "analysis_situs_feature_hint_preferred": "Analysis Situs 推荐语义", + "analysis_situs_feature_hint_label": "Analysis Situs 推荐语义名称", + "analysis_situs_feature_hint_score": "Analysis Situs 特征提示分", + "analysis_situs_feature_hint_summary": "Analysis Situs 特征提示", + "analysis_situs_feature_hint_related_face_ids": "Analysis Situs 相关 Face", + "analysis_situs_slot_hint_score": "Analysis Situs 槽提示分", + "analysis_situs_boss_hint_score": "Analysis Situs 凸台提示分", + "analysis_situs_fillet_hint_score": "Analysis Situs 圆角提示分", "recognition_ready_actions": "当前可改", "recognition_limited_actions": "当前受限修改", "recognition_blockers": "识别限制", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 390d25b..b29727b 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -74,6 +74,13 @@ def _unit_triple_or_none(value: object) -> tuple[float, float, float] | None: return (triple[0] / length, triple[1] / length, triple[2] / length) +def _int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + def _compact_plan_value(value: object) -> str: text = _format_value(value) return text if len(text) <= 120 else text[:117] + "..." @@ -1592,8 +1599,9 @@ class WindowActionMixin: if plan.get("status") == "blocked": self._show_blocked_plan_message(title, plan, blocked_status) return False + supports_isolation = bool(plan.get("supports_isolation")) or self._quick_edit_title_supports_isolation(title) if ( - not self._quick_edit_title_supports_isolation(title) + not supports_isolation and self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan) ): return False @@ -1620,7 +1628,7 @@ class WindowActionMixin: + f"\n{plan.get('message', '')}\n\n" + ( "本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n" - if str(plan.get("risk")) == "high" and self._quick_edit_title_supports_isolation(title) + if str(plan.get("risk")) == "high" and supports_isolation else "" ) + "为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;" @@ -1687,6 +1695,10 @@ class WindowActionMixin: "resize_sphere_radius", "resize_torus_radius", "resize_cylindrical_hole", + "edit_cylindrical_holes_by_refs", + "resize_cylindrical_holes_by_refs", + "move_cylindrical_holes_by_offset", + "suppress_cylindrical_holes_by_refs", "resize_cylindrical_owning_scale", "move_cylindrical_hole_axis", "suppress_cylindrical_hole", @@ -2351,6 +2363,200 @@ class WindowActionMixin: isolation=isolation, ) + def _multi_selected_hole_refs(self) -> list[dict[str, object]]: + refs: list[dict[str, object]] = [] + for item in getattr(self, "multi_selected_hole_entries", []) or []: + if not isinstance(item, dict): + continue + center = _triple_or_none(item.get("axis_center")) + diameter = _float_or_none(item.get("diameter")) + face_id = _int_or_none(item.get("face_id")) + logical_id = _int_or_none(item.get("logical_id")) + if center is None or diameter is None or diameter <= 0: + continue + refs.append( + { + "face_id": face_id, + "logical_id": logical_id, + "diameter": float(diameter), + "axis_center": [float(center[0]), float(center[1]), float(center[2])], + "part_id": item.get("part_id"), + "solid_id": item.get("solid_id"), + } + ) + return refs + + def _run_multi_selected_hole_edit( + self, + *, + target_diameter: float | None = None, + offset: tuple[float, float, float] | None = None, + ) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再批量修改孔。"): + return + refs = self._multi_selected_hole_refs() + if len(refs) < 2: + QMessageBox.information(self, "不能修改", "请先按 Ctrl 选择至少两个完整圆柱孔。") + return + if target_diameter is None and offset is None: + QMessageBox.information(self, "不能修改", "请先输入孔径目标值或位置偏移量。") + return + if target_diameter is not None and target_diameter <= 0: + QMessageBox.information(self, "不能修改", "孔径必须大于 0。") + return + if offset is not None and _vector_length(offset) <= 1e-9: + offset = None + if target_diameter is None and offset is None: + QMessageBox.information(self, "不能修改", "位置偏移量为 0,不需要修改。") + return + + operation_label_parts: list[str] = [] + if target_diameter is not None: + operation_label_parts.append("改孔径") + if offset is not None: + operation_label_parts.append("移动位置") + operation_name = "批量孔" + " + ".join(operation_label_parts) + logical_ids = [item.get("logical_id") for item in refs if item.get("logical_id") is not None] + refs_arg = [dict(item) for item in refs] + offset_arg = list(offset) if offset is not None else None + isolation = { + "operation": "edit_cylindrical_holes_by_refs", + "args": [refs_arg, target_diameter, offset_arg], + "timeout_seconds": 300.0, + "reason": "multi-hole-isolated-occ-edit", + } + + self.clear_edit_preview(render=False) + + def action(): + return self.model.edit_cylindrical_holes_by_refs( + refs_arg, + target_diameter=target_diameter, + offset=offset, + ) + + self._run_edit_action( + action, + operation_name=operation_name, + target=f"{len(refs)} holes", + parameters={ + "surface": "cylinder", + "feature_type": "multi cylindrical hole", + "feature_guess": "hole/groove candidate", + "multi_selected_count": len(refs), + "multi_selected_logical_ids": tuple(logical_ids), + "target_diameter": target_diameter, + "axis_move_vector": offset, + "resize_strategy": "multi-hole-fill-and-recut", + "edit_strategy_label": "批量圆柱孔参数化", + "edit_semantics": "按当前多选孔引用逐个重新定位孔组,统一修改孔径或按相同偏移移动位置;失败时整体回滚。", + "multi_hole_status": "ready", + "multi_hole_risk": "medium", + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind=None, + target_id=None, + isolation=isolation, + ) + + def resize_multi_selected_holes(self) -> None: + try: + target_diameter = float(self.hole_diameter_input.text()) + except (AttributeError, ValueError): + QMessageBox.information(self, "不能修改", "请输入数字形式的目标孔径。") + return + self._run_multi_selected_hole_edit(target_diameter=target_diameter) + + def move_multi_selected_holes_by_offset(self) -> None: + try: + offset = ( + float(self.translate_x_input.text()), + float(self.translate_y_input.text()), + float(self.translate_z_input.text()), + ) + except (AttributeError, ValueError): + QMessageBox.information(self, "不能修改", "请输入 X/Y/Z 三个数字形式的位置偏移量。") + return + self._run_multi_selected_hole_edit(offset=offset) + + def suppress_multi_selected_holes(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再批量封堵孔。"): + return + refs = self._multi_selected_hole_refs() + if len(refs) < 2: + QMessageBox.information(self, "不能修改", "请先按 Ctrl 选择至少两个完整圆柱孔。") + return + + refs_arg = [dict(item) for item in refs] + logical_ids = [item.get("logical_id") for item in refs if item.get("logical_id") is not None] + isolation = { + "operation": "suppress_cylindrical_holes_by_refs", + "args": [refs_arg], + "timeout_seconds": 300.0, + "reason": "multi-hole-suppress-isolated-occ-edit", + } + self.clear_edit_preview(render=False) + + def action(): + return self.model.suppress_cylindrical_holes_by_refs(refs_arg) + + self._run_edit_action( + action, + operation_name="批量封堵孔", + target=f"{len(refs)} holes", + parameters={ + "surface": "cylinder", + "feature_type": "multi cylindrical hole", + "feature_guess": "hole/groove candidate", + "multi_selected_count": len(refs), + "multi_selected_logical_ids": tuple(logical_ids), + "suppress_strategy": "multi-hole-fill", + "edit_strategy_label": "批量圆柱孔封堵", + "edit_semantics": "按当前多选孔引用逐个封堵;任意孔失败时整次批量操作会回滚。", + "multi_hole_status": "ready", + "multi_hole_risk": "medium", + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind=None, + target_id=None, + isolation=isolation, + ) + + def apply_multi_selected_hole_property_edit(self, changed: list[tuple[int, dict[str, object], str]]) -> None: + target_diameter: float | None = None + offset: tuple[float, float, float] | None = None + for _row, spec, text in changed: + validation_error = self._property_target_validation_error(spec, text) + if validation_error: + QMessageBox.information(self, "目标值无效", validation_error) + return + key = str(spec.get("key") or "") + try: + if key == "multi_hole_diameter": + diameter_value = float(text) + if target_diameter is not None and abs(target_diameter - diameter_value) > 1e-9: + QMessageBox.information(self, "目标值无效", "孔径和半径换算后的目标孔径不一致,请只修改其中一个。") + return + target_diameter = diameter_value + elif key == "multi_hole_radius": + diameter_value = float(text) * 2.0 + if target_diameter is not None and abs(target_diameter - diameter_value) > 1e-9: + QMessageBox.information(self, "目标值无效", "孔径和半径换算后的目标孔径不一致,请只修改其中一个。") + return + target_diameter = diameter_value + elif key == "multi_hole_position_delta": + offset = self._parse_property_vector3(text) + except ValueError as exc: + QMessageBox.information(self, "目标值无效", str(exc)) + return + self._run_multi_selected_hole_edit(target_diameter=target_diameter, offset=offset) + def move_cylindrical_slot_axis(self) -> None: if self.model is None: return @@ -8979,6 +9185,11 @@ class WindowActionMixin: if isinstance(timings, dict): timings["finish_ui"] = time.perf_counter() - finish_started locator_note = self._locate_operation_record(record) + relation_note = "" + if hasattr(self, "_refresh_relation_formulas_after_model_edit"): + relation_note = self._refresh_relation_formulas_after_model_edit() + if relation_note: + locator_note = f"{locator_note}\n{relation_note}" if locator_note else relation_note except Exception as exc: rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None) self._end_edit_task(clear_preview=True) diff --git a/step_editor/window_core.py b/step_editor/window_core.py index f79e509..edf5c7b 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( ) from .model import StepModel +from .asitus_bridge import run_asitus_hole_recognition from .records import OperationRecord from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker @@ -178,6 +179,10 @@ class WindowCoreMixin: elif watched is getattr(self, "property_table", None): if event.type() == QEvent.Type.Resize and hasattr(self, "_resize_property_table_columns"): QTimer.singleShot(0, self._resize_property_table_columns) + elif watched is getattr(self, "relation_formula_input", None): + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: + if hasattr(self, "_accept_relation_formula_completion") and self._accept_relation_formula_completion(): + return True return super().eventFilter(watched, event) def _should_suppress_transient_tooltip(self, watched) -> bool: @@ -959,6 +964,11 @@ class WindowCoreMixin: self.statusBar().showMessage("扫描正在进行,请等待扫描完成后再关闭窗口。") event.ignore() return + asitus_thread_running = bool(self.asitus_thread is not None and self.asitus_thread.isRunning()) + if asitus_thread_running: + self.statusBar().showMessage("孔组识别正在后台预热,请稍后再关闭窗口。") + event.ignore() + return super().closeEvent(event) def _request_thread_quit(self, thread: QThread | None) -> None: @@ -1174,6 +1184,85 @@ class WindowCoreMixin: self._update_action_states() if edge_deferred: QTimer.singleShot(80, self._rebuild_deferred_edge_display) + QTimer.singleShot(160, self._start_asitus_hole_recognition_preload) + + def _start_asitus_hole_recognition_preload(self) -> None: + if self.model is None or self.step_path is None: + return + if self.asitus_thread is not None and self.asitus_thread.isRunning(): + return + if not hasattr(self.model, "begin_asitus_hole_region_load"): + return + if not self.model.begin_asitus_hole_region_load(): + return + step_path = Path(self.step_path) + context = { + "path": step_path, + "model_id": id(self.model), + } + self.pending_asitus_context = dict(context) + + def action(path=step_path): + return run_asitus_hole_recognition(path) + + thread = QThread(self) + worker = ScanWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_asitus_thread) + self.asitus_thread = thread + self.asitus_worker = worker + try: + thread.start(QThread.Priority.LowPriority) + except TypeError: + thread.start() + + @Slot(object) + def _finish_asitus_hole_recognition(self, result: object) -> None: + try: + context = dict(self.pending_asitus_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 + mapped_groups = self.model.install_asitus_hole_recognition_result(result) + if mapped_groups: + self._refresh_selection_after_asitus_holes(mapped_groups) + finally: + self._request_thread_quit(self.asitus_thread) + + @Slot(str) + def _fail_asitus_hole_recognition(self, message: str) -> None: + try: + context = dict(self.pending_asitus_context or {}) + if self.model is not None and id(self.model) == context.get("model_id"): + self.model.fail_asitus_hole_region_load(message) + finally: + self._request_thread_quit(self.asitus_thread) + + def _refresh_selection_after_asitus_holes(self, mapped_groups: list[tuple[int, ...]]) -> None: + if self.model is None or self.selected_face_id is None: + return + selected_id = int(self.selected_face_id) + if not any(selected_id in group for group in mapped_groups): + return + if getattr(self, "operation_in_progress", False): + return + pick_position = self.selected_pick_position + if self.selected_kind == "feature": + self.select_feature(selected_id, pick_position=pick_position) + elif self.selected_kind == "face": + self.select_face(selected_id, pick_position=pick_position) + + @Slot() + def _forget_asitus_thread(self) -> None: + self.asitus_thread = None + self.asitus_worker = None + self.pending_asitus_context = None @Slot(object) def _finish_initial_load(self, result: object) -> None: @@ -1567,6 +1656,12 @@ class WindowCoreMixin: face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] edge_ids = _int_values(info.get("feature_boundary_edge_ids")) label = f"特征 Face {self.selected_face_id}" + elif self.selected_kind == "multi_feature": + info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {} + face_ids = _int_values(info.get("feature_highlight_face_ids")) + if not face_ids: + face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", [])) + label = f"多选孔 {len(getattr(self, 'multi_selected_hole_entries', []) or [])} 个" elif self.selected_kind == "face" and self.selected_face_id is not None: face_ids = [self.selected_face_id] edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id) @@ -1639,6 +1734,13 @@ class WindowCoreMixin: face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False) if edge_ids: edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) + elif self.selected_kind == "multi_feature": + info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {} + face_ids = _int_values(info.get("feature_highlight_face_ids")) + if not face_ids: + face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", [])) + if face_ids: + face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False) elif self.selected_kind == "face" and self.selected_face_id is not None: face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False) @@ -1675,6 +1777,13 @@ class WindowCoreMixin: info = {} face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] self._highlight_faces(face_ids=face_ids) + elif self.selected_kind == "multi_feature": + info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {} + face_ids = _int_values(info.get("feature_highlight_face_ids")) + if not face_ids: + face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", [])) + if face_ids: + self._highlight_faces(face_ids=face_ids) elif self.selected_kind == "face" and self.selected_face_id is not None: face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] self._highlight_faces(face_ids=face_ids) @@ -2077,8 +2186,94 @@ class WindowCoreMixin: return self._clear_hover(render=False) + if self._is_multi_feature_toggle_request(target): + self._toggle_multi_feature_selection(target, pick_position=target.get("pick_position")) + return self._select_pick_target(target) + def _is_multi_feature_toggle_request(self, target: dict[str, object]) -> bool: + if str(target.get("kind") or "") not in {"feature", "face"}: + return False + try: + return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier) + except Exception: + return False + + def _toggle_multi_feature_selection( + self, + target: dict[str, object], + *, + pick_position: tuple[float, float, float] | None = None, + ) -> None: + if self.model is None: + return + face_id = _safe_int_or_none(target.get("target_id")) + if face_id is None: + return + entry = self._hole_multi_select_entry(face_id) if hasattr(self, "_hole_multi_select_entry") else None + if entry is None: + self.statusBar().showMessage("多选第一版只支持完整圆柱孔;请按 Ctrl 选择孔壁。") + return + + entries = [dict(item) for item in (getattr(self, "multi_selected_hole_entries", []) or [])] + if not entries and self.selected_kind == "feature" and self.selected_face_id is not None: + current_entry = self._hole_multi_select_entry(int(self.selected_face_id)) + if current_entry is not None: + entries.append(dict(current_entry)) + + logical_id = int(entry.get("logical_id", face_id)) + existing_index = next( + ( + index + for index, item in enumerate(entries) + if int(item.get("logical_id", -1)) == logical_id + ), + None, + ) + if existing_index is None: + entries.append(dict(entry)) + else: + entries.pop(existing_index) + + if len(entries) < 2: + if entries: + self.select_feature(int(entries[0]["face_id"]), pick_position=pick_position) + else: + self._reset_selection() + return + + part_ids = { + int(item["part_id"]) + for item in entries + if item.get("part_id") not in {None, ""} + } + solid_ids = { + int(item["solid_id"]) + for item in entries + if item.get("solid_id") not in {None, ""} + } + self._reset_selection(clear_highlight=False, clear_info=False) + self.multi_selected_hole_entries = entries + self.multi_selected_feature_face_ids = [int(item["face_id"]) for item in entries] + self.multi_selection_active = True + self.selected_kind = "multi_feature" + self.selected_face_id = int(entries[-1]["face_id"]) + self.selected_edge_id = None + self.selected_part_id = next(iter(part_ids)) if len(part_ids) == 1 else None + self.selected_solid_id = next(iter(solid_ids)) if len(solid_ids) == 1 else None + self.selected_pick_position = pick_position + + info = self._multi_hole_selection_info() + if hasattr(self, "_set_selection_mode"): + self._set_selection_mode("Feature") + if hasattr(self, "_sync_id_picker"): + self._sync_id_picker("Feature", int(entries[-1].get("logical_id", entries[-1]["face_id"]))) + self._highlight_faces(face_ids=_int_values(info.get("feature_highlight_face_ids")) or self.multi_selected_feature_face_ids) + self._show_pick_marker(pick_position) + self.set_info(self._with_pick_info(info, pick_position)) + ids_text = ", ".join(str(item.get("logical_id")) for item in entries if item.get("logical_id") is not None) + self.statusBar().showMessage(f"已多选 {len(entries)} 个孔:{ids_text}。再次 Ctrl 点击可移除。") + def on_pointer_button_press(self, _obj, _event) -> None: if not self._is_ui_thread(): self._invoke_on_ui_thread(self._handle_pointer_button_press) @@ -2642,8 +2837,7 @@ class WindowCoreMixin: self._sync_id_picker("Feature" if feature_mode else "Face", logical_id) self.set_info(self._with_pick_info(input_info, pick_position)) self._update_action_states() - raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else "" - message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}" + 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]: @@ -2687,8 +2881,7 @@ class WindowCoreMixin: if hasattr(self, "_feature_display_label"): feature_type = self._feature_display_label(feature_type) self._update_action_states() - raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else "" - self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", pick_position)) + self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position)) def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: fillet_radius_suggestion: str | None = None diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 5505d72..e40a714 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -4,15 +4,17 @@ from datetime import datetime import math from pathlib import Path import time -from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtCore import Qt, QThread, QTimer, Slot, QStringListModel from PySide6.QtWidgets import ( QApplication, QCheckBox, + QCompleter, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, + QListWidgetItem, QMessageBox, QPushButton, QSizePolicy, @@ -27,6 +29,15 @@ from PySide6.QtGui import QColor from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY from .model import StepModel from .parametric_component import component_edit_config_from_spec +from .relation_formulas import ( + ObjectParameterRef, + RelationFormulaError, + Vector3, + evaluate_relation_formula, + parse_relation_formula, + relation_value_to_text, + rewrite_relation_formula_ids, +) from .records import OperationRecord from .ui_helpers import * # noqa: F403 from .widgets import NoWheelComboBox @@ -36,12 +47,12 @@ from .workers import EditWorker, LoadWorker, ScanWorker PROPERTY_VALUE_TOLERANCE = 1e-9 PROPERTY_LABEL_COLUMN = 0 PROPERTY_CURRENT_COLUMN = 1 -PROPERTY_SCOPE_COLUMN = 2 -PROPERTY_TARGET_COLUMN = 3 +PROPERTY_TARGET_COLUMN = 2 +PROPERTY_SCOPE_COLUMN = 3 PROPERTY_INPUT_COLUMN = 4 -PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "建模意图", "目标值", "输入参数") -PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 66, 62, 50) -PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 88, 82, 66) +PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "目标值", "建模意图", "输入参数") +PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 62, 66, 50) +PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 82, 88, 66) PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS = { "face_center_position", "edge_center_point", @@ -66,13 +77,52 @@ PROPERTY_COMMAND_SUBTITLES = { } PROPERTY_COMMAND_HELP = { "offset": "沿 Face 法线调整位置,适合平面推拉、偏移或切除深度类修改。", - "move": "移动当前 Face、Edge、特征或 Solid 的中心/轴心,不直接修改尺寸。", + "move": "移动当前 Face、Edge、特征或 Solid 的中心/位置,不直接修改尺寸。", "scale": "修改长度、宽度、高度、半径、直径等尺寸参数。", "rotate": "设置旋转轴或旋转角度,适合 Part/Solid 的整体姿态调整。", "feature": "执行孔、槽、圆角、倒角、封堵等离散特征命令。", "diagnostics": "查看识别依据、一级拓扑关系和当前不支持修改的原因。", } +RELATION_VECTOR_COMPONENTS = { + "位置X": ("位置", 0), + "位置Y": ("位置", 1), + "位置Z": ("位置", 2), + "中心X": ("中心", 0), + "中心Y": ("中心", 1), + "中心Z": ("中心", 2), +} +RELATION_SPEC_ALIASES = { + "diameter": ("直径",), + "boss_diameter": ("直径",), + "generic_cylinder_diameter": ("直径",), + "multi_hole_diameter": ("直径",), + "circle_edge_diameter": ("直径",), + "sphere_diameter": ("直径",), + "cone_reference_diameter": ("直径", "参考直径"), + "radius": ("半径",), + "multi_hole_radius": ("半径",), + "circle_edge_radius": ("半径",), + "sphere_radius": ("半径",), + "existing_fillet_radius_estimate": ("圆角半径", "半径"), + "boss_radius": ("半径",), + "hole_axis_center": ("位置",), + "slot_axis_center": ("位置",), + "boss_axis_center": ("位置",), + "multi_hole_position_delta": ("位置偏移",), + "circle_edge_axis_center": ("位置", "圆心", "轴心"), + "face_target_normal_position": ("偏移",), + "local_face_width": ("面内长度",), + "local_face_height": ("面内宽度",), + "edge_length": ("长度",), + "target_length": ("长度",), + "cylinder_height": ("高度",), + "boss_height": ("高度",), + "hole_depth_estimate": ("深度",), + "slot_sagitta_depth_estimate": ("槽深", "深度"), + "slot_chord_width_estimate": ("槽宽", "宽度"), +} + FEATURE_EDIT_SEMANTICS_KEYS = { "cad_modeling_form", "cad_recommended_operation", @@ -218,7 +268,7 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: "slot_sagitta_depth_estimate", "slot_total_length_estimate", ) - return ("diameter", "hole_depth_estimate") + return ("diameter", "hole_axis_center", "hole_depth_estimate") if feature_guess == "boss/outer-round candidate": return ("boss_diameter", "boss_height") if feature_guess == "round/fillet candidate": @@ -286,6 +336,7 @@ def _int_or_none(value: object) -> int | None: class WindowStateMixin: def _reset_selection(self, clear_highlight: bool = True, clear_info: bool = True) -> None: + self._clear_multi_feature_selection_state() self.selected_kind = None self.selected_part_id = None self.selected_solid_id = None @@ -305,6 +356,106 @@ class WindowStateMixin: self._selected_action_info_cache_key = None self._selected_action_info_cache_value = None + def _clear_multi_feature_selection_state(self) -> None: + self.multi_selected_feature_face_ids = [] + self.multi_selected_hole_entries = [] + self.multi_selection_active = False + + def _axis_center_from_feature_info(self, info: dict[str, object]) -> tuple[float, float, float] | None: + center = _triple_or_none(info.get("axis_center")) + if center is not None: + return center + axis_point = _triple_or_none(info.get("axis_point")) + axis_direction = _triple_or_none(info.get("axis")) + axis_range = info.get("same_domain_v_range") or info.get("v_range") + if axis_point is None or axis_direction is None or not isinstance(axis_range, (list, tuple)) or len(axis_range) < 2: + return None + v_min = _float_or_none(axis_range[0]) + v_max = _float_or_none(axis_range[1]) + if v_min is None or v_max is None: + return None + v_mid = (v_min + v_max) * 0.5 + return ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + + def _hole_multi_select_entry(self, face_id: int) -> dict[str, object] | None: + if self.model is None or face_id < 0 or face_id >= len(self.model.faces): + return None + try: + info = self.model.feature_info(face_id) + except Exception: + try: + info = self.model.quick_face_info(face_id) + except Exception: + return None + if info.get("surface") != "cylinder" or str(info.get("feature_guess") or "") != "hole/groove candidate": + return None + if not _is_effectively_full_cylinder(info): + return None + diameter = _float_or_none(info.get("diameter")) + center = self._axis_center_from_feature_info(info) + if diameter is None or diameter <= 0 or center is None: + return None + try: + logical_id = int(self.model.face_region_logical_id(face_id)) + except Exception: + logical_id = int(face_id) + try: + region_face_ids = self.model.face_region_ids(face_id) + except Exception: + region_face_ids = _int_values(info.get("same_domain_face_ids")) or [face_id] + highlight_face_ids = tuple( + sorted({int(item) for item in region_face_ids if 0 <= int(item) < len(self.model.faces)}) + ) or (int(face_id),) + return { + "face_id": int(face_id), + "logical_id": logical_id, + "diameter": float(diameter), + "axis_center": center, + "feature_highlight_face_ids": highlight_face_ids, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + } + + def _multi_hole_selection_info(self) -> dict[str, object]: + entries = [dict(item) for item in getattr(self, "multi_selected_hole_entries", []) or []] + face_ids = [int(item["face_id"]) for item in entries if item.get("face_id") is not None] + logical_ids = [int(item["logical_id"]) for item in entries if item.get("logical_id") is not None] + diameters = [float(item["diameter"]) for item in entries if _float_or_none(item.get("diameter")) is not None] + centers = [_triple_or_none(item.get("axis_center")) for item in entries] + centers = [item for item in centers if item is not None] + highlight_ids: set[int] = set() + for entry in entries: + highlight_ids.update(_int_values(entry.get("feature_highlight_face_ids"))) + diameter_text = "" + if diameters: + first = diameters[0] + if all(abs(value - first) <= PROPERTY_VALUE_TOLERANCE for value in diameters): + diameter_text = _format_float(first) + else: + diameter_text = f"{min(diameters):g} ~ {max(diameters):g}" + return { + "kind": "multi_feature", + "selection_title": f"多选孔 {len(entries)} 个", + "selection_display_id": ", ".join(str(item) for item in logical_ids), + "surface": "cylinder", + "feature_guess": "hole/groove candidate", + "feature_type": "多选圆柱孔", + "feature_edit_actions": "批量改孔径/半径;按 ΔX/ΔY/ΔZ 整体移动孔位置;批量封堵", + "feature_highlight_face_ids": tuple(sorted(highlight_ids or set(face_ids))), + "multi_selection_kind": "holes", + "multi_selected_count": len(entries), + "multi_selected_face_ids": tuple(face_ids), + "multi_selected_logical_ids": tuple(logical_ids), + "multi_selected_hole_entries": tuple(entries), + "multi_hole_diameter_text": diameter_text, + "multi_hole_all_same_diameter": bool(diameters and diameter_text and "~" not in diameter_text), + "multi_hole_relation_status": "当前支持批量孔径/半径、位置偏移和封堵;等直径、保持中心距、Z 差值固定会作为关系式阶段继续接入。", + } + def _clear_current_object_info(self) -> None: self.current_info_values = {} self.current_info_text = "" @@ -343,8 +494,6 @@ class WindowStateMixin: except Exception: logical_id = int(face_id) title = f"{title_prefix} {logical_id}" - if logical_id != int(face_id): - title += f"(当前拓扑 Face {face_id})" result = { "selection_title": title, "selection_display_id": logical_id, @@ -1440,6 +1589,15 @@ class WindowStateMixin: self.object_edit_box.setTitle(f"{section_label}:{self._selected_object_title_suffix()}") def _selected_object_title_suffix(self) -> str: + if self.selected_kind == "multi_feature": + count = len(getattr(self, "multi_selected_hole_entries", []) or []) + ids = [ + str(item.get("logical_id")) + for item in (getattr(self, "multi_selected_hole_entries", []) or [])[:4] + if item.get("logical_id") is not None + ] + suffix = f":{', '.join(ids)}" if ids else "" + return f"多选孔 {count} 个{suffix}" if self.selected_kind == "part" and self.selected_part_id is not None: return f"零件 {self.selected_part_id}" if self.selected_kind == "solid" and self.selected_solid_id is not None: @@ -1456,9 +1614,7 @@ class WindowStateMixin: logical_id = self.current_info_values.get("face_region_logical_id") if logical_id in {None, ""}: logical_id = self.current_info_values.get("logical_face_id") - source_suffix = f"来源 Face {self.selected_face_id}" - if logical_id not in {None, "", self.selected_face_id}: - source_suffix = f"来源 Face {logical_id}(拓扑 {self.selected_face_id})" + source_suffix = f"来源 Face {logical_id if logical_id not in {None, ''} else self.selected_face_id}" return f"{feature_label}{confidence_suffix} · {source_suffix}{related_suffix}" if self.selected_kind == "feature" and self.selected_face_id is not None: feature_label = self._selected_feature_label() @@ -1473,8 +1629,8 @@ class WindowStateMixin: logical_id = self.current_info_values.get("logical_face_id") feature_label = self._selected_feature_label() feature_suffix = f"(识别为{feature_label})" if feature_label else "" - if logical_id not in {None, "", self.selected_face_id}: - return f"Face {logical_id}(拓扑 {self.selected_face_id}){feature_suffix}" + if logical_id not in {None, ""}: + return f"Face {logical_id}{feature_suffix}" return f"Face {self.selected_face_id}{feature_suffix}" if self.selected_kind == "edge" and self.selected_edge_id is not None: return f"Edge {self.selected_edge_id}" @@ -1527,6 +1683,8 @@ class WindowStateMixin: self._clear_property_cards() self._clear_property_command_bar() self._update_current_capability_panel() + self._update_relation_formula_completions() + self._refresh_relation_formula_list() self._resize_property_table_height() self._update_property_apply_state(False) @@ -1603,8 +1761,8 @@ class WindowStateMixin: detail = "" tooltip = ( "Face:面内长度/宽度、偏移、壳体厚度。\n" - "孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n" - "凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n" + "孔/槽:孔径、位置、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n" + "凸台:圆柱凸台直径/高度/位置;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n" "圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n" "Edge/解析曲面:直线 Edge 长度/端点、圆/椭圆 Edge、简单圆锥/球/环面。\n" "一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n" @@ -2033,15 +2191,15 @@ class WindowStateMixin: input_editable = editable and value_type != "command" label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False) current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False) - scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False) target_item = self._property_table_item( "" if input_editable else str(effective_spec.get("target_text", "")), editable=False, ) + scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False) target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) input_item = self._property_table_item("", editable=False) input_item.setToolTip("勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py。") - row_items = (label_item, current_item, scope_item, target_item, input_item) + row_items = (label_item, current_item, target_item, scope_item, input_item) self._style_property_row_items(row_items, editable=editable, spec=effective_spec) for column, item in enumerate(row_items): item.setToolTip(item.toolTip() or item.text()) @@ -2065,6 +2223,8 @@ class WindowStateMixin: self._clear_property_command_bar() self._clear_property_cards() self._update_current_capability_panel() + self._update_relation_formula_completions() + self._refresh_relation_formula_list() self._resize_property_table_height() finally: self.property_table.blockSignals(was_blocked) @@ -2072,6 +2232,647 @@ class WindowStateMixin: self._resize_property_table_columns() self._update_property_apply_state() + def _on_relation_formula_input_changed(self, _text: str = "") -> None: + self._update_relation_formula_completions() + self._update_relation_formula_buttons() + + def _update_relation_formula_completions(self) -> None: + completer = getattr(self, "relation_formula_completer", None) + if not isinstance(completer, QCompleter): + return + completions = sorted(set(self._relation_formula_completion_tokens()), key=lambda item: item.lower()) + model = getattr(self, "relation_formula_completer_model", None) + if isinstance(model, QStringListModel): + model.setStringList(completions) + else: + completer.setModel(QStringListModel(completions, completer)) + + def _relation_formula_completion_tokens(self) -> list[str]: + tokens: list[str] = [] + for object_label, _row, spec in self._relation_visible_parameter_specs(): + for name in self._relation_parameter_names_for_spec(spec): + if self._relation_parameter_name_is_safe(name): + tokens.append(f"{object_label}.{name}") + if self.selected_kind == "multi_feature": + for entry in getattr(self, "multi_selected_hole_entries", []) or []: + if not isinstance(entry, dict): + continue + logical_id = _int_or_none(entry.get("logical_id")) + if logical_id is None: + continue + for name in ("直径", "半径", "位置", "位置X", "位置Y", "位置Z"): + tokens.append(f"Face{logical_id}.{name}") + return tokens + + def _relation_visible_parameter_specs(self) -> list[tuple[str, int, dict[str, object]]]: + if not hasattr(self, "property_table"): + return [] + rows: list[tuple[str, int, dict[str, object]]] = [] + for row, spec in enumerate(getattr(self, "property_editor_specs", []) or []): + effective = self._effective_property_spec(spec, row=row) + if not effective.get("action") or str(effective.get("value_type", "number")) == "command": + continue + object_label = self._relation_object_label_for_spec(effective) + if not object_label: + continue + rows.append((object_label, row, effective)) + return rows + + def _relation_object_label_for_spec(self, spec: dict[str, object]) -> str: + source_face_id = _int_or_none(spec.get("source_face_id")) + if source_face_id is None and self.selected_kind in {"face", "feature"}: + source_face_id = _int_or_none(self.selected_face_id) + model_faces = getattr(self.model, "faces", None) if self.model is not None else None + if source_face_id is not None and model_faces is not None and 0 <= source_face_id < len(model_faces): + try: + return f"Face{int(self.model.face_region_logical_id(source_face_id))}" + except Exception: + return f"Face{int(source_face_id)}" + if source_face_id is not None: + return f"Face{int(source_face_id)}" + if self.selected_kind == "edge" and self.selected_edge_id is not None: + return f"Edge{int(self.selected_edge_id)}" + return "" + + def _relation_parameter_names_for_spec(self, spec: dict[str, object]) -> tuple[str, ...]: + names: list[str] = [] + label = str(spec.get("label") or "").strip() + if label: + names.append(label) + key = str(spec.get("key") or "") + names.extend(RELATION_SPEC_ALIASES.get(key, ())) + value_type = str(spec.get("value_type", "number")) + for name in tuple(names): + if value_type == "vector3": + if name == "位置": + names.extend(("位置X", "位置Y", "位置Z")) + elif name == "中心": + names.extend(("中心X", "中心Y", "中心Z")) + compact: list[str] = [] + for name in names: + if name and name not in compact: + compact.append(name) + return tuple(compact) + + @staticmethod + def _relation_parameter_name_is_safe(name: str) -> bool: + return bool(name) and all(char == "_" or char.isalnum() for char in name) + + def _update_relation_formula_buttons(self) -> None: + has_model = self.model is not None and not ( + self.operation_in_progress or self.scan_in_progress or self.load_in_progress + ) + add_button = getattr(self, "add_relation_formula_button", None) + if isinstance(add_button, QPushButton): + text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else "" + self._set_control_state( + add_button, + bool(has_model and text), + "添加当前关系式。第一版会在参数化建模前计算并回填当前参数表目标值。", + "请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。", + ) + remove_button = getattr(self, "remove_relation_formula_button", None) + formula_list = getattr(self, "relation_formula_list", None) + if isinstance(remove_button, QPushButton): + has_selected = bool(formula_list is not None and formula_list.selectedItems()) + self._set_control_state( + remove_button, + has_selected, + "删除选中的关系式。", + "请先在已有关系式列表里选择一条公式。", + ) + + def add_relation_formula(self) -> None: + if self.model is None: + self.statusBar().showMessage("请先导入模型后再添加关系式。") + return + text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else "" + try: + formula = parse_relation_formula(text) + self._validate_relation_formula_references(formula) + except RelationFormulaError as exc: + QMessageBox.information(self, "关系式无效", str(exc)) + return + formula_id = int(getattr(self, "relation_formula_next_id", 1) or 1) + self.relation_formula_next_id = formula_id + 1 + item = { + "id": formula_id, + "text": formula.text, + "enabled": True, + "status": "ready", + "message": "等待参数化建模时计算。", + "signatures": self._relation_signatures_for_formula(formula), + } + self.relation_formula_items = [*list(getattr(self, "relation_formula_items", []) or []), item] + self.relation_formula_input.clear() + self._refresh_relation_formula_list() + self._update_property_apply_state() + self.statusBar().showMessage(f"已添加关系式:{formula.text}") + + def remove_selected_relation_formula(self) -> None: + formula_list = getattr(self, "relation_formula_list", None) + if formula_list is None: + return + ids = { + int(item.data(Qt.ItemDataRole.UserRole)) + for item in formula_list.selectedItems() + if item.data(Qt.ItemDataRole.UserRole) is not None + } + if not ids: + return + self.relation_formula_items = [ + dict(item) + for item in (getattr(self, "relation_formula_items", []) or []) + if int(item.get("id", -1)) not in ids + ] + self._refresh_relation_formula_list() + self._update_property_apply_state() + self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式。") + + def _refresh_relation_formula_list(self) -> None: + formula_list = getattr(self, "relation_formula_list", None) + if formula_list is None: + return + formula_list.clear() + for item in getattr(self, "relation_formula_items", []) or []: + status = str(item.get("status") or "ready") + message = str(item.get("message") or "").strip() + text = str(item.get("text") or "") + suffix = "" + if status == "invalid": + suffix = f" [失效:{message or '需要重新选择'}]" + elif status == "pending": + suffix = f" [待选择:{message or '目标不在当前参数表'}]" + elif status == "applied": + suffix = f" [已计算:{message}]" + row_item = QListWidgetItem(f"{text}{suffix}") + row_item.setData(Qt.ItemDataRole.UserRole, int(item.get("id", -1))) + row_item.setToolTip(message or text) + if status == "invalid": + row_item.setForeground(QColor("#b91c1c")) + elif status == "pending": + row_item.setForeground(QColor("#92400e")) + elif status == "applied": + row_item.setForeground(QColor("#166534")) + formula_list.addItem(row_item) + self._update_relation_formula_buttons() + + def _validate_relation_formula_references(self, formula) -> None: + self._relation_parameter_supported(formula.target, target=True) + for ref in formula.references: + self._relation_value_for_ref(ref) + + def _relation_parameter_supported(self, ref: ObjectParameterRef, *, target: bool = False) -> None: + if target and self._relation_visible_spec_for_ref(ref) is not None: + return + try: + self._relation_value_for_ref(ref) + except RelationFormulaError as exc: + if target: + raise RelationFormulaError(f"关系式目标暂不支持:{ref.token}。{exc}") from exc + raise + + def _relation_formulas_have_current_targets(self) -> bool: + for item in getattr(self, "relation_formula_items", []) or []: + if not bool(item.get("enabled", True)): + continue + try: + formula = parse_relation_formula(str(item.get("text") or "")) + except RelationFormulaError: + continue + if self._relation_visible_spec_for_ref(formula.target) is None: + continue + try: + for ref in formula.references: + self._relation_value_for_ref(ref) + except RelationFormulaError: + continue + return True + return False + + def _apply_relation_formulas_to_property_targets(self) -> int: + applied = 0 + changed_items: list[dict[str, object]] = [] + for raw_item in getattr(self, "relation_formula_items", []) or []: + item = dict(raw_item) + if not bool(item.get("enabled", True)): + changed_items.append(item) + continue + try: + formula = parse_relation_formula(str(item.get("text") or "")) + if self._relation_visible_spec_for_ref(formula.target) is None: + item["status"] = "pending" + item["message"] = "目标对象当前没有显示在特征参数表里。" + changed_items.append(item) + continue + value = evaluate_relation_formula(formula, self._relation_value_for_ref) + self._set_relation_target_value(formula.target, value) + item["status"] = "applied" + item["message"] = relation_value_to_text(value) + item["signatures"] = self._relation_signatures_for_formula(formula) + applied += 1 + except RelationFormulaError as exc: + item["status"] = "invalid" + item["message"] = str(exc) + changed_items.append(item) + self.relation_formula_items = changed_items + self._refresh_relation_formula_list() + if applied: + self.statusBar().showMessage(f"已根据关系式回填 {applied} 个目标值。") + return applied + + def _relation_visible_spec_for_ref(self, ref: ObjectParameterRef) -> tuple[int, dict[str, object]] | None: + base_name, _component = self._relation_base_parameter(ref.parameter) + for _object_label, row, spec in self._relation_visible_parameter_specs(): + if not bool(spec.get("enabled")): + continue + if not self._relation_ref_matches_spec_object(ref, spec): + continue + names = self._relation_parameter_names_for_spec(spec) + if base_name in names or ref.parameter in names: + return row, spec + return None + + def _relation_ref_matches_spec_object(self, ref: ObjectParameterRef, spec: dict[str, object]) -> bool: + if ref.kind == "Face": + source_face_id = _int_or_none(spec.get("source_face_id")) + if source_face_id is None and self.selected_kind in {"face", "feature"}: + source_face_id = _int_or_none(self.selected_face_id) + if source_face_id is None: + return False + return self._relation_face_ref_matches_face_id(ref, source_face_id) + if ref.kind == "Edge": + if self.selected_kind != "edge" or self.selected_edge_id is None: + return False + return int(ref.object_id) == int(self.selected_edge_id) + return False + + def _relation_face_ref_matches_face_id(self, ref: ObjectParameterRef, face_id: int) -> bool: + if self.model is None or not hasattr(self.model, "resolve_face_selection_id"): + return int(ref.object_id) == int(face_id) + ref_face_id = self.model.resolve_face_selection_id(int(ref.object_id)) + if ref_face_id is None: + return False + try: + ref_region = set(self.model.face_region_ids(ref_face_id) or [ref_face_id]) + except Exception: + ref_region = {ref_face_id} + try: + target_region = set(self.model.face_region_ids(int(face_id)) or [int(face_id)]) + except Exception: + target_region = {int(face_id)} + return bool(ref_region & target_region) + + @staticmethod + def _relation_base_parameter(parameter: str) -> tuple[str, int | None]: + if parameter in RELATION_VECTOR_COMPONENTS: + return RELATION_VECTOR_COMPONENTS[parameter] + return parameter, None + + def _relation_value_for_ref(self, ref: ObjectParameterRef) -> object: + visible = self._relation_visible_spec_for_ref(ref) + if visible is not None: + row, spec = visible + return self._relation_value_from_spec(ref, row, spec) + if ref.kind == "Face": + return self._relation_value_from_face(ref) + if ref.kind == "Edge": + return self._relation_value_from_edge(ref) + raise RelationFormulaError(f"不支持的对象类型:{ref.kind}") + + def _relation_value_from_spec(self, ref: ObjectParameterRef, row: int, spec: dict[str, object]) -> object: + value_type = str(spec.get("value_type", "number")) + text = self._property_target_text(row) + use_target = bool(text and self._property_target_changed(spec, text) and not self._property_target_validation_error(spec, text)) + if use_target: + if value_type == "vector3": + value: object = self._parse_property_vector3(text) + elif value_type == "number_pair": + value = self._parse_property_number_pair(text) + elif value_type in {"integer", "integer_or_empty"}: + value = int(text) if text else 0 + else: + value = float(text) + else: + value = spec.get("current_raw") + _base_name, component = self._relation_base_parameter(ref.parameter) + if component is not None: + vector = _triple_or_none(value) + if vector is None: + raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。") + return float(vector[component]) + return value + + def _relation_value_from_face(self, ref: ObjectParameterRef) -> object: + if self.model is None: + raise RelationFormulaError("当前没有已导入模型。") + face_id = self.model.resolve_face_selection_id(int(ref.object_id)) + if face_id is None: + raise RelationFormulaError(f"找不到 {ref.kind}{ref.object_id}。") + try: + info = self.model.cached_feature_info(face_id) or self.model.feature_info(face_id) + except Exception: + info = self.model.quick_face_info(face_id) + base_name, component = self._relation_base_parameter(ref.parameter) + value: object + if base_name == "直径": + diameter = _float_or_none(info.get("diameter")) + if diameter is None: + radius = _float_or_none(info.get("radius")) + diameter = radius * 2.0 if radius is not None else None + value = diameter + elif base_name == "半径": + value = _float_or_none(info.get("radius")) + elif base_name in {"位置", "中心"}: + value = ( + self._axis_center_from_feature_info(info) + or _triple_or_none(info.get("area_center")) + or _triple_or_none(info.get("bbox_center")) + ) + elif base_name == "偏移": + value = _float_or_none(info.get("normal_position")) or _float_or_none(info.get("plane_position")) + elif base_name == "面内长度": + value = _float_or_none(info.get("local_face_width")) + elif base_name == "面内宽度": + value = _float_or_none(info.get("local_face_height")) + elif base_name == "高度": + value = _float_or_none(info.get("height_estimate")) or _float_or_none(info.get("same_domain_height_estimate")) + elif base_name == "深度": + value = _float_or_none(info.get("hole_depth_estimate")) or _float_or_none(info.get("slot_sagitta_depth_estimate")) + elif base_name in {"槽宽", "宽度"}: + value = _float_or_none(info.get("slot_chord_width_estimate")) or _float_or_none(info.get("local_face_height")) + else: + raise RelationFormulaError(f"{ref.token} 不是当前已知的 Face 参数。") + if value in {None, ""}: + raise RelationFormulaError(f"{ref.token} 当前没有可用数值。") + if component is not None: + vector = _triple_or_none(value) + if vector is None: + raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。") + return float(vector[component]) + return value + + def _relation_value_from_edge(self, ref: ObjectParameterRef) -> object: + if self.model is None: + raise RelationFormulaError("当前没有已导入模型。") + edge_id = int(ref.object_id) + if edge_id < 0 or edge_id >= len(self.model.edges): + raise RelationFormulaError(f"找不到 Edge{edge_id}。") + info = self.model.edge_info(edge_id) + base_name, component = self._relation_base_parameter(ref.parameter) + if base_name == "长度": + value: object = _float_or_none(info.get("length")) + elif base_name == "半径": + value = _float_or_none(info.get("radius")) + elif base_name == "直径": + diameter = _float_or_none(info.get("diameter")) + if diameter is None: + radius = _float_or_none(info.get("radius")) + diameter = radius * 2.0 if radius is not None else None + value = diameter + elif base_name in {"位置", "中心"}: + value = _triple_or_none(info.get("center")) or _triple_or_none(info.get("length_center")) + else: + raise RelationFormulaError(f"{ref.token} 不是当前已知的 Edge 参数。") + if value in {None, ""}: + raise RelationFormulaError(f"{ref.token} 当前没有可用数值。") + if component is not None: + vector = _triple_or_none(value) + if vector is None: + raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。") + return float(vector[component]) + return value + + def _set_relation_target_value(self, ref: ObjectParameterRef, value: object) -> None: + visible = self._relation_visible_spec_for_ref(ref) + if visible is None: + raise RelationFormulaError(f"{ref.token} 当前没有显示在特征参数表里。") + row, spec = visible + base_name, component = self._relation_base_parameter(ref.parameter) + value_type = str(spec.get("value_type", "number")) + if component is not None: + scalar = float(value) + current_vector = self._relation_current_vector_target(row, spec) + values = [current_vector[0], current_vector[1], current_vector[2]] + values[component] = scalar + text = relation_value_to_text(Vector3(values)) + elif value_type == "vector3": + text = relation_value_to_text(value) + else: + if isinstance(value, Vector3) or isinstance(value, (tuple, list)): + raise RelationFormulaError(f"{ref.token} 需要数字结果,不能写入三维坐标。") + text = relation_value_to_text(value) + validation_error = self._property_target_validation_error(spec, text) + if validation_error: + raise RelationFormulaError(f"{ref.token} 的公式结果无效:{validation_error}") + widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) + if isinstance(widget, QLineEdit): + widget.setText(text) + else: + item = self.property_table.item(row, PROPERTY_TARGET_COLUMN) + if item is not None: + item.setText(text) + + def _relation_current_vector_target(self, row: int, spec: dict[str, object]) -> tuple[float, float, float]: + text = self._property_target_text(row) + if text and not self._property_target_validation_error(spec, text): + try: + return self._parse_property_vector3(text) + except ValueError: + pass + current = _triple_or_none(spec.get("current_raw")) + if current is None: + raise RelationFormulaError(f"{spec.get('label', '目标')} 当前不是可用三维坐标。") + return current + + def _relation_signatures_for_formula(self, formula) -> dict[str, object]: + refs = [formula.target, *formula.references] + signatures: dict[str, object] = {} + for ref in refs: + signatures[ref.token] = self._relation_signature_for_ref(ref) + return signatures + + def _relation_signature_for_ref(self, ref: ObjectParameterRef) -> dict[str, object]: + if self.model is None or ref.kind != "Face" or not hasattr(self.model, "resolve_face_selection_id"): + return {"kind": ref.kind, "object_id": ref.object_id, "parameter": ref.parameter} + face_id = self.model.resolve_face_selection_id(int(ref.object_id)) + if face_id is None: + return {"kind": "Face", "object_id": ref.object_id, "parameter": ref.parameter, "status": "missing"} + try: + info = self.model.quick_face_info(face_id) + except Exception: + info = {} + return { + "kind": "Face", + "object_id": int(ref.object_id), + "parameter": ref.parameter, + "face_id": int(face_id), + "logical_id": int(ref.object_id), + "current_logical_id": self._relation_face_display_id(face_id), + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "surface": info.get("surface"), + "area": info.get("area"), + "center": _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")), + "radius": info.get("radius"), + "axis": _triple_or_none(info.get("axis")), + } + + def _relation_face_display_id(self, face_id: int) -> int: + if self.model is None: + return int(face_id) + try: + return int(self.model.face_region_logical_id(face_id)) + except Exception: + try: + return int(self.model.face_logical_id(face_id)) + except Exception: + return int(face_id) + + def _refresh_relation_formulas_after_model_edit(self) -> str: + if self.model is None or not getattr(self, "relation_formula_items", None): + return "" + updated_items: list[dict[str, object]] = [] + changed_count = 0 + invalid_count = 0 + for raw_item in self.relation_formula_items: + item = dict(raw_item) + signatures = item.get("signatures") + if not isinstance(signatures, dict): + try: + signatures = self._relation_signatures_for_formula(parse_relation_formula(str(item.get("text") or ""))) + except RelationFormulaError: + signatures = {} + face_id_map: dict[int, int] = {} + item_invalid = False + for signature in signatures.values(): + if not isinstance(signature, dict) or signature.get("kind") != "Face": + continue + old_id = _int_or_none(signature.get("object_id")) + if old_id is None: + continue + new_id = self._relation_current_face_id_from_signature(signature) + if new_id is None: + item_invalid = True + continue + if int(new_id) != int(old_id): + face_id_map[int(old_id)] = int(new_id) + if face_id_map: + new_text = rewrite_relation_formula_ids(str(item.get("text") or ""), face_id_map) + if new_text != item.get("text"): + item["text"] = new_text + changed_count += 1 + if item_invalid: + item["status"] = "invalid" + item["message"] = "编辑后没有找到唯一匹配的 Face,需要重新选择。" + invalid_count += 1 + else: + try: + item["signatures"] = self._relation_signatures_for_formula(parse_relation_formula(str(item.get("text") or ""))) + if item.get("status") == "invalid": + item["status"] = "ready" + item["message"] = "已重新定位引用对象。" + except RelationFormulaError as exc: + item["status"] = "invalid" + item["message"] = str(exc) + invalid_count += 1 + updated_items.append(item) + self.relation_formula_items = updated_items + self._refresh_relation_formula_list() + parts: list[str] = [] + if changed_count: + parts.append(f"关系式已更新 {changed_count} 处 Face ID") + if invalid_count: + parts.append(f"{invalid_count} 条关系式需要重新选择") + return ";".join(parts) + + def _relation_current_face_id_from_signature(self, signature: dict[str, object]) -> int | None: + if self.model is None: + return None + old_logical = _int_or_none(signature.get("logical_id", signature.get("object_id"))) + if old_logical is not None: + try: + matches = self.model.face_ids_for_logical_id(old_logical) + except Exception: + matches = [] + if len(matches) == 1: + return self._relation_face_display_id(matches[0]) + if len(matches) > 1: + display_ids = {self._relation_face_display_id(item) for item in matches} + if len(display_ids) == 1: + return next(iter(display_ids)) + matched = self._relation_match_face_signature(signature) + if matched is None: + return None + return self._relation_face_display_id(matched) + + def _relation_match_face_signature(self, signature: dict[str, object]) -> int | None: + if self.model is None: + return None + surface = str(signature.get("surface") or "") + center = _triple_or_none(signature.get("center")) + radius = _float_or_none(signature.get("radius")) + area = _float_or_none(signature.get("area")) + part_id = _int_or_none(signature.get("part_id")) + candidates: list[tuple[float, int]] = [] + for face_id in range(len(self.model.faces)): + if part_id is not None and 0 <= part_id != int(self.model.face_part_ids[face_id]): + continue + try: + info = self.model.quick_face_info(face_id) + except Exception: + continue + if surface and str(info.get("surface") or "") != surface: + continue + score = 0.0 + candidate_center = _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")) + if center is not None and candidate_center is not None: + score += math.sqrt(sum((candidate_center[index] - center[index]) ** 2 for index in range(3))) + elif center is not None: + score += 1e9 + candidate_radius = _float_or_none(info.get("radius")) + if radius is not None: + if candidate_radius is None: + score += 1e9 + else: + score += abs(candidate_radius - radius) * 10.0 + candidate_area = _float_or_none(info.get("area")) + if area is not None and area > 0 and candidate_area is not None: + score += abs(candidate_area - area) / max(abs(area), 1.0) + candidates.append((score, face_id)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + best_score, best_face_id = candidates[0] + if len(candidates) > 1 and abs(candidates[1][0] - best_score) <= max(best_score * 0.05, 1e-6): + return None + return best_face_id + + def _accept_relation_formula_completion(self) -> bool: + completer = getattr(self, "relation_formula_completer", None) + editor = getattr(self, "relation_formula_input", None) + if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit): + return False + self._update_relation_formula_completions() + completion = completer.currentCompletion() + if not completion: + model = completer.model() + if model is not None and model.rowCount() > 0: + completion = str(model.index(0, 0).data() or "") + if not completion: + return False + text = editor.text() + cursor = editor.cursorPosition() + prefix_start = max(text.rfind(" ", 0, cursor), text.rfind("=", 0, cursor)) + 1 + prefix = text[prefix_start:cursor] + if "." in prefix and completion.lower().startswith(prefix.lower()): + new_text = f"{text[:prefix_start]}{completion}{text[cursor:]}" + else: + new_text = f"{text[:cursor]}{completion}{text[cursor:]}" + editor.setText(new_text) + editor.setCursorPosition(prefix_start + len(completion)) + return True + def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]: def rank(spec: dict[str, object]) -> int: editable = bool(spec.get("editable")) @@ -2097,7 +2898,7 @@ class WindowStateMixin: editable: bool, spec: dict[str, object] | None = None, ) -> None: - label_item, current_item, scope_item, target_item = items[:4] + label_item, current_item, target_item, scope_item = items[:4] input_item = items[4] if len(items) > 4 else None if spec is not None and bool(spec.get("pin_top")): for item in items: @@ -2453,6 +3254,8 @@ class WindowStateMixin: info: dict[str, object], action_info: dict[str, object], ) -> list[dict[str, object]]: + if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes": + return self._multi_hole_property_specs(action_info) editable_specs, _used_keys = self._editable_property_specs(action_info) if self.selected_kind == "feature": return self._feature_context_property_specs(editable_specs, action_info) @@ -2466,6 +3269,121 @@ class WindowStateMixin: and _property_parameter_is_visible(spec) ] + def _multi_hole_property_specs(self, action_info: dict[str, object]) -> list[dict[str, object]]: + entries = list(getattr(self, "multi_selected_hole_entries", []) or []) + count = len(entries) + if count < 2: + return [] + diameters = [ + float(item["diameter"]) + for item in entries + if _float_or_none(item.get("diameter")) is not None and float(item.get("diameter")) > 0 + ] + common_diameter = None + if diameters and all(abs(value - diameters[0]) <= PROPERTY_VALUE_TOLERANCE for value in diameters): + common_diameter = diameters[0] + diameter_current = ( + _format_float(common_diameter) + if common_diameter is not None + else f"{min(diameters):g} ~ {max(diameters):g}" if diameters else "" + ) + diameter_target = _format_float(common_diameter) if common_diameter is not None else "" + radii = [value * 0.5 for value in diameters] + common_radius = common_diameter * 0.5 if common_diameter is not None else None + radius_current = ( + _format_float(common_radius) + if common_radius is not None + else f"{min(radii):g} ~ {max(radii):g}" if radii else "" + ) + radius_target = _format_float(common_radius) if common_radius is not None else "" + multi_hole_refs = tuple( + { + "face_id": _int_or_none(item.get("face_id")), + "logical_id": _int_or_none(item.get("logical_id")), + "diameter": _float_or_none(item.get("diameter")), + "axis_center": _triple_or_none(item.get("axis_center")), + "part_id": item.get("part_id"), + "solid_id": item.get("solid_id"), + } + for item in entries + if isinstance(item, dict) + ) + return [ + { + "key": "multi_hole_diameter", + "label": "孔径", + "current_raw": common_diameter if common_diameter is not None else "", + "current_text": diameter_current, + "target_text": diameter_target, + "editable": True, + "enabled": bool(diameters), + "status_text": "可修改" if diameters else "不可修改", + "scope_text": "统一孔径", + "action": "resize_multi_selected_holes", + "target_attr": "hole_diameter_input", + "value_type": "positive", + "multi_hole_refs": multi_hole_refs, + "enabled_tip": "把当前多选孔统一改成同一个目标孔径;失败时整次批量操作会回滚。", + "disabled_tip": "当前多选对象里没有稳定圆柱孔径。", + "range_hint": "请输入大于 0 的目标孔径;单位同模型。", + }, + { + "key": "multi_hole_radius", + "label": "半径", + "current_raw": common_radius if common_radius is not None else "", + "current_text": radius_current, + "target_text": radius_target, + "editable": True, + "enabled": bool(radii), + "status_text": "可修改" if radii else "不可修改", + "scope_text": "统一半径", + "action": "resize_multi_selected_holes", + "target_attr": "hole_diameter_input", + "target_transform": "radius_to_diameter", + "value_type": "positive", + "multi_hole_refs": multi_hole_refs, + "enabled_tip": "按半径输入,程序会换算成目标孔径后批量修改当前多选孔;失败时整次批量操作会回滚。", + "disabled_tip": "当前多选对象里没有稳定圆柱孔半径。", + "range_hint": "请输入大于 0 的目标半径;单位同模型。", + }, + { + "key": "multi_hole_position_delta", + "label": "位置偏移", + "current_raw": (0.0, 0.0, 0.0), + "current_text": "(0, 0, 0)", + "target_text": "(0, 0, 0)", + "editable": True, + "enabled": True, + "status_text": "可修改", + "scope_text": "相同偏移", + "action": "move_multi_selected_holes_by_offset", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "value_type": "vector3", + "multi_hole_refs": multi_hole_refs, + "enabled_tip": "输入 ΔX, ΔY, ΔZ;每个孔会保持自身孔径并整体平移相同偏移量。", + "disabled_tip": "", + "range_hint": "这是偏移量,不是绝对坐标。例如 (0, 0, -3.5) 表示所有选中孔沿 Z 方向移动 -3.5。", + }, + { + "key": "multi_hole_suppress", + "label": "封堵", + "current_raw": "未封堵", + "current_text": "未封堵", + "target_text": "无需输入", + "editable": True, + "enabled": True, + "status_text": "可执行", + "scope_text": "批量封堵", + "action": "suppress_multi_selected_holes", + "value_type": "command", + "multi_hole_refs": multi_hole_refs, + "enabled_tip": "用补料体批量封堵当前多选孔;任意一个孔失败时整次批量操作会回滚。", + "disabled_tip": "", + "range_hint": "该操作会直接改变几何并写入历史;复杂孔失败时会恢复到操作前状态。", + "button_text": "封堵", + }, + ] + def _feature_property_specs( self, specs: list[dict[str, object]], @@ -3142,16 +4060,16 @@ class WindowStateMixin: if is_hole_or_groove: if is_slot_or_half_hole: return ( - "工程特征 / 槽:按槽宽、槽深、弧长、总长或轴心做局部重建。", + "工程特征 / 槽:按槽宽、槽深、弧长、总长或位置做局部重建。", "这类对象优先按 Creo 式槽特征理解;当前只传播直接相邻的一级关系,复杂二级/三级联动后续再开放。", ) return ( - "工程特征 / 孔:按孔径、轴心、封堵或盲孔/盲槽深度做重切/补料。", + "工程特征 / 孔:按孔径、位置、封堵或盲孔/盲槽深度做重切/补料。", "这类对象优先按 Creo 式孔特征理解;通孔、盲孔、锥孔和槽孔会根据识别结果开放不同参数。", ) if is_boss: return ( - "工程特征 / 凸台:按直径、高度或轴心做局部重建。", + "工程特征 / 凸台:按直径、高度或位置做局部重建。", "凸台修改会尽量按局部补料、切除端盖或重建包络处理;也可以选择缩放所属对象这类整体语义。", ) if is_existing_fillet: @@ -3231,17 +4149,17 @@ class WindowStateMixin: if is_hole_or_groove: if is_slot_or_half_hole: return ( - "优先改槽宽、槽深或槽孔总长度;要换位置时改轴心。", + "优先改槽宽、槽深或槽孔总长度;要换位置时改位置。", "槽类修改先走局部重建;如果用户选择缩放特征,会连同所属对象上的其它尺寸一起变化。", ) return ( - "优先改孔径/半径;盲孔改深度;要换位置时改轴心。", + "优先改孔径/半径;盲孔改深度;要换位置时改位置。", "孔类修改会尽量先补旧孔再按目标重新切孔;螺纹孔、复杂孔组和底面识别不稳的盲孔仍可能受限。", ) if is_boss: return ( - "优先改凸台直径或高度;要换位置时改轴心。", - "凸台高度更接近拉伸/切除端盖;凸台直径和轴心更接近移除旧包络后重建局部凸台。", + "优先改凸台直径或高度;要换位置时改位置。", + "凸台高度更接近拉伸/切除端盖;凸台直径和位置更接近移除旧包络后重建局部凸台。", ) if is_existing_fillet: return ( @@ -3399,17 +4317,17 @@ class WindowStateMixin: tip=( "槽/半孔宽度、深度和圆弧参数会换算成圆柱半径或圆弧角度," "然后填旧槽、切新槽;槽孔总长度会优先按长圆槽两端整体重建。" - "轴心坐标有两种语义:局部重建槽/半孔,或平移所属对象。" + "位置坐标有两种语义:局部重建槽/半孔,或平移所属对象。" ), ) else: add_readonly_spec( key="hole_edit_semantics", label="建模意图", - text="孔:同轴重切孔壁;移动轴心会先填旧孔再切新孔。", + text="孔:同轴重切孔壁;移动位置会先填旧孔再切新孔。", tip=( "孔径/半径不是缩放整个模型,而是在当前孔轴线上做受限布尔重切;" - "轴心选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动特征”时" + "位置选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动特征”时" "会移动整个所属对象;盲孔/盲槽深度会按底面方向切削或补料。" ), ) @@ -3417,11 +4335,11 @@ class WindowStateMixin: add_readonly_spec( key="boss_edit_semantics", label="建模意图", - text="凸台:直径重建包络;高度拉伸/切除端盖;轴心先移除再补新凸台。", + text="凸台:直径重建包络;高度拉伸/切除端盖;位置先移除再补新凸台。", tip=( "凸台直径会通过局部布尔补料或移除旧包络后重建目标圆柱;" - "凸台高度会拉伸/切除识别到的端盖 Face;凸台轴心会移除旧凸台包络后在目标轴心补出同直径凸台;" - "带“平移所属对象”的轴心坐标会移动整个所属对象。" + "凸台高度会拉伸/切除识别到的端盖 Face;凸台位置会移除旧凸台包络后在目标位置补出同直径凸台;" + "带“平移所属对象”的位置坐标会移动整个所属对象。" ), ) elif is_existing_fillet: @@ -4096,8 +5014,10 @@ class WindowStateMixin: axis_point = _triple_or_none(action_info.get("axis_point")) axis_direction = _triple_or_none(action_info.get("axis")) axis_range_value = action_info.get("same_domain_v_range") or action_info.get("v_range") - current_axis_center = None + current_axis_center = _triple_or_none(action_info.get("axis_center")) if ( + current_axis_center is None + and axis_point is not None and axis_direction is not None and isinstance(axis_range_value, (list, tuple)) @@ -4115,7 +5035,7 @@ class WindowStateMixin: if not is_slot_or_half_hole: add_scoped_spec( key="hole_axis_center", - label="轴心", + label="位置", current_raw=current_axis_center if current_axis_center is not None else "", target_text=vector_text(current_axis_center), scope_default="local", @@ -4125,8 +5045,8 @@ class WindowStateMixin: "action": "move_cylindrical_hole_axis", "target_attrs": ("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"), "enabled": bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None), - "enabled_tip": "输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。", - "disabled_tip": "当前只对接近完整圆柱的孔放行轴心坐标修改;槽/半孔会使用扇形槽轴心坐标修改。", + "enabled_tip": "输入完整圆柱孔位置的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。", + "disabled_tip": "当前只对接近完整圆柱的孔放行位置坐标修改;槽/半孔会使用扇形槽位置坐标修改。", "range_hint": translation_hint(), }, "owning": { @@ -4138,10 +5058,10 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入目标孔轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," + "输入目标孔位置 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," "不会填旧孔再切新孔。" ), - "disabled_tip": "当前孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "disabled_tip": "当前孔缺少稳定位置或所属对象,不能按位置平移所属对象。", "range_hint": ( "这不是移动孔本身,而是整体搬动所属对象;" f"{translation_hint()}" @@ -4161,7 +5081,7 @@ class WindowStateMixin: action="suppress_hole", enabled=bool(is_full_cylinder and current_diameter is not None), enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。", - disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;槽/半孔请使用槽/孔尺寸或轴心修改。", + disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;槽/半孔请使用槽/孔尺寸或位置修改。", value_type="command", range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。", button_text="封堵", @@ -4169,7 +5089,7 @@ class WindowStateMixin: if is_slot_or_half_hole: add_scoped_spec( key="slot_axis_center", - label="轴心", + label="位置", current_raw=current_axis_center if current_axis_center is not None else "", target_text=vector_text(current_axis_center), scope_default="local", @@ -4179,8 +5099,8 @@ class WindowStateMixin: "action": "move_cylindrical_slot_axis", "target_attrs": ("slot_center_x_input", "slot_center_y_input", "slot_center_z_input"), "enabled": bool(current_axis_center is not None and current_diameter is not None), - "enabled_tip": "输入槽/半孔轴心的目标坐标 X, Y, Z;程序会先填旧扇形槽,再按同宽度、同角度切出新槽。", - "disabled_tip": "当前槽/半孔缺少稳定轴心或宽度,不能移动轴心坐标。", + "enabled_tip": "输入槽/半孔位置的目标坐标 X, Y, Z;程序会先填旧扇形槽,再按同宽度、同角度切出新槽。", + "disabled_tip": "当前槽/半孔缺少稳定位置或宽度,不能移动位置坐标。", "range_hint": translation_hint(), }, "owning": { @@ -4192,10 +5112,10 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入目标槽/半孔轴心 X, Y, Z 坐标;程序会平移所属特征或 Solid," + "输入目标槽/半孔位置 X, Y, Z 坐标;程序会平移所属特征或 Solid," "不会填旧槽再切新槽。" ), - "disabled_tip": "当前槽/半孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "disabled_tip": "当前槽/半孔缺少稳定位置或所属对象,不能按位置平移所属对象。", "range_hint": ( "这不是移动槽/半孔本身,而是整体搬动所属对象;" f"{translation_hint()}" @@ -4607,7 +5527,7 @@ class WindowStateMixin: ) add_scoped_spec( key="boss_axis_center", - label="轴心", + label="位置", current_raw=current_boss_axis_center if current_boss_axis_center is not None else "", target_text=vector_text(current_boss_axis_center), scope_default="local", @@ -4617,8 +5537,8 @@ class WindowStateMixin: "action": "move_cylindrical_boss_axis", "target_attrs": ("boss_center_x_input", "boss_center_y_input", "boss_center_z_input"), "enabled": bool(is_full_cylinder and current_boss_axis_center is not None and current_boss is not None), - "enabled_tip": "输入完整圆柱凸台轴心的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。", - "disabled_tip": "当前只对接近完整圆柱的凸台候选放行轴心坐标修改。", + "enabled_tip": "输入完整圆柱凸台位置的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标位置补出凸台。", + "disabled_tip": "当前只对接近完整圆柱的凸台候选放行位置坐标修改。", "range_hint": translation_hint(), }, "owning": { @@ -4630,10 +5550,10 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入目标凸台轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," + "输入目标凸台位置 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid," "不会移除旧凸台再补新凸台。" ), - "disabled_tip": "当前凸台缺少稳定轴心或所属对象,不能按轴心平移所属对象。", + "disabled_tip": "当前凸台缺少稳定位置或所属对象,不能按位置平移所属对象。", "range_hint": ( "这不是移动凸台本身,而是整体搬动所属对象;" f"{translation_hint()}" @@ -5905,8 +6825,9 @@ class WindowStateMixin: setattr(widget, "_geom_param_button_state", button_state) if hasattr(self, "apply_property_button"): changed = self._changed_property_rows() - enabled = bool(has_model and changed) - disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。" + formula_ready = self._relation_formulas_have_current_targets() + enabled = bool(has_model and (changed or formula_ready)) + disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值,或添加能作用于当前参数表的关系式。" if changed: count = len(changed) enabled_tip = ( @@ -5914,6 +6835,8 @@ class WindowStateMixin: if count == 1 else f"按表格顺序依次应用当前被修改的 {count} 个参数;失败时会停止后续修改。" ) + elif formula_ready: + enabled_tip = "先计算关系式并回填目标值,再执行参数化建模。" else: enabled_tip = "应用当前被修改的参数。" self._set_control_state( @@ -6267,9 +7190,13 @@ class WindowStateMixin: return abs(value - current) > PROPERTY_VALUE_TOLERANCE def apply_current_property_edit(self) -> None: + self._apply_relation_formulas_to_property_targets() changed = self._changed_property_rows() if not changed: - self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。") + self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值,或添加能回填当前参数表的关系式。") + return + if self.selected_kind == "multi_feature" and hasattr(self, "apply_multi_selected_hole_property_edit"): + self.apply_multi_selected_hole_property_edit(changed) return if len(changed) > 1: self._start_property_batch_edit(changed) @@ -6794,6 +7721,14 @@ class WindowStateMixin: def _selected_action_info(self) -> dict[str, object]: if self.model is None: return {} + multi_key = tuple( + ( + (_int_or_none(item.get("logical_id")) if _int_or_none(item.get("logical_id")) is not None else -1), + (_int_or_none(item.get("face_id")) if _int_or_none(item.get("face_id")) is not None else -1), + ) + for item in (getattr(self, "multi_selected_hole_entries", []) or []) + if isinstance(item, dict) + ) cache_key = ( id(self.model), self.selected_kind, @@ -6801,6 +7736,7 @@ class WindowStateMixin: self.selected_solid_id, self.selected_face_id, self.selected_edge_id, + multi_key, id(getattr(self, "current_info_values", None)), getattr(self, "feature_detection_level", "current-only"), ) @@ -6810,6 +7746,11 @@ class WindowStateMixin: ): return dict(self._selected_action_info_cache_value) current_info = dict(getattr(self, "current_info_values", {}) or {}) + if self.selected_kind == "multi_feature" and len(getattr(self, "multi_selected_hole_entries", []) or []) >= 2: + result = self._multi_hole_selection_info() + self._selected_action_info_cache_key = cache_key + self._selected_action_info_cache_value = dict(result) + return dict(result) current_face_id = _int_or_none( current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id"))) ) diff --git a/tools/asitus_probe/CMakeLists.txt b/tools/asitus_probe/CMakeLists.txt new file mode 100644 index 0000000..d000e41 --- /dev/null +++ b/tools/asitus_probe/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.16) +project(asitus_probe LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(REPO_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +set(ASITUS_ROOT "${REPO_ROOT}/third_party/AnalysisSitus") +set(ASITUS_BUILD "${REPO_ROOT}/third_party/AnalysisSitus_build_algo_occt77") +set(THIRD_PARTY_ROOT "${REPO_ROOT}/third_party/3rdparty") +set(OCCT_ROOT "${THIRD_PARTY_ROOT}/OCCT") + +file(GLOB_RECURSE ASITUS_PUBLIC_HEADERS CONFIGURE_DEPENDS + "${ASITUS_ROOT}/src/asiAlgo/*.h" + "${ASITUS_ROOT}/src/asiAlgo/*.hpp" + "${ASITUS_ROOT}/src/asiActiveData/*.h" + "${ASITUS_ROOT}/src/asiActiveData/*.hpp" +) + +set(ASITUS_INCLUDE_DIRS) +foreach(header ${ASITUS_PUBLIC_HEADERS}) + get_filename_component(header_dir "${header}" DIRECTORY) + list(APPEND ASITUS_INCLUDE_DIRS "${header_dir}") +endforeach() +list(REMOVE_DUPLICATES ASITUS_INCLUDE_DIRS) + +file(GLOB OCCT_LIBS CONFIGURE_DEPENDS "${OCCT_ROOT}/win64/vc14/lib/*.lib") + +add_executable(recognize_holes recognize_holes.cpp) + +target_include_directories(recognize_holes PRIVATE + ${ASITUS_INCLUDE_DIRS} + "${OCCT_ROOT}/inc" + "${THIRD_PARTY_ROOT}/eigen-3.4.0" + "${THIRD_PARTY_ROOT}/rapidjson-1.1.0/include" +) + +target_link_directories(recognize_holes PRIVATE + "${ASITUS_BUILD}/win64/vc14/lib" + "${OCCT_ROOT}/win64/vc14/lib" +) + +target_link_libraries(recognize_holes PRIVATE + asiAlgo + asiActiveData + ${OCCT_LIBS} +) diff --git a/tools/asitus_probe/recognize_holes.cpp b/tools/asitus_probe/recognize_holes.cpp new file mode 100644 index 0000000..b5906c0 --- /dev/null +++ b/tools/asitus_probe/recognize_holes.cpp @@ -0,0 +1,339 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ +void printFeature(const asiAlgo_Feature& feature) +{ + std::cout << "["; + bool first = true; + for (TColStd_MapIteratorOfPackedMapOfInteger it(feature); it.More(); it.Next()) + { + if (!first) + std::cout << ", "; + first = false; + std::cout << it.Key(); + } + std::cout << "]"; +} + +void printPackedMap(const TColStd_PackedMapOfInteger& values) +{ + std::cout << "["; + bool first = true; + for (TColStd_MapIteratorOfPackedMapOfInteger it(values); it.More(); it.Next()) + { + if (!first) + std::cout << ", "; + first = false; + std::cout << it.Key(); + } + std::cout << "]"; +} + +const char* surfaceTypeName(const GeomAbs_SurfaceType type) +{ + switch (type) + { + case GeomAbs_Plane: + return "plane"; + case GeomAbs_Cylinder: + return "cylinder"; + case GeomAbs_Cone: + return "cone"; + case GeomAbs_Sphere: + return "sphere"; + case GeomAbs_Torus: + return "torus"; + case GeomAbs_BezierSurface: + return "bezier"; + case GeomAbs_BSplineSurface: + return "bspline"; + case GeomAbs_SurfaceOfRevolution: + return "revolution"; + case GeomAbs_SurfaceOfExtrusion: + return "extrusion"; + case GeomAbs_OffsetSurface: + return "offset"; + default: + return "other"; + } +} + +void printStringIntMap(const std::map& values) +{ + std::cout << "{"; + bool first = true; + for (const auto& item : values) + { + if (!first) + std::cout << ", "; + first = false; + std::cout << "\"" << item.first << "\": " << item.second; + } + std::cout << "}"; +} + +void printGeometricRelation( + const int faceId, + const int neighborId, + const std::string& type, + const double residual, + const char* source, + const bool first) +{ + if (!first) + std::cout << ",\n"; + std::cout << " { \"faceIds\": [" << faceId << ", " << neighborId << "]" + << ", \"type\": \"" << type << "\"" + << ", \"residual\": " << residual + << ", \"source\": \"" << source << "\" }"; +} +} + +int main(int argc, char** argv) +{ + if (argc < 2) + { + std::cerr << "Usage: recognize_holes [max-radius]\n"; + return 1; + } + + const TCollection_AsciiString filename(argv[1]); + const double radius = argc >= 3 ? std::atof(argv[2]) : Precision::Infinite(); + + TopoDS_Shape shape; + if (!asiAlgo_STEP::Import(filename, shape)) + { + std::cerr << "Failed to import STEP: " << argv[1] << "\n"; + return 2; + } + + TopTools_IndexedMapOfShape faces; + TopExp::MapShapes(shape, TopAbs_FACE, faces); + + BRepCheck_Analyzer analyzer(shape); + Handle(asiAlgo_AAG) aag = new asiAlgo_AAG( + shape, + false, + 1.0e-4, + asiAlgo_AAG::CachedMap_All); + + asiAlgo_RecognizeDrillHoles recognizer(aag, true); + if (!recognizer.Perform(radius)) + { + std::cerr << "Hole recognition failed.\n"; + return 3; + } + + const asiAlgo_Feature& holeFaceIds = recognizer.GetResultIndices(); + std::vector holes; + aag->GetConnectedComponents(holeFaceIds, holes); + + std::map surfaceSummary; + std::map angleSummary; + std::map geometricRelationSummary; + for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId) + { + const TopoDS_Face& face = aag->GetFace(faceId); + BRepAdaptor_Surface surface(face); + surfaceSummary[surfaceTypeName(surface.GetType())]++; + } + + std::cout << "{\n"; + std::cout << " \"validBreP\": " << (analyzer.IsValid() ? "true" : "false") << ",\n"; + std::cout << " \"faceCount\": " << faces.Extent() << ",\n"; + std::cout << " \"aagNodeCount\": " << aag->GetNumberOfNodes() << ",\n"; + std::cout << " \"holeFaceIds\": "; + printFeature(holeFaceIds); + std::cout << ",\n"; + std::cout << " \"holeCount\": " << holes.size() << ",\n"; + std::cout << " \"holes\": [\n"; + for (std::size_t i = 0; i < holes.size(); ++i) + { + std::cout << " { \"index\": " << (i + 1) << ", \"faceIds\": "; + printFeature(holes[i]); + std::cout << " }"; + if (i + 1 < holes.size()) + std::cout << ","; + std::cout << "\n"; + } + std::cout << " ],\n"; + + std::cout << " \"faces\": [\n"; + for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId) + { + const TopoDS_Face& face = aag->GetFace(faceId); + BRepAdaptor_Surface surface(face); + std::cout << " { \"id\": " << faceId + << ", \"surface\": \"" << surfaceTypeName(surface.GetType()) << "\"" + << ", \"neighbors\": "; + if (aag->HasNeighbors(faceId)) + printFeature(aag->GetNeighbors(faceId)); + else + std::cout << "[]"; + std::cout << " }"; + if (faceId < aag->GetNumberOfNodes()) + std::cout << ","; + std::cout << "\n"; + } + std::cout << " ],\n"; + + std::cout << " \"adjacency\": [\n"; + bool firstAdjacency = true; + std::vector> tangentPairs; + for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId) + { + if (!aag->HasNeighbors(faceId)) + continue; + const asiAlgo_Feature& neighbors = aag->GetNeighbors(faceId); + for (TColStd_MapIteratorOfPackedMapOfInteger it(neighbors); it.More(); it.Next()) + { + const int neighborId = it.Key(); + if (neighborId <= faceId) + continue; + + const asiAlgo_AAG::t_arc arc(faceId, neighborId); + Handle(asiAlgo_FeatureAttrAngle) angleAttr = + Handle(asiAlgo_FeatureAttrAngle)::DownCast(aag->GetArcAttribute(arc)); + Handle(asiAlgo_FeatureAttrAdjacency) adjacencyAttr = + Handle(asiAlgo_FeatureAttrAdjacency)::DownCast(aag->GetArcAttribute(arc)); + + std::string angleType = "adjacent"; + double angleRad = 0.0; + if (!angleAttr.IsNull()) + { + angleType = asiAlgo_FeatureAngle::ToString(angleAttr->GetAngleType()); + angleRad = angleAttr->GetAngleRad(); + } + angleSummary[angleType]++; + if (angleType.find("smooth") != std::string::npos) + tangentPairs.emplace_back(faceId, neighborId); + + if (!firstAdjacency) + std::cout << ",\n"; + firstAdjacency = false; + std::cout << " { \"faceIds\": [" << faceId << ", " << neighborId << "]" + << ", \"angleType\": \"" << angleType << "\"" + << ", \"angleRad\": " << angleRad + << ", \"edgeIds\": "; + if (!adjacencyAttr.IsNull()) + printPackedMap(adjacencyAttr->GetEdgeIndices()); + else + std::cout << "[]"; + std::cout << " }"; + } + } + std::cout << "\n ],\n"; + + std::cout << " \"geometricRelations\": [\n"; + bool firstRelation = true; + const double angleTol = 1.0e-7; + const double linearTol = 1.0e-4; + const int geometricPairFaceLimit = 800; + const bool runFullGeometricPairScan = aag->GetNumberOfNodes() <= geometricPairFaceLimit; + if (runFullGeometricPairScan) + { + for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId) + { + const TopoDS_Face& leftFace = aag->GetFace(faceId); + BRepAdaptor_Surface leftSurface(leftFace); + for (int neighborId = faceId + 1; neighborId <= aag->GetNumberOfNodes(); ++neighborId) + { + const TopoDS_Face& rightFace = aag->GetFace(neighborId); + BRepAdaptor_Surface rightSurface(rightFace); + std::string relationType; + double residual = 0.0; + + if (leftSurface.GetType() == GeomAbs_Plane && rightSurface.GetType() == GeomAbs_Plane) + { + const gp_Pln leftPlane = leftSurface.Plane(); + const gp_Pln rightPlane = rightSurface.Plane(); + const gp_Dir leftDir = leftPlane.Axis().Direction(); + const gp_Dir rightDir = rightPlane.Axis().Direction(); + if (leftDir.IsParallel(rightDir, angleTol)) + { + residual = leftPlane.Distance(rightPlane.Location()); + relationType = residual <= linearTol ? "coplanar" : "parallel"; + } + else if (leftDir.IsNormal(rightDir, angleTol)) + { + residual = std::abs(leftDir.Dot(rightDir)); + relationType = "perpendicular"; + } + } + else if (leftSurface.GetType() == GeomAbs_Cylinder && rightSurface.GetType() == GeomAbs_Cylinder) + { + const gp_Cylinder leftCylinder = leftSurface.Cylinder(); + const gp_Cylinder rightCylinder = rightSurface.Cylinder(); + const gp_Ax1 leftAxis = leftCylinder.Axis(); + const gp_Ax1 rightAxis = rightCylinder.Axis(); + if (leftAxis.IsCoaxial(rightAxis, angleTol, linearTol) || + leftAxis.IsCoaxial(rightAxis.Reversed(), angleTol, linearTol)) + { + residual = std::abs(leftCylinder.Radius() - rightCylinder.Radius()); + relationType = "coaxial"; + } + else if (leftAxis.Direction().IsParallel(rightAxis.Direction(), angleTol)) + { + residual = 0.0; + relationType = "parallel_axis"; + } + } + + if (!relationType.empty()) + { + printGeometricRelation(faceId, neighborId, relationType, residual, "analysis-situs-probe", firstRelation); + firstRelation = false; + geometricRelationSummary[relationType]++; + } + } + } + } + for (const auto& item : tangentPairs) + { + printGeometricRelation(item.first, item.second, "tangent", 0.0, "analysis-situs-aag-angle", firstRelation); + firstRelation = false; + geometricRelationSummary["tangent"]++; + } + std::cout << "\n ],\n"; + + std::cout << " \"surfaceSummary\": "; + printStringIntMap(surfaceSummary); + std::cout << ",\n"; + std::cout << " \"angleSummary\": "; + printStringIntMap(angleSummary); + std::cout << ",\n"; + std::cout << " \"geometricRelationMode\": \"" + << (runFullGeometricPairScan ? "all-pairs" : "aag-smooth-only-large-model") << "\",\n"; + std::cout << " \"geometricRelationSummary\": "; + printStringIntMap(geometricRelationSummary); + std::cout << "\n"; + std::cout << "}\n"; + + return 0; +}