Compare commits
2 Commits
066d28660b
...
a633b5a338
| Author | SHA1 | Date | |
|---|---|---|---|
| a633b5a338 | |||
| 70b59c1de6 |
+3
-1
@@ -22,13 +22,15 @@ dist/
|
||||
*.zip
|
||||
|
||||
assets/screenshots/
|
||||
/screenshot.png
|
||||
|
||||
local/
|
||||
tmp.md
|
||||
data.json
|
||||
nodes/
|
||||
Analysis-Component/
|
||||
third_party/AnalysisSitus/
|
||||
occt_feature_editor/
|
||||
third_party/
|
||||
|
||||
# Local reference docs; keep them on disk, never commit them.
|
||||
Face一级关系专项测试说明.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 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
+102
-1
@@ -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("参数化建模")
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
+92
-4
@@ -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(
|
||||
|
||||
@@ -15,6 +15,9 @@ def _optional_int(value: object) -> int | None:
|
||||
|
||||
|
||||
def _point3(value: object, operation: str) -> tuple[float, float, float]:
|
||||
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.")
|
||||
@@ -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":
|
||||
|
||||
+630
-2
@@ -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,7 +389,33 @@ 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._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
|
||||
@@ -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 []
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[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, "<relation-formula>", "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}"
|
||||
@@ -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": "识别限制",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+197
-4
@@ -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
|
||||
|
||||
+994
-53
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
#include <asiAlgo_AAG.h>
|
||||
#include <asiAlgo_FeatureAttrAdjacency.h>
|
||||
#include <asiAlgo_FeatureAttrAngle.h>
|
||||
#include <asiAlgo_FeatureFaces.h>
|
||||
#include <asiAlgo_RecognizeDrillHoles.h>
|
||||
#include <asiAlgo_STEP.h>
|
||||
|
||||
#include <BRepAdaptor_Surface.hxx>
|
||||
#include <BRepCheck_Analyzer.hxx>
|
||||
#include <GeomAbs_SurfaceType.hxx>
|
||||
#include <Precision.hxx>
|
||||
#include <TColStd_MapIteratorOfPackedMapOfInteger.hxx>
|
||||
#include <TopAbs_ShapeEnum.hxx>
|
||||
#include <TopExp.hxx>
|
||||
#include <TopTools_IndexedMapOfShape.hxx>
|
||||
#include <gp_Ax1.hxx>
|
||||
#include <gp_Cylinder.hxx>
|
||||
#include <gp_Dir.hxx>
|
||||
#include <gp_Pln.hxx>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string, int>& 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 <step-file> [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<asiAlgo_Feature> holes;
|
||||
aag->GetConnectedComponents(holeFaceIds, holes);
|
||||
|
||||
std::map<std::string, int> surfaceSummary;
|
||||
std::map<std::string, int> angleSummary;
|
||||
std::map<std::string, int> 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<std::pair<int, int>> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user