2026-08-04 18:15:29 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeTorus
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
|
|
|
for path in (PROJECT_ROOT, SCRIPTS_DIR):
|
|
|
|
|
if str(path) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(path))
|
|
|
|
|
|
|
|
|
|
from step_editor.model import StepModel
|
|
|
|
|
from step_editor.step_io import _write_step
|
|
|
|
|
from step_editor.ui_helpers import INFO_LABELS
|
|
|
|
|
|
2026-08-07 18:08:32 +08:00
|
|
|
from verify_hole_resize import _first_hole_face, _write_blind_hole_model, _write_through_hole_model # noqa: E402
|
2026-08-10 14:56:50 +08:00
|
|
|
from verify_slot_resize import _first_slot_face, _write_cross_obround_slot_model, _write_half_round_slot_model # noqa: E402
|
2026-08-07 18:08:32 +08:00
|
|
|
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
|
2026-08-10 14:56:50 +08:00
|
|
|
from verify_edge_round_chamfer import ( # noqa: E402
|
|
|
|
|
_first_existing_fillet_face,
|
2026-08-14 18:42:39 +08:00
|
|
|
_write_filleted_box_model,
|
2026-08-10 14:56:50 +08:00
|
|
|
_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
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert(condition: bool, message: str) -> None:
|
|
|
|
|
if not condition:
|
|
|
|
|
raise AssertionError(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _top_planar_face(model: StepModel) -> int:
|
|
|
|
|
best: tuple[float, int] | None = None
|
|
|
|
|
for face_id in range(len(model.faces)):
|
|
|
|
|
info = model.face_info(face_id)
|
|
|
|
|
if info.get("surface") != "plane":
|
|
|
|
|
continue
|
|
|
|
|
center = info.get("area_center")
|
|
|
|
|
if not isinstance(center, tuple) or len(center) != 3:
|
|
|
|
|
continue
|
|
|
|
|
z_value = float(center[2])
|
|
|
|
|
if best is None or z_value > best[0]:
|
|
|
|
|
best = (z_value, face_id)
|
|
|
|
|
if best is None:
|
|
|
|
|
raise AssertionError("no planar Face was found")
|
|
|
|
|
return best[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_planar_face_with_inner_boundary(model: StepModel) -> int:
|
|
|
|
|
for face_id in range(len(model.faces)):
|
|
|
|
|
info = model.face_info(face_id)
|
|
|
|
|
if info.get("surface") == "plane" and int(info.get("inner_boundary_wires", 0) or 0) > 0:
|
|
|
|
|
return face_id
|
|
|
|
|
raise AssertionError("no planar Face with an inner boundary was found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_surface_face(model: StepModel, surface: str) -> int:
|
|
|
|
|
for face_id in range(len(model.faces)):
|
|
|
|
|
if model.face_info(face_id).get("surface") == surface:
|
|
|
|
|
return face_id
|
|
|
|
|
raise AssertionError(f"no {surface} Face was found")
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 18:42:39 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 18:08:32 +08:00
|
|
|
def _first_quick_candidate(
|
|
|
|
|
model: StepModel,
|
|
|
|
|
*,
|
|
|
|
|
feature_guess: str,
|
|
|
|
|
feature_type: str,
|
|
|
|
|
) -> tuple[int, dict[str, object]]:
|
|
|
|
|
for face_id in range(len(model.faces)):
|
|
|
|
|
info = model.quick_face_info(face_id)
|
|
|
|
|
if info.get("feature_guess") == feature_guess and info.get("feature_type") == feature_type:
|
|
|
|
|
return face_id, info
|
|
|
|
|
raise AssertionError(f"no quick {feature_type} was found")
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]) -> None:
|
|
|
|
|
summary = str(info.get("recognition_summary") or "")
|
|
|
|
|
candidate = str(info.get("recognition_candidate") or "")
|
|
|
|
|
confidence = str(info.get("recognition_confidence") or "")
|
|
|
|
|
risk = str(info.get("recognition_risk") or "")
|
|
|
|
|
decision = str(info.get("recognition_decision") or "")
|
|
|
|
|
evidence_keys = set(str(item) for item in tuple(info.get("recognition_evidence_keys") or ()))
|
|
|
|
|
evidence = str(info.get("recognition_evidence") or "")
|
|
|
|
|
score = int(info.get("recognition_score", -1))
|
2026-08-07 18:08:32 +08:00
|
|
|
user_priority = int(info.get("recognition_user_priority", -1))
|
|
|
|
|
user_priority_label = str(info.get("recognition_user_priority_label") or "")
|
|
|
|
|
user_priority_reason = str(info.get("recognition_user_priority_reason") or "")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
_assert(summary, f"{label}: recognition_summary is missing")
|
|
|
|
|
_assert(candidate, f"{label}: recognition_candidate is missing")
|
|
|
|
|
_assert(confidence in {"unchecked", "none", "low", "medium", "high"}, f"{label}: bad confidence {confidence!r}")
|
|
|
|
|
_assert(risk in {"low", "medium", "high", "blocked"}, f"{label}: bad risk {risk!r}")
|
|
|
|
|
_assert(0 <= score <= 100, f"{label}: bad recognition_score {score!r}")
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert(1 <= user_priority <= 99, f"{label}: bad user priority {user_priority!r}")
|
|
|
|
|
_assert(user_priority_label, f"{label}: recognition_user_priority_label is missing")
|
|
|
|
|
_assert(user_priority_reason, f"{label}: recognition_user_priority_reason is missing")
|
2026-08-04 18:15:29 +08:00
|
|
|
_assert(
|
|
|
|
|
decision in {"高可信候选", "可尝试候选", "需人工确认", "不建议自动修改", "已阻止"},
|
|
|
|
|
f"{label}: bad recognition_decision {decision!r}",
|
|
|
|
|
)
|
|
|
|
|
_assert(required_keys <= evidence_keys, f"{label}: evidence keys missing {required_keys - evidence_keys}")
|
|
|
|
|
_assert(evidence, f"{label}: recognition_evidence is missing")
|
|
|
|
|
_assert(candidate in summary, f"{label}: summary should mention candidate {candidate!r}: {summary!r}")
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert("优先级=" in summary, f"{label}: summary should mention user priority: {summary!r}")
|
2026-08-04 18:15:29 +08:00
|
|
|
_assert(f"评分={score}" in summary, f"{label}: summary should mention score {score}: {summary!r}")
|
|
|
|
|
_assert(decision in summary, f"{label}: summary should mention decision {decision!r}: {summary!r}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_planar_summary(root: Path) -> None:
|
|
|
|
|
path = root / "box.step"
|
|
|
|
|
_write_step(BRepPrimAPI_MakeBox(10.0, 8.0, 6.0).Shape(), path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _top_planar_face(model)
|
|
|
|
|
quick_info = model.quick_face_info(face_id)
|
|
|
|
|
full_info = model.feature_info(face_id)
|
|
|
|
|
_assert_summary(quick_info, "quick planar Face", {"surface", "boundary_edges"})
|
|
|
|
|
_assert_summary(full_info, "feature planar Face", {"surface", "boundary_edges", "first_level_topology"})
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert(int(full_info.get("recognition_user_priority", 99)) == 10, f"planar Face should be first priority: {full_info}")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_hole_summary(root: Path) -> None:
|
|
|
|
|
path = root / "through_hole.step"
|
|
|
|
|
_write_through_hole_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_hole_face(model, blind=False)
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert(info.get("feature_guess") == "hole/groove candidate", f"hole was not recognized: {info}")
|
|
|
|
|
_assert_summary(info, "through-hole feature", {"surface", "material_votes", "first_level_topology"})
|
|
|
|
|
_assert(info.get("resize_status") == "ready", f"hole diameter resize should be ready: {info}")
|
|
|
|
|
_assert(info.get("recognition_risk") != "blocked", f"ready hole candidate should not be globally blocked: {info}")
|
|
|
|
|
_assert(info.get("recognition_decision") != "已阻止", f"ready hole candidate should not be marked blocked: {info}")
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert(int(info.get("recognition_user_priority", 99)) == 20, f"hole should use hole priority: {info}")
|
2026-08-04 18:15:29 +08:00
|
|
|
_assert(not str(info.get("recognition_blockers") or ""), f"ready hole blockers should stay empty: {info}")
|
|
|
|
|
ready_actions = str(info.get("recognition_ready_actions") or "")
|
|
|
|
|
limited_actions = str(info.get("recognition_limited_actions") or "")
|
|
|
|
|
_assert("孔/槽/圆柱直径" in ready_actions, f"hole ready actions should include diameter resize: {info}")
|
|
|
|
|
_assert("盲孔/盲槽深度" in limited_actions, f"through-hole limited actions should include blind depth: {info}")
|
|
|
|
|
limitations = str(info.get("recognition_limitations") or "")
|
|
|
|
|
_assert(limitations, f"ready hole should still explain unsupported sub-capabilities: {info}")
|
|
|
|
|
_assert("受限能力" in str(info.get("recognition_summary") or ""), f"hole summary should show limitations: {info}")
|
|
|
|
|
_assert("可改:" in str(info.get("recognition_summary") or ""), f"hole summary should show ready actions: {info}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 18:08:32 +08:00
|
|
|
def _verify_quick_cylinder_recognition(root: Path) -> None:
|
|
|
|
|
hole_path = root / "quick_through_hole.step"
|
|
|
|
|
_write_through_hole_model(hole_path)
|
|
|
|
|
hole_model = StepModel.load(hole_path)
|
|
|
|
|
_hole_face_id, hole_info = _first_quick_candidate(
|
|
|
|
|
hole_model,
|
|
|
|
|
feature_guess="hole/groove candidate",
|
|
|
|
|
feature_type="圆柱孔候选",
|
|
|
|
|
)
|
|
|
|
|
_assert_summary(hole_info, "quick through-hole feature", {"surface", "user_operation_priority"})
|
|
|
|
|
_assert(int(hole_info.get("recognition_user_priority", 99)) == 20, f"quick hole should use hole priority: {hole_info}")
|
|
|
|
|
_assert("孔/槽/圆柱直径" in str(hole_info.get("recognition_ready_actions") or ""), f"quick hole should expose diameter: {hole_info}")
|
|
|
|
|
|
|
|
|
|
blind_path = root / "quick_blind_hole_cached.step"
|
|
|
|
|
_write_blind_hole_model(blind_path)
|
|
|
|
|
blind_model = StepModel.load(blind_path)
|
|
|
|
|
blind_face_id = _first_hole_face(blind_model, blind=True)
|
|
|
|
|
cached_quick = blind_model.quick_face_info(blind_face_id)
|
|
|
|
|
_assert(
|
|
|
|
|
cached_quick.get("feature_type") == "圆柱孔候选",
|
|
|
|
|
f"quick hole should keep its feature label after face_info cache is populated: {cached_quick}",
|
|
|
|
|
)
|
|
|
|
|
_assert(cached_quick.get("cylinder_end_type") == "blind", f"quick blind hole should expose end type: {cached_quick}")
|
|
|
|
|
_assert(cached_quick.get("depth_status") == "ready", f"quick blind hole depth should be ready: {cached_quick}")
|
|
|
|
|
_assert(
|
|
|
|
|
"盲孔/盲槽深度" in str(cached_quick.get("recognition_ready_actions") or ""),
|
|
|
|
|
f"quick blind hole should expose depth in ready actions: {cached_quick}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
slot_path = root / "quick_half_round_slot.step"
|
|
|
|
|
_write_half_round_slot_model(slot_path)
|
|
|
|
|
slot_model = StepModel.load(slot_path)
|
|
|
|
|
_slot_face_id, slot_info = _first_quick_candidate(
|
|
|
|
|
slot_model,
|
|
|
|
|
feature_guess="hole/groove candidate",
|
|
|
|
|
feature_type="槽/半孔候选",
|
|
|
|
|
)
|
|
|
|
|
_assert_summary(slot_info, "quick half-round slot feature", {"surface", "slot_geometry", "user_operation_priority"})
|
|
|
|
|
_assert(int(slot_info.get("recognition_user_priority", 99)) == 30, f"quick slot should use slot priority: {slot_info}")
|
|
|
|
|
_assert(slot_info.get("slot_status") == "candidate", f"quick slot should expose slot candidate fields: {slot_info}")
|
|
|
|
|
|
|
|
|
|
boss_path = root / "quick_boss.step"
|
|
|
|
|
_write_boss_model(boss_path)
|
|
|
|
|
boss_model = StepModel.load(boss_path)
|
|
|
|
|
_boss_face_id, boss_info = _first_quick_candidate(
|
|
|
|
|
boss_model,
|
|
|
|
|
feature_guess="boss/outer-round candidate",
|
|
|
|
|
feature_type="凸台/外圆候选",
|
|
|
|
|
)
|
|
|
|
|
_assert_summary(boss_info, "quick boss feature", {"surface", "user_operation_priority"})
|
|
|
|
|
_assert(int(boss_info.get("recognition_user_priority", 99)) == 40, f"quick boss should use boss priority: {boss_info}")
|
|
|
|
|
ready_actions = str(boss_info.get("recognition_ready_actions") or "")
|
|
|
|
|
limited_actions = str(boss_info.get("recognition_limited_actions") or "")
|
|
|
|
|
_assert("圆柱凸台直径/高度/轴心" in ready_actions, f"quick boss should expose boss editing: {boss_info}")
|
|
|
|
|
_assert("孔/槽/圆柱直径" not in ready_actions, f"quick boss should not be exposed as hole resize: {boss_info}")
|
|
|
|
|
_assert("孔/槽/圆柱直径" not in limited_actions, f"quick boss should not show cross-feature hole limits: {boss_info}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
def _verify_holed_planar_summary(root: Path) -> None:
|
|
|
|
|
path = root / "holed_plate.step"
|
|
|
|
|
_write_through_hole_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_planar_face_with_inner_boundary(model)
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert_summary(info, "holed planar Face", {"surface", "boundary_edges", "first_level_topology"})
|
|
|
|
|
_assert(info.get("local_face_deform_ready") is False, f"holed Face should block local deformation: {info}")
|
|
|
|
|
_assert(info.get("recognition_risk") != "blocked", f"holed Face should keep push/pull available: {info}")
|
|
|
|
|
_assert(info.get("recognition_decision") != "已阻止", f"holed Face should not be globally blocked: {info}")
|
|
|
|
|
_assert(not str(info.get("recognition_blockers") or ""), f"holed Face blockers should stay empty: {info}")
|
|
|
|
|
ready_actions = str(info.get("recognition_ready_actions") or "")
|
|
|
|
|
limited_actions = str(info.get("recognition_limited_actions") or "")
|
2026-08-05 15:08:16 +08:00
|
|
|
_assert("平面拉伸/切除" in ready_actions, f"holed Face should expose push/pull as ready: {info}")
|
2026-08-04 18:15:29 +08:00
|
|
|
_assert(
|
2026-08-05 15:08:16 +08:00
|
|
|
"局部重建尺寸/中心/偏移" in limited_actions,
|
2026-08-04 18:15:29 +08:00
|
|
|
f"holed Face should expose local deformation as limited: {info}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_slot_summary(root: Path) -> None:
|
|
|
|
|
path = root / "half_round_slot.step"
|
|
|
|
|
_write_half_round_slot_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_slot_face(model)
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert(info.get("slot_status") == "candidate", f"slot was not recognized: {info}")
|
|
|
|
|
_assert_summary(info, "half-round slot feature", {"surface", "material_votes", "slot_geometry"})
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_boss_summary(root: Path) -> None:
|
|
|
|
|
path = root / "boss.step"
|
|
|
|
|
_write_boss_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_boss_face(model)
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert(info.get("feature_guess") == "boss/outer-round candidate", f"boss was not recognized: {info}")
|
|
|
|
|
_assert_summary(info, "boss feature", {"surface", "material_votes", "first_level_topology"})
|
|
|
|
|
_assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
2026-08-14 18:42:39 +08:00
|
|
|
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}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
def _verify_torus_summary(root: Path) -> None:
|
|
|
|
|
path = root / "torus.step"
|
|
|
|
|
_write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_surface_face(model, "torus")
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert(info.get("feature_type") == "环面候选", f"torus was not recognized safely: {info}")
|
|
|
|
|
_assert(info.get("feature_highlight_face_ids") == (face_id,), f"torus highlight should stay on source Face: {info}")
|
|
|
|
|
_assert_summary(info, "torus feature", {"surface", "boundary_edges"})
|
2026-08-07 18:08:32 +08:00
|
|
|
_assert(int(info.get("recognition_user_priority", 0)) == 80, f"torus should be lower-priority analytic surface: {info}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_user_priority_scan_order(root: Path) -> None:
|
|
|
|
|
path = root / "through_hole_scan.step"
|
|
|
|
|
_write_through_hole_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
candidates = model.editable_feature_candidates(limit=12, detailed=False)
|
|
|
|
|
operations = tuple(str(item.get("operation_key")) for item in candidates)
|
|
|
|
|
_assert(operations, "editable feature scan returned no candidates")
|
|
|
|
|
_assert(operations[0] == "push_pull_plane", f"Face push/pull should be first in common-user scan order: {operations}")
|
|
|
|
|
if "resize_cylinder" in operations:
|
|
|
|
|
_assert(
|
|
|
|
|
operations.index("push_pull_plane") < operations.index("resize_cylinder"),
|
|
|
|
|
f"Face push/pull should rank before hole diameter: {operations}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 18:28:16 +08:00
|
|
|
def _verify_candidate_scan_cache(root: Path) -> None:
|
|
|
|
|
path = root / "candidate_cache.step"
|
|
|
|
|
_write_through_hole_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
|
|
|
|
|
first = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
|
|
|
|
|
_assert(first, "editable candidate cache probe returned no candidates")
|
|
|
|
|
cache = getattr(model, "_editable_feature_candidates_cache", {})
|
|
|
|
|
_assert(cache, "editable candidate scan should populate the model-level cache")
|
|
|
|
|
first[0]["operation_key"] = "mutated-by-caller"
|
|
|
|
|
second = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
|
|
|
|
|
_assert(
|
|
|
|
|
second[0].get("operation_key") != "mutated-by-caller",
|
|
|
|
|
"editable candidate cache should return defensive copies",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
|
|
|
|
|
_assert(cylinders, "cylindrical candidate cache probe returned no candidates")
|
|
|
|
|
cylinder_cache = getattr(model, "_cylindrical_feature_candidates_cache", {})
|
|
|
|
|
_assert(cylinder_cache, "cylindrical candidate scan should populate the model-level cache")
|
|
|
|
|
cylinders[0]["feature_guess"] = "mutated-by-caller"
|
|
|
|
|
second_cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
|
|
|
|
|
_assert(
|
|
|
|
|
second_cylinders[0].get("feature_guess") != "mutated-by-caller",
|
|
|
|
|
"cylindrical candidate cache should return defensive copies",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 18:08:32 +08:00
|
|
|
def _verify_ellipse_edge_scan_entries(root: Path) -> None:
|
|
|
|
|
path = root / "ellipse_edge_scan.step"
|
|
|
|
|
_write_ellipse_face_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
candidates = model.editable_feature_candidates(limit=20, detailed=False)
|
|
|
|
|
operations = tuple(str(item.get("operation_key")) for item in candidates)
|
|
|
|
|
labels = {str(item.get("current_value_label")) for item in candidates}
|
|
|
|
|
_assert(
|
|
|
|
|
"resize_ellipse_edge_major_radius" in operations,
|
|
|
|
|
f"ellipse Edge scan should expose major radius instead of generic length: {operations}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"resize_ellipse_edge_minor_radius" in operations,
|
|
|
|
|
f"ellipse Edge scan should expose minor radius instead of generic length: {operations}",
|
|
|
|
|
)
|
|
|
|
|
_assert("major_radius" in labels and "minor_radius" in labels, f"ellipse Edge scan labels are unclear: {labels}")
|
|
|
|
|
for item in candidates:
|
|
|
|
|
if item.get("operation_key") == "resize_edge_length" and item.get("current_value_label") == "length":
|
|
|
|
|
raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
2026-08-10 14:56:50 +08:00
|
|
|
def _verify_complex_slot_guard(root: Path) -> None:
|
|
|
|
|
path = root / "cross_obround_slot.step"
|
|
|
|
|
_write_cross_obround_slot_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
blocked_slot_faces: list[int] = []
|
|
|
|
|
for face_id in range(len(model.faces)):
|
|
|
|
|
info = model.face_info(face_id)
|
|
|
|
|
if info.get("surface") != "cylinder":
|
|
|
|
|
continue
|
|
|
|
|
feature = model.feature_info(face_id)
|
|
|
|
|
if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked":
|
|
|
|
|
blocked_slot_faces.append(face_id)
|
|
|
|
|
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
|
|
|
|
|
_assert("交叉槽" in blockers or "多槽组" in blockers, f"complex slot blocker is unclear: {feature}")
|
|
|
|
|
_assert("复杂槽" in str(feature.get("feature_type") or ""), f"complex slot label is unclear: {feature}")
|
|
|
|
|
_assert(blocked_slot_faces, "cross obround slot should be recognized as blocked complex slots")
|
|
|
|
|
|
|
|
|
|
candidates = model.editable_feature_candidates(limit=80, detailed=False)
|
|
|
|
|
forbidden_actions = {
|
|
|
|
|
"resize_cylinder",
|
|
|
|
|
"resize_slot_width",
|
|
|
|
|
"resize_slot_depth",
|
|
|
|
|
"resize_slot_arc_length",
|
|
|
|
|
"resize_slot_angular_span",
|
|
|
|
|
}
|
|
|
|
|
leaked = [
|
|
|
|
|
(item.get("operation_key"), item.get("target_id"))
|
|
|
|
|
for item in candidates
|
|
|
|
|
if int(item.get("target_id", -1)) in blocked_slot_faces and item.get("operation_key") in forbidden_actions
|
|
|
|
|
]
|
|
|
|
|
_assert(not leaked, f"complex slot should not leak editable scan entries: {leaked}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_mixed_radius_fillet_chain_guard(root: Path) -> None:
|
|
|
|
|
path = root / "mixed_radius_fillet_chain.step"
|
|
|
|
|
_write_mixed_radius_filleted_box_model(path, 1.0, 1.8)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_existing_fillet_face(model, 1.0, 2e-4)
|
|
|
|
|
feature = model.feature_info(face_id)
|
|
|
|
|
_assert(feature.get("existing_fillet_status") == "blocked", f"mixed-radius fillet chain should be blocked: {feature}")
|
|
|
|
|
_assert(
|
|
|
|
|
feature.get("existing_fillet_chain_status") == "variable-radius-chain-candidate",
|
|
|
|
|
f"mixed-radius fillet chain should expose variable-radius status: {feature}",
|
|
|
|
|
)
|
|
|
|
|
blockers = str(feature.get("recognition_blockers") or feature.get("existing_fillet_blockers") or "")
|
|
|
|
|
_assert("变半径" in blockers, f"mixed-radius fillet blocker should mention variable radius: {feature}")
|
|
|
|
|
_assert(feature.get("recognition_decision") == "已阻止", f"mixed-radius fillet should be globally blocked: {feature}")
|
|
|
|
|
_assert(
|
|
|
|
|
"已有圆角半径" not in str(feature.get("recognition_ready_actions") or ""),
|
|
|
|
|
f"mixed-radius fillet chain should not be ready-editable: {feature}",
|
|
|
|
|
)
|
|
|
|
|
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
|
|
|
|
|
candidates = model.editable_feature_candidates(limit=40, detailed=False)
|
|
|
|
|
leaked = [
|
|
|
|
|
(item.get("operation_key"), item.get("target_id"))
|
|
|
|
|
for item in candidates
|
|
|
|
|
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
|
|
|
|
|
]
|
|
|
|
|
_assert(not leaked, f"mixed-radius fillet chain should not leak editable scan entries: {leaked}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_open_shell_context_summary(root: Path) -> None:
|
|
|
|
|
path = root / "open_thin_wall_box.step"
|
|
|
|
|
_write_open_thin_wall_box_model(path)
|
|
|
|
|
model = StepModel.load(path)
|
|
|
|
|
face_id = _first_open_shell_wall_face(model, 2.0, 2e-4)
|
|
|
|
|
info = model.feature_info(face_id)
|
|
|
|
|
_assert(info.get("open_shell_context_status") == "limited", f"open shell context should be limited: {info}")
|
|
|
|
|
_assert_summary(info, "open shell context", {"surface", "first_level_topology", "open_shell_context"})
|
|
|
|
|
_assert(
|
|
|
|
|
"完整抽壳/开口面编辑" in str(info.get("recognition_limited_actions") or ""),
|
|
|
|
|
f"open shell should list full shell/opening edit as limited: {info}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"完整抽壳/开口面" in str(info.get("recognition_limitations") or ""),
|
|
|
|
|
f"open shell limitation should explain unsupported complete shell edit: {info}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"壳体厚度" in str(info.get("recognition_ready_actions") or ""),
|
|
|
|
|
f"open shell wall should keep local shell thickness ready: {info}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
def main() -> int:
|
|
|
|
|
for key in (
|
|
|
|
|
"recognition_summary",
|
|
|
|
|
"recognition_candidate",
|
|
|
|
|
"recognition_confidence",
|
|
|
|
|
"recognition_risk",
|
|
|
|
|
"recognition_score",
|
|
|
|
|
"recognition_decision",
|
2026-08-07 18:08:32 +08:00
|
|
|
"recognition_user_priority",
|
|
|
|
|
"recognition_user_priority_label",
|
|
|
|
|
"recognition_user_priority_reason",
|
2026-08-04 18:15:29 +08:00
|
|
|
"recognition_evidence",
|
2026-08-14 18:42:39 +08:00
|
|
|
"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",
|
2026-08-04 18:15:29 +08:00
|
|
|
"recognition_ready_actions",
|
|
|
|
|
"recognition_limited_actions",
|
|
|
|
|
"recognition_blockers",
|
|
|
|
|
"recognition_limitations",
|
|
|
|
|
):
|
|
|
|
|
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
|
|
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="geom_param_recognition_summary_") as temp_dir:
|
|
|
|
|
root = Path(temp_dir)
|
|
|
|
|
_verify_planar_summary(root)
|
|
|
|
|
_verify_holed_planar_summary(root)
|
2026-08-07 18:08:32 +08:00
|
|
|
_verify_quick_cylinder_recognition(root)
|
2026-08-04 18:15:29 +08:00
|
|
|
_verify_hole_summary(root)
|
|
|
|
|
_verify_slot_summary(root)
|
2026-08-07 18:08:32 +08:00
|
|
|
_verify_boss_summary(root)
|
2026-08-14 18:42:39 +08:00
|
|
|
_verify_asitus_slot_boss_fillet_hints(root)
|
2026-08-04 18:15:29 +08:00
|
|
|
_verify_torus_summary(root)
|
2026-08-07 18:08:32 +08:00
|
|
|
_verify_user_priority_scan_order(root)
|
2026-08-11 18:28:16 +08:00
|
|
|
_verify_candidate_scan_cache(root)
|
2026-08-07 18:08:32 +08:00
|
|
|
_verify_ellipse_edge_scan_entries(root)
|
2026-08-10 14:56:50 +08:00
|
|
|
_verify_complex_slot_guard(root)
|
|
|
|
|
_verify_mixed_radius_fillet_chain_guard(root)
|
|
|
|
|
_verify_open_shell_context_summary(root)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
print("feature recognition summary ok")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|