feat: 完善 STEP 一级参数化编辑识别与关系式建模

This commit is contained in:
2026-08-14 18:42:39 +08:00
parent 70b59c1de6
commit a633b5a338
26 changed files with 4676 additions and 77 deletions
+19
View File
@@ -0,0 +1,19 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$sourceDir = Join-Path $repoRoot "tools\asitus_probe"
$buildDir = Join-Path $repoRoot "third_party\asitus_probe_tools_build"
cmake -S $sourceDir -B $buildDir -A x64
cmake --build $buildDir --config $Configuration
$exePath = Join-Path $buildDir "$Configuration\recognize_holes.exe"
if (-not (Test-Path $exePath)) {
throw "Analysis Situs probe build finished but recognize_holes.exe was not found: $exePath"
}
Write-Host "Analysis Situs probe built: $exePath"
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.asitus_bridge import (
default_asitus_recognize_holes_path,
parse_asitus_hole_groups,
parse_asitus_probe_payload,
run_asitus_hole_recognition,
)
from step_editor.model import StepModel
from step_editor.recognition_graph import recognition_summary
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _install_external_regions(model: StepModel, groups: list[tuple[int, ...]]) -> None:
model._asitus_hole_regions_attempted = True # noqa: SLF001
model._asitus_hole_region_cache.clear() # noqa: SLF001
for group in groups:
for face_id in group:
model._asitus_hole_region_cache[int(face_id)] = list(group) # noqa: SLF001
model._same_domain_face_ids_cache.clear() # noqa: SLF001
def main() -> int:
payload = {
"validBreP": True,
"faceCount": 158,
"aagNodeCount": 158,
"holeFaceIds": [85, 86, 87, 88, 95, 96, 97, 98],
"holeCount": 4,
"holes": [
{"index": 1, "faceIds": [85, 98]},
{"index": 2, "faceIds": [86, 97]},
{"index": 3, "faceIds": [87, 96]},
{"index": 4, "faceIds": [88, 95]},
],
"surfaceSummary": {"plane": 106, "cylinder": 52},
"angleSummary": {"smooth": 4, "convex": 8},
"geometricRelationMode": "all-pairs",
"geometricRelationSummary": {
"coaxial": 1,
"coplanar": 1,
"parallel": 1,
"parallel_axis": 1,
"perpendicular": 1,
"tangent": 1,
},
"faces": [
{"id": 88, "surface": "cylinder", "neighbors": [12, 95]},
{"id": 95, "surface": "cylinder", "neighbors": [88, 42]},
],
"adjacency": [
{"faceIds": [88, 95], "angleType": "smooth", "angleRad": 0.0, "edgeIds": [501, 502]},
],
"geometricRelations": [
{"faceIds": [88, 95], "type": "coaxial", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "tangent", "residual": 0.0, "source": "analysis-situs-aag-angle"},
{"faceIds": [88, 95], "type": "coplanar", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "parallel", "residual": 2.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "perpendicular", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "parallel_axis", "residual": 0.0, "source": "analysis-situs-probe"},
],
}
raw_groups = parse_asitus_hole_groups(payload)
_assert(raw_groups == [(85, 98), (86, 97), (87, 96), (88, 95)], f"unexpected parsed groups: {raw_groups}")
parsed = parse_asitus_probe_payload(payload)
_assert(parsed.get("surface_summary") == {"plane": 106, "cylinder": 52}, f"bad surface summary: {parsed}")
_assert(parsed.get("geometric_relation_mode") == "all-pairs", f"bad geometric relation mode: {parsed}")
expected_geometric_summary = {
"coaxial": 1,
"coplanar": 1,
"parallel": 1,
"parallel_axis": 1,
"perpendicular": 1,
"tangent": 1,
}
_assert(
parsed.get("geometric_relation_summary") == expected_geometric_summary,
f"bad geometric summary: {parsed}",
)
_assert(len(tuple(parsed.get("faces", ()))) == 2, f"bad face summary parse: {parsed}")
_assert(len(tuple(parsed.get("adjacency", ()))) == 1, f"bad adjacency parse: {parsed}")
_assert(len(tuple(parsed.get("geometric_relations", ()))) == 6, f"bad geometric relation parse: {parsed}")
if not MODEL_PATH.exists():
print("asitus hole bridge mapping skipped: local ICEPAK-NATURAL.stp is not present")
return 0
model = StepModel.load(MODEL_PATH)
mapped = model._map_asitus_hole_groups(raw_groups) # noqa: SLF001
expected = [(84, 97), (85, 96), (86, 95), (87, 94)]
_assert(mapped == expected, f"Analysis Situs 1-based AAG face ids should map to Python face ids: {mapped}")
async_model = StepModel.load(MODEL_PATH)
_assert(async_model.begin_asitus_hole_region_load() is True, "background preload should be accepted once")
_assert(
async_model._asitus_hole_region_ids(87) == [], # noqa: SLF001
"selection should not synchronously run Analysis Situs while background preload is pending",
)
async_mapped = async_model.install_asitus_hole_recognition_result({"ok": True, "reason": "ok", "groups": raw_groups})
_assert(async_mapped == expected, f"background result should install mapped groups: {async_mapped}")
_assert(async_model.face_region_ids(87) == [87, 94], "installed background result should drive whole-hole selection")
relation_model = StepModel.load(MODEL_PATH)
relation_result = {
"ok": True,
"reason": "ok",
"groups": raw_groups,
"faces": parsed.get("faces", ()),
"adjacency": parsed.get("adjacency", ()),
"geometric_relations": parsed.get("geometric_relations", ()),
"surface_summary": parsed.get("surface_summary", {}),
"angle_summary": parsed.get("angle_summary", {}),
"geometric_relation_summary": parsed.get("geometric_relation_summary", {}),
}
relation_mapped = relation_model.install_asitus_hole_recognition_result(relation_result)
_assert(relation_mapped == expected, f"relation install should preserve hole mapping: {relation_mapped}")
relation_info = relation_model.quick_face_info(87)
_assert(relation_info.get("asitus_relation_status") == "ready", f"missing AAG relation fields: {relation_info}")
_assert(
relation_info.get("asitus_adjacent_face_ids") == (11, 94),
f"Analysis Situs 1-based neighbors should map to Python face ids: {relation_info}",
)
_assert(
"smooth:1" in str(relation_info.get("asitus_adjacent_relation_summary") or ""),
f"missing AAG angle relation summary: {relation_info}",
)
_assert(
"coaxial:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""),
f"missing AAG geometric relation summary: {relation_info}",
)
_assert(
"tangent:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""),
f"missing AAG tangent relation summary: {relation_info}",
)
graph_summary = recognition_summary(relation_model)
relation_counts = dict(graph_summary.get("relation_counts", {}) or {})
_assert(
relation_counts.get("external_coaxial", 0) >= 1,
f"recognition graph should include external coaxial evidence: {graph_summary}",
)
for relation_type in (
"external_coplanar",
"external_parallel",
"external_parallel_axis",
"external_perpendicular",
"external_tangent",
):
_assert(
relation_counts.get(relation_type, 0) >= 1,
f"recognition graph should include {relation_type} evidence: {graph_summary}",
)
_assert(
int(relation_info.get("recognition_external_relation_score_bonus") or 0) > 0,
f"feature summary should expose an external relation confidence bonus: {relation_info}",
)
candidates = relation_model.editable_feature_candidates(limit=160, detailed=False, max_scan_faces=120)
related_candidates = [
item
for item in candidates
if int(item.get("face_id", -1) or -1) == 87
or int(item.get("target_id", -1) or -1) == 87
]
_assert(related_candidates, f"editable scan should include the Analysis Situs-backed Face 87 candidate: {candidates}")
_assert(
any(int(item.get("recognition_external_relation_score_bonus") or 0) > 0 for item in related_candidates),
f"editable candidates should carry external relation sorting support: {related_candidates}",
)
_assert(
any("coaxial" in str(item.get("external_recognition_relation_summary") or "") for item in related_candidates),
f"editable candidates should expose external relation summary: {related_candidates}",
)
_install_external_regions(model, mapped)
_assert(model.face_region_ids(87) == [87, 94], "Face 87 should select the complete external hole group")
_assert(model.face_region_ids(94) == [87, 94], "Face 94 should select the complete external hole group")
info = model.quick_face_info(87)
_assert(list(info.get("feature_highlight_face_ids") or []) == [87, 94], "quick UI highlight should use external hole group")
cli = default_asitus_recognize_holes_path(PROJECT_ROOT)
if cli is not None:
result = run_asitus_hole_recognition(MODEL_PATH, project_root=PROJECT_ROOT, timeout_seconds=6.0)
_assert(result.get("ok") is True, f"local Analysis Situs CLI should recognize ICEPAK holes: {result}")
external_mapped = model._map_asitus_hole_groups(result.get("groups", ())) # noqa: SLF001
for group in expected:
_assert(group in external_mapped, f"local Analysis Situs CLI mapping missed {group}: {external_mapped}")
print("asitus hole bridge ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -39,6 +39,10 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS = {
"resize_cylindrical_hole",
"edit_cylindrical_holes_by_refs",
"resize_cylindrical_holes_by_refs",
"move_cylindrical_holes_by_offset",
"suppress_cylindrical_holes_by_refs",
"resize_cylindrical_owning_scale",
"move_cylindrical_hole_axis",
"suppress_cylindrical_hole",
@@ -22,6 +22,7 @@ from verify_boss_resize import _first_boss_face, _write_boss_model # noqa: E402
from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402
from verify_edge_round_chamfer import ( # noqa: E402
_first_existing_fillet_face,
_write_filleted_box_model,
_write_mixed_radius_filleted_box_model,
)
from verify_shell_thickness_resize import _first_open_shell_wall_face, _write_open_thin_wall_box_model # noqa: E402
@@ -64,6 +65,66 @@ def _first_surface_face(model: StepModel, surface: str) -> int:
raise AssertionError(f"no {surface} Face was found")
def _first_adjacent_face(model: StepModel, face_id: int) -> int:
edge_ids = model._face_boundary_edge_ids(face_id) # noqa: SLF001
adjacent_ids = sorted(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001
for adjacent_id in adjacent_ids:
if 0 <= int(adjacent_id) < len(model.faces):
return int(adjacent_id)
raise AssertionError(f"Face {face_id} has no adjacent Face")
def _install_synthetic_asitus_support(
model: StepModel,
face_id: int,
*,
relation_type: str,
angle_type: str,
) -> int:
adjacent_id = _first_adjacent_face(model, face_id)
face_info = model.quick_face_info(face_id)
adjacent_info = model.quick_face_info(adjacent_id)
result = {
"ok": True,
"reason": "ok",
"groups": (),
"faces": (
{
"id": face_id + 1,
"surface": str(face_info.get("surface") or ""),
"neighbor_ids": (adjacent_id + 1,),
},
{
"id": adjacent_id + 1,
"surface": str(adjacent_info.get("surface") or ""),
"neighbor_ids": (face_id + 1,),
},
),
"adjacency": (
{
"face_ids": (face_id + 1, adjacent_id + 1),
"angle_type": angle_type,
"angle_rad": 0.0,
"edge_ids": (),
},
),
"geometric_relations": (
{
"face_ids": (face_id + 1, adjacent_id + 1),
"relation_type": relation_type,
"residual": 0.0,
"source": "synthetic-analysis-situs-test",
},
),
"surface_summary": {},
"angle_summary": {angle_type: 1},
"geometric_relation_summary": {relation_type: 1},
"geometric_relation_mode": "synthetic-test",
}
model.install_asitus_hole_recognition_result(result)
return adjacent_id
def _first_quick_candidate(
model: StepModel,
*,
@@ -245,6 +306,79 @@ def _verify_boss_summary(root: Path) -> None:
_assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}")
def _assert_asitus_hint(
info: dict[str, object],
*,
preferred: str,
label: str,
) -> None:
_assert(
info.get("analysis_situs_feature_hint_preferred") == preferred,
f"{label}: Analysis Situs hint should prefer {preferred}: {info}",
)
_assert(
int(info.get("analysis_situs_feature_hint_score") or 0) > 0,
f"{label}: Analysis Situs hint score is missing: {info}",
)
_assert(
"analysis_situs_feature_hint" in set(info.get("recognition_evidence_keys") or ()),
f"{label}: recognition evidence should mention Analysis Situs feature hint: {info}",
)
def _verify_asitus_slot_boss_fillet_hints(root: Path) -> None:
slot_path = root / "asitus_slot_hint.step"
_write_half_round_slot_model(slot_path)
slot_model = StepModel.load(slot_path)
slot_face_id = _first_slot_face(slot_model)
_install_synthetic_asitus_support(slot_model, slot_face_id, relation_type="tangent", angle_type="smooth")
slot_info = slot_model.feature_info(slot_face_id)
_assert_asitus_hint(slot_info, preferred="slot", label="slot hint")
slot_candidates = [
item
for item in slot_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == slot_face_id
]
_assert(
any(int(item.get("analysis_situs_slot_hint_score") or 0) > 0 for item in slot_candidates),
f"slot candidates should carry Analysis Situs slot support: {slot_candidates}",
)
boss_path = root / "asitus_boss_hint.step"
_write_boss_model(boss_path)
boss_model = StepModel.load(boss_path)
boss_face_id = _first_boss_face(boss_model)
_install_synthetic_asitus_support(boss_model, boss_face_id, relation_type="parallel", angle_type="convex")
boss_info = boss_model.feature_info(boss_face_id)
_assert_asitus_hint(boss_info, preferred="boss", label="boss hint")
boss_candidates = [
item
for item in boss_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == boss_face_id
]
_assert(
any(int(item.get("analysis_situs_boss_hint_score") or 0) > 0 for item in boss_candidates),
f"boss candidates should carry Analysis Situs boss support: {boss_candidates}",
)
fillet_path = root / "asitus_fillet_hint.step"
_write_filleted_box_model(fillet_path, 1.0)
fillet_model = StepModel.load(fillet_path)
fillet_face_id = _first_existing_fillet_face(fillet_model, 1.0, 2e-4)
_install_synthetic_asitus_support(fillet_model, fillet_face_id, relation_type="tangent", angle_type="smooth")
fillet_info = fillet_model.feature_info(fillet_face_id)
_assert_asitus_hint(fillet_info, preferred="fillet", label="fillet hint")
fillet_candidates = [
item
for item in fillet_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == fillet_face_id
]
_assert(
any(int(item.get("analysis_situs_fillet_hint_score") or 0) > 0 for item in fillet_candidates),
f"fillet candidates should carry Analysis Situs fillet support: {fillet_candidates}",
)
def _verify_torus_summary(root: Path) -> None:
path = root / "torus.step"
_write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path)
@@ -416,6 +550,16 @@ def main() -> int:
"recognition_user_priority_label",
"recognition_user_priority_reason",
"recognition_evidence",
"recognition_external_relation_score_bonus",
"analysis_situs_feature_hint_status",
"analysis_situs_feature_hint_preferred",
"analysis_situs_feature_hint_label",
"analysis_situs_feature_hint_score",
"analysis_situs_feature_hint_summary",
"analysis_situs_feature_hint_related_face_ids",
"analysis_situs_slot_hint_score",
"analysis_situs_boss_hint_score",
"analysis_situs_fillet_hint_score",
"recognition_ready_actions",
"recognition_limited_actions",
"recognition_blockers",
@@ -431,6 +575,7 @@ def main() -> int:
_verify_hole_summary(root)
_verify_slot_summary(root)
_verify_boss_summary(root)
_verify_asitus_slot_boss_fillet_hints(root)
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_candidate_scan_cache(root)
@@ -71,6 +71,7 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Property editor specs", ("verify_property_editor_specs.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Parametric component export", ("verify_parametric_component_export.py",)),
("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)),
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
("First-level fact graph", ("verify_first_level_fact_graph.py",)),
@@ -32,6 +32,10 @@ class _SelectionHarness(WindowCoreMixin, WindowStateMixin):
self.selected_solid_id = None
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
self.multi_selected_feature_face_ids = []
self.multi_selected_hole_entries = []
self.multi_selection_active = False
self.current_info_values = {}
def _current_feature_detection_level(self) -> str:
return str(self.feature_detection_level)
@@ -76,6 +80,38 @@ def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root:
return str(response.get("message") or "")
def _isolated_hole_axis_move(
model: StepModel,
face_id: int,
target_center: tuple[float, float, float],
root: Path,
) -> str:
input_path = root / "face85_axis_input.brep"
output_path = root / "face85_axis_output.brep"
request_path = root / "face85_axis_request.json"
model.export_internal_brep(input_path)
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"input_format": "brep",
"output_format": "brep",
"operation": "move_cylindrical_hole_axis",
"args": [face_id, list(target_center)],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
code = run_request(request_path)
response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8"))
_assert(code == 0 and response.get("ok") is True, f"isolated Face {face_id} axis move should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} axis move should write output BREP")
return str(response.get("message") or "")
def _edge_ids_from_polydata(polydata) -> set[int]:
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
if edge_arr is None:
@@ -137,6 +173,98 @@ def _assert_rectangular_face_parameters(model: StepModel, harness: _SelectionHar
_assert(not missing, f"ICEPAK Face 9 feature parameters should expose length, width and offset: {missing}")
def _assert_face85_hole_axis_move(model: StepModel, harness: _SelectionHarness) -> tuple[float, float, float]:
face_id = 85
info = model.quick_face_info(face_id)
_assert(info.get("surface") == "cylinder", "ICEPAK Face 85 should be cylindrical")
_assert(
list(info.get("feature_highlight_face_ids") or []) == [85, 96],
f"ICEPAK Face 85 should highlight the complete split hole region: {info.get('feature_highlight_face_ids')}",
)
harness.select_feature_face(face_id)
feature = model.feature_info(face_id)
specs = harness._property_editor_specs(feature, feature)
editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))}
_assert("hole_axis_center" in editable_keys, "ICEPAK Face 85 should expose the hole axis-center move parameter")
current_center = info.get("axis_center") or feature.get("axis_center")
if not (isinstance(current_center, (list, tuple)) and len(current_center) == 3):
axis_point = feature.get("axis_point") or info.get("axis_point")
axis_direction = feature.get("axis") or info.get("axis")
axis_range = feature.get("same_domain_v_range") or info.get("same_domain_v_range") or feature.get("v_range")
if (
isinstance(axis_point, (list, tuple))
and len(axis_point) == 3
and isinstance(axis_direction, (list, tuple))
and len(axis_direction) == 3
and isinstance(axis_range, (list, tuple))
and len(axis_range) >= 2
):
axis_mid = (float(axis_range[0]) + float(axis_range[1])) * 0.5
current_center = tuple(float(axis_point[index]) + float(axis_direction[index]) * axis_mid for index in range(3))
_assert(isinstance(current_center, (list, tuple)) and len(current_center) == 3, "Face 85 should have an axis center")
near_target_center = (float(current_center[0]) + 0.1, float(current_center[1]), float(current_center[2]))
plan = model.cylindrical_axis_move_plan(face_id, near_target_center)
_assert(plan.get("status") != "blocked", f"Face 85 axis move should not be blocked as a half cylinder: {plan}")
_assert(list(plan.get("same_domain_face_ids") or []) == [85, 96], "Face 85 axis move should use both side fragments")
_assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 85 axis move should use full same-domain span")
_assert(
abs(float(plan.get("selected_angular_span") or 0.0) - 3.141592653589793) <= 1e-6,
"Face 85 selected fragment span should still be recorded separately",
)
target_center = (float(current_center[0]), float(current_center[1]), 6.0)
high_risk_plan = model.cylindrical_axis_move_plan(face_id, target_center)
_assert(
high_risk_plan.get("status") != "blocked",
f"Face 85 axis move to Z=6 should be high-risk but still plannable: {high_risk_plan}",
)
_assert(high_risk_plan.get("risk") == "high", f"Face 85 far axis move should be classified high risk: {high_risk_plan}")
_assert(
bool(high_risk_plan.get("supports_isolation")),
f"Face 85 high-risk axis move should be routed to isolated execution: {high_risk_plan}",
)
_assert(
tuple(round(float(item), 7) for item in high_risk_plan.get("target_axis_center", ())) == (0.5, 1.0, 6.0),
f"Face 85 high-risk axis move target should match the requested location: {high_risk_plan}",
)
return target_center
def _assert_face85_face87_multi_hole_specs(model: StepModel, harness: _SelectionHarness) -> None:
entry85 = harness._hole_multi_select_entry(85)
entry87 = harness._hole_multi_select_entry(87)
_assert(entry85 is not None, "Face 85 should be eligible for multi-hole selection")
_assert(entry87 is not None, "Face 87 should be eligible for multi-hole selection")
_assert(entry85["logical_id"] != entry87["logical_id"], "Face 85 and Face 87 should be separate hole groups")
harness.multi_selected_hole_entries = [entry85, entry87]
harness.multi_selected_feature_face_ids = [85, 87]
harness.multi_selection_active = True
harness.selected_kind = "multi_feature"
harness.selected_face_id = 87
info = harness._selected_action_info()
_assert(info.get("multi_selection_kind") == "holes", f"multi selection info should describe holes: {info}")
_assert(int(info.get("multi_selected_count") or 0) == 2, f"multi selection should include two holes: {info}")
_assert(
set(info.get("feature_highlight_face_ids") or ()) == {85, 96, 87, 94},
f"multi-hole highlight should include both split-cylinder regions: {info.get('feature_highlight_face_ids')}",
)
specs = harness._property_editor_specs(info, info)
keys = [str(spec.get("key") or "") for spec in specs]
_assert(
keys == ["multi_hole_diameter", "multi_hole_radius", "multi_hole_position_delta", "multi_hole_suppress"],
f"multi-hole specs should expose batch hole dimensions and commands: {keys}",
)
actions = {str(spec.get("action") or "") for spec in specs}
_assert(
actions
== {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset", "suppress_multi_selected_holes"},
f"multi-hole specs should expose batch edit actions: {actions}",
)
def main() -> int:
if not MODEL_PATH.exists():
print("icepak cylindrical region selection skipped: local ICEPAK-NATURAL.stp is not present")
@@ -146,7 +274,11 @@ def main() -> int:
_assert_default_edges_hide_same_domain_internal_edges(model, "ICEPAK before edit")
harness = _SelectionHarness(model)
_assert_rectangular_face_parameters(model, harness)
_assert_face85_hole_axis_move(model, harness)
_assert_face85_face87_multi_hole_specs(model, harness)
expected_regions = {
85: [85, 96],
96: [85, 96],
87: [87, 94],
94: [87, 94],
84: [84, 97],
@@ -176,6 +308,15 @@ def main() -> int:
)
_assert(bool(feature.get("is_full_cylinder")), f"Face {face_id} should be treated as a full cylinder")
axis_move_model = StepModel.load(MODEL_PATH)
axis_move_target = _assert_face85_hole_axis_move(axis_move_model, _SelectionHarness(axis_move_model))
axis_move_result = axis_move_model.move_cylindrical_hole_axis(85, axis_move_target)
_assert(
"Cylindrical hole axis move completed" in axis_move_result,
f"Face 85 axis move should complete: {axis_move_result}",
)
_assert_default_edges_hide_same_domain_internal_edges(axis_move_model, "ICEPAK after Face 85 axis move")
resize_model = StepModel.load(MODEL_PATH)
plan = resize_model.cylindrical_resize_plan(87, 0.3)
_assert(plan.get("feature_type") == "圆柱孔候选", "Face 87 should plan as a cylindrical hole")
@@ -191,6 +332,10 @@ def main() -> int:
worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir))
_assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete")
with tempfile.TemporaryDirectory(prefix="icepak_face85_axis_isolated_") as temp_dir:
worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir))
_assert("Cylindrical hole axis move completed" in worker_message, "Face 85 isolated axis move should complete")
print("icepak cylindrical region selection ok")
return 0
+54 -1
View File
@@ -9,14 +9,16 @@ import tempfile
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QEvent, QObject
from PySide6.QtCore import QEvent, QObject, QStringListModel
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QCompleter,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QPushButton,
QScrollArea,
QTableWidget,
@@ -62,6 +64,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.property_command_active_key = ""
self.property_command_buttons = {}
self.property_editor_specs = []
self.relation_formula_items = []
self.relation_formula_next_id = 1
self.selected_kind = "feature"
self.selected_part_id = None
self.selected_solid_id = None
@@ -93,6 +97,13 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.current_capability_headline = QLabel()
self.apply_property_button = QPushButton()
self.export_parameters_button = QPushButton()
self.relation_formula_input = QLineEdit()
self.relation_formula_completer_model = QStringListModel(self)
self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self)
self.relation_formula_input.setCompleter(self.relation_formula_completer)
self.add_relation_formula_button = QPushButton()
self.remove_relation_formula_button = QPushButton()
self.relation_formula_list = QListWidget()
self.face_width_input = QLineEdit()
self.face_height_input = QLineEdit()
@@ -446,6 +457,34 @@ def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe)
_assert(diagnostic_label not in table_labels, f"{diagnostic_label} should not be shown as a feature parameter")
def _assert_relation_formula_editor() -> None:
probe = _PropertyTableProbe()
probe._refresh_property_editor()
completions = set(probe.relation_formula_completer_model.stringList())
_assert("Face0.面内长度" in completions, f"relation formula completion missing face length: {completions}")
_assert("Face0.面内宽度" in completions, f"relation formula completion missing face width: {completions}")
probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2")
probe.add_relation_formula()
_assert(len(probe.relation_formula_items) == 1, f"formula was not stored: {probe.relation_formula_items}")
_assert(probe.relation_formula_list.count() == 1, "formula list should display the stored formula")
probe._update_property_apply_state()
_assert(probe.apply_property_button.isEnabled(), "formula targeting current table should enable parametric modeling")
probe.apply_current_property_edit()
for _index in range(6):
QApplication.processEvents()
if not getattr(probe, "property_batch_active", False):
break
_assert(
probe.executed_property_actions == [("resize_face_height_local", "12")],
f"formula should fill target value and execute the existing row action: {probe.executed_property_actions}",
)
width_row = _row_by_label(probe, "面内宽度")
width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN)
_assert(isinstance(width_widget, QLineEdit), "formula target row should still have a target editor")
_assert(width_widget.text().strip() == "12", "formula result should be written back to the target value cell")
_assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be marked as applied")
def _assert_mouse_selection_guards() -> None:
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
@@ -502,6 +541,7 @@ def _assert_quick_blind_depth_spec() -> None:
"radius": 2.0,
"axis_point": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_center": (0.0, 0.0, 3.0),
"angular_span": math.tau,
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔候选",
@@ -513,6 +553,18 @@ def _assert_quick_blind_depth_spec() -> None:
}
blind_specs, _blind_used = blind_probe._editable_property_specs(quick_blind_info)
blind_feature_specs = blind_probe._feature_property_specs(blind_specs, quick_blind_info)
blind_axis_specs = [
spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_axis_center"
]
_assert(blind_axis_specs, "quick blind hole axis center should be visible in feature parameters")
blind_axis_spec = blind_axis_specs[0]
_assert(str(blind_axis_spec.get("value_type", "")) == "vector3", "hole axis center should accept X/Y/Z")
blind_axis_effective = blind_probe._effective_property_spec(blind_axis_spec)
_assert(bool(blind_axis_effective.get("enabled")), "quick blind hole axis center should be editable")
_assert(
str(blind_axis_effective.get("action", "")) == "move_cylindrical_hole_axis",
f"quick blind hole axis center should move the hole itself: {blind_axis_effective}",
)
blind_depth_specs = [
spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_depth_estimate"
]
@@ -671,6 +723,7 @@ def main() -> int:
)
_assert_diagnostics_stay_out_of_parameter_table(probe)
_assert_relation_formula_editor()
_assert_mouse_selection_guards()
_assert_quick_blind_depth_spec()
_assert_user_facing_failure_messages()
+18 -4
View File
@@ -740,7 +740,15 @@ def main() -> int:
cylinder_specs = _specs(cylinder_info)
cylinder_keys = {str(spec.get("key", "")) for spec in cylinder_specs}
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "cylindrical hole feature")
hole_axis_spec = _spec(cylinder_specs, "hole_axis_center")
hole_axis_local = _scope_mode(cylinder_specs, "hole_axis_center", "local")
if str(hole_axis_spec.get("label") or "") != "位置":
raise SystemExit(f"hole axis center should be shown to users as position: {hole_axis_spec}")
if str(hole_axis_spec.get("value_type") or "") != "vector3":
raise SystemExit(f"hole axis center should use an X/Y/Z vector target: {hole_axis_spec}")
if str(hole_axis_local.get("action") or "") != "move_cylindrical_hole_axis":
raise SystemExit(f"hole axis center should move the hole itself by default: {hole_axis_local}")
_assert_current_text_contains(
cylinder_specs,
"cad_modeling_form",
@@ -750,7 +758,7 @@ def main() -> int:
_assert_current_text_contains(
cylinder_specs,
"cad_recommended_operation",
("孔径", "盲孔", "轴心"),
("孔径", "盲孔", "位置"),
"cylindrical hole feature",
)
@@ -789,6 +797,12 @@ def main() -> int:
cylinder_feature_probe = _PropertySpecProbe()
cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info)
cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info)
cylinder_feature_keys = {str(spec.get("key", "")) for spec in cylinder_feature_rows}
_assert_contains(
cylinder_feature_keys,
{"diameter", "hole_axis_center"},
"cylindrical feature mode display specs",
)
_assert_keys_absent(
cylinder_feature_rows,
(
@@ -876,7 +890,7 @@ def main() -> int:
"feature_bottom_face_ids": (),
}
)
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole")
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "split full cylindrical hole")
if "slot_chord_width_estimate" in split_full_hole_keys:
raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}")
@@ -915,7 +929,7 @@ def main() -> int:
_assert_current_text_contains(
slot_specs,
"cad_recommended_operation",
("槽宽", "槽深", "轴心"),
("槽宽", "槽深", "位置"),
"slot/half-hole feature",
)
blocked_slot_specs = _display_specs(