from __future__ import annotations import math from pathlib import Path import sys import tempfile import time from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, BRepPrimAPI_MakeCylinder from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec 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.model import StepModel from step_editor.geometry_utils import _finalize_boolean_result from step_editor.step_io import _write_step from step_editor.window_actions import WindowActionMixin from step_editor.window_state import WindowStateMixin class _ActionProbe(WindowActionMixin): def __init__(self, model: StepModel, path: Path) -> None: self.model = model self.step_path = path class _PropertyProbe(WindowStateMixin): def __init__(self, model: object | None = None, face_id: int = 0) -> None: self.model = object() if model is None else model self.operation_in_progress = False self.scan_in_progress = False self.load_in_progress = False self.selected_face_id = face_id self.selected_edge_id = None self.selected_kind = "feature" self.selected_part_id = 1 self.selected_solid_id = 1 self.manual_bottom_face_id = None self.manual_slot_pair_face_id = None def _write_one_degree_cone(path: Path) -> None: height = 10.0 top_radius = 1.0 bottom_radius = top_radius + height * math.tan(math.radians(1.0)) _write_step(BRepPrimAPI_MakeCone(bottom_radius, top_radius, height).Shape(), path) def _write_one_degree_tip_cone(path: Path) -> None: height = 10.0 bottom_radius = height * math.tan(math.radians(1.0)) _write_step(BRepPrimAPI_MakeCone(bottom_radius, 0.0, height).Shape(), path) def _write_rotated_translated_cone(path: Path) -> None: shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape() rotate = gp_Trsf() rotate.SetRotation(gp_Ax1(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 1.0, 0.0)), math.radians(37.0)) rotated = BRepBuilderAPI_Transform(shape, rotate, True).Shape() translate = gp_Trsf() translate.SetTranslation(gp_Vec(13.0, -4.0, 7.0)) moved = BRepBuilderAPI_Transform(rotated, translate, True).Shape() _write_step(moved, path) def _write_embedded_countersink(path: Path) -> None: block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape() cutter = BRepPrimAPI_MakeCone( gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)), 2.0, 5.0, 5.0, ).Shape() cut = BRepAlgoAPI_Cut(block, cutter) _write_step(_finalize_boolean_result(cut, "verify embedded countersink cone cut"), path) def _write_countersunk_through_hole(path: Path) -> None: block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape() through_hole = BRepPrimAPI_MakeCylinder( gp_Ax2(gp_Pnt(15.0, 15.0, -0.5), gp_Dir(0.0, 0.0, 1.0)), 2.0, 11.0, ).Shape() cut_hole = BRepAlgoAPI_Cut(block, through_hole) shape = _finalize_boolean_result(cut_hole, "verify countersunk through-hole cylinder cut") countersink = BRepPrimAPI_MakeCone( gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)), 2.0, 5.0, 5.0, ).Shape() cut_sink = BRepAlgoAPI_Cut(shape, countersink) _write_step(_finalize_boolean_result(cut_sink, "verify countersunk through-hole cone cut"), path) def _first_cone_face(model: StepModel) -> int: for face_id in range(len(model.faces)): if model.face_info(face_id).get("surface") == "cone": return face_id raise SystemExit("test model did not expose a cone Face") def _best_cone_face_by_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, dict[str, object], float]: best: tuple[int, dict[str, object], float] | None = None for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "cone": continue angle = info.get("semi_angle") if not isinstance(angle, (int, float)): continue angle_degrees = abs(math.degrees(float(angle))) score = abs(angle_degrees - target_angle_degrees) if best is None or score < best[2]: best = (face_id, info, score) if best is None: raise SystemExit("no analytic cone Face was found") return best def _has_cylinder_diameter(model: StepModel, target_diameter: float, tolerance: float = 1e-4) -> bool: for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "cylinder": continue diameter = info.get("diameter") if isinstance(diameter, (int, float)) and abs(float(diameter) - target_diameter) <= tolerance: return True return False def _best_cone_angle_degrees(model: StepModel) -> float | None: best_angle: float | None = None for edited_face_id in range(len(model.faces)): info = model.face_info(edited_face_id) if info.get("surface") != "cone": continue angle = info.get("semi_angle") if not isinstance(angle, (int, float)): continue angle_degrees = abs(math.degrees(float(angle))) if best_angle is None or abs(angle_degrees - 50.0) < abs(best_angle - 50.0): best_angle = angle_degrees return best_angle def _unit(values: tuple[float, float, float]) -> tuple[float, float, float]: length = math.sqrt(sum(float(value) ** 2 for value in values)) if length <= 1e-12: raise SystemExit(f"cannot normalize vector {values}") return tuple(float(value) / length for value in values) def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: return sum(float(left[index]) * float(right[index]) for index in range(3)) def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: return math.sqrt(sum((float(left[index]) - float(right[index])) ** 2 for index in range(3))) def _cone_semi_angle_spec_action() -> str: probe = _PropertyProbe() specs, _used = probe._editable_property_specs( { "surface": "cone", "reference_radius": 1.0, "reference_diameter": 2.0, "semi_angle": math.radians(1.0), "area": 10.0, "area_center": (0.0, 0.0, 0.0), "bbox_center": (0.0, 0.0, 0.0), "axis": (0.0, 0.0, 1.0), "axis_point": (0.0, 0.0, 0.0), } ) for spec in specs: if spec.get("key") == "cone_semi_angle_degrees": if spec.get("target_transform"): raise SystemExit(f"cone semi-angle should not be converted through reference radius: {spec}") return str(spec.get("action") or "") raise SystemExit("cone semi-angle property spec was not found") def _cone_property_spec_for_model(model: StepModel, face_id: int, key: str) -> dict[str, object]: probe = _PropertyProbe(model, face_id) specs, _used = probe._editable_property_specs(model.face_info(face_id)) for spec in specs: if spec.get("key") == key: return spec raise SystemExit(f"{key} property spec was not found for Face {face_id}") def _cone_semi_angle_spec_for_model(model: StepModel, face_id: int) -> dict[str, object]: return _cone_property_spec_for_model(model, face_id, "cone_semi_angle_degrees") def _verify_non_cap_reference_radius_rebuild(model: StepModel, plan: dict[str, object]) -> None: fake_plan = dict(plan) current_reference_radius = float(plan.get("current_reference_radius") or 0.0) scale = float(plan.get("affine_scale") or 0.0) if current_reference_radius <= 0 or scale <= 0: raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}") fake_current_reference_radius = current_reference_radius * 0.95 fake_plan.update( { "current_reference_radius": fake_current_reference_radius, "target_reference_radius": fake_current_reference_radius * scale, "current_reference_diameter": fake_current_reference_radius * 2.0, "target_reference_diameter": fake_current_reference_radius * scale * 2.0, "resize_strategy": "radial-affine-scale-cone-semi-angle", "analytic_rebuild_available": False, } ) model._annotate_simple_conical_rebuild_plan(fake_plan, "semi-angle") if not fake_plan.get("analytic_rebuild_available"): raise SystemExit(f"non-cap cone reference radius should still allow semi-angle rebuild: {fake_plan}") if fake_plan.get("analytic_cone_reference_radius_matches_current"): raise SystemExit(f"test setup should simulate a reference radius that is not a cap radius: {fake_plan}") if fake_plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle": raise SystemExit(f"non-cap cone reference rebuild chose wrong strategy: {fake_plan}") model._apply_conical_analytic_rebuild_if_simple(fake_plan) best_angle = _best_cone_angle_degrees(model) if best_angle is None: raise SystemExit("non-cap reference rebuild should keep an analytic cone Face") if abs(best_angle - 50.0) > 0.5: raise SystemExit(f"non-cap reference rebuild produced {best_angle:g}deg instead of 50deg") def _verify_non_cap_reference_radius_direct_edit_is_blocked(model: StepModel, plan: dict[str, object]) -> None: fake_plan = dict(plan) current_reference_radius = float(plan.get("current_reference_radius") or 0.0) scale = float(plan.get("affine_scale") or 0.0) if current_reference_radius <= 0 or scale <= 0: raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}") fake_current_reference_radius = current_reference_radius * 0.95 fake_plan.update( { "status": "caution", "risk": "medium", "message": "", "warnings": "", "blockers": "", "current_reference_radius": fake_current_reference_radius, "target_reference_radius": fake_current_reference_radius * scale, "current_reference_diameter": fake_current_reference_radius * 2.0, "target_reference_diameter": fake_current_reference_radius * scale * 2.0, "resize_strategy": "radial-affine-scale-cone-reference-radius", "analytic_rebuild_available": False, } ) model._annotate_simple_conical_rebuild_plan(fake_plan, "reference-radius") model._block_unstable_conical_reference_radius_plan(fake_plan) if fake_plan.get("status") != "blocked": raise SystemExit(f"non-cap direct reference-radius edit should be blocked: {fake_plan}") if fake_plan.get("resize_strategy") != "blocked-cone-reference-radius-non-cap": raise SystemExit(f"non-cap direct reference-radius edit chose wrong strategy: {fake_plan}") message = str(fake_plan.get("message") or "") if "简单圆锥" not in message or "锥孔/沉孔" not in message: raise SystemExit(f"non-cap direct reference-radius blocker should explain supported paths: {fake_plan}") def _verify_tip_cone_rebuild(path: Path) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) for key in ("cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"): spec = _cone_property_spec_for_model(model, face_id, key) if not spec.get("enabled"): raise SystemExit(f"simple cone {key} property should stay editable: {spec}") before = model.stats() plan = model.conical_semi_angle_plan(face_id, 50.0) if plan["status"] == "blocked": raise SystemExit(f"tip cone semi-angle plan was blocked: {plan}") if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle": raise SystemExit(f"tip cone should use analytic rebuild, got {plan}") result = model.resize_conical_semi_angle(face_id, 50.0) after = model.stats() if after.solids != before.solids: raise SystemExit(f"tip cone edit changed solid count: before={before.solids}, after={after.solids}") best_angle = _best_cone_angle_degrees(model) if best_angle is None: raise SystemExit(f"tip cone edit should keep an analytic cone Face: {result}") if abs(best_angle - 50.0) > 0.5: raise SystemExit(f"tip cone edit produced {best_angle:g}deg instead of 50deg") def _verify_rotated_cone_rebuild(path: Path) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) before_info = model.face_info(face_id) before_axis = _unit(before_info["axis"]) before_axis_point = tuple(float(value) for value in before_info["axis_point"]) before = model.stats() plan = model.conical_semi_angle_plan(face_id, 50.0) if plan["status"] == "blocked": raise SystemExit(f"rotated cone semi-angle plan was blocked: {plan}") if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle": raise SystemExit(f"rotated cone should use analytic rebuild, got {plan}") result = model.resize_conical_semi_angle(face_id, 50.0) after = model.stats() if after.solids != before.solids: raise SystemExit(f"rotated cone edit changed solid count: before={before.solids}, after={after.solids}") best_face: tuple[int, dict[str, object], float] | None = None for edited_face_id in range(len(model.faces)): info = model.face_info(edited_face_id) if info.get("surface") != "cone": continue angle = info.get("semi_angle") if not isinstance(angle, (int, float)): continue score = abs(abs(math.degrees(float(angle))) - 50.0) if best_face is None or score < best_face[2]: best_face = (edited_face_id, info, score) if best_face is None: raise SystemExit(f"rotated cone edit should keep an analytic cone Face: {result}") edited_info = best_face[1] after_axis = _unit(edited_info["axis"]) if abs(_dot(before_axis, after_axis)) < 0.999: raise SystemExit(f"rotated cone edit changed the cone axis direction: before={before_axis}, after={after_axis}") after_axis_point = tuple(float(value) for value in edited_info["axis_point"]) if _distance(before_axis_point, after_axis_point) > 1e-4: raise SystemExit( f"rotated cone edit moved the cone axis point unexpectedly: before={before_axis_point}, after={after_axis_point}" ) def _verify_embedded_countersink_recut(path: Path, target_angle_degrees: float) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) before = model.stats() plan = model.conical_semi_angle_plan(face_id, target_angle_degrees) if plan["status"] == "blocked": raise SystemExit(f"embedded countersink cone semi-angle plan was blocked: {plan}") if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle": raise SystemExit(f"embedded countersink cone should use bounded local recut, got {plan}") expected_mode = "enlarge" if target_angle_degrees > 30.963756532 else "shrink" if plan.get("embedded_cone_recut_mode") != expected_mode: raise SystemExit(f"embedded countersink cone recut mode mismatch: expected={expected_mode}, plan={plan}") result = model.resize_conical_semi_angle(face_id, target_angle_degrees) after = model.stats() if after.solids != before.solids: raise SystemExit(f"embedded countersink edit changed solid count: before={before.solids}, after={after.solids}") edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, target_angle_degrees) angle_degrees = abs(math.degrees(float(edited_info["semi_angle"]))) if abs(angle_degrees - target_angle_degrees) > 0.5: raise SystemExit( f"embedded countersink edit produced {angle_degrees:g}deg instead of {target_angle_degrees:g}deg" ) circles = model._conical_face_circle_boundaries( edited_face_id, tuple(float(value) for value in edited_info["axis_point"]), _unit(tuple(float(value) for value in edited_info["axis"])), ) radii = sorted(float(circle["radius"]) for circle in circles) if len(radii) != 2: raise SystemExit(f"embedded countersink edited cone should keep two circular boundaries: {circles}") target_large_radius = 2.0 + 5.0 * math.tan(math.radians(target_angle_degrees)) if abs(radii[0] - 2.0) > 1e-4: raise SystemExit(f"embedded countersink should keep the small radius fixed at 2, got {radii}") if abs(radii[1] - target_large_radius) > 1e-3: raise SystemExit(f"embedded countersink large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}") if "embedded local cone recut" not in result: raise SystemExit(f"embedded countersink edit should report local cone recut: {result}") def _verify_countersunk_through_hole_recut(path: Path, target_angle_degrees: float) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) if not _has_cylinder_diameter(model, 4.0): raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole") before = model.stats() plan = model.conical_semi_angle_plan(face_id, target_angle_degrees) if plan["status"] == "blocked": raise SystemExit(f"countersunk through-hole cone semi-angle plan was blocked: {plan}") if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle": raise SystemExit(f"countersunk through-hole cone should use bounded local recut, got {plan}") if "径向缩放" in str(plan.get("message") or ""): raise SystemExit(f"embedded local recut plan should not show radial-scaling copy: {plan}") result = model.resize_conical_semi_angle(face_id, target_angle_degrees) after = model.stats() if after.solids != before.solids: raise SystemExit(f"countersunk through-hole edit changed solid count: before={before.solids}, after={after.solids}") _best_cone_face_by_angle(model, target_angle_degrees) if not _has_cylinder_diameter(model, 4.0): raise SystemExit(f"countersunk through-hole edit should keep the lower 4mm cylindrical hole: {result}") def _verify_embedded_cone_reference_radius_recut( path: Path, target_reference_radius: float, *, expect_lower_cylinder: bool = False, ) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0): raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole") before = model.stats() before_info = model.face_info(face_id) current_angle_degrees = abs(math.degrees(float(before_info["semi_angle"]))) plan = model.conical_reference_radius_plan(face_id, target_reference_radius) if plan["status"] == "blocked": raise SystemExit(f"embedded cone reference-radius plan was blocked: {plan}") if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius": raise SystemExit(f"embedded cone reference radius should use bounded local recut, got {plan}") expected_mode = "enlarge" if target_reference_radius > 2.0 else "shrink" if plan.get("embedded_cone_recut_mode") != expected_mode: raise SystemExit(f"embedded cone reference recut mode mismatch: expected={expected_mode}, plan={plan}") if "径向缩放" in str(plan.get("message") or ""): raise SystemExit(f"embedded reference recut plan should not show radial-scaling copy: {plan}") result = model.resize_conical_reference_radius(face_id, target_reference_radius) after = model.stats() if after.solids != before.solids: raise SystemExit(f"embedded cone reference edit changed solid count: before={before.solids}, after={after.solids}") edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, current_angle_degrees) edited_angle = abs(math.degrees(float(edited_info["semi_angle"]))) if abs(edited_angle - current_angle_degrees) > 0.5: raise SystemExit( f"embedded cone reference edit should preserve angle {current_angle_degrees:g}deg, got {edited_angle:g}deg" ) circles = model._conical_face_circle_boundaries( edited_face_id, tuple(float(value) for value in edited_info["axis_point"]), _unit(tuple(float(value) for value in edited_info["axis"])), ) radii = sorted(float(circle["radius"]) for circle in circles) if len(radii) != 2: raise SystemExit(f"embedded cone reference edit should keep two circular boundaries: {circles}") target_large_radius = target_reference_radius + 3.0 if abs(radii[0] - target_reference_radius) > 1e-3: raise SystemExit( f"embedded cone reference edit small radius mismatch: got {radii[0]:g}, target={target_reference_radius:g}" ) if abs(radii[1] - target_large_radius) > 1e-3: raise SystemExit(f"embedded cone reference edit large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}") if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0): raise SystemExit(f"embedded cone reference edit should keep the lower 4mm cylindrical hole: {result}") if "reference radius resize completed by embedded local cone recut" not in result: raise SystemExit(f"embedded cone reference edit should report local cone recut: {result}") def _verify_embedded_cone_non_cap_reference_radius_recut(path: Path) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) info = model.face_info(face_id) current_reference_radius = 3.5 target_reference_radius = 4.0 plan = { "status": "caution", "risk": "medium", "message": "", "warnings": "", "blockers": "", "face_id": face_id, "part_id": int(model.face_part_ids[face_id]), "solid_id": int(model.face_solid_ids[face_id]), "surface": "cone", "current_reference_radius": current_reference_radius, "target_reference_radius": target_reference_radius, "axis_point": info.get("axis_point"), "axis": info.get("axis"), "resize_strategy": "radial-affine-scale-cone-reference-radius", "affine_scale": target_reference_radius / current_reference_radius, "affine_target_kind": "part", } model._annotate_embedded_conical_recut_plan(plan, "reference-radius") if not plan.get("embedded_cone_recut_available"): raise SystemExit(f"non-cap embedded reference radius should allow local recut: {plan}") if plan.get("embedded_cone_reference_matches_small") or plan.get("embedded_cone_reference_matches_large"): raise SystemExit(f"test setup should use a non-cap reference section: {plan}") if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius": raise SystemExit(f"non-cap embedded reference should use bounded local recut, got {plan}") expected_small_radius = 2.5 expected_large_radius = 5.5 if abs(float(plan.get("embedded_cone_target_small_radius") or 0.0) - expected_small_radius) > 1e-6: raise SystemExit(f"non-cap embedded target small radius mismatch: {plan}") if abs(float(plan.get("embedded_cone_target_large_radius") or 0.0) - expected_large_radius) > 1e-6: raise SystemExit(f"non-cap embedded target large radius mismatch: {plan}") before = model.stats() if not model._apply_embedded_conical_recut_if_available(plan): raise SystemExit(f"non-cap embedded reference recut was not applied: {plan}") after = model.stats() if after.solids != before.solids: raise SystemExit(f"non-cap embedded reference edit changed solid count: before={before.solids}, after={after.solids}") edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, 30.963756532) circles = model._conical_face_circle_boundaries( edited_face_id, tuple(float(value) for value in edited_info["axis_point"]), _unit(tuple(float(value) for value in edited_info["axis"])), ) radii = sorted(float(circle["radius"]) for circle in circles) if len(radii) != 2: raise SystemExit(f"non-cap embedded reference edit should keep two circular boundaries: {circles}") if abs(radii[0] - expected_small_radius) > 1e-3 or abs(radii[1] - expected_large_radius) > 1e-3: raise SystemExit( f"non-cap embedded reference edit boundary mismatch: got={radii}, " f"target={[expected_small_radius, expected_large_radius]}" ) def _verify_embedded_cone_feature_info_boundary_metrics(path: Path) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) info = model.feature_info(face_id) expected = { "feature_cone_small_radius": 2.0, "feature_cone_large_radius": 5.0, "feature_cone_height": 5.0, } for key, target in expected.items(): value = info.get(key) if not isinstance(value, (int, float)) or abs(float(value) - target) > 1e-4: raise SystemExit(f"embedded cone feature info {key} mismatch: got={value}, target={target}") angle = info.get("feature_cone_boundary_half_angle_degrees") if not isinstance(angle, (int, float)) or abs(float(angle) - 30.963756532) > 1e-4: raise SystemExit(f"embedded cone feature info half-angle mismatch: {info}") if "半角" not in str(info.get("feature_edit_actions") or ""): raise SystemExit(f"cone feature edit actions should mention half-angle: {info}") def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None: model = StepModel.load(path) face_id = _first_cone_face(model) part_id = int(model.face_part_ids[face_id]) plan = model.conical_reference_radius_plan(face_id, 3.0) if plan["status"] == "blocked": raise SystemExit(f"embedded reference-radius isolation plan was blocked: {plan}") if str(plan.get("risk")) != "high": raise SystemExit(f"embedded reference-radius isolation case should be high risk, got {plan}") if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius": raise SystemExit(f"embedded reference-radius isolation should use local recut, got {plan}") probe = _ActionProbe(model, path) isolation = probe._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, 3.0]) if isolation is None: raise SystemExit(f"embedded reference-radius edit should request isolated execution: {plan}") context = { "operation_name": "resize embedded cone reference radius", "target": f"Face {face_id}", "parameters": { "part_id": part_id, "surface": "cone", "target_reference_radius": plan.get("target_reference_radius"), "resize_strategy": plan.get("resize_strategy"), }, "target_kind": "face", "target_id": face_id, "target_logical_id": model.face_region_logical_id(face_id), "pick_position": None, "show_same_domain_internal_edges": False, "edit_result_deflection": 2.4, } result = probe._run_isolated_edit_job( context=context, isolation=isolation, snapshot=model.snapshot(), before_stats=model.stats(), before_part_stats=model.part_topology_stats(part_id), before_quality=probe._edit_quality_info_or_none(model, context, part_id), before_geometry={}, ) if not result.get("message"): raise SystemExit("isolated embedded reference-radius edit did not return a completion message") if probe.model is model: raise SystemExit("successful isolated embedded reference-radius edit should load the edited model") edited_face_id, edited_info, _score = _best_cone_face_by_angle(probe.model, 30.963756532) circles = probe.model._conical_face_circle_boundaries( edited_face_id, tuple(float(value) for value in edited_info["axis_point"]), _unit(tuple(float(value) for value in edited_info["axis"])), ) radii = sorted(float(circle["radius"]) for circle in circles) if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-3 or abs(radii[1] - 6.0) > 1e-3: raise SystemExit(f"isolated embedded reference-radius edit produced wrong cone boundaries: {radii}") def _verify_complex_shallow_cone_half_angle_is_blocked() -> None: model_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" model = StepModel.load(model_path) checked_face_ids: list[int] = [] for face_id in range(len(model.faces)): info = model.quick_face_info(face_id) if info.get("surface") != "cone": continue angle = info.get("semi_angle") reference_radius = info.get("reference_radius") if not isinstance(angle, (int, float)) or not isinstance(reference_radius, (int, float)): continue if abs(math.degrees(float(angle))) > 1.5 or float(reference_radius) < 1000.0: continue for target_angle in (10.0, 50.0): plan = model.conical_semi_angle_plan(face_id, target_angle) if plan.get("status") != "blocked" or plan.get("risk") != "blocked": raise SystemExit( f"complex shallow cone half-angle edit should be blocked for Face {face_id}, " f"target={target_angle:g}: {plan}" ) if plan.get("resize_strategy") not in { "blocked-complex-cone-semi-angle-extreme-scale", "blocked-complex-cone-semi-angle-shallow-far-axis", }: raise SystemExit(f"complex shallow cone blocker chose wrong strategy: {plan}") message = str(plan.get("message") or "") if "复杂浅锥/拔模面" not in message or "无效 B-Rep" not in message: raise SystemExit(f"complex shallow cone blocker should explain the real risk: {plan}") spec = _cone_semi_angle_spec_for_model(model, face_id) if spec.get("enabled"): raise SystemExit(f"complex shallow cone semi-angle property should be disabled: {spec}") disabled_tip = str(spec.get("disabled_tip") or "") if ( "复杂浅锥/拔模面" not in disabled_tip and "圆锥面/拔模面" not in disabled_tip ) or "无效 B-Rep" not in disabled_tip: raise SystemExit(f"complex shallow cone disabled tip should explain the risk: {spec}") for key in ("cone_reference_radius", "cone_reference_diameter"): ref_spec = _cone_property_spec_for_model(model, face_id, key) if ref_spec.get("enabled"): raise SystemExit(f"complex shallow cone {key} property should be disabled: {ref_spec}") ref_tip = str(ref_spec.get("disabled_tip") or "") if ( "复杂圆锥面/拔模面" not in ref_tip and "复杂浅锥/拔模面" not in ref_tip and "圆锥面/拔模面" not in ref_tip ) or "无效 B-Rep" not in ref_tip: raise SystemExit(f"complex shallow cone {key} disabled tip should explain the risk: {ref_spec}") reference_plan = model.conical_reference_radius_plan(face_id, float(reference_radius) * 1.05) if reference_plan.get("status") != "blocked" or reference_plan.get("risk") != "blocked": raise SystemExit(f"complex shallow cone reference-radius edit should be blocked: {reference_plan}") if reference_plan.get("resize_strategy") not in { "blocked-complex-cone-reference-radius-unsupported-fallback", "blocked-complex-cone-reference-radius-shallow-far-axis", }: raise SystemExit(f"complex shallow cone reference-radius blocker chose wrong strategy: {reference_plan}") if 1490.0 <= float(reference_radius) <= 1510.0: start = time.perf_counter() user_plan = model.conical_reference_radius_plan(face_id, 1700.0) elapsed = time.perf_counter() - start if elapsed > 1.0: raise SystemExit( f"complex shallow cone reference-radius 1700 plan should be blocked quickly; " f"face={face_id}, elapsed={elapsed:.3f}s, plan={user_plan}" ) if user_plan.get("status") != "blocked" or user_plan.get("risk") != "blocked": raise SystemExit(f"complex shallow cone reference-radius 1700 edit should be blocked: {user_plan}") checked_face_ids.append(face_id) if len(checked_face_ids) >= 3: break if len(checked_face_ids) < 3: raise SystemExit(f"complex shallow cone half-angle guard found too few real-model cases: {checked_face_ids}") def main() -> int: action = _cone_semi_angle_spec_action() if action != "resize_cone_semi_angle": raise SystemExit(f"cone semi-angle property should call resize_cone_semi_angle, got {action!r}") with tempfile.TemporaryDirectory(prefix="geom_param_cone_semi_angle_isolation_") as temp_dir: cone_path = Path(temp_dir) / "cone_1deg.step" tip_cone_path = Path(temp_dir) / "tip_cone_1deg.step" rotated_cone_path = Path(temp_dir) / "rotated_cone.step" countersink_path = Path(temp_dir) / "embedded_countersink.step" through_countersink_path = Path(temp_dir) / "countersunk_through_hole.step" _write_one_degree_cone(cone_path) _write_one_degree_tip_cone(tip_cone_path) _write_rotated_translated_cone(rotated_cone_path) _write_embedded_countersink(countersink_path) _write_countersunk_through_hole(through_countersink_path) _verify_tip_cone_rebuild(tip_cone_path) _verify_rotated_cone_rebuild(rotated_cone_path) _verify_embedded_countersink_recut(countersink_path, 50.0) _verify_embedded_countersink_recut(countersink_path, 20.0) _verify_embedded_cone_feature_info_boundary_metrics(countersink_path) _verify_countersunk_through_hole_recut(through_countersink_path, 50.0) _verify_countersunk_through_hole_recut(through_countersink_path, 20.0) _verify_embedded_cone_reference_radius_recut(countersink_path, 3.0) _verify_embedded_cone_reference_radius_recut(countersink_path, 1.5) _verify_embedded_cone_reference_radius_recut(through_countersink_path, 3.0, expect_lower_cylinder=True) _verify_embedded_cone_reference_radius_recut(through_countersink_path, 1.5, expect_lower_cylinder=True) _verify_embedded_cone_non_cap_reference_radius_recut(countersink_path) _verify_embedded_reference_radius_isolated_window_job(countersink_path) model = StepModel.load(cone_path) face_id = _first_cone_face(model) part_id = int(model.face_part_ids[face_id]) solid_id = int(model.face_solid_ids[face_id]) plan = model.conical_semi_angle_plan(face_id, 50.0) if str(plan.get("risk")) != "high": raise SystemExit(f"1deg -> 50deg cone semi-angle should be high risk, got {plan}") fake_reference_model = StepModel.load(cone_path) _verify_non_cap_reference_radius_rebuild(fake_reference_model, dict(plan)) fake_direct_reference_model = StepModel.load(cone_path) _verify_non_cap_reference_radius_direct_edit_is_blocked(fake_direct_reference_model, dict(plan)) probe = _ActionProbe(model, cone_path) isolation = probe._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, 50.0]) if isolation is None: raise SystemExit(f"high-risk cone semi-angle edit should request isolated execution: {plan}") context = { "operation_name": "修改圆锥半角(整体)", "target": f"Face {face_id}", "parameters": { "part_id": part_id, "solid_id": solid_id, "surface": "cone", "target_reference_radius": plan.get("target_reference_radius"), "target_semi_angle_degrees": plan.get("target_semi_angle_degrees"), "resize_strategy": plan.get("resize_strategy"), }, "target_kind": "face", "target_id": face_id, "target_logical_id": model.face_region_logical_id(face_id), "pick_position": None, "show_same_domain_internal_edges": False, "edit_result_deflection": 2.4, } snapshot = model.snapshot() result = probe._run_isolated_edit_job( context=context, isolation=isolation, snapshot=snapshot, before_stats=model.stats(), before_part_stats=model.part_topology_stats(part_id), before_quality=probe._edit_quality_info_or_none(model, context, part_id), before_geometry={}, ) if not result.get("message"): raise SystemExit("isolated cone semi-angle edit did not return a completion message") if probe.model is model: raise SystemExit("successful isolated cone semi-angle edit should load the edited model") edited_model = probe.model best_angle = _best_cone_angle_degrees(edited_model) if best_angle is None: raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face") if abs(best_angle - 50.0) > 0.5: raise SystemExit(f"isolated cone semi-angle edit produced {best_angle:g}deg instead of 50deg") _verify_complex_shallow_cone_half_angle_is_blocked() print("cone semi-angle isolated analytic rebuild ok") return 0 if __name__ == "__main__": raise SystemExit(main())