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