feat: 完善 Face 一级关系编辑和稳定性校验

This commit is contained in:
2026-08-04 18:15:29 +08:00
parent 5799d5d813
commit a76282d7dd
28 changed files with 6872 additions and 270 deletions
@@ -17,6 +17,7 @@ from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_actions import WindowActionMixin
from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402
class _Probe(WindowActionMixin):
@@ -194,6 +195,183 @@ def main() -> int:
after_model=edited_cube,
),
)
rollback_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
rollback_before_stats = rollback_cube.stats()
rollback_plan = rollback_cube.face_area_local_resize_plan(0, 144.0)
rollback_plan["target_area"] = 999.0
_expect_blocked(
"Face model-layer rollback on failed target check",
lambda: rollback_cube._run_checked_face_edit(
rollback_plan,
lambda: rollback_cube._apply_local_face_deform(rollback_plan),
),
)
if rollback_cube.stats() != rollback_before_stats:
raise SystemExit(
"Face model-layer rollback should restore topology stats: "
f"before={rollback_before_stats}, after={rollback_cube.stats()}"
)
if abs(float(rollback_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
raise SystemExit("Face model-layer rollback should restore the original Face area")
topology_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
topology_before_stats = topology_guard_cube.stats()
topology_plan = topology_guard_cube.face_area_local_resize_plan(0, 144.0)
original_topology_method = topology_guard_cube.face_first_level_topology
def apply_face_then_weaken_first_level_topology():
topology_guard_cube._apply_local_face_deform(topology_plan)
def weak_topology(face_id: int) -> dict[str, object]:
topology = dict(original_topology_method(face_id))
topology["first_level_boundary_edge_count"] = 3
topology["first_level_boundary_vertex_count"] = 3
topology["first_level_adjacent_face_count"] = 1
return topology
topology_guard_cube.face_first_level_topology = weak_topology
try:
_expect_blocked(
"Face model-layer rollback on weakened first-level topology",
lambda: topology_guard_cube._run_checked_face_edit(
topology_plan,
apply_face_then_weaken_first_level_topology,
),
)
finally:
topology_guard_cube.face_first_level_topology = original_topology_method
if topology_guard_cube.stats() != topology_before_stats:
raise SystemExit(
"Face model-layer topology rollback should restore topology stats: "
f"before={topology_before_stats}, after={topology_guard_cube.stats()}"
)
if abs(float(topology_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
raise SystemExit("Face model-layer topology rollback should restore the original Face area")
fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
fact_before_stats = fact_guard_cube.stats()
fact_plan = fact_guard_cube.face_area_local_resize_plan(0, 144.0)
original_fact_method = fact_guard_cube.face_first_level_facts
def apply_face_then_weaken_first_level_facts():
fact_guard_cube._apply_local_face_deform(fact_plan)
def weak_facts(face_id: int, scope: str = "auto") -> dict[str, object]:
facts = dict(original_fact_method(face_id, scope=scope))
facts["first_level_fact_boundary_edge_count"] = 1
facts["first_level_fact_boundary_vertex_count"] = 1
facts["first_level_fact_adjacent_face_count"] = 0
facts["first_level_fact_ignored_relation_depths"] = ("second-level",)
return facts
fact_guard_cube.face_first_level_facts = weak_facts
try:
_expect_blocked(
"Face model-layer rollback on weakened first-level fact graph",
lambda: fact_guard_cube._run_checked_face_edit(
fact_plan,
apply_face_then_weaken_first_level_facts,
),
)
finally:
fact_guard_cube.face_first_level_facts = original_fact_method
if fact_guard_cube.stats() != fact_before_stats:
raise SystemExit(
"Face model-layer fact-graph rollback should restore topology stats: "
f"before={fact_before_stats}, after={fact_guard_cube.stats()}"
)
if abs(float(fact_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
raise SystemExit("Face model-layer fact-graph rollback should restore the original Face area")
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_coplanar_area_") as temp_dir:
split_path = Path(temp_dir) / "split_top_box.step"
_write_split_top_box(split_path)
split_model = StepModel.load(split_path)
top_faces = _top_faces(split_model, 10.0, 2e-4)
if len(top_faces) != 2:
raise SystemExit(f"coplanar area guard expected two top Faces, got {top_faces}")
split_plan = split_model.push_pull_plan(top_faces[0], 1.0)
if split_plan["status"] == "blocked":
raise SystemExit(f"coplanar area guard plan was blocked: {split_plan['message']}")
original_scope_area = float(split_plan.get("push_pull_scope_area") or 0.0)
if abs(original_scope_area - 100.0) > 2e-4:
raise SystemExit(f"coplanar area guard expected source area 100, got {original_scope_area:g}")
split_model.push_pull_face(top_faces[0], 1.0)
split_plan["push_pull_scope_area"] = original_scope_area * 2.0
_expect_blocked(
"Face push/pull scope area mismatch",
lambda: split_model._checked_face_edit_result_summary(split_plan),
)
plan_fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
plan_info = plan_fact_guard_cube.face_info(0)
plan_center = plan_info.get("area_center") or plan_info.get("bbox_center")
if not isinstance(plan_center, tuple) or len(plan_center) != 3:
raise SystemExit(f"Face plan guard probe needs a stable center: {plan_info}")
plan_width = float(plan_info.get("local_face_width") or 0.0)
plan_height = float(plan_info.get("local_face_height") or 0.0)
if plan_width <= 0 or plan_height <= 0:
raise SystemExit(f"Face plan guard probe needs local width/height: {plan_info}")
original_fact_plan_method = plan_fact_guard_cube._first_level_fact_plan_fields
def unavailable_fact_plan(face_id: int, scope: str) -> dict[str, object]:
return {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_source_model": scope,
"first_level_fact_status": "unavailable",
"first_level_fact_relation_depth": 1,
"first_level_fact_relation_boundary": "shared-edge",
"first_level_fact_scope": scope,
"first_level_fact_subject_role": "selected Face",
"first_level_fact_subject_face_ids": (face_id,),
"first_level_fact_subject_face_count": 1,
"first_level_fact_boundary_edge_ids": (),
"first_level_fact_boundary_edge_count": 0,
"first_level_fact_boundary_vertex_points": (),
"first_level_fact_boundary_vertex_count": 0,
"first_level_fact_adjacent_face_ids": (),
"first_level_fact_adjacent_face_count": 0,
"first_level_fact_included_face_ids": (face_id,),
"first_level_fact_included_face_count": 1,
"first_level_fact_role_groups": (),
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"first_level_fact_summary": "test-only unavailable first-level fact graph",
}
plan_fact_guard_cube._first_level_fact_plan_fields = unavailable_fact_plan
try:
local_plans = (
(
"Face local center plan",
plan_fact_guard_cube.face_center_local_move_plan(
0,
(float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])),
),
),
("Face local area plan", plan_fact_guard_cube.face_area_local_resize_plan(0, 144.0)),
("Face local width plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_width * 1.2, "width")),
("Face local height plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_height * 1.2, "height")),
("Face local plane offset plan", plan_fact_guard_cube.face_plane_offset_local_plan(0, 1.0)),
)
for label, plan in local_plans:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked when first-level facts are unavailable: {plan}")
blocker_text = str(plan.get("message") or plan.get("blockers") or "")
if "一级事实图" not in blocker_text:
raise SystemExit(f"{label} should explain the first-level fact blocker: {plan}")
owning_plan = plan_fact_guard_cube.face_center_owning_translation_plan(
0,
(float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])),
)
if owning_plan.get("status") == "blocked":
raise SystemExit(
"Owning-shape Face translation should not be blocked by local first-level fact guard: "
f"{owning_plan}"
)
finally:
plan_fact_guard_cube._first_level_fact_plan_fields = original_fact_plan_method
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_sphere_") as temp_dir:
sphere_path = Path(temp_dir) / "sphere.step"
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path)
@@ -299,6 +477,35 @@ def main() -> int:
after_model=edited_cone,
),
)
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_cone_rollback_") as temp_dir:
cone_path = Path(temp_dir) / "simple_cone.step"
_write_step(BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape(), cone_path)
rollback_cone = StepModel.load(cone_path)
cone_face_id = _first_face_by_surface(rollback_cone, "cone")
before_stats = rollback_cone.stats()
before_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0)
rollback_plan = rollback_cone.conical_reference_radius_plan(cone_face_id, 5.0)
def apply_cone_then_fail_target_check():
applied = rollback_cone._apply_conical_analytic_rebuild_if_simple(rollback_plan)
rollback_plan["target_reference_radius"] = 999.0
return applied
_expect_blocked(
"Conical Face model-layer rollback on failed target check",
lambda: rollback_cone._run_checked_face_edit(rollback_plan, apply_cone_then_fail_target_check),
)
if rollback_cone.stats() != before_stats:
raise SystemExit(
"Conical Face model-layer rollback should restore topology stats: "
f"before={before_stats}, after={rollback_cone.stats()}"
)
restored_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0)
if abs(restored_radius - before_radius) > 1e-5:
raise SystemExit(
"Conical Face model-layer rollback should restore the original reference radius: "
f"before={before_radius:g}, after={restored_radius:g}"
)
_expect_allowed(
"Non-Face edit solid-count change only warns",
lambda: probe._verified_edit_quality_warnings(