Compare commits

..

2 Commits

27 changed files with 8414 additions and 78 deletions
+3 -1
View File
@@ -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
+68 -3
View File
@@ -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
+19
View File
@@ -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"
+207
View File
@@ -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
+54 -1
View File
@@ -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()
+18 -4
View File
@@ -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
View File
@@ -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("参数化建模")
+308
View File
@@ -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
View File
@@ -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(
+13
View File
@@ -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
View File
@@ -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 []
+163
View File
@@ -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":
+39
View File
@@ -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
+539
View File
@@ -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]))
+77 -1
View File
@@ -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,
)
+244
View File
@@ -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}"
+15
View File
@@ -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": "识别限制",
+213 -2
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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}
)
+339
View File
@@ -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;
}