Files
pythonocc-step-editor/scripts/verify_cone_semi_angle_isolation.py
T

767 lines
38 KiB
Python

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 not model:
raise SystemExit("isolated embedded reference-radius edit should not replace the window model off the UI thread")
edited_model = result.get("after_model")
if not isinstance(edited_model, StepModel):
raise SystemExit(f"isolated embedded reference-radius edit should return after_model: {result}")
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
raise SystemExit("isolated embedded reference-radius edit should return prebuilt display polydata")
edited_face_id, edited_info, _score = _best_cone_face_by_angle(edited_model, 30.963756532)
circles = edited_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 not model:
raise SystemExit("isolated cone semi-angle edit should not replace the window model off the UI thread")
edited_model = result.get("after_model")
if not isinstance(edited_model, StepModel):
raise SystemExit(f"isolated cone semi-angle edit should return after_model: {result}")
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
raise SystemExit("isolated cone semi-angle edit should return prebuilt display polydata")
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())