2026-08-04 18:15:29 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-08-10 18:05:18 +08:00
|
|
|
from step_editor.geometry_utils import _int_values
|
2026-08-04 18:15:29 +08:00
|
|
|
from step_editor.model import StepModel
|
|
|
|
|
from step_editor.ui_helpers import INFO_LABELS
|
|
|
|
|
from step_editor.window_state import WindowStateMixin
|
|
|
|
|
|
2026-08-10 18:05:18 +08:00
|
|
|
from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402
|
|
|
|
|
from verify_face_mixed_surface_guard import _top_planar_cap_face, _write_hollow_cylinder # noqa: E402
|
2026-08-04 18:15:29 +08:00
|
|
|
from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402
|
|
|
|
|
from verify_shell_thickness_resize import _first_shell_face, _write_plate_model # noqa: E402
|
|
|
|
|
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FactProbe(WindowStateMixin):
|
|
|
|
|
def __init__(self, model: StepModel) -> None:
|
|
|
|
|
self.model = model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert(condition: bool, message: str) -> None:
|
|
|
|
|
if not condition:
|
|
|
|
|
raise AssertionError(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _center(info: dict[str, object]) -> tuple[float, float, float]:
|
|
|
|
|
value = info.get("area_center") or info.get("bbox_center")
|
|
|
|
|
if not isinstance(value, tuple) or len(value) != 3:
|
|
|
|
|
raise AssertionError(f"Face has no stable center: {info}")
|
|
|
|
|
return float(value[0]), float(value[1]), float(value[2])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _number(value: object, default: float) -> float:
|
|
|
|
|
try:
|
|
|
|
|
return float(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _top_plane_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 = _center(info)
|
|
|
|
|
if best is None or center[2] > best[0]:
|
|
|
|
|
best = (center[2], face_id)
|
|
|
|
|
if best is None:
|
|
|
|
|
raise AssertionError("No planar Face found.")
|
|
|
|
|
return best[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_common_facts(facts: dict[str, object], label: str) -> None:
|
|
|
|
|
_assert(facts.get("first_level_fact_model") == "STEP/B-Rep first-level fact graph", f"{label}: bad model")
|
|
|
|
|
_assert(facts.get("first_level_fact_status") == "ready", f"{label}: bad status {facts}")
|
|
|
|
|
_assert(facts.get("first_level_fact_relation_depth") == 1, f"{label}: bad relation depth")
|
|
|
|
|
_assert(facts.get("first_level_fact_relation_boundary") == "shared-edge", f"{label}: bad boundary")
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_fact_subject_face_count", 0) or 0) >= 1,
|
|
|
|
|
f"{label}: missing subject Faces",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) >= 1,
|
|
|
|
|
f"{label}: missing boundary Edges",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) >= 1,
|
|
|
|
|
f"{label}: missing first-level adjacent Faces",
|
|
|
|
|
)
|
|
|
|
|
ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ())
|
|
|
|
|
_assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing")
|
|
|
|
|
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing")
|
2026-08-10 14:56:50 +08:00
|
|
|
_assert("first_level_planar_relation_summary" in facts, f"{label}: planar relation summary is missing")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None:
|
|
|
|
|
_assert_common_facts(plan, label)
|
|
|
|
|
_assert(plan.get("first_level_fact_scope") == scope, f"{label}: wrong fact scope {plan}")
|
|
|
|
|
_assert(
|
|
|
|
|
int(plan.get("first_level_fact_included_face_count", 0) or 0) >=
|
|
|
|
|
int(plan.get("first_level_fact_subject_face_count", 0) or 0),
|
|
|
|
|
f"{label}: included facts should cover subject Faces",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_planar_face_facts() -> None:
|
|
|
|
|
model = StepModel.load(DEFAULT_MODEL)
|
|
|
|
|
face_id = _top_plane_face(model)
|
|
|
|
|
info = model.face_info(face_id)
|
|
|
|
|
facts = model.face_first_level_facts(face_id, scope="face")
|
|
|
|
|
_assert_common_facts(facts, "cube planar Face")
|
|
|
|
|
_assert(facts.get("first_level_fact_scope") == "face", f"cube planar Face: wrong scope {facts}")
|
|
|
|
|
_assert(int(facts.get("first_level_fact_subject_face_count", 0) or 0) == 1, f"cube subject count: {facts}")
|
|
|
|
|
_assert(int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) == 4, f"cube boundary count: {facts}")
|
|
|
|
|
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}")
|
|
|
|
|
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}")
|
|
|
|
|
_assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}")
|
2026-08-10 14:56:50 +08:00
|
|
|
_assert(
|
|
|
|
|
facts.get("first_level_planar_relation_status") == "ready",
|
|
|
|
|
f"cube planar Face should expose planar relation facts: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_planar_relation_count", 0) or 0) == 4,
|
|
|
|
|
f"cube top Face should have four subject-adjacent planar relations: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) == 4,
|
|
|
|
|
f"cube top Face should have four perpendicular adjacent side Faces: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"一级平面关系" in str(facts.get("first_level_fact_summary") or ""),
|
|
|
|
|
f"cube planar Face fact summary should include planar relation facts: {facts}",
|
|
|
|
|
)
|
2026-08-10 18:05:18 +08:00
|
|
|
_assert(facts.get("first_level_same_domain_status") == "ready", f"cube same-domain status: {facts}")
|
|
|
|
|
_assert(
|
|
|
|
|
facts.get("first_level_same_domain_relation") == "coplanar",
|
|
|
|
|
f"cube same-domain relation should be coplanar: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_same_domain_face_count", 0) or 0) == 1,
|
|
|
|
|
f"cube should expose a single same-domain planar Face: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"first_level_coaxial_cylinder_summary" in facts,
|
|
|
|
|
f"cube should still expose coaxial-cylinder fact fields: {facts}",
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
probe = _FactProbe(model)
|
|
|
|
|
fields = probe._face_first_level_selection_fields(face_id)
|
|
|
|
|
_assert(fields.get("first_level_fact_summary"), f"selection fields should expose fact summary: {fields}")
|
|
|
|
|
enriched = model.quick_face_info(face_id)
|
|
|
|
|
enriched.update(fields)
|
|
|
|
|
enriched.update(model._recognition_summary_fields(enriched)) # noqa: SLF001
|
|
|
|
|
_assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"summary missed facts: {enriched}")
|
|
|
|
|
|
|
|
|
|
center = _center(info)
|
|
|
|
|
area = _number(info.get("area"), 100.0)
|
|
|
|
|
width = _number(info.get("local_face_width"), 10.0)
|
|
|
|
|
plans = (
|
|
|
|
|
(
|
|
|
|
|
model.face_center_local_move_plan(face_id, (center[0], center[1], center[2] + 2.0)),
|
|
|
|
|
"cube Face center local move plan",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
model.face_center_owning_translation_plan(face_id, (center[0], center[1], center[2] + 2.0)),
|
|
|
|
|
"cube Face center owning translation plan",
|
|
|
|
|
),
|
|
|
|
|
(model.face_area_local_resize_plan(face_id, area * 1.2), "cube Face area local resize plan"),
|
|
|
|
|
(model.face_area_scale_plan(face_id, area * 1.2), "cube Face area owning scale plan"),
|
|
|
|
|
(model.face_size_local_resize_plan(face_id, width * 1.2, "width"), "cube Face width local resize plan"),
|
|
|
|
|
(model.face_size_owning_scale_plan(face_id, width * 1.2, "width"), "cube Face width owning scale plan"),
|
|
|
|
|
(model.face_plane_offset_local_plan(face_id, 2.0), "cube Face plane offset local plan"),
|
|
|
|
|
(model.face_plane_offset_owning_translation_plan(face_id, 2.0), "cube Face plane offset owning plan"),
|
|
|
|
|
(model.push_pull_plan(face_id, 2.0), "cube Face push-pull plan"),
|
|
|
|
|
)
|
|
|
|
|
for plan, label in plans:
|
|
|
|
|
_assert_plan_facts(plan, label, "face")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 18:05:18 +08:00
|
|
|
def _verify_coplanar_same_domain_facts(root: Path) -> None:
|
|
|
|
|
split_path = root / "split_top_box.step"
|
|
|
|
|
_write_split_top_box(split_path)
|
|
|
|
|
model = StepModel.load(split_path)
|
|
|
|
|
top_faces = _top_faces(model, 10.0, 2e-4)
|
|
|
|
|
_assert(len(top_faces) == 2, f"split-top box should have two coplanar top Faces, got {top_faces}")
|
|
|
|
|
|
|
|
|
|
face_id = top_faces[0]
|
|
|
|
|
facts = model.face_first_level_facts(face_id, scope="face")
|
|
|
|
|
_assert_common_facts(facts, "split coplanar Face")
|
|
|
|
|
_assert(
|
|
|
|
|
facts.get("first_level_same_domain_status") == "ready",
|
|
|
|
|
f"split coplanar Face should expose same-domain facts: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
facts.get("first_level_same_domain_relation") == "coplanar",
|
|
|
|
|
f"split coplanar Face should be marked coplanar: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
set(_int_values(facts.get("first_level_same_domain_face_ids"))) == set(top_faces),
|
|
|
|
|
f"split coplanar facts should include both top Faces: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_same_domain_fragment_face_count", 0) or 0) == 1,
|
|
|
|
|
f"split coplanar facts should expose one synchronized fragment: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
"共面" in str(facts.get("first_level_same_domain_summary") or ""),
|
|
|
|
|
f"split coplanar summary should mention coplanar relation: {facts}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
plan = model.push_pull_plan(face_id, 1.0)
|
|
|
|
|
_assert_plan_facts(plan, "split coplanar push-pull plan", "face")
|
|
|
|
|
_assert(
|
|
|
|
|
set(_int_values(plan.get("first_level_same_domain_face_ids"))) == set(top_faces),
|
|
|
|
|
f"split coplanar plan should carry same-domain facts: {plan}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_coaxial_cylinder_facts(root: Path) -> None:
|
|
|
|
|
hollow_path = root / "hollow_cylinder.step"
|
|
|
|
|
_write_hollow_cylinder(hollow_path)
|
|
|
|
|
model = StepModel.load(hollow_path)
|
|
|
|
|
cap_face_id = _top_planar_cap_face(model)
|
|
|
|
|
facts = model.face_first_level_facts(cap_face_id, scope="face")
|
|
|
|
|
_assert_common_facts(facts, "hollow cylinder cap")
|
|
|
|
|
_assert(
|
|
|
|
|
facts.get("first_level_coaxial_cylinder_status") == "ready",
|
|
|
|
|
f"hollow cylinder cap should expose ready coaxial-cylinder facts: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_coaxial_cylinder_count", 0) or 0) >= 1,
|
|
|
|
|
f"hollow cylinder cap should have at least one coaxial cylinder relation: {facts}",
|
|
|
|
|
)
|
|
|
|
|
_assert(
|
|
|
|
|
int(facts.get("first_level_coaxial_cylinder_face_count", 0) or 0) >= 2,
|
|
|
|
|
f"hollow cylinder cap should include inner and outer cylindrical Faces: {facts}",
|
|
|
|
|
)
|
|
|
|
|
rows = tuple(row for row in facts.get("first_level_coaxial_cylinder_rows", ()) or () if isinstance(row, dict))
|
|
|
|
|
radii = sorted({round(float(radius), 4) for row in rows for radius in tuple(row.get("radii") or ())})
|
|
|
|
|
_assert(radii == [3.0, 8.0], f"hollow cylinder cap should expose inner/outer coaxial radii, got {radii}: {facts}")
|
|
|
|
|
_assert(
|
|
|
|
|
"同轴圆柱" in str(facts.get("first_level_coaxial_cylinder_summary") or ""),
|
|
|
|
|
f"hollow cylinder cap summary should mention coaxial cylinders: {facts}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
plan = model.push_pull_plan(cap_face_id, 1.0)
|
|
|
|
|
_assert_plan_facts(plan, "hollow cylinder cap push-pull plan", "face")
|
|
|
|
|
_assert(
|
|
|
|
|
plan.get("first_level_coaxial_cylinder_status") == "ready",
|
|
|
|
|
f"hollow cylinder cap plan should carry coaxial-cylinder facts: {plan}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
def _verify_shell_plan_facts(root: Path) -> None:
|
|
|
|
|
shell_path = root / "thin_plate.step"
|
|
|
|
|
_write_plate_model(shell_path)
|
|
|
|
|
model = StepModel.load(shell_path)
|
|
|
|
|
face_id = _first_shell_face(model, 2.0, 2e-4)
|
|
|
|
|
plans = (
|
|
|
|
|
(model.shell_thickness_plan(face_id, 3.0), "thin wall thickness local plan"),
|
|
|
|
|
(model.shell_thickness_owning_scale_plan(face_id, 3.0), "thin wall thickness owning plan"),
|
|
|
|
|
)
|
|
|
|
|
for plan, label in plans:
|
|
|
|
|
_assert_plan_facts(plan, label, "face")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_cylindrical_facts(root: Path) -> None:
|
|
|
|
|
hole_path = root / "through_hole.step"
|
|
|
|
|
_write_through_hole_model(hole_path)
|
|
|
|
|
hole_model = StepModel.load(hole_path)
|
|
|
|
|
hole_face_id = _first_hole_face(hole_model, blind=False)
|
|
|
|
|
hole_facts = hole_model.face_first_level_facts(hole_face_id, scope="cylindrical-feature")
|
|
|
|
|
_assert_common_facts(hole_facts, "through-hole cylinder")
|
|
|
|
|
_assert(
|
|
|
|
|
hole_facts.get("first_level_fact_scope") == "cylindrical-feature",
|
|
|
|
|
f"through-hole cylinder: wrong scope {hole_facts}",
|
|
|
|
|
)
|
|
|
|
|
role_groups = tuple(hole_facts.get("first_level_fact_role_groups") or ())
|
|
|
|
|
_assert(any(isinstance(item, dict) and item.get("role") == "cylindrical-side" for item in role_groups), role_groups)
|
|
|
|
|
_assert(any(isinstance(item, dict) and item.get("role") == "end/opening" for item in role_groups), role_groups)
|
|
|
|
|
|
|
|
|
|
probe = _FactProbe(hole_model)
|
|
|
|
|
fields = probe._cylindrical_first_level_selection_fields(hole_face_id)
|
|
|
|
|
enriched = hole_model.feature_info(hole_face_id)
|
|
|
|
|
enriched.update(fields)
|
|
|
|
|
enriched.update(hole_model._recognition_summary_fields(enriched)) # noqa: SLF001
|
|
|
|
|
_assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"hole summary missed facts: {enriched}")
|
|
|
|
|
|
|
|
|
|
diameter = _number(hole_model.face_info(hole_face_id).get("diameter"), 6.0)
|
|
|
|
|
plan = hole_model.cylindrical_resize_plan(hole_face_id, diameter * 1.2)
|
|
|
|
|
_assert_plan_facts(plan, "through-hole diameter resize plan", "cylindrical-feature")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_slot_facts(root: Path) -> None:
|
|
|
|
|
slot_path = root / "half_round_slot.step"
|
|
|
|
|
_write_half_round_slot_model(slot_path)
|
|
|
|
|
model = StepModel.load(slot_path)
|
|
|
|
|
face_id = _first_slot_face(model)
|
|
|
|
|
facts = model.face_first_level_facts(face_id, scope="cylindrical-feature")
|
|
|
|
|
_assert_common_facts(facts, "half-round slot")
|
|
|
|
|
role_groups = tuple(facts.get("first_level_fact_role_groups") or ())
|
|
|
|
|
_assert(any(isinstance(item, dict) and item.get("role") == "slot-boundary" for item in role_groups), role_groups)
|
|
|
|
|
|
|
|
|
|
feature = model.feature_info(face_id)
|
|
|
|
|
width = _number(feature.get("slot_width"), _number(model.face_info(face_id).get("diameter"), 6.0))
|
|
|
|
|
plan = model.cylindrical_slot_resize_plan(face_id, width * 1.1, "width")
|
|
|
|
|
_assert_plan_facts(plan, "half-round slot width resize plan", "cylindrical-feature")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
for key in (
|
|
|
|
|
"first_level_fact_model",
|
|
|
|
|
"first_level_fact_status",
|
|
|
|
|
"first_level_fact_summary",
|
|
|
|
|
"first_level_fact_subject_face_count",
|
|
|
|
|
"first_level_fact_adjacent_face_count",
|
2026-08-10 18:05:18 +08:00
|
|
|
"first_level_same_domain_summary",
|
|
|
|
|
"first_level_coaxial_cylinder_summary",
|
2026-08-04 18:15:29 +08:00
|
|
|
):
|
|
|
|
|
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
|
|
|
|
|
|
|
|
|
|
_verify_planar_face_facts()
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="geom_param_first_level_facts_") as temp_dir:
|
|
|
|
|
root = Path(temp_dir)
|
2026-08-10 18:05:18 +08:00
|
|
|
_verify_coplanar_same_domain_facts(root)
|
|
|
|
|
_verify_coaxial_cylinder_facts(root)
|
2026-08-04 18:15:29 +08:00
|
|
|
_verify_shell_plan_facts(root)
|
|
|
|
|
_verify_cylindrical_facts(root)
|
|
|
|
|
_verify_slot_facts(root)
|
|
|
|
|
|
|
|
|
|
print("first-level fact graph ok")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|