Compare commits

...

8 Commits

Author SHA1 Message Date
nikelaluo b4feab24d2 feat: 完善 SCDM-first 参数化编辑交付版 2026-08-20 17:12:01 +08:00
nikelaluo 4e7877e05c feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
2026-08-19 18:02:47 +08:00
nikelaluo a3eb7e2476 feat: 推进SCDM-first后端接入和大模型编辑优化 2026-08-19 10:28:09 +08:00
nikelaluo 722256a41f feat: 完善参数化关系式与SCDM模型修改验证 2026-08-17 18:53:03 +08:00
nikelaluo a633b5a338 feat: 完善 STEP 一级参数化编辑识别与关系式建模 2026-08-14 18:42:39 +08:00
nikelaluo 70b59c1de6 chore: 提交ICEPAK测试模型并忽略本地第三方目录 2026-08-14 09:31:30 +08:00
nikelaluo 066d28660b chore: 忽略Analysis Situs第三方源码 2026-08-13 17:57:00 +08:00
nikelaluo 7d814d2939 feat: 推进一级关系参数化编辑与参数导出 2026-08-13 17:50:20 +08:00
49 changed files with 29854 additions and 2019 deletions
+11
View File
@@ -22,13 +22,24 @@ dist/
*.zip
assets/screenshots/
/screenshot.png
local/
tmp.md
tmp_scdm*
data.json
nodes/
Analysis-Component/
occt_feature_editor/
third_party/
# Local SCDM experiment outputs; keep committed sample models explicit.
assets/models/*_scdm_probe_*.stp
assets/models/*_scdm_*_inner_*.stp
assets/models/*_scdm_*_outer_*.stp
# Local reference docs; keep them on disk, never commit them.
概念.md
Face一级关系专项测试说明.md
Creo软件具备哪些建模形式.md
中文版Creo+4.0从入门到精通.pdf
+399 -1498
View File
File diff suppressed because it is too large Load Diff
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())
@@ -15,6 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
EXPECTED_FACE_ISOLATED_OPERATIONS = {
"push_pull_face",
"push_pull_face_keep_relations",
"translate_face_plane_offset_owning",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
@@ -27,6 +28,8 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
"resize_cylindrical_boss_height",
"resize_cylindrical_boss",
"move_cylindrical_boss_axis",
"resize_cylindrical_height_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
@@ -36,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",
@@ -232,6 +239,8 @@ def _face_property_actions_from_specs() -> set[str]:
"bbox_center": (5.0, 5.0, 0.0),
"local_face_width": 10.0,
"local_face_height": 10.0,
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
"local_face_size_center": (5.0, 5.0, 0.0),
"plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
+25 -1
View File
@@ -55,6 +55,13 @@ def main() -> int:
)
assert_keys(
{"surface": "plane"},
(
"face_center_position",
"face_target_normal_position",
),
)
assert_keys(
{"surface": "plane", "local_face_size_edit_ready": True},
(
"local_face_width",
"local_face_height",
@@ -64,6 +71,14 @@ def main() -> int:
)
assert_keys(
{"surface": "plane", "shell_region_status": "candidate"},
(
"face_center_position",
"face_target_normal_position",
"shell_thickness_estimate",
),
)
assert_keys(
{"surface": "plane", "shell_region_status": "candidate", "local_face_size_edit_ready": True},
(
"local_face_width",
"local_face_height",
@@ -78,7 +93,16 @@ def main() -> int:
"prismatic_profile_status": "candidate",
"prismatic_extrusion_status": "candidate",
},
("local_face_width", "local_face_height", "shell_thickness_estimate"),
("face_target_normal_position", "shell_thickness_estimate"),
)
assert_keys(
{
"surface": "plane",
"prismatic_profile_status": "candidate",
"prismatic_extrusion_status": "candidate",
"local_face_size_edit_ready": True,
},
("local_face_width", "local_face_height", "face_target_normal_position", "shell_thickness_estimate"),
)
assert_keys(
{"surface": "torus"},
@@ -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,
*,
@@ -234,6 +295,53 @@ def _verify_slot_summary(root: Path) -> None:
_assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}")
def _verify_split_cylinder_slot_and_hole_guard() -> None:
geom_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
if geom_path.exists():
geom_model = StepModel.load(geom_path)
for slot_face_id in (1360, 1722):
slot_info = geom_model.feature_info(slot_face_id)
_assert(
"" in str(slot_info.get("feature_type") or "")
and "圆柱孔候选" not in str(slot_info.get("feature_type") or ""),
f"geom_extract Face{slot_face_id} should be treated as a slot/groove, not a cylindrical hole: {slot_info}",
)
_assert(
slot_info.get("slot_status") in {"candidate", "blocked"},
f"geom_extract Face{slot_face_id} should keep slot classification fields: {slot_info}",
)
_assert(
"封堵圆柱孔" not in str(slot_info.get("feature_edit_actions") or ""),
f"geom_extract Face{slot_face_id} should not expose cylindrical-hole suppress wording: {slot_info}",
)
_assert(
int(slot_info.get("recognition_user_priority", 99)) == 30,
f"geom_extract Face{slot_face_id} should use slot priority: {slot_info}",
)
through_hole_info = geom_model.feature_info(1591)
_assert(
through_hole_info.get("feature_type") == "圆柱孔候选",
f"geom_extract Face1591 should remain a cylindrical hole guard case: {through_hole_info}",
)
_assert(
int(through_hole_info.get("recognition_user_priority", 99)) == 20,
f"geom_extract Face1591 should keep hole priority: {through_hole_info}",
)
icepak_path = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
if icepak_path.exists():
icepak_model = StepModel.load(icepak_path)
hole_info = icepak_model.feature_info(87)
_assert(
hole_info.get("feature_type") == "圆柱孔候选",
f"ICEPAK Face87 is a split through-hole and should remain a hole: {hole_info}",
)
_assert(
int(hole_info.get("recognition_user_priority", 99)) == 20,
f"ICEPAK Face87 should keep hole priority: {hole_info}",
)
def _verify_boss_summary(root: Path) -> None:
path = root / "boss.step"
_write_boss_model(path)
@@ -245,6 +353,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 +597,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",
@@ -430,7 +621,9 @@ def main() -> int:
_verify_quick_cylinder_recognition(root)
_verify_hole_summary(root)
_verify_slot_summary(root)
_verify_split_cylinder_slot_and_hole_guard()
_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)
+60 -35
View File
@@ -47,27 +47,33 @@ def _verify_readme_mentions(readme: str) -> None:
"当前整体验证基线",
"不等于 CAD 级完成",
"Face 阶段的当前验收口径",
"参数化编辑路线",
"用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚",
"`[x]` 已实现",
"`[~]` 部分实现/进行中",
"`[ ]` 未实现",
"`[x]` 不是“所有 CAD 形态都能改”",
"R1 Face 是当前主线的第一阶段收口对象",
"R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里",
"STEP/B-Rep 参数化编辑主线",
"[不能修改 -> 立即说明原因]",
"[一级影响范围 -> 明确显示]",
"Face 阶段的当前验收口径(R1 已收口)",
"已验收:平面 Face 的 `面内长度`、`面内宽度`、`偏移`",
"SCDM-first 主路线",
"核心路线只保留下面这一棵树",
"SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM",
"本软件不再把手写一级、二级、三级传播当成新增能力主线",
"`[x]` 已适配",
"`[~]` 部分适配",
"`[ ]` 待适配",
"[scdm_probe_job.json -> /RunScript 扫描 STEP]",
"[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[公式输入 -> 支持 Face85.直径 = Face87.半径",
"[批量联动 -> 多条公式先求值成一组目标参数",
"[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接",
"[Face 偏移 -> face.offset / OffsetFaces]",
"[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces",
"[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]",
"Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口)",
"已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`",
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
"孔/槽阶段的当前验收口径(R2/R3 已收口)",
"孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口)",
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
"已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`",
"未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动",
"2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`",
"覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径",
"0~7 其它阶段的当前基线",
"本地 OCCT 兜底能力的当前基线",
"verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot",
@@ -92,42 +98,61 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None:
start_marker = "STEP/B-Rep 参数化编辑主线"
end_marker = "└── 8. 二级 / 三级关系"
start_marker = "SCDM-first 主路线"
end_marker = "└── 8. 交付与兜底边界"
start = readme.find(start_marker)
end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README roadmap should contain a 0~7 active scope before stage 8")
_assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary")
active_scope = readme[start:end]
deferred_scope = readme[end:]
required_active_fragments = (
"├── 0. 先让用户知道“能不能改”",
"│ ├── [x] [不能修改 -> 立即说明原因]",
"│ └── [x] [路线图 -> 验收脚本守门]",
"├── 1. 平面 Face,第一条主线",
"├── 2. 孔,第二条主线",
"├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征",
"├── 4. 凸台 / Boss,从切除特征扩展到加料特征",
"├── 5. 圆角 / 倒角,从主形体扩展到边修饰",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力",
"├── 0. 后端发现与可用性",
"│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]",
"├── 1. SCDM 识别与缓存",
"│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]",
"│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 2. 能力字典、参数表和用户入口",
"├── 3. 关系式与参数联动",
"[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]",
"[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]",
"[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]",
"├── 4. SCDM 参数化建模执行",
"├── 5. 结果校验、回滚和 ID 续接",
"├── 6. 当前已开放或正在开放的 SCDM 能力",
"[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项]",
"[阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补]",
"[壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补]",
"├── 7. 下一批只按 SCDM 能力适配",
"[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]",
)
for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
forbidden_active_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"└── 8. 二级 / 三级关系",
"[Face 二级传播",
"[孔组 ->",
"多槽组 ->",
"二级传播 ->",
"三级传播 ->",
"二级 / 三级关系",
)
for fragment in forbidden_active_fragments:
_assert(fragment not in active_scope, f"README 0~7 roadmap should defer this to stage 8: {fragment}")
_assert(fragment not in active_scope, f"README SCDM-first roadmap should not keep old route scope: {fragment}")
_assert("[Face 二级传播 -> 孔底/槽底/台阶联动]" in deferred_scope, "README should keep Face deeper propagation in stage 8")
_assert("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard")
_assert("[Analysis Situs -> 只做辅助定位、兜底识别和开源对照]" in deferred_scope, "README should keep Analysis Situs as auxiliary boundary")
_assert("[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]" in deferred_scope, "README should keep OCCT as fallback boundary")
_assert("[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]" in deferred_scope, "README should defer non-SCDM expansion")
forbidden_global_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
)
for fragment in forbidden_global_fragments:
_assert(fragment not in readme, f"README should keep only one SCDM-first route, found old title: {fragment}")
def main() -> int:
@@ -70,6 +70,15 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Smoke test", ("main.py", "--smoke-test")),
("Property editor specs", ("verify_property_editor_specs.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Relation formula rules", ("verify_relation_formula_rules.py",)),
("Parametric component export", ("verify_parametric_component_export.py",)),
("SCDM backend discovery", ("verify_scdm_backend.py",)),
("SCDM runtime status", ("verify_scdm_status.py",)),
("SCDM probe pipeline", ("verify_scdm_probe_pipeline.py",)),
("SCDM edit runner", ("verify_scdm_edit_runner.py",)),
("SCDM result validator", ("verify_scdm_result_validator.py",)),
("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)),
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
("First-level fact graph", ("verify_first_level_fact_graph.py",)),
("Associated feature probe and display budget", ("verify_associated_features.py",)),
@@ -0,0 +1,402 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
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.isolated_edit_worker import run_request
from step_editor.model import StepModel
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import WindowStateMixin
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
class _SelectionHarness(WindowCoreMixin, WindowStateMixin):
def __init__(self, model: StepModel) -> None:
self.model = model
self.feature_detection_level = "current-only"
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = None
self.selected_edge_id = None
self.selected_kind = None
self.selected_part_id = None
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)
def select_feature_face(self, face_id: int) -> None:
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "feature"
self.selected_part_id = self.model.face_part_ids[face_id]
self.selected_solid_id = self.model.face_solid_ids[face_id]
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root: Path) -> str:
input_path = root / "face87_input.brep"
output_path = root / "face87_output.brep"
request_path = root / "face87_resize_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": "resize_cylindrical_hole",
"args": [face_id, diameter],
},
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} resize should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} resize should write output BREP")
return str(response.get("message") or "")
def _assert_isolated_hole_resize_disables_stale_external_regions(
model: StepModel,
face_id: int,
diameter: float,
root: Path,
) -> None:
input_path = root / "face87_stale_input.brep"
output_path = root / "face87_stale_output.brep"
request_path = root / "face87_stale_resize_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": "resize_cylindrical_hole",
"args": [face_id, diameter],
},
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 stale-region resize should pass: {response}")
message = str(response.get("message") or "")
marker = "verified_face="
marker_index = message.find(marker)
_assert(marker_index >= 0, f"isolated resize should report verified_face: {message}")
verified_text = message[marker_index + len(marker):].split(".", 1)[0].strip()
verified_face = int(float(verified_text))
result_model = StepModel.load_internal_brep(output_path)
result_model.filename = MODEL_PATH
stale_region = result_model.face_region_ids(verified_face)
_assert(
len(stale_region) > 1,
f"test fixture should expose the stale external-region bug before marking: {stale_region}",
)
result_model.mark_external_recognition_stale("isolated-edit-result")
current_region = result_model.face_region_ids(verified_face)
_assert(
current_region == [verified_face],
f"isolated edit result should not reuse original STEP Analysis Situs hole groups: {current_region}",
)
result_model.assign_logical_face_region_exclusive(face_id, current_region)
_assert(
result_model.face_ids_for_logical_id(face_id) == current_region,
f"logical Face {face_id} should only attach to the edited hole, not a neighboring hole: "
f"{result_model.face_ids_for_logical_id(face_id)}",
)
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:
return set()
return {int(edge_arr.GetValue(index)) for index in range(edge_arr.GetNumberOfTuples())}
def _polydata_edge_sample_counts(polydata) -> dict[int, list[int]]:
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
if edge_arr is None:
return {}
counts: dict[int, list[int]] = {}
for cell_id in range(polydata.GetNumberOfCells()):
edge_id = int(edge_arr.GetValue(cell_id))
counts.setdefault(edge_id, []).append(int(polydata.GetCell(cell_id).GetNumberOfPoints()))
return counts
def _assert_default_edges_hide_same_domain_internal_edges(model: StepModel, label: str) -> None:
visible_edge_ids = _edge_ids_from_polydata(model.build_edge_polydata(show_same_domain_internal_edges=False))
hidden_internal_ids = set(model._same_domain_internal_edge_ids()) # noqa: SLF001
leaked = sorted(visible_edge_ids & hidden_internal_ids)
_assert(not leaked, f"{label} default edge display should hide same-domain/internal duplicate edges: {leaked[:12]}")
def _assert_small_circle_edges_are_smooth(model: StepModel, label: str) -> None:
edge_polydata = model.build_edge_polydata(deflection=1.6, show_same_domain_internal_edges=False)
sample_counts = _polydata_edge_sample_counts(edge_polydata)
rough_edges: list[tuple[int, int, float]] = []
for edge_id, counts in sample_counts.items():
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
radius = float(info.get("radius") or 0.0)
if radius <= 0.0 or radius > 0.35:
continue
point_count = max(counts or [0])
if point_count < 9:
rough_edges.append((edge_id, point_count, radius))
_assert(not rough_edges, f"{label} small circular display edges should not be drawn as triangles: {rough_edges[:12]}")
def _assert_rectangular_face_parameters(model: StepModel, harness: _SelectionHarness) -> None:
face_id = 9
info = model.feature_info(face_id)
_assert(info.get("surface") == "plane", "ICEPAK Face 9 should be a planar rectangular face")
_assert(info.get("prismatic_profile_status") == "candidate", "ICEPAK Face 9 should be a rectangular profile")
_assert(bool(info.get("local_face_size_edit_ready")), f"ICEPAK Face 9 size parameters should be visible: {info}")
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
_assert(abs(width - 8.0) <= 1e-7, f"ICEPAK Face 9 length should be read from rectangle edges, got {width:g}")
_assert(abs(height - 4.9) <= 1e-7, f"ICEPAK Face 9 width should be read from rectangle edges, got {height:g}")
harness.select_feature_face(face_id)
specs = harness._property_editor_specs(info, info)
editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))}
expected = {"local_face_width", "local_face_height", "face_target_normal_position"}
missing = sorted(expected - editable_keys)
_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")
return 0
model = StepModel.load(MODEL_PATH)
_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],
97: [84, 97],
}
for face_id, expected in expected_regions.items():
info = model.quick_face_info(face_id)
_assert(info.get("surface") == "cylinder", f"Face {face_id} should be cylindrical")
_assert(model.face_region_ids(face_id) == expected, f"Face {face_id} region should be {expected}")
_assert(
list(info.get("feature_highlight_face_ids") or []) == expected,
f"Face {face_id} quick info highlight should include the whole same-domain region: {info.get('feature_highlight_face_ids')}",
)
_assert(
harness._selection_same_domain_face_ids(face_id) == expected,
f"Face {face_id} UI selection highlight should include the whole same-domain region",
)
context = harness._feature_context_info(face_id)
_assert(
list(context.get("feature_highlight_face_ids") or []) == expected,
f"Face {face_id} feature context highlight should include the whole same-domain region: {context.get('feature_highlight_face_ids')}",
)
feature = model.feature_info(face_id)
_assert(
list(feature.get("same_domain_face_ids") or []) == expected,
f"Face {face_id} feature info should use the whole same-domain region",
)
_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")
_assert(list(plan.get("same_domain_face_ids") or []) == [87, 94], "Face 87 resize plan should use both side fragments")
_assert(bool(plan.get("is_full_cylinder")), "Face 87 resize plan should preserve full-cylinder semantics")
_assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 87 resize plan should not use the selected half-face span")
result = resize_model.resize_cylindrical_hole(87, 0.3)
_assert("diameter 0.5 -> 0.3" in result, "Face 87 diameter shrink should complete")
_assert_default_edges_hide_same_domain_internal_edges(resize_model, "ICEPAK after Face 87 shrink")
_assert_small_circle_edges_are_smooth(resize_model, "ICEPAK after Face 87 shrink")
with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as temp_dir:
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")
_assert_isolated_hole_resize_disables_stale_external_regions(model, 87, 0.3, Path(temp_dir))
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
if __name__ == "__main__":
raise SystemExit(main())
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GeomAbs import GeomAbs_Plane
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.window_actions import WindowActionMixin
from step_editor.isolated_edit_worker import run_request
from step_editor.model import StepModel
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return sum(left[index] * right[index] for index in range(3))
def _plane_axis(model: StepModel, face_id: int) -> tuple[float, float, float]:
surf = BRepAdaptor_Surface(model.faces[face_id])
_assert(surf.GetType() == GeomAbs_Plane, f"Face {face_id} should be planar")
direction = surf.Plane().Axis().Direction()
return (float(direction.X()), float(direction.Y()), float(direction.Z()))
def _isolated_push_pull(model: StepModel, face_id: int, distance: float, temp_dir: Path, stem: str) -> tuple[StepModel, str]:
input_path = temp_dir / f"{stem}_input.brep"
output_path = temp_dir / f"{stem}_output.brep"
request_path = temp_dir / f"{stem}_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": "push_pull_face",
"args": [face_id, distance],
},
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} push-pull should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} push-pull should write an output BREP")
return StepModel.load_internal_brep(output_path), str(response.get("message") or "")
def _ui_push_pull_context(model: StepModel, face_id: int, plan: dict[str, object]) -> dict[str, object]:
return {
"operation_name": "拉伸/切除平面",
"target": f"Face {face_id}",
"target_kind": "face",
"target_id": face_id,
"target_logical_id": model.face_logical_id(face_id),
"parameters": {
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"semantic_distance": plan.get("distance"),
"distance_rule": "positive=outward fuse, negative=inward cut",
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"plane_direction": plan.get("plane_direction"),
"current_plane_position": plan.get("current_plane_position"),
"target_plane_position": plan.get("target_plane_position"),
"current_plane_center": plan.get("current_plane_center"),
"target_plane_center": plan.get("target_plane_center"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"push_pull_risk": plan.get("risk"),
"push_pull_status": plan.get("status"),
"push_pull_message": plan.get("message"),
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
"push_pull_scope_note": plan.get("push_pull_scope_note"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"selected_boundary_wires": plan.get("selected_boundary_wires"),
"selected_inner_boundary_wires": plan.get("selected_inner_boundary_wires"),
"selected_has_inner_boundaries": plan.get("selected_has_inner_boundaries"),
},
}
def main() -> int:
if not MODEL_PATH.exists():
print("icepak face45 push-pull skipped: local ICEPAK-NATURAL.stp is not present")
return 0
model = StepModel.load(MODEL_PATH)
face_id = 45
plan = model.push_pull_plan(face_id, 10.0)
_assert(plan.get("status") != "blocked", f"Face 45 positive push-pull should be plannable: {plan}")
_assert(float(plan.get("current_plane_position") or 0.0) == 5.0, f"Face 45 should use outward offset +5: {plan}")
_assert(float(plan.get("target_plane_position") or 0.0) == 15.0, f"Face 45 target should be +15: {plan}")
_assert(plan.get("target_plane_center") is not None, "Face push-pull plan should carry target_plane_center")
inward_plan = model.push_pull_plan(face_id, -10.0)
_assert(inward_plan.get("status") == "blocked", f"Face 45 inward cut through material should be blocked: {inward_plan}")
_assert("材料厚度" in str(inward_plan.get("blockers") or ""), f"blocked reason should mention material depth: {inward_plan}")
direct_model = StepModel.load(MODEL_PATH)
direct_model.push_pull_face(face_id, 10.0)
direct_followup_plan = direct_model.push_pull_plan(47, 10.0)
direct_direction = tuple(float(item) for item in (direct_followup_plan.get("plane_direction") or ()))
_assert(
len(direct_direction) == 3 and abs(_dot(direct_direction, _plane_axis(direct_model, 47))) >= 0.92,
f"Face 47 follow-up direction should stay aligned with its selected plane: {direct_followup_plan}",
)
direct_followup_message = direct_model.push_pull_face(47, 10.0, plan=dict(direct_followup_plan))
direct_followup_target = float(direct_followup_plan.get("target_plane_position") or 0.0)
_assert(
f"target={direct_followup_target:g}" in direct_followup_message,
f"direct second push-pull should match Face 47 target: {direct_followup_message}",
)
with tempfile.TemporaryDirectory(prefix="icepak_face45_isolated_") as temp_dir:
root = Path(temp_dir)
edited_model, message = _isolated_push_pull(model, face_id, 10.0, root, "face45")
_assert("actual=15" in message and "target=15" in message, f"result check should match target face: {message}")
followup_face_id = 47
followup_plan = edited_model.push_pull_plan(followup_face_id, 10.0)
followup_context = _ui_push_pull_context(edited_model, followup_face_id, followup_plan)
followup_current = float(followup_plan.get("current_plane_position") or 0.0)
followup_target = float(followup_plan.get("target_plane_position") or 0.0)
_assert(followup_plan.get("status") != "blocked", f"Face 47 follow-up should be plannable: {followup_plan}")
_assert(
abs(followup_target - followup_current - 10.0) <= 1e-8,
f"Face 47 follow-up target should preserve the requested +10 offset: {followup_plan}",
)
_second_model, followup_message = _isolated_push_pull(edited_model, followup_face_id, 10.0, root, "face47")
_assert(
f"actual={followup_target:g}" in followup_message and f"target={followup_target:g}" in followup_message,
f"second isolated push-pull should match Face 47 target: {followup_message}",
)
checker = WindowActionMixin()
blocker = checker._face_target_integrity_blocker(_second_model, followup_context)
_assert(blocker == "", f"UI target integrity check should accept the second push-pull: {blocker}")
print("icepak face45/face47 push-pull ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+79 -1
View File
@@ -19,13 +19,19 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.records import OperationRecord
from step_editor.window_actions import WindowActionMixin
from step_editor.window_state import WindowStateMixin
class _WindowActionProbe(WindowActionMixin):
pass
class _WindowStateProbe(WindowStateMixin):
pass
def _wire_count(face) -> int:
count = 0
explorer = TopExp_Explorer(face, TopAbs_WIRE)
@@ -145,6 +151,9 @@ def main() -> int:
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}")
stepped_isolation = _WindowActionProbe()._isolation_for_plan(plan, "push_pull_face", [face_id, 89.0])
if not stepped_isolation or stepped_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large stepped cap should use the smooth UI background process: {stepped_isolation}")
started = time.perf_counter()
result = model.push_pull_face(face_id, 89.0)
@@ -233,7 +242,13 @@ def main() -> int:
multi_face_id = _large_multi_boundary_cap_face(multi_model)
multi_logical_id = multi_model.face_region_logical_id(multi_face_id)
multi_before_topology = _face_topology_counts(multi_model, multi_face_id)
started = time.perf_counter()
multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5)
multi_plan_elapsed = time.perf_counter() - started
if multi_plan_elapsed > 5.0:
raise SystemExit(
f"multi-boundary cap push/pull plan should be quick: {multi_plan_elapsed:.3f}s; plan={multi_plan}"
)
if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9:
raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}")
if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9:
@@ -242,6 +257,22 @@ def main() -> int:
raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}")
if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}")
multi_isolation = _WindowActionProbe()._isolation_for_plan(multi_plan, "push_pull_face", [multi_face_id, 34.5])
if not multi_isolation or multi_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"multi-boundary cap should use the smooth UI background process: {multi_isolation}")
ui_probe = _WindowActionProbe()
ui_probe.model = multi_model
ui_probe.current_info_values = multi_model.quick_face_info(multi_face_id)
ui_plan = ui_probe._push_pull_plan_for_action(multi_face_id, 34.5)
if bool(ui_plan.get("ui_deferred_model_plan")):
raise SystemExit(f"large multi-boundary UI plan should use the fast local plan now: {ui_plan}")
if ui_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"large multi-boundary UI plan should use boundary-shell rebuild: {ui_plan}")
ui_isolation = ui_probe._isolation_for_plan(ui_plan, "push_pull_face", [multi_face_id, 34.5])
if not ui_isolation or ui_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large multi-boundary UI plan should use the smooth UI background process: {ui_isolation}")
if ui_probe._edit_preflight_blocker({"parameters": ui_plan}) is not None:
raise SystemExit(f"large multi-boundary UI plan should not be blocked before editing: {ui_plan}")
started = time.perf_counter()
multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0)
multi_inward_elapsed = time.perf_counter() - started
@@ -346,6 +377,53 @@ def main() -> int:
multi_after_topology,
min_inner_wires=5,
)
locator_probe = _WindowStateProbe()
locator_probe.model = multi_model
locator_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters={
"part_id": multi_plan.get("part_id"),
"solid_id": multi_plan.get("solid_id"),
"surface": "plane",
"outward_direction": multi_plan.get("outward_direction") or multi_plan.get("plane_direction"),
"target_plane_position": multi_plan.get("target_plane_position"),
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
},
result_message=multi_result,
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
resolved_after_edit = locator_probe._resolve_record_face_id(locator_record)
locator_elapsed = time.perf_counter() - started
if resolved_after_edit != multi_retained_ids[0] or locator_elapsed > 0.5:
raise SystemExit(
f"large multi-boundary operation history locator should use the fast result Face: "
f"resolved={resolved_after_edit}, expected={multi_retained_ids[0]}, elapsed={locator_elapsed:.3f}s"
)
no_hint_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters=locator_record.parameters,
result_message="Planar face push/pull completed without result face hint.",
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
fallback_after_edit = locator_probe._record_plane_position_face_id(no_hint_record)
fallback_elapsed = time.perf_counter() - started
if fallback_after_edit != multi_retained_ids[0] or fallback_elapsed > 1.0:
raise SystemExit(
f"large multi-boundary fallback locator should use lightweight plane positions: "
f"resolved={fallback_after_edit}, expected={multi_retained_ids[0]}, elapsed={fallback_elapsed:.3f}s"
)
with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir:
temp_root = Path(temp_dir)
@@ -442,7 +520,7 @@ def main() -> int:
)
print(
"large multi-boundary cap push/pull ok: "
f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, "
f"face_id={multi_face_id}, plan_elapsed={multi_plan_elapsed:.3f}s, elapsed={multi_elapsed:.3f}s, "
f"isolated_elapsed={multi_isolated_elapsed:.3f}s, "
f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}"
)
@@ -0,0 +1,90 @@
from __future__ import annotations
import json
from pathlib import Path
import py_compile
import sys
import tempfile
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.parametric_component import export_parametric_component
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_component_export_") as temp_dir:
root = Path(temp_dir)
source_step = root / "source.step"
source_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
parameters = [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "10",
}
]
edits = [
{
"parameter": "面内长度",
"displayName": "面内长度",
"targetKind": "feature",
"targetId": 0,
"uiAction": "resize_face_width_local",
"operation": "resize_face_size_local",
"args": [0, {"param": "面内长度"}, "width"],
"default": 10.0,
"valueType": "positive",
"scope": "local",
"scopeLabel": "局部重建",
"sourceStep": str(source_step),
"parameterKey": "local_face_width",
}
]
main_py = export_parametric_component(
parameters=parameters,
edits=edits,
source_step=source_step,
component_root=root / "nodes",
component_name="测试组件",
)
text = main_py.read_text(encoding="utf-8")
_assert("INPUT_PARAMETERS = " in text, "generated main.py should embed selected input parameter list")
_assert("PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS" in text, "generated main.py should expose FlowEditor parameters")
_assert("NODE_INFO = " in text, "generated main.py should expose FlowEditor node info")
_assert("def execute(inputs, params, context):" in text, "generated main.py should expose FlowEditor execute entry")
_assert("COMPONENT = " in text, "generated main.py should embed execution config")
_assert("step_edit_config.json" not in text, "generated component should not require a sidecar config JSON")
_assert(not (main_py.parent / "data.json").exists(), "component directory should not contain data.json")
_assert(not (main_py.parent / "step_edit_config.json").exists(), "component directory should not contain step_edit_config.json")
py_compile.compile(str(main_py), doraise=True)
namespace: dict[str, object] = {}
exec(compile(text, str(main_py), "exec"), namespace)
embedded_input_parameters = namespace.get("INPUT_PARAMETERS")
embedded_parameters = namespace.get("PARAMETERS")
node_info = namespace.get("NODE_INFO")
embedded_component = namespace.get("COMPONENT")
_assert(embedded_input_parameters == parameters, f"embedded INPUT_PARAMETERS mismatch: {embedded_input_parameters}")
_assert(isinstance(embedded_parameters, list), "embedded PARAMETERS should be a list")
_assert(embedded_parameters[: len(parameters)] == parameters, f"embedded input parameter prefix mismatch: {embedded_parameters}")
_assert(any(row.get("name") == "output_step" and row.get("ioRole") == "output" for row in embedded_parameters if isinstance(row, dict)), "generated PARAMETERS should include output_step output port")
_assert(isinstance(node_info, dict), "NODE_INFO should be a dict")
_assert(node_info.get("parameters") == embedded_parameters, "NODE_INFO should point to PARAMETERS")
_assert(isinstance(embedded_component, dict), "embedded COMPONENT should be a dict")
_assert(embedded_component.get("edits") == edits, f"embedded edits mismatch: {json.dumps(embedded_component, ensure_ascii=False)}")
print("parametric component export ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+82 -5
View File
@@ -27,6 +27,18 @@ class _PropertySpecProbe(WindowStateMixin):
self.manual_slot_pair_face_id = None
class _PlaneOffsetDirectionModel:
faces = (object(),)
def face_info(self, face_id: int) -> dict[str, object]:
if face_id != 0:
raise ValueError(f"unexpected face id {face_id}")
return {
"plane_origin": (4.25, -5.0, 0.0),
"push_pull_outward_direction": (0.0, -1.0, 0.0),
}
def _spec_keys(info: dict[str, object]) -> set[str]:
return {str(spec.get("key", "")) for spec in _specs(info)}
@@ -332,6 +344,36 @@ def _assert_target_change_detection() -> None:
raise SystemExit("changed vector Face target was not detected")
def _assert_plane_offset_uses_push_pull_direction() -> None:
probe = _PropertySpecProbe()
probe.model = _PlaneOffsetDirectionModel()
probe.selected_face_id = 0
info = {
"face_id": 0,
"topological_face_id": 0,
"surface": "plane",
"plane_origin": (4.25, -5.0, 0.0),
"normal": (0.0, 1.0, 0.0),
"area_center": (4.25, -5.0, 2.5),
"bbox_diagonal": 8.0,
"local_face_deform_ready": True,
"local_face_width": 1.5,
"local_face_height": 4.0,
}
specs, _used = probe._editable_property_specs(info)
offset_spec = _spec(specs, "face_target_normal_position")
current = float(offset_spec.get("current_raw"))
if abs(current - 5.0) > 1e-9:
raise SystemExit(f"plane offset should use push-pull outward direction, got current={current:g}")
push_pull = _scoped_effective_spec(specs, "face_target_normal_position", "push_pull")
distance = probe._transform_property_scalar_target(push_pull, 5.0)
if abs(distance) > 1e-9:
raise SystemExit(f"unchanged plane offset target should convert to zero distance, got {distance:g}")
inward_distance = probe._transform_property_scalar_target(push_pull, -5.0)
if abs(inward_distance + 10.0) > 1e-9:
raise SystemExit(f"opposite plane offset target should convert relative to outward current, got {inward_distance:g}")
def _assert_property_table_column_widths() -> None:
for width in (320, 340, 360, 400, 520):
columns = _property_table_column_widths(width)
@@ -369,9 +411,16 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
"has_inner_boundaries": True,
"local_face_deform_ready": False,
"local_face_deform_blocker": "has inner boundary",
"local_face_size_edit_ready": False,
"local_face_size_edit_blocker": "has inner boundary",
}
)
for key in ("local_face_width", "local_face_height", "face_center_position"):
keys = {str(spec.get("key", "")) for spec in specs}
for key in ("local_face_width", "local_face_height"):
if key in keys:
raise SystemExit(f"{key} should be hidden for a holed planar Face, got {keys}")
for key in ("face_center_position",):
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face")
@@ -438,6 +487,19 @@ def main() -> int:
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
}
quick_plane_display_specs = _display_specs(plane_info)
quick_plane_display_keys = {str(spec.get("key", "")) for spec in quick_plane_display_specs}
if "local_face_width" in quick_plane_display_keys or "local_face_height" in quick_plane_display_keys:
raise SystemExit(
"quick plane Face should not show面内长度/面内宽度 until local size edit is explicitly ready: "
f"{sorted(quick_plane_display_keys)}"
)
plane_info.update(
{
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
}
)
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
@@ -678,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",
@@ -688,7 +758,7 @@ def main() -> int:
_assert_current_text_contains(
cylinder_specs,
"cad_recommended_operation",
("孔径", "盲孔", "轴心"),
("孔径", "盲孔", "位置"),
"cylindrical hole feature",
)
@@ -727,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,
(
@@ -814,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}")
@@ -853,7 +929,7 @@ def main() -> int:
_assert_current_text_contains(
slot_specs,
"cad_recommended_operation",
("槽宽", "槽深", "轴心"),
("槽宽", "槽深", "位置"),
"slot/half-hole feature",
)
blocked_slot_specs = _display_specs(
@@ -1217,6 +1293,7 @@ def main() -> int:
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
_assert_target_change_detection()
_assert_plane_offset_uses_push_pull_direction()
_assert_property_table_column_widths()
_assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.relation_formulas import ( # noqa: E402
RelationFormulaError,
Vector3,
evaluate_relation_formula,
parse_relation_formula,
validate_relation_formula_graph,
)
def _parse_many(texts: list[str]):
return [parse_relation_formula(text) for text in texts]
def _assert_ok(texts: list[str]) -> None:
validate_relation_formula_graph(_parse_many(texts))
def _assert_fails(texts: list[str], expected_fragment: str) -> None:
try:
validate_relation_formula_graph(_parse_many(texts))
except RelationFormulaError as exc:
message = str(exc)
if expected_fragment not in message:
raise AssertionError(f"expected {expected_fragment!r} in error message, got {message!r}") from exc
return
raise AssertionError(f"expected relation formulas to fail: {texts!r}")
def _assert_value(text: str, expected: object, values: dict[str, object] | None = None) -> None:
formula = parse_relation_formula(text)
values = dict(values or {})
value = evaluate_relation_formula(formula, lambda ref: values[ref.token])
if isinstance(value, Vector3):
actual = tuple(value.values)
expected_tuple = tuple(expected) # type: ignore[arg-type]
if len(actual) != len(expected_tuple) or any(abs(float(left) - float(right)) > 1.0e-12 for left, right in zip(actual, expected_tuple)):
raise AssertionError(f"expected {expected_tuple!r}, got {actual!r} for {text!r}")
return
if abs(float(value) - float(expected)) > 1.0e-12:
raise AssertionError(f"expected {expected!r}, got {value!r} for {text!r}")
def main() -> int:
_assert_ok(["Face87.直径 = Face87.半径 + 0.1"])
_assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"])
_assert_ok(["Face85.直径 = Face87.半径", "Face11.直径 = Face85.半径"])
_assert_ok(["Face87.直径 = 10mm"])
_assert_ok(["Face87.直径 = 1 cm + 2mm"])
_assert_value("Face87.直径 = 1cm + 2毫米", 12.0)
_assert_value("Face87.直径 = .5m / 10", 50.0)
_assert_value(
"Face87.位置 = Face85.位置 + (0mm, 1cm, -0.002m)",
(1.0, 12.0, 1.0),
{"Face85.位置": (1.0, 2.0, 3.0)},
)
_assert_fails(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身")
_assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数")
_assert_fails(["Face85.直径 = Face87.直径", "Face87.直径 = Face85.直径"], "循环依赖")
_assert_fails(
[
"Face1.位置 = Face2.位置",
"Face2.位置 = Face3.位置",
"Face3.位置 = Face1.位置",
],
"循环依赖",
)
print("relation formula rules ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import ast
import os
import re
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ( # noqa: E402
SCDM_DISABLE_ENV,
SCDM_PATH_ENV_VARS,
ScdmBackendInfo,
default_scdm_cache_path,
discover_scdm_backend_candidates,
load_scdm_backend_cache,
resolve_scdm_backend,
save_scdm_backend_cache,
scdm_run_script_command,
verify_scdm_backend,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
@contextmanager
def _patched_env(values: dict[str, str | None]) -> Iterator[None]:
original = {key: os.environ.get(key) for key in values}
try:
for key, value in values.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _fake_spaceclaim(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
return path
def _fake_runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
script_args = [item for item in command if item.startswith("/RunScript=")]
_assert(script_args, f"missing /RunScript argument: {command}")
script_path = Path(script_args[0].split("=", 1)[1])
script = script_path.read_text(encoding="utf-8")
match = re.search(r"report_path\s*=\s*(.+)", script)
_assert(match is not None, f"smoke script should define report_path: {script}")
report_path = Path(ast.literal_eval(match.group(1).strip()))
report_path.write_text('{"ok": true, "version": "fake-2022R2", "message": "fake smoke ok"}', encoding="utf-8")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe")
backend = ScdmBackendInfo(
path=fake_exe,
source="test",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
message="cached",
)
cache_path = save_scdm_backend_cache(backend, project_root_override=root)
_assert(cache_path == default_scdm_cache_path(root), f"unexpected cache path: {cache_path}")
loaded = load_scdm_backend_cache(project_root_override=root)
_assert(loaded is not None, "cache should load")
_assert(loaded.path == fake_exe.resolve(strict=False), f"cache should preserve path: {loaded}")
_assert(loaded.run_script_ok is True and loaded.license_ok is True, f"cache should preserve verification: {loaded}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_env_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
env_clear = {name: None for name in SCDM_PATH_ENV_VARS}
env_clear[SCDM_DISABLE_ENV] = None
env_clear["STEP_EDITOR_SCDM_EXE"] = str(fake_exe)
with _patched_env(env_clear):
candidates = discover_scdm_backend_candidates(
include_registry=False,
include_common=False,
include_path=False,
)
_assert(len(candidates) == 1, f"env discovery should find exactly one candidate: {candidates}")
_assert(candidates[0].source == "env:STEP_EDITOR_SCDM_EXE", f"bad source: {candidates[0]}")
resolved = resolve_scdm_backend(
project_root_override=root,
validate=False,
include_registry=False,
include_common=False,
include_path=False,
)
_assert(resolved.get("ok") is True, f"env backend should resolve: {resolved}")
_assert(Path(str(resolved.get("path"))) == fake_exe.resolve(strict=False), f"bad resolved path: {resolved}")
_assert(load_scdm_backend_cache(project_root_override=root) is not None, "resolve should write cache")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_common_") as temp:
root = Path(temp)
common_root = root / "Program Files" / "ANSYS Inc"
fake_exe = _fake_spaceclaim(common_root / "v231" / "SCDM" / "SpaceClaim.exe")
candidates = discover_scdm_backend_candidates(
include_env=False,
include_registry=False,
include_common=True,
include_path=False,
common_roots=(common_root,),
)
_assert(candidates and candidates[0].path == fake_exe.resolve(strict=False), f"common discovery failed: {candidates}")
_assert(candidates[0].version == "v231", f"version should be parsed from ANSYS folder: {candidates[0]}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_smoke_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
command = scdm_run_script_command(fake_exe, root / "smoke.py")
_assert(command[0].endswith("SpaceClaim.exe"), f"bad command executable: {command}")
_assert(any(item.startswith("/RunScript=") for item in command), f"bad command script arg: {command}")
smoke = verify_scdm_backend(fake_exe, work_dir=root, runner=_fake_runner)
_assert(smoke.get("ok") is True, f"fake smoke should pass: {smoke}")
_assert(smoke.get("runScriptOk") is True and smoke.get("licenseOk") is True, f"bad smoke flags: {smoke}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_disabled_") as temp:
root = Path(temp)
with _patched_env({SCDM_DISABLE_ENV: "1"}):
resolved = resolve_scdm_backend(project_root_override=root, validate=False)
_assert(resolved.get("ok") is False and resolved.get("reason") == "disabled", f"disable env failed: {resolved}")
missing = verify_scdm_backend(Path("Z:/not-installed/SpaceClaim.exe"))
_assert(missing.get("ok") is False and missing.get("reason") == "missing-exe", f"missing path should be clean: {missing}")
print("scdm backend discovery ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+755
View File
@@ -0,0 +1,755 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_result_validator import ( # noqa: E402
build_scdm_id_mapping,
check_scdm_summary_delta,
check_scdm_unedited_objects,
check_scdm_target,
match_scdm_object_by_signature,
rewrite_scdm_relation_formula_ids,
validate_scdm_edit_result,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _hole(object_id: str, face_id: int, *, diameter: float, center: tuple[float, float, float]) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": "hole",
"geometrySignature": {
"objectType": "hole",
"faceIds": [face_id],
"surfaceType": "cylinder",
"center": list(center),
"axis": [0.0, 0.0, 1.0],
"diameter": diameter,
},
"capabilities": [
{"key": "hole.diameter", "currentValue": diameter},
{"key": "hole.position", "currentValue": list(center)},
],
}
def _feature(
object_id: str,
object_type: str,
face_id: int,
*,
center: tuple[float, float, float],
capability_key: str,
) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": object_type,
"geometrySignature": {
"objectType": object_type,
"faceIds": [face_id],
"center": list(center),
"axis": [0.0, 0.0, 1.0],
},
"capabilities": [
{"key": capability_key, "currentValue": list(center)},
],
}
def _cache(*objects: dict[str, object]) -> dict[str, object]:
return {
"schemaVersion": 1,
"source": "SCDM",
"objects": list(objects),
"diagnostics": {},
}
def _cache_with_summary(summary: dict[str, int], *objects: dict[str, object]) -> dict[str, object]:
cache = _cache(*objects)
cache["diagnostics"] = {"raw_summary": dict(summary)}
return cache
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_validate_") as temp:
root = Path(temp)
output_step = root / "result.step"
output_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
after = _cache(
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
before_signature = before["objects"][0]["geometrySignature"] # type: ignore[index]
match = match_scdm_object_by_signature(before_signature, after, capability_key="hole.diameter")
_assert(match.get("status") == "unique", f"changed diameter should still match by center/axis/type: {match}")
_assert(match.get("object", {}).get("objectId") == "hole:90", f"wrong match: {match}")
ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(ok.get("ok") is True, f"validated edit should pass: {ok}")
_assert(ok.get("targetCheck", {}).get("ok") is True, f"target diameter should be checked: {ok}")
_assert(ok.get("topologyCheck", {}).get("ok") is True, f"unchanged objects should be checked: {ok}")
drift_after = _cache(_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)))
drift = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=drift_after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
)
_assert(drift.get("ok") is False and drift.get("reason") == "unexpected-object-drift", f"missing unrelated hole should fail: {drift}")
direct_drift = check_scdm_unedited_objects(before, drift_after, edited_object_id="hole:85", edited_signature=before_signature)
_assert(direct_drift.get("ok") is False and direct_drift.get("checked") == 1, f"direct drift check should inspect one unedited object: {direct_drift}")
mismatch = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.9,
)
_assert(mismatch.get("ok") is False and mismatch.get("reason") == "target-mismatch", f"wrong target should fail: {mismatch}")
missing = validate_scdm_edit_result({"ok": True, "output_step": str(root / "missing.step")})
_assert(missing.get("ok") is False and missing.get("reason") == "missing-output-step", f"missing result STEP should fail: {missing}")
mapping = build_scdm_id_mapping(before, after, capability_key="hole.diameter")
_assert(mapping.get("faceIdMap") == {85: 90, 87: 91}, f"face IDs should remap through signatures: {mapping}")
rewritten = rewrite_scdm_relation_formula_ids("Face87.直径 = Face85.半径", mapping)
_assert(rewritten == "Face91.直径 = Face90.半径", f"formula IDs should follow SCDM remap: {rewritten}")
position_after = _cache(_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 6.0)))
position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0])
_assert(position_check.get("ok") is True, f"position target should pass: {position_check}")
before_slot = _cache(_feature("slot:30", "slot", 30, center=(1.0, 2.0, 3.0), capability_key="slot.position"))
after_slot = _cache(_feature("slot:40", "slot", 40, center=(1.0, 2.0, 6.0), capability_key="slot.position"))
slot_before_signature = before_slot["objects"][0]["geometrySignature"] # type: ignore[index]
slot_match = match_scdm_object_by_signature(slot_before_signature, after_slot, capability_key="slot.position")
_assert(slot_match.get("status") == "unique", f"moved slot should match without old center lock: {slot_match}")
slot_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_before_signature,
before_cache=before_slot,
after_cache=after_slot,
capability_key="slot.position",
expected_target=[1.0, 2.0, 6.0],
edited_object_id="slot:30",
)
_assert(slot_ok.get("ok") is True, f"slot.position result should validate target center: {slot_ok}")
_assert(slot_ok.get("targetCheck", {}).get("ok") is True, f"slot target should be checked: {slot_ok}")
before_slot_width = _cache(
{
"objectId": "slot:30",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [30],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.0,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.0}],
}
)
after_slot_width = _cache(
{
"objectId": "slot:40",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [40],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.5,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.5}],
}
)
slot_width_before_signature = before_slot_width["objects"][0]["geometrySignature"] # type: ignore[index]
slot_width_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_width_before_signature,
before_cache=before_slot_width,
after_cache=after_slot_width,
capability_key="slot.width",
expected_target=2.5,
edited_object_id="slot:30",
)
_assert(slot_width_ok.get("ok") is True, f"slot.width result should validate target width: {slot_width_ok}")
slot_width_mismatch = check_scdm_target(after_slot_width["objects"][0], capability_key="slot.width", expected_target=2.1) # type: ignore[index]
_assert(slot_width_mismatch.get("ok") is False and slot_width_mismatch.get("reason") == "target-mismatch", f"slot.width mismatch should fail: {slot_width_mismatch}")
before_slot_depth = _cache(
{
"objectId": "slot:31",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [31],
"center": [1.0, 2.0, 3.0],
"depth": 1.5,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 1.5}],
}
)
after_slot_depth = _cache(
{
"objectId": "slot:41",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [41],
"center": [1.0, 2.0, 3.0],
"depth": 2.0,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 2.0}],
}
)
slot_depth_before_signature = before_slot_depth["objects"][0]["geometrySignature"] # type: ignore[index]
slot_depth_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_depth_before_signature,
before_cache=before_slot_depth,
after_cache=after_slot_depth,
capability_key="slot.depth",
expected_target=2.0,
edited_object_id="slot:31",
)
_assert(slot_depth_ok.get("ok") is True, f"slot.depth result should validate target depth: {slot_depth_ok}")
slot_depth_mismatch = check_scdm_target(after_slot_depth["objects"][0], capability_key="slot.depth", expected_target=1.5) # type: ignore[index]
_assert(slot_depth_mismatch.get("ok") is False and slot_depth_mismatch.get("reason") == "target-mismatch", f"slot.depth mismatch should fail: {slot_depth_mismatch}")
boss_after = _feature("boss:50", "cylindrical_boss", 50, center=(3.0, 0.0, 2.0), capability_key="boss.position")
boss_check = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[3.0, 0.0, 2.0])
_assert(boss_check.get("ok") is True, f"boss.position target should pass: {boss_check}")
boss_mismatch = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[4.0, 0.0, 2.0])
_assert(boss_mismatch.get("ok") is False and boss_mismatch.get("reason") == "target-mismatch", f"boss.position mismatch should fail: {boss_mismatch}")
before_boss_height = _cache(
{
"objectId": "boss:50",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [50, 51, 52],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.height", "currentValue": 4.0}],
}
)
after_boss_height = _cache(
{
"objectId": "boss:55",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [55, 56, 57],
"center": [0.0, 0.0, 2.75],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 5.5,
},
"capabilities": [{"key": "boss.height", "currentValue": 5.5}],
}
)
boss_height_signature = before_boss_height["objects"][0]["geometrySignature"] # type: ignore[index]
boss_height_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_height_signature,
before_cache=before_boss_height,
after_cache=after_boss_height,
capability_key="boss.height",
expected_target=5.5,
edited_object_id="boss:50",
)
_assert(boss_height_ok.get("ok") is True, f"boss.height result should validate target height: {boss_height_ok}")
boss_height_mismatch = check_scdm_target(after_boss_height["objects"][0], capability_key="boss.height", expected_target=4.5) # type: ignore[index]
_assert(boss_height_mismatch.get("ok") is False and boss_height_mismatch.get("reason") == "target-mismatch", f"boss.height mismatch should fail: {boss_height_mismatch}")
before_boss_diameter = _cache(
{
"objectId": "boss:60",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [60, 61, 62],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 3.0}],
}
)
after_boss_diameter = _cache(
{
"objectId": "boss:63",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [63, 64, 65],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 4.5,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 4.5}],
}
)
boss_diameter_signature = before_boss_diameter["objects"][0]["geometrySignature"] # type: ignore[index]
boss_diameter_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_diameter_signature,
before_cache=before_boss_diameter,
after_cache=after_boss_diameter,
capability_key="boss.diameter",
expected_target=4.5,
edited_object_id="boss:60",
)
_assert(boss_diameter_ok.get("ok") is True, f"boss.diameter result should validate target diameter: {boss_diameter_ok}")
boss_diameter_mismatch = check_scdm_target(after_boss_diameter["objects"][0], capability_key="boss.diameter", expected_target=3.5) # type: ignore[index]
_assert(boss_diameter_mismatch.get("ok") is False and boss_diameter_mismatch.get("reason") == "target-mismatch", f"boss.diameter mismatch should fail: {boss_diameter_mismatch}")
before_round_radius = _cache(
{
"objectId": "round:70",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [70],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.5,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.5}],
}
)
after_round_radius = _cache(
{
"objectId": "round:71",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [71],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.75,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.75}],
}
)
round_radius_signature = before_round_radius["objects"][0]["geometrySignature"] # type: ignore[index]
round_radius_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_radius_signature,
before_cache=before_round_radius,
after_cache=after_round_radius,
capability_key="round.radius",
expected_target=0.75,
edited_object_id="round:70",
)
_assert(round_radius_ok.get("ok") is True, f"round.radius result should validate target radius: {round_radius_ok}")
round_radius_mismatch = check_scdm_target(after_round_radius["objects"][0], capability_key="round.radius", expected_target=0.5) # type: ignore[index]
_assert(round_radius_mismatch.get("ok") is False and round_radius_mismatch.get("reason") == "target-mismatch", f"round.radius mismatch should fail: {round_radius_mismatch}")
before_chamfer_distance = _cache(
{
"objectId": "chamfer:80",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [80],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 0.8,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 0.8}],
}
)
after_chamfer_distance = _cache(
{
"objectId": "chamfer:81",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [81],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 1.2,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 1.2}],
}
)
chamfer_distance_signature = before_chamfer_distance["objects"][0]["geometrySignature"] # type: ignore[index]
chamfer_distance_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=chamfer_distance_signature,
before_cache=before_chamfer_distance,
after_cache=after_chamfer_distance,
capability_key="chamfer.distance",
expected_target=1.2,
edited_object_id="chamfer:80",
)
_assert(chamfer_distance_ok.get("ok") is True, f"chamfer.distance result should validate target distance: {chamfer_distance_ok}")
chamfer_distance_mismatch = check_scdm_target(after_chamfer_distance["objects"][0], capability_key="chamfer.distance", expected_target=0.8) # type: ignore[index]
_assert(chamfer_distance_mismatch.get("ok") is False and chamfer_distance_mismatch.get("reason") == "target-mismatch", f"chamfer.distance mismatch should fail: {chamfer_distance_mismatch}")
before_pattern_spacing = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 5.0}],
}
)
after_pattern_spacing = _cache(
{
"objectId": "pattern:holes-new",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [90, 91, 92],
"center": [7.5, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 7.5,
"pitch": 7.5,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [7.5, 0.0, 0.0], [15.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 7.5}],
}
)
pattern_spacing_before_signature = before_pattern_spacing["objects"][0]["geometrySignature"] # type: ignore[index]
pattern_spacing_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=pattern_spacing_before_signature,
before_cache=before_pattern_spacing,
after_cache=after_pattern_spacing,
capability_key="pattern.spacing",
expected_target=7.5,
edited_object_id="pattern:holes",
)
_assert(pattern_spacing_ok.get("ok") is True, f"pattern.spacing result should validate target spacing: {pattern_spacing_ok}")
pattern_spacing_mismatch = check_scdm_target(after_pattern_spacing["objects"][0], capability_key="pattern.spacing", expected_target=5.0) # type: ignore[index]
_assert(pattern_spacing_mismatch.get("ok") is False and pattern_spacing_mismatch.get("reason") == "target-mismatch", f"pattern.spacing mismatch should fail: {pattern_spacing_mismatch}")
before_pattern_segment = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"segmentIndex": 1,
"movingSide": "after",
"patternInstances": [
{"sourceObjectId": "a", "center": [0.0, 0.0, 0.0], "faceIds": [85]},
{"sourceObjectId": "b", "center": [5.0, 0.0, 0.0], "faceIds": [87]},
{"sourceObjectId": "c", "center": [10.0, 0.0, 0.0], "faceIds": [89]},
],
},
"capabilities": [{"key": "pattern.segment_spacing", "currentValue": 5.0}],
},
_hole("hole:unrelated", 999, diameter=1.0, center=(100.0, 0.0, 0.0)),
)
after_pattern_segment = _cache(_hole("hole:unrelated-new", 999, diameter=1.0, center=(100.0, 0.0, 0.0)))
pattern_segment_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"result": {
"applied": {
"segmentSpacing": 6.5,
"targetSpacing": 6.5,
"segmentIndex": 1,
"spacingMode": "segment_after",
}
},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
pattern_segment_ok.get("ok") is True
and pattern_segment_ok.get("targetCheck", {}).get("spacingMode") == "segment_after",
f"pattern.segment_spacing should validate from the applied edit result even when the old uniform pattern no longer matches: {pattern_segment_ok}",
)
pattern_segment_mismatch = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.0, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
pattern_segment_mismatch.get("ok") is False and pattern_segment_mismatch.get("reason") == "target-mismatch",
f"pattern.segment_spacing mismatch should fail from the applied edit result: {pattern_segment_mismatch}",
)
single_left_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_left_signature["movingSide"] = "single_left"
single_left_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_left"},
},
before_signature=single_left_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_left_ok.get("ok") is True
and single_left_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_left",
f"pattern.segment_spacing should validate move-only-left-instance mode: {single_left_ok}",
)
single_right_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_right_signature["movingSide"] = "single_right"
single_right_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_right"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_right_ok.get("ok") is True
and single_right_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_right",
f"pattern.segment_spacing should validate move-only-right-instance mode: {single_right_ok}",
)
wrong_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
wrong_mode.get("ok") is False and wrong_mode.get("reason") == "pattern-segment-spacing-mode-mismatch",
f"pattern.segment_spacing should fail when SCDM reports a different modeling intent: {wrong_mode}",
)
missing_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
missing_mode.get("ok") is False and missing_mode.get("reason") == "pattern-segment-spacing-mode-missing",
f"pattern.segment_spacing should require SCDM to report a known modeling intent: {missing_mode}",
)
shell_thickness_ok = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=1.6,
)
_assert(shell_thickness_ok.get("ok") is True, f"shell.thickness result should validate target thickness: {shell_thickness_ok}")
shell_thickness_mismatch = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=2.0,
)
_assert(shell_thickness_mismatch.get("ok") is False and shell_thickness_mismatch.get("reason") == "target-mismatch", f"shell.thickness mismatch should fail: {shell_thickness_mismatch}")
summary_before = _cache_with_summary(
{"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396},
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
)
summary_after_ok = _cache_with_summary(
{"bodyCount": 13, "objectCount": 550, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_ok = check_scdm_summary_delta(summary_before, summary_after_ok, capability_key="hole.diameter")
_assert(summary_ok.get("ok") is True, f"small summary changes should pass: {summary_ok}")
summary_after_repartition = _cache_with_summary(
{"bodyCount": 9, "objectCount": 548, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.9, center=(0.5, 1.0, 9.5)),
)
summary_repartition = check_scdm_summary_delta(summary_before, summary_after_repartition, capability_key="hole.diameter")
_assert(
summary_repartition.get("ok") is None and summary_repartition.get("reason") == "body-count-repartitioned",
f"SCDM body repartition should be a warning, not a hard failure: {summary_repartition}",
)
repartition_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=summary_before,
after_cache=summary_after_repartition,
capability_key="hole.diameter",
expected_target=0.9,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(
repartition_ok.get("ok") is True
and repartition_ok.get("summaryCheck", {}).get("reason") == "body-count-repartitioned"
and repartition_ok.get("validationWarnings"),
f"target-verified SCDM hole diameter edit should survive body repartition warnings: {repartition_ok}",
)
summary_after_bad = _cache_with_summary(
{"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_bad = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=summary_before,
after_cache=summary_after_bad,
capability_key="hole.diameter",
expected_target=0.75,
)
_assert(summary_bad.get("ok") is False and summary_bad.get("reason") == "summary-drift", f"large summary drift should fail: {summary_bad}")
summary_fill = check_scdm_summary_delta(summary_before, summary_after_bad, capability_key="feature.fill")
_assert(summary_fill.get("ok") is None and summary_fill.get("reason") == "skipped-command-feature", f"fill should skip summary count guard: {summary_fill}")
fill_before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
fill_after = _cache(_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)))
fill_signature = fill_before["objects"][0]["geometrySignature"] # type: ignore[index]
fill_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_after,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(fill_ok.get("ok") is True, f"feature.fill should pass when the edited feature disappears: {fill_ok}")
_assert(fill_ok.get("removalCheck", {}).get("ok") is True, f"feature.fill should record removal evidence: {fill_ok}")
fill_still_present = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_before,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(
fill_still_present.get("ok") is False and fill_still_present.get("reason") == "feature-still-present",
f"feature.fill should fail when the edited feature still matches: {fill_still_present}",
)
fill_no_cache = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
capability_key="feature.fill",
)
_assert(
fill_no_cache.get("ok") is False and fill_no_cache.get("reason") == "removal-check-unavailable",
f"feature.fill should require a new cache for removal verification: {fill_no_cache}",
)
round_before = _cache(_feature("round:60", "round", 60, center=(1.0, 0.0, 2.0), capability_key="feature.delete_round_or_chamfer"))
round_after = _cache(_feature("hole:85", "hole", 85, center=(5.0, 0.0, 2.0), capability_key="hole.diameter"))
round_delete_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=round_before,
after_cache=round_after,
capability_key="feature.delete_round_or_chamfer",
edited_object_id="round:60",
)
_assert(round_delete_ok.get("ok") is True, f"round/chamfer delete should pass when the edited feature disappears: {round_delete_ok}")
_assert(round_delete_ok.get("targetCheck", {}).get("reason") == "removed", f"round/chamfer delete should use removal target check: {round_delete_ok}")
ambiguous_after = _cache(
_hole("hole:100", 100, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:101", 101, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
ambiguous = match_scdm_object_by_signature(before_signature, ambiguous_after, capability_key="hole.diameter")
_assert(ambiguous.get("status") == "multiple", f"ambiguous matches should be reported: {ambiguous}")
print("scdm result validator ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ScdmBackendInfo, save_scdm_backend_cache # noqa: E402
from step_editor.scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
empty = summarize_scdm_runtime(cache_state="empty")
_assert(empty.get("backendReady") is False, f"empty backend should not be ready: {empty}")
_assert("未配置" in str(empty.get("headline")), f"empty headline should be clear: {empty}")
_assert("导入 STEP" in str(empty.get("detail")), f"empty detail should explain next step: {empty}")
backend = ScdmBackendInfo(
path=Path("D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe"),
source="common:D:/softwaresInstallDir/ANSYS Inc",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
)
feature_cache = {
"objects": [
{"objectId": "face:1", "capabilities": [{"key": "face.offset"}]},
{"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]},
]
}
ready = summarize_scdm_runtime(backend=backend, cache_state="ready", feature_cache=feature_cache)
_assert(ready.get("backendReady") is True, f"backend should be ready: {ready}")
_assert("已配置 v222" in str(ready.get("headline")), f"version should be visible: {ready}")
_assert("常见安装目录" in str(ready.get("headline")), f"source should be product text: {ready}")
_assert(ready.get("objectCount") == 2 and ready.get("capabilityCount") == 3, f"cache counts should be summarized: {ready}")
_assert("识别缓存已就绪" in str(ready.get("detail")), f"ready detail should be explicit: {ready}")
_assert("/RunScript:可用" in str(ready.get("tooltip")), f"tooltip should include /RunScript status: {ready}")
running = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="running")
_assert("正在后台识别" in str(running.get("detail")), f"running state should explain background probe: {running}")
failed = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="failed", cache_message="SpaceClaim.exe was not found.")
_assert("识别未启用" in str(failed.get("detail")), f"failed state should be clear: {failed}")
_assert("已有能力" in str(failed.get("detail")), f"failed state should explain fallback: {failed}")
disabled = summarize_scdm_runtime(backend={"disabled": True}, cache_state="ready", feature_cache=feature_cache)
_assert(disabled.get("backendReady") is False, f"disabled backend should not be ready: {disabled}")
_assert("已关闭" in str(disabled.get("headline")), f"disabled state should be clear: {disabled}")
_assert("不会启动" in str(disabled.get("detail")), f"disabled detail should explain behavior: {disabled}")
stale = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="stale", cache_message="模型已重新加载,SCDM cache 已失效。")
_assert("失效" in str(stale.get("detail")), f"stale state should be visible: {stale}")
progress_cache = {
"objects": [
{
"objectId": "face:1",
"capabilities": [{"key": "face.offset", "displayName": "偏移"}],
},
{
"objectId": "hole:1",
"capabilities": [
{"key": "hole.diameter", "displayName": "直径", "blockReason": "SCDM 当前脚本环境缺少 OffsetFaces 命令。"},
{"key": "hole.position", "displayName": "位置"},
{"key": "feature.fill", "displayName": "填孔/删除小特征"},
],
},
],
"diagnostics": {
"face_adjacency": [
{"bodyIndex": 0, "faceOrdinals": [1, 2], "edgeCount": 1},
{"bodyIndex": 0, "faceOrdinals": [2, 3], "edgeCount": 2},
],
"edge_geometry_summary": {
"totalEdgeCount": 12,
"edgeKindCounts": {"linear": 8, "circular": 4},
"circularEdgeCount": 4,
"circularRadiusBuckets": [{"radius": "0.25", "count": 4}],
},
"feature_inventory": {
"objectTypeCounts": {"face": 4, "hole": 2, "edge": 12, "slot": 1},
"surfaceTypeCounts": {"plane": 4, "cylinder": 3},
"operationCounts": {"pull_face_offset": 4, "change_hole_diameter": 2, "change_slot_width": 1},
},
"geometry_candidate_hints": [
{
"capabilityKey": "boss.height",
"displayName": "凸台高度",
"evidenceCount": 3,
"confidence": "low",
},
{
"capabilityKey": "pattern.instance_position",
"displayName": "阵列实例位置",
"evidenceCount": 2,
"confidence": "low",
},
],
"derived_feature_candidates": [
{
"objectId": "derived:linear_pattern:hole-a|hole-b|hole-c",
"objectType": "linear_pattern",
"geometrySignature": {"spacing": 5.0, "instanceCount": 3},
}
],
"planned_not_productized": [],
"discovered_not_productized": [
{"objectType": "mystery_feature"},
{"objectType": "mystery_feature"},
],
},
}
progress = summarize_scdm_capability_progress(
feature_cache=progress_cache,
execution_ready={
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
},
)
summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}")
_assert(summary.get("productized") == 17, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, instance position, shell thickness, Move-based S7 entries and round/chamfer delete: {summary}")
_assert(summary.get("runnerReady") == 17, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}")
_assert(summary.get("plannedDetected") == 0, f"planned S7 detections should be counted: {summary}")
_assert(summary.get("discoveredNotProductized") == 2, f"unknown discoveries should be counted: {summary}")
_assert(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}")
_assert(
summary.get("inventoryObjectTypes") == 19 and summary.get("inventoryOperationCandidates") == 7,
f"probe feature inventory should be counted: {summary}",
)
_assert(summary.get("geometryHints") == 5, f"geometry candidate hints should be counted: {summary}")
_assert(summary.get("derivedFeatureCandidates") == 1, f"derived S7 candidate count should be visible: {summary}")
productized_lines = "\n".join(str(line) for line in progress.get("productizedLines", []))
planned_lines = "\n".join(str(line) for line in progress.get("plannedLines", []))
evidence_lines = "\n".join(str(line) for line in progress.get("probeEvidence", {}).get("lines", []))
_assert("偏移:已开放" in productized_lines, f"open SCDM capability should be visible: {productized_lines}")
_assert("直径:已开放但被后端阻止" in productized_lines, f"blocked SCDM capability should be explicit: {productized_lines}")
_assert("填孔/删除小特征:已开放" in productized_lines, f"recognized command capability should be visible as open: {productized_lines}")
_assert("槽深:已开放待识别" in productized_lines, f"productized slot.depth should be visible: {productized_lines}")
_assert("凸台直径:已开放待识别" in productized_lines, f"productized boss.diameter should be visible: {productized_lines}")
_assert("凸台高度:已开放待识别" in productized_lines, f"productized boss.height should be visible: {productized_lines}")
_assert("圆角半径:已开放待识别" in productized_lines, f"productized round.radius should be visible: {productized_lines}")
_assert("倒角距离:已开放待识别" in productized_lines, f"productized chamfer.distance should be visible: {productized_lines}")
_assert("阵列间距:已开放待识别" in productized_lines, f"productized pattern.spacing should be visible: {productized_lines}")
_assert("局部间距:已开放待识别" in productized_lines, f"productized pattern.segment_spacing should be visible: {productized_lines}")
_assert("阵列实例位置:已开放待识别" in productized_lines, f"productized pattern.instance_position should be visible: {productized_lines}")
_assert("壳体厚度:已开放待识别" in productized_lines, f"productized shell.thickness should be visible: {productized_lines}")
_assert("阵列实例位置" not in planned_lines, f"pattern.instance_position should no longer be a planned-only line: {planned_lines}")
_assert("Face 邻接 2 组" in evidence_lines and "圆边 4 条" in evidence_lines, f"probe evidence lines should be readable: {evidence_lines}")
_assert("对象分布" in evidence_lines and "命令候选分布" in evidence_lines, f"probe inventory lines should be readable: {evidence_lines}")
_assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}")
_assert("linear_pattern:1" in evidence_lines, f"derived S7 candidate lines should be readable: {evidence_lines}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_status_") as temp:
root = Path(temp)
fake_exe = root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe"
fake_exe.parent.mkdir(parents=True, exist_ok=True)
fake_exe.write_text("fake", encoding="utf-8")
saved_backend = ScdmBackendInfo(path=fake_exe, source="manual", version="v222", run_script_ok=True, license_ok=True)
save_scdm_backend_cache(saved_backend, project_root_override=root)
cached = cached_scdm_backend_payload(root)
_assert(isinstance(cached, dict), f"cached backend should load: {cached}")
_assert(str(cached.get("source")) == "manual", f"cached source should be preserved: {cached}")
print("scdm status summary ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"]
def __getattr__(name: str):
if name == "StepModel":
from .model import StepModel
return StepModel
if name in {"StepEditorWindow", "main"}:
from .app import StepEditorWindow, main
+208 -15
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
@@ -169,7 +173,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.orientation_marker_prop = None
self.step_coordinate_axes_actor = None
self.hide_edges_during_camera_interaction = False
self.hide_overlays_during_camera_interaction = False
self.edge_visibility_before_camera_interaction: int | None = None
self.overlay_visibility_before_camera_interaction: dict[str, int] = {}
self.prefer_fxaa_antialiasing = True
self.fallback_multi_samples = 2
self.interactive_multi_samples = 0
@@ -180,6 +186,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None
self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None
self.large_model_edge_overlay_skipped = False
self.large_model_hover_disabled = False
self.hover_interval_ms = 260
self.hover_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None
@@ -220,6 +228,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
self.edge_overlay_polydata_cache: dict[int, object] = {}
self.overlay_cache_limit = 160
self.scene_rebuild_in_progress = False
self.show_internal_edges_checkbox: QCheckBox | None = None
self.scene_isolated = False
self.undo_stack: list[dict[int, object]] = []
@@ -253,6 +262,38 @@ 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.scdm_thread: QThread | None = None
self.scdm_worker: ScanWorker | None = None
self.pending_scdm_context: dict[str, object] | None = None
self.scdm_backend_status: dict[str, object] | None = None
self.scdm_auto_config_prompt_seen = False
self.scdm_auto_config_prompt_active = False
self.scdm_feature_cache: dict[str, object] | None = None
self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = ""
self.scdm_feature_cache_path = ""
self.scdm_edit_runner_ready = {
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
}
self.load_in_progress = False
self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None
@@ -278,6 +319,18 @@ 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.relation_formula_base_snapshot: dict[object, object] | None = None
self.relation_formula_replay_queue: list[dict[str, object]] = []
self.relation_formula_replay_total = 0
self.relation_formula_replay_done = 0
self.relation_formula_replay_active = False
self.relation_formula_replay_current_id: int | None = None
self.relation_formula_replay_callback_seen = False
self._relation_formula_replay_running_action = False
self._relation_formula_object_label_cache_key: object = None
self._relation_formula_object_label_cache: dict[str, object] = {}
self._build_ui()
self._build_vtk()
@@ -486,10 +539,40 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #bbf7d0;
color: #14532d;
}
QPushButton#softwareProgressButton {
background: #f0fdf4;
border: 1px solid #86efac;
border-left: 5px solid #16a34a;
border-radius: 7px;
color: #14532d;
font-weight: 800;
min-height: 30px;
padding: 5px 10px;
text-align: left;
}
QPushButton#softwareProgressButton:hover {
background: #dcfce7;
border-color: #22c55e;
}
QPushButton#softwareProgressButton:pressed {
background: #bbf7d0;
border-color: #16a34a;
padding-top: 6px;
padding-bottom: 4px;
}
QLabel#capabilityHeadline {
color: #14532d;
font-weight: 800;
}
QLabel#scdmBackendStatus {
color: #166534;
font-size: 11px;
font-weight: 700;
}
QLabel#scdmBackendDetail {
color: #3f6212;
font-size: 11px;
}
QLabel#capabilityDetail {
color: #166534;
font-size: 11px;
@@ -879,6 +962,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;
@@ -945,7 +1069,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
mode_box = QWidget()
mode_box.setMinimumHeight(62)
help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。")
help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征。")
self.mode_section_title = QLabel("选择模式", mode_box)
self.mode_section_title.setObjectName("modeSectionTitle")
self.mode_section_title.setFixedSize(74, 20)
@@ -980,7 +1104,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_combo.setMaximumWidth(112)
help_tip(
self.mode_combo,
"选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选",
"选择模式决定鼠标点击模型时要选什么:Part、Solid、Face、Edge,或用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征",
)
self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
mode_pick_layout.addWidget(self.mouse_mode_label)
@@ -1192,6 +1316,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。",
)
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state())
object_edit_layout.addWidget(self.property_table)
self.property_command_summary_label = QLabel("未选择可编辑对象")
self.property_command_summary_label.setObjectName("propertyCommandSummary")
@@ -1237,13 +1362,83 @@ 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.setFilterMode(Qt.MatchFlag.MatchStartsWith)
self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.relation_formula_completer.setMaxVisibleItems(12)
self.relation_formula_completer.activated[str].connect(self._on_relation_formula_completion_activated)
relation_popup = self.relation_formula_completer.popup()
if relation_popup is not None:
relation_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus)
relation_popup.installEventFilter(self)
self.relation_formula_completer.setWidget(self.relation_formula_input)
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)
self.import_relation_formula_button = QPushButton("导入公式")
self.import_relation_formula_button.setObjectName("relationFormulaImportButton")
self.import_relation_formula_button.clicked.connect(self.import_relation_formulas)
relation_button_row.addWidget(self.import_relation_formula_button)
self.export_relation_formula_button = QPushButton("导出公式")
self.export_relation_formula_button.setObjectName("relationFormulaExportButton")
self.export_relation_formula_button.clicked.connect(self.export_relation_formulas)
relation_button_row.addWidget(self.export_relation_formula_button)
relation_button_row.addStretch(1)
self.toggle_relation_formula_button = QPushButton("停用公式")
self.toggle_relation_formula_button.setObjectName("relationFormulaToggleButton")
self.toggle_relation_formula_button.clicked.connect(self.toggle_selected_relation_formula)
relation_button_row.addWidget(self.toggle_relation_formula_button)
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("参数化建模")
self.apply_property_button.setObjectName("parametricModelButton")
self.apply_property_button.setMinimumHeight(34)
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的一个参数;一次只执行一个几何修改,成功后可撤销")
help_tip(self.apply_property_button, "应用当前被修改的数值参数;命令型参数需先选中该行。多个项目会按表格顺序依次执行,失败时停止后续修改")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
@@ -1255,7 +1450,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.export_parameters_button.setObjectName("exportParametersButton")
self.export_parameters_button.setMinimumHeight(34)
self.export_parameters_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.export_parameters_button, "把已勾选为输入参数的尺寸导出为 data.json。")
help_tip(self.export_parameters_button, "把已勾选的尺寸输入导出为 data.json,并生成可外部调用的参数化组件 main.py")
self.export_parameters_button.clicked.connect(self.export_selected_parameters)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
@@ -1579,16 +1774,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2)
panel_layout.addWidget(self.object_edit_box)
self.current_capability_box = QGroupBox("软件进度")
self.current_capability_box.setObjectName("capabilitySection")
capability_layout = QVBoxLayout(self.current_capability_box)
capability_layout.setContentsMargins(8, 8, 8, 7)
capability_layout.setSpacing(2)
self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体")
self.current_capability_headline.setObjectName("capabilityHeadline")
self.current_capability_headline.setWordWrap(True)
capability_layout.addWidget(self.current_capability_headline)
panel_layout.addWidget(self.current_capability_box)
self.current_capability_button = QPushButton("软件进度")
self.current_capability_button.setObjectName("softwareProgressButton")
self.current_capability_button.setMinimumHeight(32)
self.current_capability_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.current_capability_button, "点击查看当前参数化能力、SCDM 后端状态和后续实施路线。")
self.current_capability_button.clicked.connect(self.show_software_progress_dialog)
panel_layout.addWidget(self.current_capability_button)
self._update_current_capability_panel()
if ENABLE_EXPORT_PANEL:
panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL:
+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.",
}
+131 -15
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]
@@ -1138,6 +1216,12 @@ class FeatureMixin:
"slot_blockers": blocker,
}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
feature_angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(info.get("angular_span"))
)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -1146,6 +1230,10 @@ class FeatureMixin:
scoped_info = dict(info)
scoped_info["height_estimate"] = axis_range["span"]
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
if feature_angular_span is not None:
scoped_info["angular_span"] = feature_angular_span
scoped_info["same_domain_angular_span"] = feature_angular_span
scoped_info["is_full_cylinder"] = feature_angular_span >= math.tau * 0.92
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
@@ -1190,7 +1278,6 @@ class FeatureMixin:
"feature_bottom_note": feature.get("feature_bottom_note"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": scoped_info.get("height_estimate"),
"same_domain_face_ids": axis_range["same_domain_face_ids"],
"same_domain_face_count": axis_range["same_domain_face_count"],
@@ -1204,6 +1291,9 @@ class FeatureMixin:
**topology_fields,
**cutter_plan,
**fill_plan,
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
}
def cylindrical_axis_move_plan(
@@ -1236,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])
@@ -1345,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(
@@ -2639,7 +2739,9 @@ class FeatureMixin:
"radius": info.get("radius"),
"axis": info.get("axis"),
"axis_point": info.get("axis_point"),
"angular_span": info.get("angular_span"),
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
"height_estimate": scoped_info.get("height_estimate"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
@@ -3331,6 +3433,7 @@ class FeatureMixin:
candidates: list[tuple[float, tuple[float, float, float], dict[str, object]]] = []
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
plane_axis = surf.Plane().Axis().Direction()
for adjacent_id in adjacent_face_ids:
if adjacent_id < 0 or adjacent_id >= len(self.faces):
continue
@@ -3346,20 +3449,31 @@ class FeatureMixin:
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
except Exception:
axis_range = {
"v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
"v_max": max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
}
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92:
continue
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
v_min = source_v_min
v_max = source_v_max
range_source = "selected-face-v-range-fast"
height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
range_source = str(axis_range.get("range_source") or "same-domain-cylinder-faces")
height = max(v_max - v_min, 1e-9)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
except Exception:
pass
if start_distance <= end_distance and start_distance <= end_tolerance:
outward = _neg_tuple(_dir_tuple(axis_dir))
end_label = "start"
@@ -3377,9 +3491,11 @@ class FeatureMixin:
{
"cap_axis_face_id": adjacent_id,
"cap_axis_end": end_label,
"cap_plane_axis_alignment": plane_axis_alignment,
"cap_axis_parameter": cap_parameter,
"cap_axis_start_parameter": v_min,
"cap_axis_end_parameter": v_max,
"cap_axis_range_source": range_source,
},
)
)
+19
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.")
@@ -28,6 +31,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.push_pull_face_keep_relations(int(args[0]), float(args[1]))
if operation == "move_face_plane_offset_local":
return model.move_face_plane_offset_local(int(args[0]), float(args[1]))
if operation == "translate_face_plane_offset_owning":
return model.translate_face_plane_offset_owning(int(args[0]), float(args[1]))
if operation == "resize_face_area_local":
return model.resize_face_area_local(int(args[0]), float(args[1]))
if operation == "resize_face_area":
@@ -62,6 +67,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_cylindrical_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss_height":
return model.resize_cylindrical_boss_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss":
return model.resize_cylindrical_boss(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_height_owning_scale":
return model.resize_cylindrical_height_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_cone_reference_radius":
@@ -74,10 +81,22 @@ 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":
return model.move_cylindrical_hole_axis(int(args[0]), _point3(args[1], operation))
if operation == "move_cylindrical_boss_axis":
return model.move_cylindrical_boss_axis(int(args[0]), _point3(args[1], operation))
if operation == "suppress_cylindrical_hole":
return model.suppress_cylindrical_hole(int(args[0]))
if operation == "resize_cylindrical_depth":
+1000 -23
View File
File diff suppressed because it is too large Load Diff
+244 -21
View File
@@ -3269,9 +3269,13 @@ class OperationMixin:
outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
current_plane_position = None
target_plane_position = None
current_plane_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
target_plane_center = None
if plane_origin is not None and outward_direction is not None:
current_plane_position = _tuple_dot(plane_origin, outward_direction)
target_plane_position = current_plane_position + float(distance)
if current_plane_center is not None:
target_plane_center = _tuple_add(current_plane_center, _tuple_scale(outward_direction, float(distance)))
return {
"status": status,
@@ -3294,6 +3298,8 @@ class OperationMixin:
"plane_direction": outward_direction,
"current_plane_position": current_plane_position,
"target_plane_position": target_plane_position,
"current_plane_center": current_plane_center,
"target_plane_center": target_plane_center,
"push_pull_inward_material_depth": inward_material_depth,
"push_pull_inward_cut_ratio": inward_cut_ratio,
"cylindrical_cap_extension_old_height": (
@@ -8746,6 +8752,7 @@ class OperationMixin:
metric = ""
target: float | tuple[float, ...] | None = None
tolerance = 1e-4
center_tolerance: float | None = None
getter: Callable[[int], float | tuple[float, ...] | None] | None = None
target_area = _float_or_none(plan.get("target_area"))
@@ -8794,6 +8801,8 @@ class OperationMixin:
target = target_position
bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or _shape_diagonal(self.shape)
tolerance = max(bbox_diagonal * 1e-4, abs(target_position) * 1e-5, 1e-4)
target_plane_center = _tuple_or_none(plan.get("target_plane_center"))
center_tolerance = max(bbox_diagonal * 0.15, tolerance * 20.0, 1e-3) if target_plane_center is not None else None
def plane_position_getter(face_id: int) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
@@ -8890,6 +8899,46 @@ class OperationMixin:
plan_face_id = _int_or_none(plan.get("face_id"))
if plan_face_id is not None:
preferred_ids.append(plan_face_id)
def candidate_center_error(face_id: int) -> float | None:
if metric != "plane_position":
return None
target_center = _tuple_or_none(plan.get("target_plane_center"))
if target_center is None:
return None
try:
info = self.quick_face_info(face_id)
except Exception:
return None
actual_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if actual_center is None:
return None
return _vector_length(_tuple_sub(actual_center, target_center))
def candidate_record(face_id: int, actual: object, error: float) -> dict[str, object]:
record: dict[str, object] = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
center_error = candidate_center_error(face_id)
if center_error is not None:
record["center_error"] = center_error
if center_tolerance is not None:
record["center_tolerance"] = center_tolerance
return record
def candidate_sort_key(record: dict[str, object]) -> tuple[int, float, float]:
center_error = _float_or_none(record.get("center_error"))
center_limit = _float_or_none(record.get("center_tolerance"))
center_penalty = 0
if center_error is not None and center_limit is not None and center_error > center_limit:
center_penalty = 1
return (center_penalty, float(record["error"]), center_error if center_error is not None else 0.0)
if preferred_ids:
preferred_best: dict[str, object] | None = None
for face_id in preferred_ids:
@@ -8902,16 +8951,9 @@ class OperationMixin:
if actual is None:
continue
error = _result_value_error(actual, target)
if preferred_best is None or error < float(preferred_best["error"]):
preferred_best = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
record = candidate_record(face_id, actual, error)
if preferred_best is None or candidate_sort_key(record) < candidate_sort_key(preferred_best):
preferred_best = record
if preferred_best is not None and float(preferred_best["error"]) <= tolerance:
return preferred_best
@@ -8924,16 +8966,9 @@ class OperationMixin:
if actual is None:
continue
error = _result_value_error(actual, target)
if best is None or error < float(best["error"]):
best = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
record = candidate_record(face_id, actual, error)
if best is None or candidate_sort_key(record) < candidate_sort_key(best):
best = record
if best is not None:
return best
return {
@@ -9011,6 +9046,11 @@ class OperationMixin:
all_ids = filtered(range(len(self.faces)))
if not all_ids and solid_id is not None and solid_id >= 0:
all_ids = filtered(range(len(self.faces)), require_solid=False)
elif target_kind == "solid" and solid_id is not None and solid_id >= 0 and surface == "plane":
relaxed_ids = filtered(range(len(self.faces)), require_solid=False)
for face_id in relaxed_ids:
if face_id not in all_ids:
all_ids.append(face_id)
combined: list[int] = []
for face_id in [*primary_ids, *all_ids]:
if face_id not in combined:
@@ -11258,6 +11298,11 @@ class OperationMixin:
return None
source_plane = source_surf.Plane()
cap_plane_point = source_plane.Location()
try:
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 2:
return None
except Exception:
pass
try:
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id]
except Exception:
@@ -11302,11 +11347,26 @@ class OperationMixin:
continue
center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center)
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
axis_range = {
"v_min": source_v_min,
"v_max": source_v_max,
"same_domain_face_ids": (adjacent_id,),
"range_source": "selected-face-v-range-fast",
}
v_min = source_v_min
v_max = source_v_max
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
@@ -13466,6 +13526,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":
+609
View File
@@ -0,0 +1,609 @@
from __future__ import annotations
import argparse
from datetime import datetime
import json
import re
from pathlib import Path
import sys
import traceback
from .isolated_edit_worker import _execute
from .model import StepModel
COMPONENT_SCHEMA = "step-editor-parametric-component-v1"
_COMPONENT_SEQUENCE_RE = re.compile(r"^(?P<index>\d{3,})_(?P<name>.+)$")
_ACTION_OPERATION_MAP = {
"push_pull_face": "push_pull_face",
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"move_selected_face_plane_position_by_translation": "translate_face_plane_offset_owning",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
"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",
"resize_slot_width": "resize_cylindrical_slot_width",
"resize_slot_depth": "resize_cylindrical_slot_depth",
"resize_slot_arc_length": "resize_cylindrical_slot_arc_length",
"resize_slot_angular_span": "resize_cylindrical_slot_angular_span",
"resize_slot_total_length": "resize_cylindrical_slot_total_length",
"resize_slot_center_distance": "resize_cylindrical_slot_center_distance",
"move_cylindrical_hole_axis": "move_cylindrical_hole_axis",
"move_cylindrical_slot_axis": "move_cylindrical_slot_axis",
"suppress_hole": "suppress_cylindrical_hole",
"resize_boss": "resize_cylindrical_boss",
"resize_boss_height": "resize_cylindrical_boss_height",
"resize_cylinder_height": "resize_cylindrical_height",
"resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale",
"move_cylindrical_boss_axis": "move_cylindrical_boss_axis",
"resize_cone_reference_radius": "resize_cone_reference_radius",
"resize_cone_semi_angle": "resize_cone_semi_angle",
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_any_edge_length": "resize_general_edge_length",
"move_edge_start_point": "move_edge_endpoint",
"move_edge_end_point": "move_edge_endpoint",
"move_edge_center_point": "move_edge_center",
"move_circular_edge_axis_center": "move_circular_edge_axis_center",
"resize_ellipse_edge_major_radius": "resize_ellipse_edge_axis_radius",
"resize_ellipse_edge_minor_radius": "resize_ellipse_edge_axis_radius",
"resize_existing_fillet": "resize_existing_fillet",
"resize_existing_chamfer": "resize_existing_chamfer",
"fillet_edge": "fillet_edge",
"chamfer_edge": "chamfer_edge",
"chamfer_edge_asymmetric": "chamfer_edge_asymmetric",
"chamfer_edge_distance_angle": "chamfer_edge_distance_angle",
}
def sanitize_component_name(value: object, fallback: str = "STEP_Parametric") -> str:
text = str(value or "").strip() or fallback
text = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', "_", text)
text = re.sub(r"\s+", "_", text).strip(" ._")
return text or fallback
def default_component_root(project_root: Path | None = None) -> Path:
root = project_root or Path(__file__).resolve().parent.parent
return root / "nodes"
def next_component_dir(root: Path, component_name: object) -> Path:
base_name = sanitize_component_name(component_name)
max_index = -1
if root.is_dir():
for child in root.iterdir():
if not child.is_dir():
continue
match = _COMPONENT_SEQUENCE_RE.match(child.name)
if match:
max_index = max(max_index, int(match.group("index")))
return root / f"{max_index + 1:03d}_{base_name}"
def component_name_from_step(step_path: object) -> str:
try:
stem = Path(str(step_path)).stem
except Exception:
stem = ""
return sanitize_component_name(f"{stem}_STEP参数化组件" if stem else "STEP参数化组件")
def numeric_text(value: object) -> str:
text = str(value if value is not None else "").strip()
return text
def json_script_literal(value: object) -> str:
return json.dumps(value, ensure_ascii=False, indent=4)
def _float_or_text(value: object) -> object:
if isinstance(value, (int, float)):
return value
text = str(value if value is not None else "").strip()
try:
return float(text)
except ValueError:
return text
def _as_list3(value: object) -> list[float] | None:
if isinstance(value, str):
chunks = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()]
elif isinstance(value, (tuple, list)):
chunks = list(value)
else:
return None
if len(chunks) != 3:
return None
try:
return [float(chunks[0]), float(chunks[1]), float(chunks[2])]
except (TypeError, ValueError):
return None
def _target_object_id(spec: dict[str, object], selected_kind: str | None, selected_face_id: int | None, selected_edge_id: int | None) -> int | None:
if spec.get("source_face_id") not in {"", None}:
return int(spec["source_face_id"])
action = str(spec.get("action") or "")
kind = str(selected_kind or "")
if "edge" in action and selected_edge_id is not None and kind == "edge":
return int(selected_edge_id)
if selected_face_id is not None:
return int(selected_face_id)
if selected_edge_id is not None:
return int(selected_edge_id)
return None
def _target_kind_for_action(action: str, selected_kind: str | None) -> str:
if "edge" in action:
return "edge"
if selected_kind in {"face", "feature", "edge"}:
return str(selected_kind)
return "face"
def operation_for_action(action: object) -> str | None:
return _ACTION_OPERATION_MAP.get(str(action or ""))
def _axis_arg_for_face_size(action: str) -> str | None:
if "face_width" in action:
return "width"
if "face_height" in action:
return "height"
return None
def _mode_arg_for_torus(action: str) -> str | None:
if action == "resize_torus_major_radius":
return "major"
if action == "resize_torus_minor_radius":
return "minor"
return None
def _endpoint_arg_for_edge(action: str) -> str | None:
if action == "move_edge_start_point":
return "start"
if action == "move_edge_end_point":
return "end"
return None
def _ellipse_axis_arg(action: str) -> str | None:
if action == "resize_ellipse_edge_major_radius":
return "major"
if action == "resize_ellipse_edge_minor_radius":
return "minor"
return None
def component_edit_config_from_spec(
*,
parameter_row: dict[str, str],
spec: dict[str, object],
selected_kind: str | None,
selected_face_id: int | None,
selected_edge_id: int | None,
step_path: Path | None,
) -> 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
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
args: list[object] = [int(target_id)]
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", {}),
}
if action == "suppress_hole":
target_value = ""
else:
args.append(target_arg)
axis_arg = _axis_arg_for_face_size(action)
if axis_arg is not None:
args.append(axis_arg)
elif action in {"resize_torus_major_radius", "resize_torus_minor_radius"}:
args.append(_mode_arg_for_torus(action))
elif action in {"move_edge_start_point", "move_edge_end_point"}:
args.insert(1, _endpoint_arg_for_edge(action))
elif action in {"resize_ellipse_edge_major_radius", "resize_ellipse_edge_minor_radius"}:
args.append(_ellipse_axis_arg(action))
elif action in {
"resize_hole_depth",
"resize_hole_depth_owning_scale",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_total_length",
"resize_slot_center_distance",
}:
manual_id = spec.get("manual_bottom_face_id")
if manual_id in {"", None}:
manual_id = spec.get("slot_pair_manual_face_id")
args.append("" if manual_id in {"", None} else manual_id)
return {
"parameter": parameter_row["name"],
"displayName": parameter_row.get("displayName", parameter_row["name"]),
"targetKind": _target_kind_for_action(action, selected_kind),
"targetId": int(target_id),
"uiAction": action,
"operation": operation,
"args": args,
"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", ""),
}
def render_component_main_py(component: dict[str, object]) -> str:
project_root = str(Path(__file__).resolve().parent.parent)
component_name = sanitize_component_name(component.get("componentName") or component_name_from_step(component.get("sourceStep")))
output_parameter = {
"name": "output_step",
"displayName": "输出STEP",
"type": "file",
"ioRole": "output",
"default": "",
}
return f'''# -*- coding: utf-8 -*-
"""
STEP 参数化组件
这个文件按 FlowEditor 节点脚本方式生成
1. INPUT_PARAMETERS 是从软件导出参数勾选行直接嵌入的输入参数
2. PARAMETERS 会额外加上输出 STEP 文件参数供节点设计器生成输出端口
3. execute(inputs, params, context) FlowEditor 调用入口
4. main() 只用于本地命令行调试
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
PROJECT_ROOT = {json.dumps(project_root, ensure_ascii=False)}
if PROJECT_ROOT and PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from step_editor.parametric_component import run_embedded_component
INPUT_PARAMETERS = {json_script_literal(component.get("parameters", []))}
OUTPUT_PARAMETERS = [
{json_script_literal(output_parameter)}
]
PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS
COMPONENT = {json_script_literal(component)}
NODE_INFO = {{
"typeName": {json.dumps(component_name, ensure_ascii=False)},
"displayName": {json.dumps(component_name, ensure_ascii=False)},
"category": "几何参数化",
"icon": "icon.svg",
"parameters": PARAMETERS,
}}
def _value_from_inputs(name, inputs, params, default=""):
value = inputs.get(name) if isinstance(inputs, dict) else None
if value in (None, "") and isinstance(params, dict):
value = params.get(name)
if value in (None, ""):
value = default
return value
def _component_input_values(inputs, params):
values = {{}}
for item in INPUT_PARAMETERS:
name = item.get("name")
if not name:
continue
values[name] = _value_from_inputs(name, inputs, params, item.get("default", ""))
return values
def _default_output_step(work_dir, output_dir):
output_root = output_dir or os.path.join(work_dir, "output")
os.makedirs(output_root, exist_ok=True)
return os.path.join(output_root, COMPONENT.get("outputName") or "modified.step")
def run(inputs=None, output_step=None, work_dir=None):
return run_embedded_component(COMPONENT, inputs=inputs, output_step=output_step, work_dir=work_dir)
def execute(inputs, params, context):
"""
FlowEditor 调用入口
inputs上游节点传入值优先级高于 params
params节点属性面板参数
contextFlowEditor 上下文常见字段包括 work_dir / input_dir / output_dir
"""
inputs = inputs or {{}}
params = params or {{}}
context = context or {{}}
work_dir = context.get("work_dir") or os.getcwd()
output_dir = context.get("output_dir") or os.path.join(work_dir, "output")
output_step = _default_output_step(work_dir, output_dir)
result = run(
inputs=_component_input_values(inputs, params),
output_step=output_step,
work_dir=work_dir,
)
if not result.get("ok"):
raise RuntimeError(result.get("error") or json.dumps(result, ensure_ascii=False))
return {{
"output_step": result.get("outputStep", output_step),
"outputStep": result.get("outputStep", output_step),
"sourceStep": result.get("sourceStep", ""),
"messages": result.get("messages", []),
}}
def main(argv=None):
parser = argparse.ArgumentParser(description="Run generated STEP parametric component.")
parser.add_argument("--inputs", default="", help="JSON file or JSON object with input parameter values.")
parser.add_argument("--output-step", default="", help="Output STEP file path.")
parser.add_argument("--work-dir", default="", help="Runtime output directory.")
args = parser.parse_args(argv)
inputs = args.inputs
if inputs:
candidate = Path(inputs)
if candidate.is_file():
inputs = json.loads(candidate.read_text(encoding="utf-8-sig"))
else:
inputs = json.loads(inputs)
else:
inputs = {{}}
result = run(inputs=inputs, output_step=args.output_step or None, work_dir=args.work_dir or None)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
'''
def export_parametric_component(
*,
parameters: list[dict[str, str]],
edits: list[dict[str, object]],
source_step: Path | None,
component_root: Path | None = None,
component_name: str | None = None,
) -> Path:
if not parameters:
raise ValueError("No input parameters selected.")
root = component_root or default_component_root()
target_dir = next_component_dir(root, component_name or component_name_from_step(source_step))
target_dir.mkdir(parents=True, exist_ok=True)
component = {
"schema": COMPONENT_SCHEMA,
"createdAt": datetime.now().isoformat(timespec="seconds"),
"componentName": component_name or component_name_from_step(source_step),
"sourceStep": str(source_step or ""),
"parameters": parameters,
"edits": edits,
"outputName": "modified.step",
}
target = target_dir / "main.py"
target.write_text(render_component_main_py(component), encoding="utf-8")
return target
def _input_values(inputs: object) -> dict[str, object]:
if not isinstance(inputs, dict):
return {}
if isinstance(inputs.get("inputs"), dict):
return dict(inputs["inputs"])
rows = inputs.get("parameters")
if isinstance(rows, list):
result: dict[str, object] = {}
for row in rows:
if isinstance(row, dict) and row.get("name"):
result[str(row["name"])] = row.get("value", row.get("default", ""))
return result
return dict(inputs)
def _parse_vector3(value: object) -> list[float]:
vector = _as_list3(value)
if vector is None:
raise ValueError(f"Expected 3D vector value, got {value!r}.")
return vector
def _apply_arg_transform(value: object, transform: str, context: object) -> object:
context = context if isinstance(context, dict) else {}
if transform == "plane_target_position_to_offset":
current = float(context.get("current_plane_position"))
return float(value) - current
if transform == "radius_to_diameter":
return float(value) * 2.0
if transform == "diameter_to_radius":
return float(value) * 0.5
if transform == "degrees_to_radians":
import math
return math.radians(float(value))
if transform == "slot_open_angle_degrees_to_angular_span":
import math
return math.tau - math.radians(float(value))
if transform == "target_center_to_translation":
current = _parse_vector3(context.get("current_center"))
target = _parse_vector3(value)
return [target[index] - current[index] for index in range(3)]
return value
def _resolve_arg(value: object, values: dict[str, object], defaults: dict[str, object]) -> object:
if isinstance(value, dict) and "param" in value:
name = str(value.get("param") or "")
resolved = values.get(name, defaults.get(name, ""))
transform = str(value.get("transform") or "")
if transform:
return _apply_arg_transform(resolved, transform, value.get("context"))
return resolved
return value
def run_embedded_component(
component: dict[str, object],
*,
inputs: object | None = None,
output_step: str | Path | None = None,
work_dir: str | Path | None = None,
) -> dict[str, object]:
started = datetime.now().isoformat(timespec="seconds")
try:
source_step = Path(str(component.get("sourceStep") or "")).expanduser()
if not source_step.is_file():
return {"ok": False, "error": f"Source STEP does not exist: {source_step}", "startedAt": started}
output_path = Path(output_step) if output_step else None
if output_path is None:
output_root = Path(work_dir) if work_dir else Path.cwd()
output_path = output_root / str(component.get("outputName") or "modified.step")
output_path.parent.mkdir(parents=True, exist_ok=True)
parameters = [row for row in component.get("parameters", []) if isinstance(row, dict)]
defaults = {str(row.get("name") or ""): row.get("default", "") for row in parameters if row.get("name")}
values = _input_values(inputs)
model = StepModel.load(source_step)
messages: list[str] = []
for edit in component.get("edits", []):
if not isinstance(edit, dict):
continue
operation = str(edit.get("operation") or "")
args = [_resolve_arg(arg, values, defaults) for arg in list(edit.get("args") or [])]
messages.append(_execute(model, operation, args))
model.export_all(output_path)
return {
"ok": True,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"sourceStep": str(source_step),
"outputStep": str(output_path),
"messages": messages,
"parameters": parameters,
}
except Exception as exc:
return {
"ok": False,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"error": str(exc),
"traceback": traceback.format_exc(),
}
def _load_inputs(path_or_json: str) -> object:
if not path_or_json:
return {}
candidate = Path(path_or_json)
if candidate.is_file():
return json.loads(candidate.read_text(encoding="utf-8-sig"))
return json.loads(path_or_json)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run a STEP parametric component JSON.")
parser.add_argument("component", help="Component JSON file.")
parser.add_argument("--inputs", default="", help="JSON file or inline JSON object.")
parser.add_argument("--output-step", default="", help="Output STEP path.")
parser.add_argument("--work-dir", default="", help="Runtime work directory.")
parsed = parser.parse_args(argv)
try:
component = json.loads(Path(parsed.component).read_text(encoding="utf-8-sig"))
result = run_embedded_component(
component,
inputs=_load_inputs(parsed.inputs),
output_step=parsed.output_step or None,
work_dir=parsed.work_dir or None,
)
except Exception as exc:
result = {"ok": False, "error": str(exc), "traceback": traceback.format_exc()}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
+27 -1
View File
@@ -69,6 +69,32 @@ def _polydata_id_key(values: Iterable[int] | None) -> tuple[int, ...] | None:
return tuple(sorted({int(value) for value in values}))
def _display_edge_samples(edge: TopoDS_Shape, deflection: float) -> list[tuple[float, float, float]]:
try:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
except Exception:
return list(discretize_edge(edge, deflection))
if curve_type in {GeomAbs_Circle, GeomAbs_Ellipse} and math.isfinite(first) and math.isfinite(last):
span = abs(last - first)
if span > 1e-9:
min_segments = 24 if span >= math.tau * 0.75 else 8
segments = max(min_segments, int(math.ceil(span / math.radians(7.5))))
segments = min(max(segments, 2), 128)
samples: list[tuple[float, float, float]] = []
for index in range(segments + 1):
parameter = first + (last - first) * (index / segments)
point = curve.Value(parameter)
samples.append((float(point.X()), float(point.Y()), float(point.Z())))
if len(samples) >= 2:
return samples
return list(discretize_edge(edge, deflection))
class PolydataMixin:
def build_face_polydata(
self,
@@ -272,7 +298,7 @@ class PolydataMixin:
continue
if edge_id in hidden_edge_ids:
continue
samples = discretize_edge(edge, deflection)
samples = _display_edge_samples(edge, deflection)
if len(samples) < 2:
continue
polyline = vtk.vtkPolyLine()
+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]))
+80 -2
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
@@ -91,7 +165,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
not _is_effectively_full_cylinder(info)
and angular_span is not None
and angular_span < math.tau * 0.92
) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or "槽/半孔候选" in feature_type:
) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or any(
token in feature_type for token in ("槽/半孔候选", "盲槽", "凹槽")
):
return 30
return 20
if feature_guess == "boss/outer-round candidate":
@@ -146,7 +222,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 +234,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,
)
+313
View File
@@ -0,0 +1,313 @@
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"
)
RELATION_UNIT_LITERAL_PATTERN = re.compile(
r"(?<![A-Za-z0-9_.])(?P<number>(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?P<unit>mm|毫米|cm|厘米|m|米)\b",
re.IGNORECASE,
)
RELATION_UNIT_MULTIPLIERS = {
"mm": 1.0,
"毫米": 1.0,
"cm": 10.0,
"厘米": 10.0,
"m": 1000.0,
"": 1000.0,
}
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}"
# 用户输入的 Face85.直径 不能直接丢给 eval;先替换成内部占位符,
# 后面只允许这些占位符和白名单 AST 节点参与计算。
safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression)
safe_expression = _replace_unit_literals(safe_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:
# 这里仍然使用 Python 表达式能力,但 builtins 为空,AST 也已校验过。
# 关系式只承担参数求值,不允许调用函数、访问属性或执行任意代码。
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 validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None:
target_to_formula: dict[str, RelationFormula] = {}
for formula in formulas:
target_token = formula.target.token
if target_token in target_to_formula:
raise RelationFormulaError(f"同一目标参数只能由一条关系式控制:{target_token}")
target_to_formula[target_token] = formula
target_tokens = set(target_to_formula)
graph: dict[str, list[str]] = {}
for target_token, formula in target_to_formula.items():
reference_tokens = [ref.token for ref in formula.references]
if target_token in reference_tokens:
raise RelationFormulaError(f"关系式不能引用自身:{target_token}")
# 只把“由其它公式控制的参数”纳入依赖图;普通测量值由模型/cache 提供,
# 不参与循环依赖判断。
graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens]
visit_state: dict[str, str] = {}
stack: list[str] = []
def visit(token: str) -> None:
state = visit_state.get(token)
if state == "visiting":
start_index = stack.index(token) if token in stack else 0
cycle = [*stack[start_index:], token]
raise RelationFormulaError(f"关系式存在循环依赖:{' -> '.join(cycle)}")
if state == "visited":
return
visit_state[token] = "visiting"
stack.append(token)
for dependency in graph.get(token, []):
visit(dependency)
stack.pop()
visit_state[token] = "visited"
for target_token in graph:
visit(target_token)
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 _replace_unit_literals(expression: str) -> str:
def replace(match: re.Match[str]) -> str:
number_text = str(match.group("number"))
unit = str(match.group("unit"))
multiplier = RELATION_UNIT_MULTIPLIERS.get(unit) or RELATION_UNIT_MULTIPLIERS.get(unit.lower())
if multiplier is None:
raise RelationFormulaError(f"不支持的单位:{unit}")
return f"({number_text}*{multiplier:.12g})"
return RELATION_UNIT_LITERAL_PATTERN.sub(replace, expression)
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}"
+623
View File
@@ -0,0 +1,623 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence
try: # pragma: no cover - exercised only on Windows hosts with registry access.
import winreg
except ImportError: # pragma: no cover
winreg = None # type: ignore[assignment]
SCDM_EXE_NAME = "SpaceClaim.exe"
SCDM_CACHE_RELATIVE_PATH = Path("local") / "scdm_backend.json"
SCDM_PATH_ENV_VARS = (
"STEP_EDITOR_SCDM_EXE",
"STEP_EDITOR_SPACECLAIM_EXE",
"SPACECLAIM_EXE",
)
SCDM_DISABLE_ENV = "STEP_EDITOR_DISABLE_SCDM"
SCDM_TIMEOUT_ENV = "STEP_EDITOR_SCDM_TIMEOUT"
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmBackendInfo:
path: Path
source: str
version: str = ""
verified_at: str = ""
run_script_ok: bool = False
license_ok: bool | None = None
message: str = ""
def to_cache(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_CACHE_SCHEMA_VERSION,
"path": str(self.path),
"source": self.source,
"version": self.version,
"verifiedAt": self.verified_at,
"runScriptOk": bool(self.run_script_ok),
"licenseOk": self.license_ok,
"message": self.message,
}
@classmethod
def from_cache(cls, payload: Mapping[str, object]) -> "ScdmBackendInfo | None":
raw_path = str(payload.get("path") or "").strip()
if not raw_path:
return None
path = Path(os.path.expandvars(raw_path)).expanduser()
if not _is_spaceclaim_exe(path):
return None
return cls(
path=path,
source=str(payload.get("source") or "cache"),
version=str(payload.get("version") or _version_from_path(path)),
verified_at=str(payload.get("verifiedAt") or ""),
run_script_ok=bool(payload.get("runScriptOk")),
license_ok=_optional_bool(payload.get("licenseOk")),
message=str(payload.get("message") or ""),
)
def project_root(project_root_override: str | Path | None = None) -> Path:
return Path(project_root_override).expanduser() if project_root_override else Path(__file__).resolve().parent.parent
def default_scdm_cache_path(project_root_override: str | Path | None = None) -> Path:
return project_root(project_root_override) / SCDM_CACHE_RELATIVE_PATH
def is_scdm_disabled(env: Mapping[str, str] | None = None) -> bool:
value = (env or os.environ).get(SCDM_DISABLE_ENV, "")
return value.strip().lower() in {"1", "true", "yes", "on"}
def load_scdm_backend_cache(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> ScdmBackendInfo | None:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
return ScdmBackendInfo.from_cache(payload)
def save_scdm_backend_cache(
backend: ScdmBackendInfo,
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> Path:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(backend.to_cache(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def discover_scdm_backend_candidates(
*,
manual_path: str | Path | None = None,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> tuple[ScdmBackendInfo, ...]:
env_map = env or os.environ
candidates: list[ScdmBackendInfo] = []
if manual_path:
candidates.extend(_info_for_user_value(manual_path, "manual"))
if include_env:
for env_name in SCDM_PATH_ENV_VARS:
raw_value = env_map.get(env_name, "").strip()
if raw_value:
candidates.extend(_info_for_user_value(raw_value, f"env:{env_name}"))
if include_registry:
candidates.extend(_registry_candidates())
if include_common:
candidates.extend(_common_install_candidates(common_roots=common_roots, env=env_map))
if include_path:
found = shutil.which(SCDM_EXE_NAME)
if found:
candidates.extend(_info_for_user_value(found, "PATH"))
return _dedupe_candidates(candidates)
def resolve_scdm_backend(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
manual_path: str | Path | None = None,
prefer_cache: bool = True,
save_cache: bool = True,
validate: bool = False,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: float | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
env_map = env or os.environ
if is_scdm_disabled(env_map):
return {"ok": False, "reason": "disabled", "backend": None, "message": "SCDM backend is disabled by environment."}
if prefer_cache:
cached = load_scdm_backend_cache(project_root_override=project_root_override, cache_path=cache_path)
if cached is not None:
if not validate or cached.run_script_ok:
return _resolution_payload(cached, reason="cache", message="Using cached SCDM backend.")
checked = verify_scdm_backend(cached, timeout_seconds=timeout_seconds, runner=runner)
if checked.get("ok"):
verified = _verified_backend_from_result(cached, checked)
if save_cache:
save_scdm_backend_cache(verified, project_root_override=project_root_override, cache_path=cache_path)
return _resolution_payload(verified, reason="cache-verified", message="Cached SCDM backend passed smoke test.")
failures: list[dict[str, object]] = []
candidates = discover_scdm_backend_candidates(
manual_path=manual_path,
include_env=include_env,
include_registry=include_registry,
include_common=include_common,
include_path=include_path,
common_roots=common_roots,
env=env_map,
)
for candidate in candidates:
backend = candidate
if validate:
checked = verify_scdm_backend(candidate, timeout_seconds=timeout_seconds, runner=runner)
if not checked.get("ok"):
failures.append(
{
"path": str(candidate.path),
"source": candidate.source,
"reason": checked.get("reason"),
"message": checked.get("message"),
}
)
continue
backend = _verified_backend_from_result(candidate, checked)
if save_cache:
save_scdm_backend_cache(backend, project_root_override=project_root_override, cache_path=cache_path)
reason = "discovered-verified" if validate else "discovered"
return _resolution_payload(backend, reason=reason, message=f"SCDM backend resolved from {backend.source}.")
return {
"ok": False,
"reason": "missing-spaceclaim",
"backend": None,
"candidates": (),
"failures": tuple(failures),
"message": "SpaceClaim.exe was not found. Ask the user to configure the SCDM path manually.",
}
def verify_scdm_backend(
backend: ScdmBackendInfo | str | Path,
*,
timeout_seconds: float | None = None,
work_dir: str | Path | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
info = backend
else:
matches = _info_for_user_value(backend, "manual")
if not matches:
return {
"ok": False,
"reason": "missing-exe",
"path": str(Path(str(backend)).expanduser()),
"message": "SpaceClaim.exe does not exist.",
}
info = matches[0]
if not _is_spaceclaim_exe(info.path):
return {"ok": False, "reason": "missing-exe", "path": str(info.path), "message": "SpaceClaim.exe does not exist."}
timeout = timeout_seconds if timeout_seconds is not None else _timeout_seconds()
temp_context = None
if work_dir is None:
temp_context = tempfile.TemporaryDirectory(prefix="step_editor_scdm_")
work_root = Path(temp_context.name)
else:
work_root = Path(work_dir).expanduser()
work_root.mkdir(parents=True, exist_ok=True)
try:
script_path = work_root / "scdm_smoke.py"
report_path = work_root / "scdm_smoke_result.json"
script_path.write_text(_smoke_script(report_path), encoding="utf-8")
command = scdm_run_script_command(info.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(work_root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "path": str(info.path), "message": "SCDM smoke test timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "path": str(info.path), "message": str(exc)}
returncode = int(getattr(completed, "returncode", -1))
stdout = str(getattr(completed, "stdout", "") or "")
stderr = str(getattr(completed, "stderr", "") or "")
if returncode != 0:
return {
"ok": False,
"reason": "run-script-failed",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": (stderr or stdout or f"SCDM returned {returncode}.").strip(),
}
if not report_path.is_file():
return {
"ok": False,
"reason": "missing-report",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": "SCDM smoke script finished but did not write a report.",
}
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "reason": "bad-report", "path": str(info.path), "message": str(exc)}
if not isinstance(report, dict) or report.get("ok") is not True:
return {"ok": False, "reason": "negative-report", "path": str(info.path), "message": str(report)}
return {
"ok": True,
"reason": "ok",
"path": str(info.path),
"source": info.source,
"version": str(report.get("version") or info.version or _version_from_path(info.path)),
"verifiedAt": _utc_now(),
"runScriptOk": True,
"licenseOk": True,
"returncode": returncode,
"message": str(report.get("message") or "SCDM /RunScript smoke test passed."),
}
finally:
if temp_context is not None:
temp_context.cleanup()
def scdm_run_script_command(spaceclaim_exe: str | Path, script_path: str | Path) -> list[str]:
exe = Path(spaceclaim_exe).expanduser().resolve(strict=False)
script = Path(script_path).expanduser().resolve(strict=False)
return [
str(exe),
f"/RunScript={script}",
"/Headless=True",
"/ExitAfterScript=True",
]
def _info_for_user_value(value: str | Path, source: str) -> list[ScdmBackendInfo]:
path = _spaceclaim_path_from_value(value)
if path is None:
return []
return [ScdmBackendInfo(path=path, source=source, version=_version_from_path(path))]
def _spaceclaim_path_from_value(value: str | Path) -> Path | None:
text = os.path.expandvars(str(value)).strip().strip('"')
if not text:
return None
path = Path(text).expanduser()
possible = [path]
if path.is_dir():
possible = [
path / SCDM_EXE_NAME,
path / "SCDM" / SCDM_EXE_NAME,
]
for candidate in possible:
if _is_spaceclaim_exe(candidate):
return candidate.resolve(strict=False)
return None
def _is_spaceclaim_exe(path: Path) -> bool:
return path.name.lower() == SCDM_EXE_NAME.lower() and path.is_file()
def _registry_candidates() -> list[ScdmBackendInfo]:
if os.name != "nt" or winreg is None:
return []
candidates: list[ScdmBackendInfo] = []
app_path_keys = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SpaceClaim.exe",
r"SOFTWARE\Classes\Applications\SpaceClaim.exe\shell\open\command",
)
roots = ((winreg.HKEY_CURRENT_USER, "HKCU"), (winreg.HKEY_LOCAL_MACHINE, "HKLM"))
views = (0, getattr(winreg, "KEY_WOW64_64KEY", 0), getattr(winreg, "KEY_WOW64_32KEY", 0))
for root, root_label in roots:
for access in views:
for key_path in app_path_keys:
for raw_value in _registry_key_values(root, key_path, access):
for path in _paths_from_registry_value(raw_value):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\{key_path}"))
candidates.extend(_uninstall_registry_candidates(root, root_label, access))
return candidates
def _registry_key_values(root: int, key_path: str, access: int) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, key_path, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
for name in ("", "Path", "InstallPath", "InstallLocation"):
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
continue
if isinstance(value, str) and value.strip():
values.append(value)
except OSError:
return []
return values
def _uninstall_registry_candidates(root: int, root_label: str, access: int) -> list[ScdmBackendInfo]:
uninstall_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
candidates: list[ScdmBackendInfo] = []
try:
with winreg.OpenKey(root, uninstall_key, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
index = 0
while True:
try:
subkey_name = winreg.EnumKey(key, index) # type: ignore[union-attr]
except OSError:
break
index += 1
try:
with winreg.OpenKey(key, subkey_name, 0, winreg.KEY_READ | access) as subkey: # type: ignore[union-attr]
display_name = _registry_string(subkey, "DisplayName")
install_location = _registry_string(subkey, "InstallLocation")
except OSError:
continue
if "spaceclaim" not in display_name.lower() and "ansys" not in display_name.lower():
continue
for path in _paths_from_registry_value(install_location):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\Uninstall"))
except OSError:
return []
return candidates
def _registry_string(key: object, name: str) -> str:
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
return ""
return value if isinstance(value, str) else ""
def _paths_from_registry_value(value: str) -> list[str]:
text = value.strip()
if not text:
return []
exe = _extract_exe_from_command(text)
if exe:
return [exe]
return [
text,
str(Path(text) / SCDM_EXE_NAME),
str(Path(text) / "SCDM" / SCDM_EXE_NAME),
]
def _extract_exe_from_command(command: str) -> str:
text = command.strip()
if not text:
return ""
if text.startswith('"'):
end = text.find('"', 1)
if end > 1:
first = text[1:end]
return first if first.lower().endswith(".exe") else ""
lowered = text.lower()
index = lowered.find(".exe")
if index >= 0:
return text[: index + 4]
return ""
def _common_install_candidates(
*,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> list[ScdmBackendInfo]:
roots = list(common_roots) if common_roots is not None else _default_common_roots(env or os.environ)
candidates: list[ScdmBackendInfo] = []
for root in roots:
base = Path(os.path.expandvars(str(root))).expanduser()
if not base.is_dir():
continue
direct_paths = (
base / SCDM_EXE_NAME,
base / "SCDM" / SCDM_EXE_NAME,
)
for path in direct_paths:
candidates.extend(_info_for_user_value(path, f"common:{base}"))
version_dirs = sorted((item for item in base.glob("v*") if item.is_dir()), key=_version_sort_key, reverse=True)
for version_dir in version_dirs:
candidates.extend(_info_for_user_value(version_dir / "SCDM" / SCDM_EXE_NAME, f"common:{base}"))
return candidates
def _default_common_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
roots: list[Path] = []
for env_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
raw = env.get(env_name, "")
if raw:
roots.append(Path(raw) / "ANSYS Inc")
for drive in ("C", "D", "E"):
roots.append(Path(f"{drive}:/Program Files/ANSYS Inc"))
roots.append(Path(f"{drive}:/softwaresInstallDir/ANSYS Inc"))
return tuple(_dedupe_paths(roots))
def _dedupe_candidates(candidates: Iterable[ScdmBackendInfo]) -> tuple[ScdmBackendInfo, ...]:
result: list[ScdmBackendInfo] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate.path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(candidate)
return tuple(result)
def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
result: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def _version_sort_key(path: Path) -> tuple[int, str]:
match = re.search(r"v(\d+)", path.name, flags=re.IGNORECASE)
return (int(match.group(1)) if match else -1, path.name.lower())
def _version_from_path(path: Path) -> str:
for part in path.parts:
match = re.fullmatch(r"v\d+", part, flags=re.IGNORECASE)
if match:
return part
return ""
def _verified_backend_from_result(candidate: ScdmBackendInfo, result: Mapping[str, object]) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=candidate.path,
source=candidate.source,
version=str(result.get("version") or candidate.version),
verified_at=str(result.get("verifiedAt") or _utc_now()),
run_script_ok=bool(result.get("runScriptOk")),
license_ok=_optional_bool(result.get("licenseOk")),
message=str(result.get("message") or candidate.message),
)
def _resolution_payload(backend: ScdmBackendInfo, *, reason: str, message: str) -> dict[str, object]:
return {
"ok": True,
"reason": reason,
"backend": backend,
"path": str(backend.path),
"source": backend.source,
"version": backend.version,
"verifiedAt": backend.verified_at,
"runScriptOk": backend.run_script_ok,
"licenseOk": backend.license_ok,
"message": message,
}
def _smoke_script(report_path: Path) -> str:
report_literal = repr(str(report_path))
return (
"from __future__ import print_function\n"
f"report_path = {report_literal}\n"
"version = ''\n"
"try:\n"
" version = str(Application.Version)\n"
"except Exception:\n"
" version = ''\n"
"payload = '{\"ok\": true, \"version\": \"' + version.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\", \"message\": \"RunScript reached\"}'\n"
"handle = open(report_path, 'w')\n"
"handle.write(payload)\n"
"handle.close()\n"
)
def _timeout_seconds() -> float:
try:
return max(float(os.environ.get(SCDM_TIMEOUT_ENV, "") or 25.0), 0.1)
except ValueError:
return 25.0
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _optional_bool(value: object) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
__all__ = [
"SCDM_CACHE_RELATIVE_PATH",
"SCDM_DISABLE_ENV",
"SCDM_EXE_NAME",
"SCDM_PATH_ENV_VARS",
"SCDM_TIMEOUT_ENV",
"ScdmBackendInfo",
"default_scdm_cache_path",
"discover_scdm_backend_candidates",
"is_scdm_disabled",
"load_scdm_backend_cache",
"project_root",
"resolve_scdm_backend",
"save_scdm_backend_cache",
"scdm_run_script_command",
"verify_scdm_backend",
]
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
@dataclass(frozen=True)
class ScdmCapabilityDefinition:
key: str
display_name: str
object_types: tuple[str, ...]
value_kind: str
current_fields: tuple[str, ...]
default_intent: str
backend_operation: str
post_check: str
required_backend_command_groups: tuple[tuple[str, ...], ...] = ()
productized: bool = True
roadmap_stage: str = "S5"
block_reason: str = ""
def to_payload(self) -> dict[str, object]:
return {
"key": self.key,
"displayName": self.display_name,
"objectTypes": self.object_types,
"valueKind": self.value_kind,
"currentFields": self.current_fields,
"defaultIntent": self.default_intent,
"backendOperation": self.backend_operation,
"postCheck": self.post_check,
"requiredBackendCommandGroups": self.required_backend_command_groups,
"productized": self.productized,
"roadmapStage": self.roadmap_stage,
"blockReason": self.block_reason,
}
# 产品能力字典:SCDM raw 对象只有进入这里,才会被翻译成客户可见的中文参数。
# 新能力要同时补 backend_operation、post_check 和验证脚本,避免只显示不能执行的参数。
CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
"hole.diameter": ScdmCapabilityDefinition(
key="hole.diameter",
display_name="直径",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改孔径",
backend_operation="change_hole_diameter",
post_check="target_hole_diameter",
required_backend_command_groups=(("StandardHoles",), ("OffsetFaces",)),
roadmap_stage="S5",
),
"hole.position": ScdmCapabilityDefinition(
key="hole.position",
display_name="位置",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动孔",
backend_operation="move_hole_axis",
post_check="target_hole_axis_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S5",
),
"face.offset": ScdmCapabilityDefinition(
key="face.offset",
display_name="偏移",
object_types=("face", "planar_face"),
value_kind="number",
current_fields=("geometry.offset", "geometry.planeOffset", "0"),
default_intent="推拉平面",
backend_operation="pull_face_offset",
post_check="target_face_offset",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S5",
),
"feature.fill": ScdmCapabilityDefinition(
key="feature.fill",
display_name="填孔/删除小特征",
object_types=("hole", "small_feature"),
value_kind="command",
current_fields=("1",),
default_intent="删除并补面",
backend_operation="fill_feature",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
roadmap_stage="S5",
),
"slot.width": ScdmCapabilityDefinition(
key="slot.width",
display_name="槽宽",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.width",),
default_intent="修改槽宽",
backend_operation="change_slot_width",
post_check="target_slot_width",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.2",
),
"slot.depth": ScdmCapabilityDefinition(
key="slot.depth",
display_name="槽深",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.slotInfo.depth", "geometry.depth"),
default_intent="修改槽深",
backend_operation="change_slot_depth",
post_check="target_slot_depth",
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.2",
),
"slot.position": ScdmCapabilityDefinition(
key="slot.position",
display_name="槽位置",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动槽",
backend_operation="move_slot",
post_check="target_slot_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.2",
),
"boss.height": ScdmCapabilityDefinition(
key="boss.height",
display_name="凸台高度",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="number",
current_fields=("geometry.height",),
default_intent="修改凸台高度",
backend_operation="change_boss_height",
post_check="target_boss_height",
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.3",
),
"boss.diameter": ScdmCapabilityDefinition(
key="boss.diameter",
display_name="凸台直径",
object_types=("boss", "cylindrical_boss"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改凸台直径",
backend_operation="change_boss_diameter",
post_check="target_boss_diameter",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.3",
),
"boss.position": ScdmCapabilityDefinition(
key="boss.position",
display_name="凸台位置",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动凸台",
backend_operation="move_boss",
post_check="target_boss_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.3",
),
"round.radius": ScdmCapabilityDefinition(
key="round.radius",
display_name="圆角半径",
object_types=("round", "fillet"),
value_kind="number",
current_fields=("geometry.roundInfo.radius", "geometry.radius"),
default_intent="修改圆角半径",
backend_operation="change_round_radius",
post_check="target_round_radius",
required_backend_command_groups=(("ConstantRound",),),
roadmap_stage="S7.4",
),
"chamfer.distance": ScdmCapabilityDefinition(
key="chamfer.distance",
display_name="倒角距离",
object_types=("chamfer",),
value_kind="number",
current_fields=("geometry.chamferInfo.distance", "geometry.distance", "geometry.offset"),
default_intent="修改倒角距离",
backend_operation="change_chamfer_distance",
post_check="target_chamfer_distance",
required_backend_command_groups=(("Chamfer",),),
roadmap_stage="S7.4",
),
"feature.delete_round_or_chamfer": ScdmCapabilityDefinition(
key="feature.delete_round_or_chamfer",
display_name="删除圆角/倒角",
object_types=("round", "fillet", "chamfer"),
value_kind="command",
current_fields=("1",),
default_intent="删除圆角/倒角并补面",
backend_operation="delete_round_or_chamfer",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
roadmap_stage="S7.4",
),
"pattern.spacing": ScdmCapabilityDefinition(
key="pattern.spacing",
display_name="阵列间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改阵列间距",
backend_operation="change_pattern_spacing",
post_check="target_pattern_spacing",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"pattern.segment_spacing": ScdmCapabilityDefinition(
key="pattern.segment_spacing",
display_name="局部间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改相邻阵列成员间距",
backend_operation="change_pattern_segment_spacing",
post_check="target_pattern_segment_spacing",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"pattern.instance_position": ScdmCapabilityDefinition(
key="pattern.instance_position",
display_name="阵列实例位置",
object_types=("pattern", "linear_pattern"),
value_kind="vector3",
current_fields=("geometry.instanceCenter", "geometry.center"),
default_intent="移动阵列实例",
backend_operation="move_pattern_instance",
post_check="target_pattern_instance_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"shell.thickness": ScdmCapabilityDefinition(
key="shell.thickness",
display_name="壳体厚度",
object_types=("shell", "thin_wall"),
value_kind="number",
current_fields=("geometry.thickness",),
default_intent="固定一侧,移动另一侧",
backend_operation="change_shell_thickness",
post_check="target_shell_thickness",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
}
def capability_definition(key: str) -> ScdmCapabilityDefinition | None:
return CAPABILITY_DEFINITIONS.get(key)
def productized_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=False)
if (definition := capability_definition(key)) is not None and definition.productized
)
def planned_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=True)
if (definition := capability_definition(key)) is not None and not definition.productized
)
def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_planned: bool = False) -> tuple[str, ...]:
object_type = str(raw_object.get("objectType") or "").strip().lower()
geometry = _mapping(raw_object.get("geometry"))
commands = tuple(_command_operations(raw_object.get("backendCommandCandidates")))
keys: list[str] = []
if object_type in {"hole", "cylindrical_hole", "cylindrical_face_group"}:
if _has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius")):
keys.append("hole.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("hole.position")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
surface_type = str(geometry.get("surfaceType") or geometry.get("surface") or "").strip().lower()
if object_type in {"face", "planar_face"} and surface_type in {"plane", "planar", ""}:
if _has_command_token(commands, ("pull", "offset", "move_face")) or _has_any(geometry, ("normal", "planeOffset")):
keys.append("face.offset")
if object_type in {"hole", "small_feature"} and _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
if object_type in {"slot", "obround_slot", "rectangular_slot"}:
if _has_any(geometry, ("width",)) or _has_command_token(commands, ("slot_width", "width")):
keys.append("slot.width")
if _has_any(geometry, ("depth",)) or _has_command_token(commands, ("slot_depth", "depth")):
keys.append("slot.depth")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("slot.position")
if object_type in {"boss", "cylindrical_boss", "rectangular_boss"}:
if _has_any(geometry, ("height",)) or _has_command_token(commands, ("boss_height", "height")):
keys.append("boss.height")
if object_type != "rectangular_boss" and (_has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius"))):
keys.append("boss.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("boss.position")
if object_type in {"round", "fillet"}:
if _has_any(geometry, ("radius",)) or _has_command_token(commands, ("round_radius", "fillet_radius", "radius")):
keys.append("round.radius")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type == "chamfer":
if _has_any(geometry, ("distance", "offset")) or _has_command_token(commands, ("chamfer_distance", "distance", "offset")):
keys.append("chamfer.distance")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type in {"pattern", "linear_pattern"}:
if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")):
keys.append("pattern.spacing")
keys.append("pattern.segment_spacing")
if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")):
keys.append("pattern.instance_position")
if object_type in {"shell", "thin_wall"}:
if _has_any(geometry, ("thickness",)) or _has_command_token(commands, ("shell_thickness", "thickness")):
keys.append("shell.thickness")
result = []
for key in keys:
definition = capability_definition(key)
if definition is None:
continue
if definition.productized or include_planned:
result.append(key)
return tuple(dict.fromkeys(result))
def _mapping(value: object) -> Mapping[str, object]:
return value if isinstance(value, Mapping) else {}
def _has_any(mapping: Mapping[str, object], names: tuple[str, ...]) -> bool:
return any(name in mapping and mapping.get(name) is not None for name in names)
def _command_operations(value: object) -> list[str]:
if not isinstance(value, list):
return []
result: list[str] = []
for item in value:
if not isinstance(item, Mapping):
continue
enabled = item.get("enabled")
if enabled is False:
continue
text = " ".join(
str(part or "")
for part in (
item.get("key"),
item.get("operation"),
item.get("command"),
item.get("type"),
)
)
result.append(text.lower())
return result
def _has_command_token(commands: tuple[str, ...], tokens: tuple[str, ...]) -> bool:
return any(token in command for command in commands for token in tokens)
__all__ = [
"CAPABILITY_DEFINITIONS",
"ScdmCapabilityDefinition",
"capability_keys_for_raw_object",
"capability_definition",
"planned_capability_keys",
"productized_capability_keys",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+972
View File
@@ -0,0 +1,972 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Callable
from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command
from .scdm_schema import ScdmProbeJob, default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json
def prepare_scdm_probe_job(
step_path: str | Path,
*,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
backend: ScdmBackendInfo | None = None,
unit: str = "model",
scan_scope: str = "all",
) -> dict[str, object]:
source = Path(step_path).expanduser()
if not source.is_file():
return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"}
fingerprint = file_fingerprint(source)
work_dir = Path(output_dir).expanduser() if output_dir else default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint)
work_dir = work_dir.resolve(strict=False)
work_dir.mkdir(parents=True, exist_ok=True)
job = ScdmProbeJob(
step_path=source.resolve(strict=False),
output_dir=work_dir,
raw_features_path=work_dir / "scdm_raw_features.json",
error_path=work_dir / "error.json",
model_fingerprint=fingerprint,
unit=unit,
scan_scope=scan_scope,
backend_path=str(backend.path) if backend else "",
backend_version=backend.version if backend else "",
)
job_path = work_dir / "scdm_probe_job.json"
script_path = work_dir / "scdm_probe.py"
write_json(job_path, job.to_payload())
script_path.write_text(generate_scdm_probe_script(job_path), encoding="utf-8")
return {
"ok": True,
"reason": "ok",
"work_dir": str(work_dir),
"job_path": str(job_path),
"script_path": str(script_path),
"raw_features_path": str(job.raw_features_path),
"error_path": str(job.error_path),
"model_fingerprint": fingerprint,
}
def run_scdm_probe(
step_path: str | Path,
*,
backend: ScdmBackendInfo | None = None,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
timeout_seconds: float = 120.0,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if backend is None:
resolved = resolve_scdm_backend(project_root_override=project_root, validate=False)
if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo):
return {
"ok": False,
"reason": str(resolved.get("reason") or "missing-scdm"),
"message": str(resolved.get("message") or "SCDM backend is not available."),
"backend_resolution": {
"ok": bool(resolved.get("ok")),
"reason": str(resolved.get("reason") or ""),
"message": str(resolved.get("message") or ""),
},
}
backend = resolved["backend"] # type: ignore[assignment]
prepared = prepare_scdm_probe_job(step_path, output_dir=output_dir, project_root=project_root, backend=backend)
if not prepared.get("ok"):
return {"backend": backend.to_cache(), **prepared}
script_path = Path(str(prepared["script_path"]))
raw_path = Path(str(prepared["raw_features_path"]))
error_path = Path(str(prepared["error_path"]))
command = scdm_run_script_command(backend.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(script_path.parent),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout_seconds), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
write_json(error_path, {"ok": False, "reason": "timeout", "message": "SCDM probe timed out."})
return {"ok": False, "reason": "timeout", "message": "SCDM probe timed out.", "backend": backend.to_cache(), **prepared}
except OSError as exc:
write_json(error_path, {"ok": False, "reason": "launch-failed", "message": str(exc)})
return {"ok": False, "reason": "launch-failed", "message": str(exc), "backend": backend.to_cache(), **prepared}
returncode = int(getattr(completed, "returncode", -1))
if returncode != 0:
message = (str(getattr(completed, "stderr", "") or "") or str(getattr(completed, "stdout", "") or "")).strip()
write_json(
error_path,
{
"ok": False,
"reason": "probe-failed",
"returncode": returncode,
"message": message,
},
)
return {"ok": False, "reason": "probe-failed", "returncode": returncode, "message": message, "backend": backend.to_cache(), **prepared}
if not raw_path.is_file():
write_json(error_path, {"ok": False, "reason": "missing-raw-output", "message": "SCDM probe did not write raw features."})
return {
"ok": False,
"reason": "missing-raw-output",
"message": "SCDM probe did not write raw features.",
"backend": backend.to_cache(),
**prepared,
}
raw = read_json(raw_path)
verified_backend = _probe_verified_backend(backend)
try:
save_scdm_backend_cache(verified_backend, project_root_override=project_root)
except Exception:
pass
return {"ok": True, "reason": "ok", "raw": raw, "backend": verified_backend.to_cache(), **prepared}
def _probe_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=backend.path,
source=backend.source,
version=backend.version,
verified_at=utc_now(),
run_script_ok=True,
license_ok=True,
message="SCDM probe completed.",
)
def generate_scdm_probe_script(job_path: str | Path) -> str:
job_literal = repr(str(Path(job_path).expanduser()))
return (
"from __future__ import print_function\n"
"import json\n"
"import traceback\n"
f"JOB_PATH = {job_literal}\n"
"\n"
"def _write_json(path, payload):\n"
" handle = open(path, 'w')\n"
" try:\n"
" handle.write(json.dumps(payload, indent=2))\n"
" finally:\n"
" handle.close()\n"
"\n"
"def _safe_name(value):\n"
" try:\n"
" return type(value).__name__\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _float_attr(value, names):\n"
" for name in names:\n"
" try:\n"
" result = getattr(value, name)\n"
" return float(result)\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _xyz(value):\n"
" if value is None:\n"
" return []\n"
" result = []\n"
" for name in ('X', 'Y', 'Z'):\n"
" try:\n"
" result.append(float(getattr(value, name)))\n"
" except Exception:\n"
" return []\n"
" return result\n"
"\n"
"def _items(collection):\n"
" if collection is None:\n"
" return []\n"
" try:\n"
" return list(collection)\n"
" except Exception:\n"
" items = []\n"
" try:\n"
" count = int(collection.Count)\n"
" for index in range(count):\n"
" items.append(collection[index])\n"
" except Exception:\n"
" pass\n"
" return items\n"
"\n"
"def _geometry_from_face(face):\n"
" geometry = {}\n"
" surface = None\n"
" for expr in ('Shape.Geometry', 'Geometry', 'Surface'):\n"
" try:\n"
" current = face\n"
" for part in expr.split('.'):\n"
" current = getattr(current, part)\n"
" surface = current\n"
" break\n"
" except Exception:\n"
" pass\n"
" surface_name = _safe_name(surface)\n"
" geometry['surfaceType'] = surface_name\n"
" lowered = surface_name.lower()\n"
" radius = _float_attr(surface, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" try:\n"
" geometry['center'] = _xyz(surface.Frame.Origin)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" geometry['axis'] = _xyz(surface.Frame.DirZ)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" center = geometry.get('center') or []\n"
" axis = geometry.get('axis') or []\n"
" if len(center) == 3 and len(axis) == 3:\n"
" geometry['planeOffset'] = center[0] * axis[0] + center[1] * axis[1] + center[2] * axis[2]\n"
" except Exception:\n"
" pass\n"
" if 'plane' in lowered:\n"
" geometry['surfaceType'] = 'plane'\n"
" elif 'cylinder' in lowered:\n"
" geometry['surfaceType'] = 'cylinder'\n"
" slot_info = _slot_info_from_face(face, geometry)\n"
" if slot_info:\n"
" geometry['slotInfo'] = slot_info\n"
" for key in ('width', 'depth', 'center', 'depthAxis'):\n"
" if slot_info.get(key) is not None:\n"
" geometry[key] = slot_info.get(key)\n"
" round_info = _round_info_from_face(face, geometry)\n"
" if round_info:\n"
" geometry['roundInfo'] = round_info\n"
" chamfer_info = _chamfer_info_from_face(face, geometry)\n"
" if chamfer_info:\n"
" geometry['chamferInfo'] = chamfer_info\n"
" return geometry\n"
"\n"
"def _round_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'cylinder':\n"
" return {}\n"
" round_info_type = globals().get('RoundInfo')\n"
" if round_info_type is None:\n"
" return {}\n"
" try:\n"
" info = round_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Radius', 'RoundRadius', 'ConstantRadius'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['radius'] = value\n"
" payload['diameter'] = value * 2.0\n"
" break\n"
" for attr in ('IsConstant', 'IsRound'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _same_object(left, right):\n"
" try:\n"
" if left is right:\n"
" return True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return left == right\n"
" except Exception:\n"
" return False\n"
"\n"
"def _slot_info_from_face(face, geometry):\n"
" slot_info_type = globals().get('SlotInfo')\n"
" if slot_info_type is None:\n"
" return {}\n"
" try:\n"
" info = slot_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for key, attrs in (\n"
" ('width', ('Width', 'SlotWidth', 'Diameter')),\n"
" ('depth', ('Depth', 'SlotDepth', 'Height')),\n"
" ):\n"
" value = _float_attr(info, attrs)\n"
" if value is not None:\n"
" payload[key] = value\n"
" center = _xyz(_first_path_value(info, ('Center', 'AxisCenter', 'Frame.Origin')))\n"
" if center:\n"
" payload['center'] = center\n"
" depth_axis = _xyz(_first_path_value(info, ('DepthAxis', 'DepthDirection', 'Direction', 'Frame.DirZ')))\n"
" if depth_axis:\n"
" payload['depthAxis'] = depth_axis\n"
" for attr in ('IsBlind', 'IsThrough', 'IsSlot'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFace', 'DepthFace', 'FloorFace'):\n"
" try:\n"
" if _same_object(getattr(info, attr), face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFaces', 'DepthFaces', 'FloorFaces'):\n"
" try:\n"
" for item in _items(getattr(info, attr)):\n"
" if _same_object(item, face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _chamfer_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'plane':\n"
" return {}\n"
" chamfer_info_type = globals().get('ChamferInfo')\n"
" if chamfer_info_type is None:\n"
" return {}\n"
" try:\n"
" info = chamfer_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Distance', 'ChamferDistance', 'Offset', 'Width'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['distance'] = value\n"
" break\n"
" distance1 = _float_attr(info, ('Distance1', 'distance1', 'FirstDistance'))\n"
" distance2 = _float_attr(info, ('Distance2', 'distance2', 'SecondDistance'))\n"
" if distance1 is not None:\n"
" payload['distance1'] = distance1\n"
" if distance2 is not None:\n"
" payload['distance2'] = distance2\n"
" if distance1 is not None and distance2 is not None and abs(distance1 - distance2) <= max(abs(distance1), abs(distance2), 1.0) * 1e-6:\n"
" payload.setdefault('distance', distance1)\n"
" payload['isEqualDistance'] = True\n"
" for attr in ('IsEqualDistance', 'IsSymmetric', 'IsChamfer'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" if payload.get('isSymmetric') is True:\n"
" payload['isEqualDistance'] = True\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _path_value(value, expr):\n"
" current = value\n"
" for part in expr.split('.'):\n"
" try:\n"
" current = getattr(current, part)\n"
" except Exception:\n"
" return None\n"
" return current\n"
"\n"
"def _first_path_value(value, exprs):\n"
" for expr in exprs:\n"
" result = _path_value(value, expr)\n"
" if result is not None:\n"
" return result\n"
" return None\n"
"\n"
"def _geometry_from_edge(edge):\n"
" geometry = {}\n"
" shape = getattr(edge, 'Shape', edge)\n"
" curve = _first_path_value(edge, ('Shape.Geometry', 'Geometry', 'Shape.Curve', 'Curve', 'Shape')) or shape\n"
" geometry['curveShapeType'] = _safe_name(shape)\n"
" geometry['curveType'] = _safe_name(curve)\n"
" length = _float_attr(edge, ('Length', 'length'))\n"
" if length is None:\n"
" length = _float_attr(shape, ('Length', 'length'))\n"
" if length is not None:\n"
" geometry['length'] = length\n"
" start = _xyz(_first_path_value(edge, ('StartPoint', 'Shape.StartPoint')))\n"
" end = _xyz(_first_path_value(edge, ('EndPoint', 'Shape.EndPoint')))\n"
" if start:\n"
" geometry['startPoint'] = start\n"
" if end:\n"
" geometry['endPoint'] = end\n"
" if len(start) == 3 and len(end) == 3:\n"
" geometry['midPoint'] = [(start[i] + end[i]) * 0.5 for i in range(3)]\n"
" radius = _float_attr(curve, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" center = _xyz(_first_path_value(curve, ('Frame.Origin', 'Circle.Frame.Origin')))\n"
" if center:\n"
" geometry['center'] = center\n"
" axis = _xyz(_first_path_value(curve, ('Frame.DirZ', 'Circle.Frame.DirZ')))\n"
" if axis:\n"
" geometry['axis'] = axis\n"
" return geometry\n"
"\n"
"def _edge_adjacent_face_ordinals(edge, face_ordinals_by_marker):\n"
" faces = []\n"
" for expr in ('Faces', 'Shape.Faces', 'GetFaces'):\n"
" value = _path_value(edge, expr)\n"
" if value is None and expr == 'GetFaces':\n"
" value = _maybe_call(edge, 'GetFaces')\n"
" faces = _items(value)\n"
" if faces:\n"
" break\n"
" ordinals = []\n"
" for face in faces:\n"
" marker = str(id(face))\n"
" if marker in face_ordinals_by_marker:\n"
" ordinals.append(face_ordinals_by_marker[marker])\n"
" return {'adjacentFaceCount': len(faces), 'adjacentFaceOrdinals': ordinals}\n"
"\n"
"def _int_or_none(value):\n"
" try:\n"
" return int(value)\n"
" except Exception:\n"
" return None\n"
"\n"
"def _edge_kind(geometry):\n"
" curve_type = str(geometry.get('curveType', '') or geometry.get('curveShapeType', '')).lower()\n"
" if geometry.get('radius') is not None or 'circle' in curve_type or 'arc' in curve_type:\n"
" return 'circular'\n"
" if 'line' in curve_type or 'segment' in curve_type:\n"
" return 'linear'\n"
" return 'other'\n"
"\n"
"def _add_edge_geometry_summary(summary, geometry):\n"
" summary['totalEdgeCount'] = int(summary.get('totalEdgeCount', 0)) + 1\n"
" kind = _edge_kind(geometry)\n"
" kind_counts = summary.setdefault('edgeKindCounts', {})\n"
" kind_counts[kind] = int(kind_counts.get(kind, 0)) + 1\n"
" radius = geometry.get('radius')\n"
" if radius is not None:\n"
" try:\n"
" radius = float(radius)\n"
" summary['circularEdgeCount'] = int(summary.get('circularEdgeCount', 0)) + 1\n"
" values = summary.setdefault('circularRadii', [])\n"
" if len(values) < 80:\n"
" values.append(radius)\n"
" except Exception:\n"
" pass\n"
" length = geometry.get('length')\n"
" if length is not None:\n"
" try:\n"
" length = float(length)\n"
" summary['minEdgeLength'] = min(float(summary.get('minEdgeLength', length)), length)\n"
" summary['maxEdgeLength'] = max(float(summary.get('maxEdgeLength', length)), length)\n"
" except Exception:\n"
" pass\n"
"\n"
"def _final_edge_geometry_summary(summary):\n"
" result = dict(summary)\n"
" radii = result.get('circularRadii')\n"
" if isinstance(radii, list) and radii:\n"
" buckets = {}\n"
" for value in radii:\n"
" try:\n"
" key = '%.6g' % float(value)\n"
" buckets[key] = int(buckets.get(key, 0)) + 1\n"
" except Exception:\n"
" pass\n"
" result['circularRadiusBuckets'] = [\n"
" {'radius': key, 'count': buckets[key]} for key in sorted(buckets.keys())[:40]\n"
" ]\n"
" result.pop('circularRadii', None)\n"
" return result\n"
"\n"
"def _record_face_adjacency(adjacency_map, body_index, edge_topology, geometry):\n"
" ordinals = []\n"
" for value in edge_topology.get('adjacentFaceOrdinals', []) or []:\n"
" number = _int_or_none(value)\n"
" if number is not None and number not in ordinals:\n"
" ordinals.append(number)\n"
" if len(ordinals) < 2:\n"
" return\n"
" ordinals.sort()\n"
" kind = _edge_kind(geometry)\n"
" for left_index in range(len(ordinals)):\n"
" for right_index in range(left_index + 1, len(ordinals)):\n"
" left = ordinals[left_index]\n"
" right = ordinals[right_index]\n"
" key = (body_index, left, right)\n"
" item = adjacency_map.setdefault(\n"
" key,\n"
" {'bodyIndex': body_index, 'faceOrdinals': [left, right], 'edgeCount': 0, 'edgeKinds': {}, 'edges': []},\n"
" )\n"
" item['edgeCount'] = int(item.get('edgeCount', 0)) + 1\n"
" edge_kinds = item.setdefault('edgeKinds', {})\n"
" edge_kinds[kind] = int(edge_kinds.get(kind, 0)) + 1\n"
" edges = item.setdefault('edges', [])\n"
" if len(edges) < 6:\n"
" edges.append({\n"
" 'edgeOrdinal': edge_topology.get('edgeOrdinal'),\n"
" 'globalEdgeOrdinal': edge_topology.get('globalEdgeOrdinal'),\n"
" 'curveType': geometry.get('curveType'),\n"
" 'kind': kind,\n"
" 'length': geometry.get('length'),\n"
" 'radius': geometry.get('radius'),\n"
" })\n"
"\n"
"def _face_adjacency_rows(adjacency_map):\n"
" rows = list(adjacency_map.values())\n"
" rows.sort(key=lambda item: (int(item.get('bodyIndex') or 0), item.get('faceOrdinals') or []))\n"
" return rows\n"
"\n"
"def _count_key(counts, key):\n"
" key = str(key or '').strip() or 'unknown'\n"
" counts[key] = int(counts.get(key, 0)) + 1\n"
"\n"
"def _feature_inventory(objects):\n"
" result = {'objectTypeCounts': {}, 'surfaceTypeCounts': {}, 'curveTypeCounts': {}, 'operationCounts': {}}\n"
" for item in objects:\n"
" if not isinstance(item, dict):\n"
" continue\n"
" _count_key(result['objectTypeCounts'], item.get('objectType'))\n"
" geometry = item.get('geometry')\n"
" if not isinstance(geometry, dict):\n"
" geometry = {}\n"
" if geometry.get('surfaceType') is not None:\n"
" _count_key(result['surfaceTypeCounts'], geometry.get('surfaceType'))\n"
" if geometry.get('curveType') is not None:\n"
" _count_key(result['curveTypeCounts'], geometry.get('curveType'))\n"
" for command in item.get('backendCommandCandidates', []) or []:\n"
" if isinstance(command, dict):\n"
" _count_key(result['operationCounts'], command.get('operation'))\n"
" return result\n"
"\n"
"def _command_candidates(object_type, geometry):\n"
" surface_type = str(geometry.get('surfaceType', '')).lower()\n"
" result = []\n"
" if object_type == 'face' and surface_type == 'plane':\n"
" result.append({'operation': 'pull_face_offset', 'enabled': True, 'parameterFields': {'distance': 0}})\n"
" if object_type in ('face', 'hole') and surface_type == 'cylinder':\n"
" result.append({'operation': 'change_hole_diameter', 'enabled': True, 'parameterFields': {'diameter': geometry.get('diameter')}})\n"
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" if object_type == 'hole' and surface_type == 'cylinder':\n"
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n"
" if object_type in ('slot', 'obround_slot', 'rectangular_slot'):\n"
" if geometry.get('width') is not None:\n"
" result.append({'operation': 'change_slot_width', 'enabled': True, 'parameterFields': {'width': geometry.get('width')}})\n"
" if geometry.get('depth') is not None:\n"
" result.append({'operation': 'change_slot_depth', 'enabled': True, 'parameterFields': {'depth': geometry.get('depth')}})\n"
" if geometry.get('center') is not None:\n"
" result.append({'operation': 'move_slot', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" round_info = geometry.get('roundInfo')\n"
" if isinstance(round_info, dict) and round_info.get('radius') is not None:\n"
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" chamfer_info = geometry.get('chamferInfo')\n"
" if isinstance(chamfer_info, dict) and chamfer_info.get('distance') is not None:\n"
" result.append({'operation': 'change_chamfer_distance', 'enabled': True, 'parameterFields': {'distance': chamfer_info.get('distance')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" return result\n"
"\n"
"def _open_step(path):\n"
" errors = []\n"
" for opener in ('DocumentOpen.Execute', 'Application.OpenDocument'):\n"
" try:\n"
" current = globals()\n"
" target = None\n"
" for part in opener.split('.'):\n"
" target = current.get(part) if isinstance(current, dict) else getattr(current, part)\n"
" current = target\n"
" target(path)\n"
" return\n"
" except Exception as exc:\n"
" errors.append(str(exc))\n"
" raise Exception('Could not open STEP: ' + '; '.join(errors))\n"
"\n"
"def _root_part():\n"
" try:\n"
" return GetRootPart()\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return Application.ActiveWindow.Document.MainPart\n"
" except Exception:\n"
" return None\n"
"\n"
"def _maybe_call(target, name):\n"
" try:\n"
" value = getattr(target, name)\n"
" except Exception:\n"
" return None\n"
" try:\n"
" return value()\n"
" except Exception:\n"
" return value\n"
"\n"
"def _safe_str(value):\n"
" if value is None:\n"
" return ''\n"
" try:\n"
" return str(value)\n"
" except Exception:\n"
" return _safe_name(value)\n"
"\n"
"def _matrix_payload(matrix):\n"
" if matrix is None:\n"
" return {}\n"
" payload = {'type': _safe_name(matrix), 'text': _safe_str(matrix)}\n"
" translation = _xyz(_path_value(matrix, 'Translation'))\n"
" if translation:\n"
" payload['translation'] = translation\n"
" for attr in ('OffsetX', 'OffsetY', 'OffsetZ'):\n"
" value = _float_attr(matrix, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload[attr] = value\n"
" return payload\n"
"\n"
"def _moniker_text(value):\n"
" try:\n"
" return _safe_str(getattr(value, 'Moniker'))\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _component_name(component):\n"
" for attr in ('Name', 'DisplayName'):\n"
" try:\n"
" text = _safe_str(getattr(component, attr)).strip()\n"
" if text:\n"
" return text\n"
" except Exception:\n"
" pass\n"
" return ''\n"
"\n"
"def _immediate_components(part):\n"
" if part is None:\n"
" return []\n"
" try:\n"
" items = _items(getattr(part, 'Components'))\n"
" if items:\n"
" return items\n"
" except Exception:\n"
" pass\n"
" return []\n"
"\n"
"def _component_content(component):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" return value\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _component_locator(component, component_index, component_path):\n"
" locator = {\n"
" 'backendId': 'component:' + '.'.join(str(item) for item in component_path),\n"
" 'componentIndex': component_index,\n"
" 'componentPath': list(component_path),\n"
" 'componentName': _component_name(component),\n"
" }\n"
" try:\n"
" locator['componentMoniker'] = _moniker_text(component)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" content = getattr(component, 'Content')\n"
" locator['contentMoniker'] = _moniker_text(content)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" template = getattr(component, 'Template')\n"
" locator['templateMoniker'] = _moniker_text(template)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" placement = _matrix_payload(getattr(component, 'Placement'))\n"
" if placement:\n"
" locator['placement'] = placement\n"
" if placement.get('translation'):\n"
" locator['placementTranslation'] = placement.get('translation')\n"
" except Exception:\n"
" pass\n"
" return locator\n"
"\n"
"def _component_entries(root):\n"
" result = []\n"
" queue = [(root, [])]\n"
" while queue:\n"
" part, path = queue.pop(0)\n"
" if part is None or len(path) > 8:\n"
" continue\n"
" for child_index, component in enumerate(_immediate_components(part)):\n"
" component_path = list(path) + [child_index]\n"
" content = _component_content(component)\n"
" entry = {\n"
" 'component': component,\n"
" 'content': content,\n"
" 'locator': _component_locator(component, len(result), component_path),\n"
" }\n"
" result.append(entry)\n"
" if content is not None:\n"
" queue.append((content, component_path))\n"
" return result\n"
"\n"
"def _component_body_locator_map(component_entries):\n"
" result = {}\n"
" for entry in component_entries:\n"
" content = entry.get('content')\n"
" if content is None:\n"
" continue\n"
" for component_body_index, body in enumerate(_items(_maybe_call(content, 'Bodies'))):\n"
" locator = dict(entry.get('locator') or {})\n"
" locator['componentBodyIndex'] = component_body_index\n"
" key = str(id(body))\n"
" result.setdefault(key, []).append(locator)\n"
" try:\n"
" master = getattr(body, 'Master')\n"
" result.setdefault(str(id(master)), []).append(locator)\n"
" except Exception:\n"
" pass\n"
" return result\n"
"\n"
"def _body_locators_for_body(component_body_locators, body, body_index):\n"
" result = [{'bodyIndex': body_index}]\n"
" seen = set(['body:' + str(body_index)])\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" item = dict(locator)\n"
" item['bodyIndex'] = body_index\n"
" key = str(item.get('componentIndex')) + ':' + '.'.join(str(value) for value in item.get('componentPath', []) or []) + ':' + str(item.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(item)\n"
" return result\n"
"\n"
"def _component_locators_for_body(component_body_locators, body):\n"
" result = []\n"
" seen = set()\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" key = str(locator.get('componentIndex')) + ':' + '.'.join(str(value) for value in locator.get('componentPath', []) or []) + ':' + str(locator.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(dict(locator))\n"
" return result\n"
"\n"
"def _component_inventory(component_entries):\n"
" result = []\n"
" for entry in component_entries:\n"
" locator = dict(entry.get('locator') or {})\n"
" content = entry.get('content')\n"
" locator['contentBodyCount'] = len(_items(_maybe_call(content, 'Bodies'))) if content is not None else 0\n"
" locator['childComponentCount'] = len(_immediate_components(content)) if content is not None else 0\n"
" result.append(locator)\n"
" return result\n"
"\n"
"def _body_faces(body):\n"
" for name in ('Faces', 'GetFaces'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _body_edges(body):\n"
" for name in ('Edges', 'GetEdges'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _child_parts(part):\n"
" children = []\n"
" for name in ('Components', 'GetAllComponents'):\n"
" for component in _items(_maybe_call(part, name)):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" children.append(value)\n"
" break\n"
" except Exception:\n"
" pass\n"
" return children\n"
"\n"
"def _all_bodies(root):\n"
" if root is None:\n"
" return []\n"
" for name in ('GetAllBodies', 'Bodies'):\n"
" items = _items(_maybe_call(root, name))\n"
" if items:\n"
" return items\n"
" bodies = []\n"
" queue = [root]\n"
" seen = set()\n"
" while queue:\n"
" part = queue.pop(0)\n"
" marker = str(id(part))\n"
" if marker in seen:\n"
" continue\n"
" seen.add(marker)\n"
" bodies.extend(_items(_maybe_call(part, 'Bodies')))\n"
" queue.extend(_child_parts(part))\n"
" return bodies\n"
"\n"
"def _hole_face_markers(bodies):\n"
" standard_holes = globals().get('StandardHoles')\n"
" if standard_holes is None:\n"
" return set()\n"
" faces = []\n"
" options = None\n"
" options_cls = globals().get('FindStandardHoleOptions')\n"
" if options_cls is not None:\n"
" try:\n"
" options = options_cls()\n"
" except Exception:\n"
" options = None\n"
" identified = []\n"
" find = getattr(standard_holes, 'Find', None)\n"
" if find is not None:\n"
" for args in ((bodies, options, None), (bodies, options), (options, None), (options,), (None,)):\n"
" try:\n"
" identified = _items(find(*args))\n"
" if identified:\n"
" break\n"
" except Exception:\n"
" pass\n"
" if identified:\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(identified))\n"
" except Exception:\n"
" faces = []\n"
" if not faces:\n"
" for hole in identified:\n"
" try:\n"
" faces.extend(_items(getattr(hole, 'Faces')))\n"
" except Exception:\n"
" pass\n"
" if faces:\n"
" return set(str(id(face)) for face in faces)\n"
" for args in ((bodies,), ()):\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(*args))\n"
" if faces:\n"
" break\n"
" except Exception:\n"
" pass\n"
" return set(str(id(face)) for face in faces)\n"
"\n"
"def _available_commands():\n"
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo', 'ChamferInfo', 'SlotInfo')\n"
" result = []\n"
" for name in names:\n"
" result.append({'name': name, 'available': globals().get(name) is not None})\n"
" return result\n"
"\n"
"def main():\n"
" job = json.load(open(JOB_PATH, 'r'))\n"
" model = job.get('model', {})\n"
" outputs = job.get('outputs', {})\n"
" raw_path = outputs.get('rawFeatures')\n"
" error_path = outputs.get('error')\n"
" try:\n"
" _open_step(model.get('path'))\n"
" root = _root_part()\n"
" bodies = _all_bodies(root)\n"
" component_entries = _component_entries(root)\n"
" component_body_locators = _component_body_locator_map(component_entries)\n"
" hole_face_markers = _hole_face_markers(bodies)\n"
" objects = []\n"
" face_adjacency = {}\n"
" edge_geometry_summary = {}\n"
" face_counter = 0\n"
" edge_counter = 0\n"
" for body_index, body in enumerate(bodies):\n"
" body_faces = _body_faces(body)\n"
" body_locators = _body_locators_for_body(component_body_locators, body, body_index)\n"
" component_locators = _component_locators_for_body(component_body_locators, body)\n"
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n"
" for face_index, face in enumerate(body_faces):\n"
" geometry = _geometry_from_face(face)\n"
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n"
" if object_type == 'face' and isinstance(geometry.get('slotInfo'), dict) and (geometry.get('depth') is not None or geometry.get('width') is not None):\n"
" object_type = 'slot'\n"
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n"
" object_type = 'round'\n"
" if object_type == 'face' and isinstance(geometry.get('chamferInfo'), dict) and geometry.get('chamferInfo', {}).get('distance') is not None:\n"
" object_type = 'chamfer'\n"
" topology_hint = {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" topology_hint['componentLocators'] = component_locators\n"
" if object_type == 'slot' and isinstance(geometry.get('slotInfo'), dict) and geometry.get('slotInfo', {}).get('depthFaceIsCurrent') is True:\n"
" topology_hint['depthFaceLocators'] = [dict(topology_hint)]\n"
" objects.append({\n"
" 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n"
" 'objectType': object_type,\n"
" 'geometry': geometry,\n"
" 'topologyHint': topology_hint,\n"
" 'backendCommandCandidates': _command_candidates(object_type, geometry),\n"
" 'rawLimitations': [],\n"
" })\n"
" face_counter += 1\n"
" for edge_index, edge in enumerate(_body_edges(body)):\n"
" geometry = _geometry_from_edge(edge)\n"
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" edge_topology['componentLocators'] = component_locators\n"
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n"
" _add_edge_geometry_summary(edge_geometry_summary, geometry)\n"
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n"
" objects.append({\n"
" 'backendId': 'body:%d/edge:%d' % (body_index, edge_index),\n"
" 'objectType': 'edge',\n"
" 'geometry': geometry,\n"
" 'topologyHint': edge_topology,\n"
" 'backendCommandCandidates': [],\n"
" 'rawLimitations': [],\n"
" })\n"
" edge_counter += 1\n"
" payload = {\n"
" 'schemaVersion': 1,\n"
" 'backend': job.get('backend', {}),\n"
" 'model': model,\n"
" 'scan': job.get('scan', {}),\n"
" 'objects': objects,\n"
" 'diagnostics': {\n"
" 'availableCommands': _available_commands(),\n"
" 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n"
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n"
" 'featureInventory': _feature_inventory(objects),\n"
" 'componentInstances': _component_inventory(component_entries),\n"
" },\n"
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers), 'componentCount': len(component_entries)},\n"
" }\n"
" _write_json(raw_path, payload)\n"
" except Exception as exc:\n"
" _write_json(error_path, {'ok': False, 'reason': 'probe-exception', 'message': str(exc), 'traceback': traceback.format_exc()})\n"
" raise\n"
"\n"
"main()\n"
)
__all__ = [
"generate_scdm_probe_script",
"prepare_scdm_probe_job",
"run_scdm_probe",
]
+819
View File
@@ -0,0 +1,819 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
def property_specs_from_scdm_cache(
cache: Mapping[str, object],
*,
selected_face_ids: Iterable[int] = (),
selected_edge_ids: Iterable[int] = (),
selected_solid_ids: Iterable[int] = (),
execution_ready: bool | Iterable[str] = False,
) -> list[dict[str, object]]:
face_ids = {int(item) for item in selected_face_ids}
edge_ids = {int(item) for item in selected_edge_ids}
solid_ids = {int(item) for item in selected_solid_ids}
if not face_ids and not edge_ids and not solid_ids:
return []
objects = cache.get("objects")
if not isinstance(objects, list):
return []
specs: list[dict[str, object]] = []
for item in objects:
if not isinstance(item, Mapping) or not _object_matches(
item,
face_ids=face_ids,
edge_ids=edge_ids,
solid_ids=solid_ids,
):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if isinstance(capability, Mapping):
if str(capability.get("key") or "") in {"pattern.segment_spacing", "pattern.instance_position"}:
continue
spec = _capability_spec(item, capability, execution_ready=execution_ready)
if spec is not None:
specs.append(spec)
specs.extend(
_pattern_instance_position_specs(
item,
selected_face_ids=face_ids,
selected_solid_ids=solid_ids,
execution_ready=execution_ready,
)
)
specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready))
return specs
def _object_matches(
raw_object: Mapping[str, object],
*,
face_ids: set[int],
edge_ids: set[int],
solid_ids: set[int],
) -> bool:
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return False
object_faces = set(_int_values(signature.get("faceIds")))
object_faces.update(_int_values(signature.get("supportFaceIds")))
object_edges = set(_int_values(signature.get("edgeIds")))
if (face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids):
return True
if not solid_ids:
return False
object_type = str(raw_object.get("objectType") or "").strip().lower()
if object_type not in {"pattern", "linear_pattern"}:
return False
return bool(_pattern_local_solid_ids(signature) & solid_ids)
def _capability_spec(
raw_object: Mapping[str, object],
capability: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> dict[str, object] | None:
key = str(capability.get("key") or "").strip()
label = str(capability.get("displayName") or key).strip()
if not key or not label:
return None
value_kind = str(capability.get("valueKind") or "number")
current = capability.get("currentValue")
value_type = _value_type(value_kind, key)
signature = raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {}
unit_scale = _unit_scale(signature if isinstance(signature, Mapping) else {})
current_display = _display_value(current, key=key, value_type=value_type, unit_scale=unit_scale)
command_value = value_type == "command"
current_text = "可执行" if command_value else _format_value(current_display, value_type=value_type)
target_text = "执行" if command_value else _format_value(current_display, value_type=value_type)
capability_block = str(capability.get("blockReason") or "").strip()
object_block = str(raw_object.get("blockReason") or "").strip()
block_reason = capability_block or object_block
if not command_value and not _current_value_available(current_display, value_type=value_type):
block_reason = block_reason or f"SCDM 已识别“{label}”,但没有返回可用于编辑的当前值。"
backend_operation = str(capability.get("backendOperation") or "")
post_check = str(capability.get("postCheck") or "")
max_value = _display_max_value(key=key, signature=signature if isinstance(signature, Mapping) else {}, unit_scale=unit_scale)
range_hint = "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。"
if key == "pattern.spacing" and max_value is not None:
range_hint = f"该阵列受承载面范围限制,保持阵列中心不变时最大间距约 {max_value:g};超过后会跑出承载面。"
can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason)
if can_execute:
disabled_tip = ""
enabled_tip = (
f"SCDM 已识别“{label}”可由 {backend_operation or '后端命令'} 修改;"
f"执行后会用 {post_check or '结果回测'} 校验。"
)
elif block_reason:
enabled_tip = ""
disabled_tip = f"SCDM 已识别该对象,但当前能力被阻止:{block_reason}"
else:
enabled_tip = ""
disabled_tip = "SCDM 已识别该参数,但 S5 修改执行器还没有接入;当前只作为后端识别结果缓存,不开放执行。"
return {
"key": f"scdm:{key}",
"label": label,
"current_raw": current_display if current_display is not None else "",
"scdm_current_raw": current if current is not None else "",
"scdm_unit_scale": unit_scale,
"current_text": current_text,
"target_text": target_text,
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": str(capability.get("defaultIntent") or "SCDM"),
"action": "apply_scdm_property_edit",
"value_type": value_type,
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"range_hint": range_hint,
"min_value": 0.0 if value_type == "positive" else None,
"min_exclusive": True if value_type == "positive" else False,
"max_value": max_value,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": key,
"scdm_backend_operation": backend_operation,
"scdm_post_check": post_check,
"scdm_geometry_signature": signature if isinstance(signature, Mapping) else {},
}
def _unit_scale(signature: Mapping[str, object]) -> float:
try:
value = float(str(signature.get("localUnitScale")).strip())
except (TypeError, ValueError):
return 1.0
return value if value > 0 else 1.0
def _display_value(value: object, *, key: str, value_type: str, unit_scale: float) -> object:
if unit_scale <= 0 or abs(unit_scale - 1.0) <= 1.0e-12 or not _uses_length_units(key, value_type):
return value
if value_type == "vector3":
values = _float_values(value)
if len(values) == 3:
return [item / unit_scale for item in values]
return value
try:
return float(str(value).strip()) / unit_scale
except (TypeError, ValueError):
return value
def _uses_length_units(key: str, value_type: str) -> bool:
if value_type == "vector3":
return True
suffixes = (
".diameter",
".radius",
".offset",
".width",
".depth",
".height",
".distance",
".thickness",
".spacing",
".segment_spacing",
".position",
)
return key.endswith(suffixes)
def _display_max_value(*, key: str, signature: Mapping[str, object], unit_scale: float) -> float | None:
if key != "pattern.spacing":
return None
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
value = fit.get("maxSpacingLocal")
try:
result = float(str(value).strip())
except (TypeError, ValueError):
backend_value = fit.get("maxSpacing")
try:
return float(str(backend_value).strip()) / unit_scale if unit_scale > 0 else None
except (TypeError, ValueError):
return None
return result if result > 0 else None
def _pattern_segment_spacing_specs(
raw_object: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() != "linear_pattern":
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
axis = _unit_vector(_float_values(signature.get("axis")))
if len(axis) != 3:
return []
instances = _sorted_pattern_instances(signature, axis)
if len(instances) < 2:
return []
unit_scale = _unit_scale(signature)
can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = []
for segment_index in range(len(instances) - 1):
# UI 上展示的是相邻实例之间的“段间距”,不是整列统一 spacing。
# 每一段都带自己的移动语义和安全范围,避免“第 1-2 间距”改成整列平移。
left = instances[segment_index]
right = instances[segment_index + 1]
left_label = _segment_instance_label(left, segment_index + 1)
right_label = _segment_instance_label(right, segment_index + 2)
segment_label = f"{left_label}-{right_label}间距"
current = max(0.0, float(right["projection"]) - float(left["projection"]))
if current <= 0:
continue
current_display = current / unit_scale if unit_scale > 0 else current
scope_modes = _segment_scope_modes(
signature,
instances,
segment_index,
current,
current_display,
unit_scale,
left_label=left_label,
right_label=right_label,
segment_label=segment_label,
can_execute=can_execute,
)
default_mode = scope_modes.get("fix_left_move_right", {}) if isinstance(scope_modes, Mapping) else {}
max_display = default_mode.get("max_value")
range_hint = str(default_mode.get("range_hint") or "")
enabled_tip = str(default_mode.get("enabled_tip") or range_hint)
segment_signature = default_mode.get("scdm_geometry_signature")
if not isinstance(segment_signature, Mapping):
segment_signature = _segment_signature(
signature,
segment_index,
current,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side="after",
motion_semantics="fix_left_move_right_group",
)
specs.append(
{
"key": f"scdm:pattern.segment_spacing:{segment_index}",
"label": segment_label,
"current_raw": current_display,
"scdm_current_raw": current,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="positive"),
"target_text": _format_value(current_display, value_type="positive"),
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": "固定前项,移动后侧",
"scope_modes": scope_modes,
"scope_default": "fix_left_move_right",
"action": "apply_scdm_property_edit",
"value_type": "positive",
"enabled_tip": enabled_tip,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"min_value": 0.0,
"min_exclusive": True,
"max_value": max_display,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.segment_spacing",
"scdm_backend_operation": "change_pattern_segment_spacing",
"scdm_post_check": "target_pattern_segment_spacing",
"scdm_geometry_signature": segment_signature,
}
)
return specs
def _pattern_instance_position_specs(
raw_object: Mapping[str, object],
*,
selected_face_ids: set[int],
selected_solid_ids: set[int],
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() not in {"pattern", "linear_pattern"}:
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
unit_scale = _unit_scale(signature)
can_execute = bool(_capability_execution_ready("pattern.instance_position", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = []
instances = _pattern_instances_in_original_order(signature)
for ordinal, instance in enumerate(instances, start=1):
if selected_face_ids and not (set(_int_values(instance.get("faceIds"))) & selected_face_ids):
continue
if selected_solid_ids and not (_instance_local_solid_ids(instance) & selected_solid_ids):
continue
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) != 3:
continue
label = _segment_instance_label({"source": instance}, ordinal)
current_display = [value / unit_scale for value in center] if unit_scale > 0 else list(center)
instance_signature = _pattern_instance_signature(signature, instance, unit_scale=unit_scale, label=label)
locatable = _pattern_instance_has_locator(instance_signature)
enabled = bool(can_execute and locatable)
disabled_tip = ""
if not can_execute:
disabled_tip = "SCDM 已识别该阵列实例,但当前修改执行器尚未开放。"
elif not locatable:
disabled_tip = "SCDM 已识别该阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定移动。"
range_hint = f"移动阵列实例:只平移 {label},不自动保持整体阵列等距;需要保持间距时请使用“阵列间距”或“局部间距”。"
specs.append(
{
"key": f"scdm:pattern.instance_position:{ordinal - 1}",
"label": f"{label}位置",
"current_raw": current_display,
"scdm_current_raw": center,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="vector3"),
"target_text": _format_value(current_display, value_type="vector3"),
"editable": True,
"enabled": enabled,
"status_text": "可修改" if enabled else "暂未接入",
"scope_text": "只移动该实例",
"action": "apply_scdm_property_edit",
"value_type": "vector3",
"enabled_tip": range_hint if enabled else "",
"disabled_tip": disabled_tip,
"range_hint": range_hint,
"min_value": None,
"min_exclusive": False,
"max_value": None,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.instance_position",
"scdm_backend_operation": "move_pattern_instance",
"scdm_post_check": "target_pattern_instance_center",
"scdm_geometry_signature": instance_signature,
}
)
return specs
def _pattern_instances_in_original_order(signature: Mapping[str, object]) -> list[Mapping[str, object]]:
value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)):
return []
return [item for item in value if isinstance(item, Mapping)]
def _pattern_instance_signature(
signature: Mapping[str, object],
instance: Mapping[str, object],
*,
unit_scale: float,
label: str,
) -> dict[str, object]:
result = dict(instance)
result["objectType"] = "pattern_instance"
result["displayLabel"] = label
result["patternObjectType"] = signature.get("objectType")
result["patternKind"] = signature.get("patternKind")
result["instanceKind"] = instance.get("instanceKind") or signature.get("instanceKind")
result["axis"] = signature.get("axis")
result["localUnitScale"] = unit_scale
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) == 3:
result["center"] = center
result["instanceCenter"] = center
return result
def _pattern_instance_has_locator(signature: Mapping[str, object]) -> bool:
if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"):
return True
if _int_values(signature.get("faceOrdinals")) or _int_values(signature.get("globalFaceOrdinals")):
return True
if _int_or_none(signature.get("faceOrdinal")) is not None or _int_or_none(signature.get("globalFaceOrdinal")) is not None:
return True
instance_kind = str(signature.get("instanceKind") or "").strip().lower()
return instance_kind in {"body", "part", "component"} and _int_or_none(signature.get("bodyIndex")) is not None
def _pattern_local_solid_ids(signature: Mapping[str, object]) -> set[int]:
ids = set(_int_values(signature.get("localSolidIds")))
local_solid = _int_or_none(signature.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
ids.update(_int_values(signature.get("bodyIndices")))
body_index = _int_or_none(signature.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
for instance in _pattern_instances_in_original_order(signature):
ids.update(_instance_local_solid_ids(instance))
return ids
def _instance_local_solid_ids(instance: Mapping[str, object]) -> set[int]:
ids = set(_int_values(instance.get("localSolidIds")))
local_solid = _int_or_none(instance.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
body_index = _int_or_none(instance.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
return ids
def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]:
value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)):
return []
result: list[dict[str, object]] = []
for index, item in enumerate(value):
if not isinstance(item, Mapping):
continue
center = _float_values(item.get("center") or item.get("instanceCenter"))
if len(center) != 3:
continue
result.append(
{
"index": index,
"source": item,
"center": center,
"projection": _point_projection(center, axis),
}
)
# 用阵列轴投影排序,比原始 cache 顺序更接近用户看到的左到右/前到后顺序。
result.sort(key=lambda item: float(item["projection"]))
return result
def _segment_max_spacing_display(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_display: float,
unit_scale: float,
*,
moving_side: str = "after",
) -> float | None:
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
projection_min = _float_or_none(fit.get("supportProjectionMinLocal"))
projection_max = _float_or_none(fit.get("supportProjectionMaxLocal"))
member_span = _float_or_none(fit.get("memberSpanLocal"))
if projection_min is None or projection_max is None or member_span is None or member_span <= 0:
return None
first_projection = float(instances[0]["projection"])
last_projection = float(instances[-1]["projection"])
first_projection_display = first_projection / unit_scale if unit_scale > 0 else first_projection
last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection
backward_capacity = first_projection_display - projection_min - (member_span * 0.5)
forward_capacity = projection_max - (member_span * 0.5) - last_projection_display
if moving_side in {"before", "left", "single_left", "only_left"}:
extra = backward_capacity
elif moving_side in {"split", "both", "center"}:
extra = 2.0 * min(backward_capacity, forward_capacity)
else:
extra = forward_capacity
return max(current_display, current_display + max(0.0, extra))
def _segment_scope_modes(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_backend: float,
current_display: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
segment_label: str,
can_execute: bool,
) -> dict[str, dict[str, object]]:
modes: dict[str, dict[str, object]] = {}
# 同一个“间距”参数有多种建模意图:固定哪一侧、是否保持中心。
# 这些模式会直接进入 scdm_edit_job,不能只作为 UI 文案存在。
for key, label, moving_side, semantics, description in (
(
"fix_left_move_right",
"固定前项,移动后侧",
"after",
"fix_left_move_right_group",
f"固定 {left_label},平移 {right_label} 及其右侧所有阵列成员,右侧已有间距保持不变。",
),
(
"fix_right_move_left",
"固定后项,移动前侧",
"before",
"fix_right_move_left_group",
f"固定 {right_label},平移 {left_label} 及其左侧所有阵列成员,左侧已有间距保持不变。",
),
(
"split_keep_center",
"两侧均分,中心不变",
"split",
"split_groups_keep_segment_center",
f"{left_label} 及左侧向前移动一半,{right_label} 及右侧向后移动一半,保持这段间距中心不变。",
),
):
max_display = _segment_max_spacing_display(
signature,
instances,
segment_index,
current_display,
unit_scale,
moving_side=moving_side,
)
mode_signature = _segment_signature(
signature,
segment_index,
current_backend,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side=moving_side,
motion_semantics=semantics,
max_display=max_display,
)
range_hint = f"{label}{description} 对象段:{segment_label},沿阵列方向由 {left_label}{right_label}"
if max_display is not None and max_display > 0:
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}"
modes[key] = {
"label": label,
"enabled": can_execute,
"enabled_tip": range_hint,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
}
for key, label, moving_side, semantics, moved_label, neighbor_warning in (
(
"move_single_left",
"只移动前项",
"single_left",
"move_only_left_instance",
left_label,
"会改变它与左侧相邻成员的距离",
),
(
"move_single_right",
"只移动后项",
"single_right",
"move_only_right_instance",
right_label,
"会改变它与右侧相邻成员的距离",
),
):
max_display = _segment_max_spacing_display(
signature,
instances,
segment_index,
current_display,
unit_scale,
moving_side=moving_side,
)
mode_signature = _segment_signature(
signature,
segment_index,
current_backend,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side=moving_side,
motion_semantics=semantics,
max_display=max_display,
)
range_hint = (
f"{label}:只平移 {moved_label},把 {left_label}-{right_label} 这段调到目标间距;"
f"{neighbor_warning},不用于保持整列等距。对象段:{segment_label}"
)
if max_display is not None and max_display > 0:
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}"
modes[key] = {
"label": label,
"enabled": can_execute,
"enabled_tip": range_hint if can_execute else "",
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
}
return modes
def _segment_signature(
signature: Mapping[str, object],
segment_index: int,
current_backend: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
moving_side: str,
motion_semantics: str,
max_display: object = None,
) -> dict[str, object]:
result = dict(signature)
segment_fit = dict(result.get("supportPatternFit") if isinstance(result.get("supportPatternFit"), Mapping) else {})
max_number = _float_or_none(max_display)
if max_number is not None and max_number > 0:
segment_fit["maxSegmentSpacingLocal"] = max_number
segment_fit["maxSegmentSpacing"] = max_number * unit_scale if unit_scale > 0 else max_number
result["supportPatternFit"] = segment_fit
result["segmentIndex"] = segment_index
result["segmentLabel"] = f"{left_label}-{right_label}"
result["segmentLeftLabel"] = left_label
result["segmentRightLabel"] = right_label
result["segmentSpacing"] = current_backend
result["movingSide"] = moving_side
result["motionSemantics"] = motion_semantics
axis = _unit_vector(_float_values(signature.get("axis")))
instances = _sorted_pattern_instances(signature, axis) if len(axis) == 3 else []
if segment_index < len(instances) - 1:
result["segmentLeft"] = _segment_instance_reference(instances[segment_index], label=left_label)
result["segmentRight"] = _segment_instance_reference(instances[segment_index + 1], label=right_label)
result["localUnitScale"] = unit_scale
return result
def _segment_instance_reference(item: Mapping[str, object], *, label: str = "") -> dict[str, object]:
source = item.get("source")
if not isinstance(source, Mapping):
return {}
return {
"displayLabel": label,
"sourceObjectId": source.get("sourceObjectId"),
"faceIds": _int_values(source.get("faceIds")),
"bodyIndex": _int_or_none(source.get("bodyIndex")),
"componentLocators": source.get("componentLocators") or source.get("bodyLocators") or [],
}
def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
source = item.get("source")
if not isinstance(source, Mapping):
return f"阵列成员{ordinal}"
instance_kind = str(source.get("instanceKind") or "").strip().lower()
if instance_kind in {"body", "part", "component"}:
local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")])))
if local_solid_ids:
return f"Solid{local_solid_ids[0]}{'' if len(local_solid_ids) > 1 else ''}"
local_part_ids = sorted(set(_int_values(source.get("localPartIds") or [source.get("localPartId")])))
if local_part_ids:
return f"Part{local_part_ids[0]}{'' if len(local_part_ids) > 1 else ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"), include_index=False)
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"Solid{body_index}"
face_ids = sorted(set(_int_values(source.get("faceIds"))))
if face_ids:
return f"Face{face_ids[0]}{'' if len(face_ids) > 1 else ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"))
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"Solid{body_index}"
source_id = str(source.get("sourceObjectId") or "").strip()
if source_id:
return source_id
return f"阵列成员{ordinal}"
def _component_locator_label(value: object, *, include_index: bool = True) -> str:
if not isinstance(value, (list, tuple)):
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
for key in ("componentName", "name", "displayName"):
text = str(locator.get(key) or "").strip()
if text:
return text
if not include_index:
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
component_index = _int_or_none(locator.get("componentIndex"))
if component_index is not None:
return f"组件{component_index + 1}"
return ""
def _unit_vector(values: list[float]) -> list[float]:
if len(values) != 3:
return []
length = sum(item * item for item in values) ** 0.5
if length <= 1.0e-12:
return []
return [item / length for item in values]
def _point_projection(point: list[float], axis: list[float]) -> float:
return sum(float(point[index]) * float(axis[index]) for index in range(3))
def _float_or_none(value: object) -> float | None:
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _value_type(value_kind: str, key: str) -> str:
if value_kind == "vector3":
return "vector3"
if value_kind == "command":
return "command"
positive_suffixes = (".diameter", ".radius", ".width", ".depth", ".height", ".distance", ".thickness", ".spacing", ".segment_spacing")
if key.endswith(positive_suffixes):
return "positive"
return "number"
def _capability_execution_ready(key: str, execution_ready: bool | Iterable[str]) -> bool:
if isinstance(execution_ready, bool):
return execution_ready
try:
return key in {str(item) for item in execution_ready}
except TypeError:
return False
def _format_value(value: object, *, value_type: str) -> str:
if value is None:
return ""
if value_type == "vector3":
values = _float_values(value)
return f"({values[0]:g}, {values[1]:g}, {values[2]:g})" if len(values) == 3 else ""
if isinstance(value, float):
return f"{value:g}"
return str(value)
def _current_value_available(value: object, *, value_type: str) -> bool:
if value is None or value == "":
return False
if value_type == "vector3":
return len(_float_values(value)) == 3
if value_type in {"number", "positive"}:
try:
return float(str(value).strip()) > 0 if value_type == "positive" else True
except (TypeError, ValueError):
return False
return True
def _float_values(value: object) -> list[float]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[float] = []
for item in values[:3]:
try:
result.append(float(item))
except (TypeError, ValueError):
return []
return result
def _int_values(value: object) -> list[int]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[int] = []
for item in values:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
__all__ = ["property_specs_from_scdm_cache"]
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
SCDM_RAW_SCHEMA_VERSION = 1
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmProbeJob:
step_path: Path
output_dir: Path
raw_features_path: Path
error_path: Path
model_fingerprint: str
unit: str = "model"
scan_scope: str = "all"
adapter: str = "spaceclaim-v1"
backend_path: str = ""
backend_version: str = ""
created_at: str = ""
def to_payload(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_RAW_SCHEMA_VERSION,
"adapter": self.adapter,
"createdAt": self.created_at or utc_now(),
"backend": {
"name": "SCDM",
"path": self.backend_path,
"version": self.backend_version,
},
"model": {
"path": str(self.step_path),
"fingerprint": self.model_fingerprint,
"unit": self.unit,
},
"scan": {
"scope": self.scan_scope,
},
"outputs": {
"rawFeatures": str(self.raw_features_path),
"error": str(self.error_path),
},
}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def file_fingerprint(path: str | Path) -> str:
source = Path(path)
digest = hashlib.sha256()
stat = source.stat()
digest.update(str(source.resolve(strict=False)).encode("utf-8", errors="replace"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(str(stat.st_mtime_ns).encode("ascii"))
with source.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_json(path: str | Path) -> dict[str, object]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"JSON payload must be an object: {path}")
return payload
def write_json(path: str | Path, payload: Mapping[str, object]) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(dict(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return target
def default_scdm_work_dir(
step_path: str | Path,
*,
project_root: str | Path | None = None,
fingerprint: str | None = None,
) -> Path:
root = Path(project_root).expanduser() if project_root else Path(__file__).resolve().parent.parent
source = Path(step_path)
short = (fingerprint or file_fingerprint(source))[:12]
return root / "local" / "scdm" / f"{source.stem}_{short}"
def payload_model_fingerprint(payload: Mapping[str, object]) -> str:
model = payload.get("model")
if not isinstance(model, Mapping):
return ""
return str(model.get("fingerprint") or "")
def payload_backend_version(payload: Mapping[str, object]) -> str:
backend = payload.get("backend")
if not isinstance(backend, Mapping):
return ""
return str(backend.get("version") or "")
__all__ = [
"SCDM_CACHE_SCHEMA_VERSION",
"SCDM_RAW_SCHEMA_VERSION",
"ScdmProbeJob",
"default_scdm_work_dir",
"file_fingerprint",
"payload_backend_version",
"payload_model_fingerprint",
"read_json",
"utc_now",
"write_json",
]
+570
View File
@@ -0,0 +1,570 @@
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from .scdm_backend import ScdmBackendInfo, is_scdm_disabled, load_scdm_backend_cache
from .scdm_capabilities import CAPABILITY_DEFINITIONS, ScdmCapabilityDefinition
def cached_scdm_backend_payload(project_root: str | Path | None = None) -> dict[str, object] | None:
if is_scdm_disabled():
return {"disabled": True, "reason": "disabled", "message": "SCDM backend is disabled by environment."}
backend = load_scdm_backend_cache(project_root_override=project_root)
return backend.to_cache() if backend is not None else None
def summarize_scdm_runtime(
*,
backend: ScdmBackendInfo | Mapping[str, object] | None = None,
cache_state: str = "",
cache_message: str = "",
feature_cache: Mapping[str, object] | None = None,
) -> dict[str, object]:
backend_payload = _backend_payload(backend)
disabled = _backend_disabled(backend)
state = str(cache_state or "empty").strip().lower()
message = _compact(str(cache_message or "").strip(), 120)
count = _feature_cache_counts(feature_cache)
if disabled:
headline = "SCDM:已关闭,当前使用 OCCT/Analysis Situs 兜底"
path = ""
elif backend_payload:
version = str(backend_payload.get("version") or "").strip()
source = _source_label(str(backend_payload.get("source") or "").strip())
version_text = f" {version}" if version else ""
headline = f"SCDM:已配置{version_text}{source}"
path = str(backend_payload.get("path") or "").strip()
else:
headline = "SCDM:未配置,当前可用 OCCT/Analysis Situs 兜底"
path = ""
if disabled:
detail = "检测到 SCDM 禁用开关;本次不会启动 SpaceClaim.exe。"
elif state == "running":
detail = "正在后台识别可修改参数;界面可继续旋转查看模型。"
elif state == "ready":
object_text = f"{count['objects']} 个对象" if count["objects"] else "0 个对象"
capability_text = f"{count['capabilities']} 项能力" if count["capabilities"] else "0 项能力"
detail = f"识别缓存已就绪:{object_text}{capability_text}"
elif state == "failed":
reason = message or "未拿到 SCDM 识别结果"
detail = f"识别未启用:{reason};当前使用本软件已有能力。"
elif state == "deferred":
detail = message or "已延后 SCDM 全量识别,优先保证导入显示、旋转和点选流畅。"
elif state == "stale":
detail = message or "缓存已失效,用户选择对象后会按需重新识别。"
else:
detail = "导入 STEP 后先显示模型;用户选择对象后再按需启动 SCDM 识别。"
tooltip_lines = [headline, detail]
if path:
tooltip_lines.append(f"路径:{path}")
if backend_payload:
run_script_ok = backend_payload.get("runScriptOk")
license_ok = backend_payload.get("licenseOk")
tooltip_lines.append(f"/RunScript{_ok_text(run_script_ok)}")
tooltip_lines.append(f"许可证:{_ok_text(license_ok)}")
return {
"headline": headline,
"detail": detail,
"tooltip": "\n".join(line for line in tooltip_lines if line),
"backendReady": bool(backend_payload) and not disabled,
"cacheState": state,
"objectCount": count["objects"],
"capabilityCount": count["capabilities"],
}
def summarize_scdm_capability_progress(
*,
feature_cache: Mapping[str, object] | None = None,
execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False,
) -> dict[str, object]:
ready_keys = _execution_ready_keys(execution_ready)
# 这里把“识别到”“计划中”“几何 hint”和“可执行”分开统计。
# 客户界面只展示能稳定解释的进度,不能把 SCDM/raw hint 直接包装成可改参数。
detection_counts = _cache_capability_counts(feature_cache)
blocked_counts = _cache_blocked_capability_counts(feature_cache)
planned_counts = _planned_capability_counts(feature_cache)
hint_counts = _geometry_candidate_hint_counts(feature_cache)
discovered_summary = _discovered_not_productized_summary(feature_cache)
probe_evidence = _probe_evidence_summary(feature_cache)
rows: list[dict[str, object]] = []
for key, definition in sorted(CAPABILITY_DEFINITIONS.items(), key=lambda item: (_stage_sort_key(item[1].roadmap_stage), item[0])):
detected = int(detection_counts.get(key, 0))
blocked = int(blocked_counts.get(key, 0))
planned_detected = int(planned_counts.get(key, 0))
hint_detected = int(hint_counts.get(key, 0))
runner_ready = _capability_runner_ready(key, execution_ready, ready_keys)
status, reason = _capability_progress_status(
key,
definition,
detected=detected,
blocked=blocked,
planned_detected=planned_detected,
hint_detected=hint_detected,
runner_ready=runner_ready,
)
executable = detected if definition.productized and runner_ready else 0
if blocked:
executable = max(0, executable - blocked)
rows.append(
{
"key": key,
"displayName": definition.display_name,
"roadmapStage": definition.roadmap_stage,
"productized": definition.productized,
"runnerReady": runner_ready,
"detectedCount": detected,
"plannedDetectedCount": planned_detected,
"hintDetectedCount": hint_detected,
"blockedCount": blocked,
"executableCount": executable,
"status": status,
"reason": reason,
}
)
executable_count = sum(int(row["executableCount"]) for row in rows)
productized_count = sum(1 for row in rows if bool(row["productized"]))
runner_ready_count = sum(1 for row in rows if bool(row["productized"]) and bool(row["runnerReady"]))
planned_detected_total = sum(int(row["plannedDetectedCount"]) for row in rows)
hint_detected_total = sum(int(row["hintDetectedCount"]) for row in rows)
blocked_total = sum(int(row["blockedCount"]) for row in rows)
return {
"rows": rows,
"summary": {
"defined": len(rows),
"productized": productized_count,
"runnerReady": runner_ready_count,
"detectedCapabilities": sum(int(row["detectedCount"]) for row in rows),
"executableCapabilities": executable_count,
"plannedDetected": planned_detected_total,
"geometryHints": hint_detected_total,
"backendBlocked": blocked_total,
"discoveredNotProductized": discovered_summary["count"],
"faceAdjacency": probe_evidence["faceAdjacency"],
"circularEdges": probe_evidence["circularEdges"],
"inventoryObjectTypes": probe_evidence["inventoryObjectTypes"],
"inventoryOperationCandidates": probe_evidence["inventoryOperationCandidates"],
"derivedFeatureCandidates": probe_evidence["derivedFeatureCandidates"],
},
"productizedLines": _capability_progress_lines(
row for row in rows if bool(row["productized"])
),
"plannedLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and (
int(row["plannedDetectedCount"]) > 0
or int(row["detectedCount"]) > 0
or int(row["hintDetectedCount"]) > 0
)
),
"roadmapLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and int(row["plannedDetectedCount"]) <= 0
and int(row["detectedCount"]) <= 0
and int(row["hintDetectedCount"]) <= 0
),
"discoveredNotProductized": discovered_summary,
"probeEvidence": probe_evidence,
}
def _backend_payload(backend: ScdmBackendInfo | Mapping[str, object] | None) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
return backend.to_cache()
if not isinstance(backend, Mapping):
return {}
nested = backend.get("backend")
if isinstance(nested, ScdmBackendInfo):
return nested.to_cache()
if isinstance(nested, Mapping):
return _backend_payload(nested)
path = str(backend.get("path") or "").strip()
if not path:
return {}
return {
"path": path,
"source": str(backend.get("source") or ""),
"version": str(backend.get("version") or ""),
"verifiedAt": str(backend.get("verifiedAt") or ""),
"runScriptOk": backend.get("runScriptOk"),
"licenseOk": backend.get("licenseOk"),
"message": str(backend.get("message") or ""),
}
def _backend_disabled(backend: ScdmBackendInfo | Mapping[str, object] | None) -> bool:
return isinstance(backend, Mapping) and bool(backend.get("disabled"))
def _feature_cache_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
if not isinstance(feature_cache, Mapping):
return {"objects": 0, "capabilities": 0}
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return {"objects": 0, "capabilities": 0}
capability_count = 0
object_count = 0
for item in objects:
if not isinstance(item, Mapping):
continue
object_count += 1
capabilities = item.get("capabilities")
if isinstance(capabilities, list):
capability_count += sum(1 for capability in capabilities if isinstance(capability, Mapping))
return {"objects": object_count, "capabilities": capability_count}
def _cache_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _cache_blocked_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
object_block = str(item.get("blockReason") or "").strip()
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if not key:
continue
capability_block = str(capability.get("blockReason") or "").strip()
if object_block or capability_block or capability.get("editable") is False:
result[key] = result.get(key, 0) + 1
return result
def _planned_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
planned = diagnostics.get("planned_not_productized")
result: dict[str, int] = {}
if not isinstance(planned, list):
return result
for item in planned:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _geometry_candidate_hint_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
hints = diagnostics.get("geometry_candidate_hints")
result: dict[str, int] = {}
if not isinstance(hints, list):
return result
for item in hints:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = _int_value(item.get("evidenceCount"))
result[key] = result.get(key, 0) + max(count, 1)
return result
def _discovered_not_productized_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
discovered = diagnostics.get("discovered_not_productized")
by_type: dict[str, int] = {}
if not isinstance(discovered, list):
return {"count": 0, "byObjectType": {}, "lines": []}
for item in discovered:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
by_type[object_type] = by_type.get(object_type, 0) + 1
lines = [f"{name}{count}" for name, count in sorted(by_type.items(), key=lambda item: (-item[1], item[0]))[:6]]
return {"count": sum(by_type.values()), "byObjectType": by_type, "lines": lines}
def _probe_evidence_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
face_adjacency = diagnostics.get("face_adjacency")
edge_summary = diagnostics.get("edge_geometry_summary")
feature_inventory = diagnostics.get("feature_inventory")
adjacency_count = len(face_adjacency) if isinstance(face_adjacency, list) else 0
edge_summary = edge_summary if isinstance(edge_summary, Mapping) else {}
feature_inventory = feature_inventory if isinstance(feature_inventory, Mapping) else {}
edge_kind_counts = edge_summary.get("edgeKindCounts")
edge_kind_counts = edge_kind_counts if isinstance(edge_kind_counts, Mapping) else {}
object_type_counts = _mapping_count_dict(feature_inventory.get("objectTypeCounts"))
surface_type_counts = _mapping_count_dict(feature_inventory.get("surfaceTypeCounts"))
operation_counts = _mapping_count_dict(feature_inventory.get("operationCounts"))
geometry_hints = diagnostics.get("geometry_candidate_hints")
geometry_hint_lines = _geometry_candidate_hint_lines(geometry_hints)
derived_candidates = diagnostics.get("derived_feature_candidates")
derived_candidate_lines = _derived_feature_candidate_lines(derived_candidates)
derived_candidate_count = len(derived_candidates) if isinstance(derived_candidates, list) else 0
circular_edges = _int_value(edge_summary.get("circularEdgeCount"))
if circular_edges <= 0:
circular_edges = _int_value(edge_kind_counts.get("circular"))
linear_edges = _int_value(edge_kind_counts.get("linear"))
total_edges = _int_value(edge_summary.get("totalEdgeCount"))
radius_buckets = edge_summary.get("circularRadiusBuckets")
radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0
lines = []
if adjacency_count:
lines.append(f"Face 邻接 {adjacency_count}")
if total_edges:
lines.append(f"Edge {total_edges}")
if circular_edges:
lines.append(f"圆边 {circular_edges}")
if linear_edges:
lines.append(f"直边 {linear_edges}")
if radius_bucket_count:
lines.append(f"圆边半径分组 {radius_bucket_count}")
object_lines = _count_summary_lines(object_type_counts, label="对象")
surface_lines = _count_summary_lines(surface_type_counts, label="曲面")
operation_lines = _count_summary_lines(operation_counts, label="命令候选")
lines.extend(object_lines[:2])
lines.extend(surface_lines[:2])
lines.extend(operation_lines[:2])
lines.extend(derived_candidate_lines[:3])
lines.extend(geometry_hint_lines[:4])
return {
"faceAdjacency": adjacency_count,
"totalEdges": total_edges,
"circularEdges": circular_edges,
"linearEdges": linear_edges,
"radiusBucketCount": radius_bucket_count,
"inventoryObjectTypes": sum(object_type_counts.values()),
"inventorySurfaceTypes": sum(surface_type_counts.values()),
"inventoryOperationCandidates": sum(operation_counts.values()),
"derivedFeatureCandidates": derived_candidate_count,
"derivedFeatureCandidateLines": derived_candidate_lines,
"objectTypeCounts": object_type_counts,
"surfaceTypeCounts": surface_type_counts,
"operationCounts": operation_counts,
"geometryHintLines": geometry_hint_lines,
"lines": lines,
}
def _derived_feature_candidate_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
counts: dict[str, int] = {}
for item in value:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
counts[object_type] = counts.get(object_type, 0) + 1
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
return [f"派生候选 {name}:{count}" for name, count in rows]
def _geometry_candidate_hint_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
best: dict[str, dict[str, object]] = {}
for item in value:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = max(_int_value(item.get("evidenceCount")), 1)
existing = best.get(key)
if existing is None or count > int(existing.get("evidenceCount") or 0):
best[key] = {
"displayName": str(item.get("displayName") or key),
"evidenceCount": count,
"confidence": str(item.get("confidence") or ""),
}
rows = sorted(best.items(), key=lambda item: (-int(item[1].get("evidenceCount") or 0), item[0]))[: max(1, int(limit))]
return [
f"几何候选 {payload['displayName']}:{payload['evidenceCount']}{payload['confidence'] or 'unknown'}"
for _key, payload in rows
]
def _mapping_count_dict(value: object) -> dict[str, int]:
if not isinstance(value, Mapping):
return {}
result: dict[str, int] = {}
for key, count in value.items():
text = str(key or "").strip() or "unknown"
number = _int_value(count)
if number > 0:
result[text] = number
return result
def _count_summary_lines(counts: Mapping[str, int], *, label: str, limit: int = 4) -> list[str]:
if not counts:
return []
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
summary = "".join(f"{name}:{count}" for name, count in rows)
return [f"{label}分布 {summary}"]
def _int_value(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _cache_diagnostics(feature_cache: Mapping[str, object] | None) -> Mapping[str, object]:
if not isinstance(feature_cache, Mapping):
return {}
diagnostics = feature_cache.get("diagnostics")
return diagnostics if isinstance(diagnostics, Mapping) else {}
def _execution_ready_keys(execution_ready: bool | set[str] | list[str] | tuple[str, ...]) -> set[str]:
if isinstance(execution_ready, bool):
return set()
try:
return {str(item) for item in execution_ready}
except TypeError:
return set()
def _capability_runner_ready(
key: str,
execution_ready: bool | set[str] | list[str] | tuple[str, ...],
ready_keys: set[str],
) -> bool:
return bool(execution_ready) if isinstance(execution_ready, bool) else key in ready_keys
def _capability_progress_status(
key: str,
definition: ScdmCapabilityDefinition,
*,
detected: int,
blocked: int,
planned_detected: int,
hint_detected: int,
runner_ready: bool,
) -> tuple[str, str]:
if definition.productized and runner_ready and detected > blocked:
return "已开放", "已识别到对象时会显示在特征参数表。"
if definition.productized and runner_ready and blocked:
return "已开放但被后端阻止", "当前模型里识别到该能力,但 SCDM 命令、对象状态或安全守门暂时阻止执行。"
if definition.productized and runner_ready:
return "已开放待识别", "执行链路已接入,当前 cache 还没有识别到可执行对象。"
if definition.productized and detected:
return "已识别待执行器", "能力已进入产品字典,但当前 UI 执行器还未开放。"
if definition.productized:
return "已产品化待对象", "能力已定义,等待 SCDM 在当前模型中识别到对象。"
if planned_detected or detected:
return "已识别待验证", definition.block_reason or "已识别到候选,但还没有完成真实 STEP 回测。"
if hint_detected:
return "几何证据待分类", "SCDM probe 已看到相关曲面/边/命令线索,但还没有确认成可执行特征对象。"
return "路线中待接入", definition.block_reason or f"{key} 还没有接入可执行闭环。"
def _capability_progress_lines(rows: object) -> list[str]:
result: list[str] = []
for row in rows: # type: ignore[assignment]
if not isinstance(row, Mapping):
continue
display = str(row.get("displayName") or row.get("key") or "").strip()
status = str(row.get("status") or "").strip()
detected = int(row.get("detectedCount") or 0)
planned = int(row.get("plannedDetectedCount") or 0)
hinted = int(row.get("hintDetectedCount") or 0)
blocked = int(row.get("blockedCount") or 0)
suffix_parts = []
if detected:
suffix_parts.append(f"cache {detected}")
if planned:
suffix_parts.append(f"候选 {planned}")
if hinted:
suffix_parts.append(f"证据 {hinted}")
if blocked:
suffix_parts.append(f"阻止 {blocked}")
suffix = f"{''.join(suffix_parts)}" if suffix_parts else ""
result.append(f"- {display}{status}{suffix}")
return result
def _stage_sort_key(stage: str) -> tuple[int, int, str]:
text = str(stage or "")
numbers: list[int] = []
for part in text.replace("S", "").split("."):
try:
numbers.append(int(part))
except ValueError:
pass
while len(numbers) < 2:
numbers.append(0)
return numbers[0], numbers[1], text
def _source_label(source: str) -> str:
if source.startswith("registry:"):
return "注册表"
if source.startswith("env:"):
return "环境变量"
if source.startswith("common:"):
return "常见安装目录"
if source.lower() == "path":
return "PATH"
if source == "manual":
return "手动配置"
if source == "cache":
return "缓存"
return source or "未知来源"
def _ok_text(value: object) -> str:
if value is True:
return "可用"
if value is False:
return "不可用"
return "未验证"
def _compact(text: str, limit: int) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: max(limit - 1, 0)].rstrip() + ""
__all__ = ["cached_scdm_backend_payload", "summarize_scdm_capability_progress", "summarize_scdm_runtime"]
+83 -2
View File
@@ -33,6 +33,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"feature_source_face_id",
],
),
(
"SCDM",
[
"scdm_backend_status",
"scdm_runtime_status",
"scdm_selection_status",
"scdm_selection_enabled_capabilities",
"scdm_selection_blocked_capabilities",
"scdm_selection_capability_count",
"scdm_selection_blocked_count",
],
),
(
"拓扑",
[
@@ -529,6 +541,13 @@ INFO_LABELS = {
"associated_feature_count": "关联特征数",
"associated_feature_face_ids": "关联特征 Face",
"feature_context_note": "关联探测",
"scdm_backend_status": "SCDM 后端",
"scdm_runtime_status": "SCDM 运行状态",
"scdm_selection_status": "SCDM 当前选择",
"scdm_selection_enabled_capabilities": "SCDM 可执行能力",
"scdm_selection_blocked_capabilities": "SCDM 未开放能力",
"scdm_selection_capability_count": "SCDM 能力数量",
"scdm_selection_blocked_count": "SCDM 未开放数量",
"recognition_summary": "识别摘要",
"recognition_candidate": "识别候选",
"recognition_confidence": "识别置信度",
@@ -540,6 +559,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": "识别限制",
@@ -1071,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
SELECTION_MODE_LABELS = {
"Part": "零件",
"Part": "Part",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"Feature": "特征",
"Feature": "Feature",
}
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
SELECTION_MODE_VALUES.update(
{
"装配零件": "Part",
"零件": "Part",
"实体": "Solid",
"": "Face",
"": "Edge",
"智能特征": "Feature",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"特征": "Feature",
}
)
SURFACE_VALUE_LABELS = {
@@ -1163,6 +1211,39 @@ def _smooth_surface_polydata(polydata):
return smoothed
def _large_model_display_deflection(
requested: float,
*,
face_count: int = 0,
edge_count: int = 0,
) -> float:
"""Use a coarser display mesh for large STEP interaction only."""
value = max(float(requested), 1e-9)
if int(face_count or 0) > 1000 or int(edge_count or 0) > 2500:
return max(value, 1.2)
if int(face_count or 0) > 600 or int(edge_count or 0) > 1600:
return max(value, 0.6)
return value
def _large_model_display_deflection_for_model(model: object, requested: float) -> float:
faces = getattr(model, "faces", ()) or ()
edges = getattr(model, "edges", ()) or ()
return _large_model_display_deflection(
requested,
face_count=len(faces),
edge_count=len(edges),
)
def _large_model_display_deflection_for_stats(stats: object, requested: float) -> float:
return _large_model_display_deflection(
requested,
face_count=int(getattr(stats, "faces", 0) or 0),
edge_count=int(getattr(stats, "edges", 0) or 0),
)
def _format_percent(value: object) -> str:
if value is None or value == "":
return ""
+567 -25
View File
@@ -30,6 +30,7 @@ from .geometry_utils import (
_tuple_sub,
_vector_length,
)
from .parametric_component import component_name_from_step, default_component_root, export_parametric_component
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
@@ -73,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] + "..."
@@ -98,6 +106,7 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
"validate": "结果校验",
"display_faces": "面显示",
"display_edges": "边线",
"result_face_mapping": "结果Face定位",
"finish_ui": "界面刷新",
"total": "总计",
}
@@ -122,6 +131,22 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
return "".join(parts)
def _start_background_thread(thread: QThread, priority: QThread.Priority = QThread.Priority.LowPriority) -> None:
try:
thread.start(priority)
except TypeError:
thread.start()
def _isolated_process_creation_flags() -> int:
if sys.platform != "win32":
return 0
flags = 0
for name in ("CREATE_NO_WINDOW", "BELOW_NORMAL_PRIORITY_CLASS"):
flags |= int(getattr(subprocess, name, 0))
return flags
class WindowActionMixin:
def _empty_edge_polydata(self):
polydata = vtk.vtkPolyData()
@@ -151,11 +176,49 @@ class WindowActionMixin:
except OSError as exc:
QMessageBox.warning(self, "导出参数失败", f"无法写入 {output_path.name}{exc}")
return
component_path: Path | None = None
component_warning = ""
try:
component_path = self._export_parametric_component_main(rows)
except Exception as exc:
component_warning = str(exc)
if component_path is not None:
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数,并生成组件 {component_path.parent.name}")
else:
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数到 {output_path.name}")
if hasattr(self, "set_plain_info"):
names = "".join(str(row.get("displayName", "")) for row in rows[:8] if row.get("displayName"))
suffix = "……" if len(rows) > 8 else ""
self.set_plain_info(f"已导出参数文件:{output_path}\n参数数量:{len(rows)}\n参数:{names}{suffix}")
lines = [
f"已导出参数文件:{output_path}",
f"参数数量:{len(rows)}",
f"参数:{names}{suffix}",
]
if component_path is not None:
lines.extend(
[
f"已生成参数化组件:{component_path.parent}",
f"组件入口:{component_path.name}",
]
)
elif component_warning:
lines.append(f"组件生成未完成:{component_warning}")
self.set_plain_info("\n".join(lines))
def _export_parametric_component_main(self, rows: list[dict[str, str]]) -> Path:
edits = self._selected_parameter_component_edits(rows) if hasattr(self, "_selected_parameter_component_edits") else []
if not edits:
raise ValueError("当前勾选参数还不能映射为可执行 STEP 编辑操作。")
source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
if source_step is None or not source_step.is_file():
raise ValueError("当前模型缺少可复用的 STEP 文件路径,请先导入 STEP 模型。")
return export_parametric_component(
parameters=rows,
edits=edits,
source_step=source_step,
component_root=default_component_root(Path(__file__).resolve().parent.parent),
component_name=component_name_from_step(source_step),
)
def export_all(self) -> None:
if self.model is None:
@@ -445,7 +508,14 @@ class WindowActionMixin:
if plan["status"] == "blocked":
self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止")
return
if plan["risk"] != "low":
if keep_relations:
operation_key = "push_pull_face_keep_relations"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
else:
operation_key = "push_pull_face"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
if plan["risk"] != "low" and not self._can_skip_edit_confirmation(plan, isolation):
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
@@ -476,10 +546,6 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消拉伸/切除平面")
return
if keep_relations:
isolation = self._isolation_for_plan(plan, "push_pull_face_keep_relations", [face_id, distance])
else:
isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan)
else:
@@ -488,7 +554,7 @@ class WindowActionMixin:
def action():
if keep_relations:
return self.model.push_pull_face_keep_relations(face_id, distance)
return self.model.push_pull_face(face_id, distance)
return self.model.push_pull_face(face_id, distance, plan=dict(plan))
self._run_edit_action(
action,
@@ -501,8 +567,11 @@ class WindowActionMixin:
"distance_rule": "positive=outward fuse, negative=inward cut",
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"plane_direction": plan.get("plane_direction"),
"current_plane_position": plan.get("current_plane_position"),
"target_plane_position": plan.get("target_plane_position"),
"current_plane_center": plan.get("current_plane_center"),
"target_plane_center": plan.get("target_plane_center"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"resize_strategy": plan.get("resize_strategy"),
@@ -564,6 +633,7 @@ class WindowActionMixin:
target_kind="face",
target_id=face_id,
isolation=isolation,
operation_key=operation_key,
)
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -775,13 +845,31 @@ class WindowActionMixin:
if model_face_count < 600:
return False
inner_wires = int(quick_plan.get("inner_boundary_wires") or 0)
boundary_wires = int(quick_plan.get("boundary_wires") or 0)
inner_wires = int(
quick_plan.get("inner_boundary_wires")
or quick_plan.get("selected_inner_boundary_wires")
or 0
)
boundary_wires = int(
quick_plan.get("boundary_wires")
or quick_plan.get("selected_boundary_wires")
or 0
)
if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")):
return False
# Large STEP + holed planar caps are exactly where a full plan can spend
# seconds scanning topology before the actual isolated edit even starts.
boundary_edges = int(
quick_plan.get("first_level_boundary_edge_count")
or quick_plan.get("selected_boundary_edge_count")
or 0
)
if boundary_wires and boundary_wires <= 16 and inner_wires <= 12:
return False
if boundary_edges and boundary_edges <= 120 and inner_wires <= 12:
return False
# Very large STEP + extremely fragmented holed caps can still spend
# noticeable time scanning topology before the actual edit starts.
return abs(float(distance)) > 1e-9
def _deferred_push_pull_model_plan(
@@ -1413,7 +1501,11 @@ class WindowActionMixin:
height_estimate = _float_or_none(info.get("hole_depth_estimate"))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
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"))
if bool(info.get("is_full_cylinder")):
angular_span = math.tau
if surface != "cylinder" or current_diameter is None or current_diameter <= 1e-9:
blockers.append("当前选中对象不是可识别的圆柱面,不能调整孔/槽直径。")
@@ -1530,8 +1622,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
@@ -1558,7 +1651,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 在界面线程卡死,本次不会先生成红/绿预览;"
@@ -1588,6 +1681,7 @@ class WindowActionMixin:
"中心(局部重建)",
"中心(保持关系)",
"圆柱高度调整",
"圆柱孔径调整",
"高度(缩放特征)缩放所属对象",
}
@@ -1603,6 +1697,7 @@ class WindowActionMixin:
isolated_geometry_operations = {
"push_pull_face",
"push_pull_face_keep_relations",
"translate_face_plane_offset_owning",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
@@ -1615,12 +1710,18 @@ class WindowActionMixin:
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
"resize_cylindrical_boss_height",
"resize_cylindrical_boss",
"move_cylindrical_boss_axis",
"resize_cylindrical_height_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
"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",
@@ -1649,13 +1750,66 @@ class WindowActionMixin:
return None
if risk not in {"low", "medium", "high"}:
return None
prefer_smooth_process = self._prefer_isolated_process_for_large_interactive_edit(plan, operation)
if not prefer_smooth_process and self._can_run_inprocess_background_edit(plan, operation):
return None
return {
"operation": operation,
"args": args,
"timeout_seconds": timeout_seconds,
"reason": f"{risk}-risk-isolated-occ-edit",
"reason": "large-model-smooth-ui-isolated-occ-edit" if prefer_smooth_process else f"{risk}-risk-isolated-occ-edit",
}
def _can_run_inprocess_background_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation != "push_pull_face":
return False
if str(plan.get("planar_cap_extension_method") or "") == "boundary-shell-rebuild":
return True
if str(plan.get("cylindrical_cap_extension_method") or "") == "local-shell-rebuild":
return True
if bool(plan.get("ui_deferred_model_plan")) and int(plan.get("selected_inner_boundary_wires", 0) or 0) > 0:
return True
return False
def _prefer_isolated_process_for_large_interactive_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation not in {"push_pull_face", "push_pull_face_keep_relations"}:
return False
method = str(plan.get("planar_cap_extension_method") or plan.get("cylindrical_cap_extension_method") or "")
if method not in {"boundary-shell-rebuild", "local-shell-rebuild"}:
return False
if method == "local-shell-rebuild":
return True
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return True
boundary_edges = _int_or_none(plan.get("first_level_boundary_edge_count")) or _int_or_none(
plan.get("planar_cap_boundary_edge_count")
) or 0
adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count")) or _int_or_none(
plan.get("planar_cap_adjacent_face_count")
) or 0
inner_wires = _int_or_none(plan.get("selected_inner_boundary_wires")) or _int_or_none(
plan.get("planar_cap_inner_boundary_wires")
) or 0
return boundary_edges >= 32 or adjacent_faces >= 32 or inner_wires >= 2
def _skip_before_quality_check_for_large_edit(
self,
operation_name: str,
operation_key: str | None = None,
) -> bool:
if operation_key not in {"push_pull_face", "push_pull_face_keep_relations"} and "拉伸/切除" not in str(
operation_name or ""
):
return False
return bool(getattr(self, "_large_model_interaction_mode", lambda: False)())
def _can_skip_edit_confirmation(self, plan: dict[str, object], isolation: dict[str, object] | None) -> bool:
if str(plan.get("status") or "") == "blocked":
return False
if not isinstance(isolation, dict):
return False
return isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit"
def _edit_failure_diagnostics(self, context: dict[str, object]) -> str:
parameters = context.get("parameters")
if not isinstance(parameters, dict):
@@ -1832,6 +1986,8 @@ class WindowActionMixin:
"does not have",
)
lower = text.lower()
if "target check failed" in lower or "没有到达目标" in text:
return "结果未达到目标", "几何内核生成了结果,但目标位置或目标尺寸校验没有通过,模型已回滚。"
if any(marker.lower() in lower for marker in unsupported_markers):
return "暂未实现", "当前版本暂未实现这类稳定修改。"
if any(marker.lower() in lower for marker in risk_markers):
@@ -2283,6 +2439,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
@@ -7391,7 +7741,7 @@ class WindowActionMixin:
thread.finished.connect(self._forget_scan_thread)
self.scan_thread = thread
self.scan_worker = worker
thread.start()
_start_background_thread(thread)
def _run_scan_task_sync(self, action) -> None:
if not self.scan_in_progress:
@@ -7409,6 +7759,8 @@ class WindowActionMixin:
@Slot(object)
def _finish_scan_task_result(self, result: object) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scan_task_result(result)):
return
scan_kind = self.pending_scan_kind
context = dict(self.pending_scan_context or {})
if scan_kind == "editable":
@@ -7427,6 +7779,8 @@ class WindowActionMixin:
@Slot(str)
def _fail_scan_task_result(self, message: str) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scan_task_result(message)):
return
scan_kind = self.pending_scan_kind
if scan_kind == "editable":
self._fail_editable_scan(message)
@@ -7704,6 +8058,7 @@ class WindowActionMixin:
target_kind: str | None = None,
target_id: int | None = None,
isolation: dict[str, object] | None = None,
operation_key: str | None = None,
) -> None:
if self.model is None:
return
@@ -7711,11 +8066,15 @@ class WindowActionMixin:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。")
return
target_logical_id = self._edit_target_logical_id(target_kind, target_id)
result_deflection = float(getattr(self, "edit_result_deflection", 1.6))
result_deflection = _large_model_display_deflection_for_model(
self.model,
float(getattr(self, "edit_result_deflection", 1.6)),
)
if operation_name == "拉伸/切除平面":
result_deflection = max(result_deflection, 0.35)
context = {
"operation_name": operation_name,
"operation_key": operation_key or "",
"target": target,
"parameters": parameters,
"target_kind": target_kind,
@@ -7726,6 +8085,10 @@ class WindowActionMixin:
"edit_result_deflection": result_deflection,
"defer_edge_polydata": True,
"isolation": dict(isolation or {}),
"skip_before_quality_check": self._skip_before_quality_check_for_large_edit(
operation_name,
operation_key,
),
}
blocker = self._edit_preflight_blocker(context)
if blocker is not None:
@@ -7745,7 +8108,7 @@ class WindowActionMixin:
thread.finished.connect(self._forget_edit_thread)
self.edit_thread = thread
self.edit_worker = worker
thread.start()
_start_background_thread(thread)
def _edit_target_logical_id(self, target_kind: str | None, target_id: int | None) -> int | None:
if self.model is None or target_id is None or target_kind not in {"face", "feature"}:
@@ -7772,7 +8135,11 @@ class WindowActionMixin:
target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
before_quality = (
None
if bool(context.get("skip_before_quality_check"))
else self._edit_quality_info_or_none(self.model, context, target_part_id)
)
before_geometry = {}
timings["snapshot"] = time.perf_counter() - started
isolation = context.get("isolation")
@@ -7932,6 +8299,7 @@ class WindowActionMixin:
text=True,
encoding="utf-8",
errors="replace",
creationflags=_isolated_process_creation_flags(),
)
self.active_isolated_edit_process = process
stdout, stderr = process.communicate(timeout=timeout_seconds)
@@ -7982,8 +8350,14 @@ class WindowActionMixin:
new_model.filename = self.step_path
except Exception:
pass
try:
new_model.mark_external_recognition_stale("isolated-edit-result")
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
started = time.perf_counter()
self._preserve_isolated_face_logical_id(new_model, context, child_message)
timings["result_face_mapping"] = time.perf_counter() - started
started = time.perf_counter()
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
@@ -8026,7 +8400,7 @@ class WindowActionMixin:
timings["total"] = time.perf_counter() - total_started
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩",
"message": f"{child_message} 已通过独立后台几何进程完成,主界面会保持可响应",
"snapshot": snapshot,
"before_stats": before_stats,
"before_part_stats": before_part_stats,
@@ -8096,9 +8470,6 @@ class WindowActionMixin:
logical_id = int(target_logical_id)
except (TypeError, ValueError):
return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
target_position = _float_or_none(parameters.get("target_plane_position"))
@@ -8106,15 +8477,51 @@ class WindowActionMixin:
_unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if 0 <= face_id < len(model.faces):
if target_position is not None and plane_direction is not None:
if self._face_target_plane_position_matches(
model,
[face_id],
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
):
try:
model.assign_logical_face_region_exclusive(logical_id, [face_id])
return
except Exception:
pass
elif self._assign_isolated_logical_face_candidate(model, logical_id, face_id, context):
return
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit":
return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
if target_position is not None and plane_direction is not None:
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
candidate_ids.extend(
self._face_ids_at_plane_position(
model,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
self._edit_integrity_int_or_none(parameters.get("part_id")),
self._edit_integrity_int_or_none(parameters.get("solid_id")),
part_id,
solid_id,
)
)
if solid_id is not None and solid_id >= 0:
candidate_ids.extend(
self._face_ids_at_plane_position(
model,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
part_id,
None,
)
)
for candidate_id in candidate_ids:
@@ -8130,6 +8537,26 @@ class WindowActionMixin:
except Exception:
continue
def _assign_isolated_logical_face_candidate(
self,
model: StepModel,
logical_id: int,
face_id: int,
context: dict[str, object],
) -> bool:
try:
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) or (
isinstance(context.get("isolation"), dict)
and context["isolation"].get("reason") == "large-model-smooth-ui-isolated-occ-edit"
):
face_ids = [int(face_id)]
else:
face_ids = model.face_region_ids(int(face_id)) or [int(face_id)]
model.assign_logical_face_region_exclusive(int(logical_id), face_ids)
return True
except Exception:
return False
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None:
return None
@@ -8470,6 +8897,10 @@ class WindowActionMixin:
for face_id in filtered(range(len(model.faces))):
if face_id not in result:
result.append(face_id)
if solid_id is not None and solid_id >= 0:
for face_id in filtered(range(len(model.faces)), require_solid=False):
if face_id not in result:
result.append(face_id)
return result
if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value:
result = filtered(range(len(model.faces)))
@@ -8873,10 +9304,16 @@ class WindowActionMixin:
pick_position=context["pick_position"],
before_snapshot=result["snapshot"],
after_snapshot=result["after_snapshot"],
isolation=dict(context.get("isolation") or {}),
)
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
edge_deferred = bool(result.get("edge_polydata_deferred"))
large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
if edge_deferred and large_model:
self.large_model_edge_overlay_skipped = True
elif not edge_deferred:
self.large_model_edge_overlay_skipped = False
if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection)
@@ -8893,6 +9330,14 @@ class WindowActionMixin:
if isinstance(timings, dict):
timings["finish_ui"] = time.perf_counter() - finish_started
locator_note = self._locate_operation_record(record)
relation_note = ""
relation_dependency_ids: list[int] = []
relation_replay_active = bool(getattr(self, "relation_formula_replay_active", False))
if hasattr(self, "_refresh_relation_formulas_after_model_edit"):
relation_note = self._refresh_relation_formulas_after_model_edit(context=context)
relation_dependency_ids = list(getattr(self, "_last_relation_formula_refresh_affected_ids", []) or [])
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)
@@ -8909,7 +9354,8 @@ class WindowActionMixin:
self._refresh_history_list()
self._end_edit_task(clear_preview=False)
timing_text = _edit_timing_summary(result.get("timings"))
if bool(result.get("edge_polydata_deferred")):
edge_deferred = bool(result.get("edge_polydata_deferred"))
if edge_deferred and not bool(getattr(self, "large_model_edge_overlay_skipped", False)):
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
@@ -8917,10 +9363,23 @@ class WindowActionMixin:
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
timing_note = f";耗时 {timing_text}" if timing_text else ""
edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else ""
if edge_deferred and bool(getattr(self, "large_model_edge_overlay_skipped", False)):
edge_note = ";边线按需生成"
self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}")
if self.selected_kind is None:
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}")
if hasattr(self, "_after_property_edit_finished"):
self._after_property_edit_finished(success=True)
if (
relation_dependency_ids
and not relation_replay_active
and hasattr(self, "_queue_relation_formula_dependency_reapply")
and hasattr(self, "_run_pending_relation_formula_dependency_reapply")
):
self._queue_relation_formula_dependency_reapply(relation_dependency_ids)
if not bool(getattr(self, "property_batch_active", False)):
self._run_pending_relation_formula_dependency_reapply()
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
@@ -8940,6 +9399,8 @@ class WindowActionMixin:
self.statusBar().showMessage(
"后台编辑已取消,模型已保持在编辑前状态" if close_after_cancel else "不能修改,模型未修改"
)
if hasattr(self, "_after_property_edit_finished"):
self._after_property_edit_finished(success=False)
if close_after_cancel:
if self.edit_thread is not None and self.edit_thread.isRunning():
self.edit_thread.quit()
@@ -8976,6 +9437,84 @@ class WindowActionMixin:
self.edit_thread = None
self.edit_worker = None
def _operation_parameters_with_recognition_sources(
self,
parameters: dict[str, object],
target_kind: str | None,
target_id: int | None,
) -> dict[str, object]:
result = dict(parameters or {})
if result.get("recognition_source") not in {None, ""}:
return result
evidence = [result, getattr(self, "current_info_values", {})]
if (
target_kind in {"face", "feature"}
and target_id is not None
and getattr(self, "model", None) is not None
):
try:
evidence.append(self.model.quick_face_info(int(target_id)))
except Exception:
pass
result["recognition_source"] = (
"Analysis Situs + internal StepModel"
if any(self._operation_has_analysis_situs_evidence(item) for item in evidence)
else "internal StepModel"
)
return result
def _operation_backend_log_lines(
self,
parameters: dict[str, object],
result_message: str,
*,
isolation: dict[str, object] | None = None,
) -> list[str]:
execution = "isolated OCCT subprocess" if isinstance(isolation, dict) and isolation else "Qt background worker"
recognition = str(parameters.get("recognition_source") or "").strip()
if not recognition:
recognition = (
"Analysis Situs + internal StepModel"
if self._operation_has_analysis_situs_evidence(parameters)
or self._operation_has_analysis_situs_evidence(result_message)
else "internal StepModel"
)
return [
"backend: OCCT",
f"execution: {execution}",
f"recognition: {recognition}",
]
def _operation_has_analysis_situs_evidence(self, value: object) -> bool:
if self._operation_value_is_empty(value):
return False
if isinstance(value, str):
lowered = value.lower()
return "analysis situs" in lowered or "analysis-situs" in lowered
if isinstance(value, dict):
for key, item in value.items():
key_text = str(key).lower()
if (
key_text.startswith("asitus_")
or key_text.startswith("analysis_situs_")
or key_text.startswith("external_recognition_")
) and not self._operation_value_is_empty(item):
return True
if self._operation_has_analysis_situs_evidence(item):
return True
return False
if isinstance(value, (tuple, list, set)):
return any(self._operation_has_analysis_situs_evidence(item) for item in value)
return False
@staticmethod
def _operation_value_is_empty(value: object) -> bool:
if value is None or value == "":
return True
if isinstance(value, (tuple, list, set, dict)) and not value:
return True
return False
def _make_operation_record(
self,
operation_name: str,
@@ -8995,7 +9534,9 @@ class WindowActionMixin:
pick_position: tuple[float, float, float] | None = None,
before_snapshot: dict[int, object] | None = None,
after_snapshot: dict[int, object] | None = None,
isolation: dict[str, object] | None = None,
) -> OperationRecord:
parameters = self._operation_parameters_with_recognition_sources(parameters, target_kind, target_id)
target_summary = target
if target_kind in {"face", "feature"} and target_logical_id is not None:
target_summary = f"{target_kind} logical {target_logical_id}"
@@ -9060,6 +9601,7 @@ class WindowActionMixin:
f"target: {target}",
f"target_kind: {target_kind or ''}",
f"target_id: {target_id if target_id is not None else ''}",
*self._operation_backend_log_lines(parameters, result_message, isolation=isolation),
"parameters:",
]
if target_logical_id is not None:
File diff suppressed because it is too large Load Diff
+3656 -128
View File
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;
}