2026-08-04 09:35:39 +08:00
|
|
|
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")
|
2026-08-04 18:15:29 +08:00
|
|
|
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(
|
2026-08-04 09:35:39 +08:00
|
|
|
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")
|
2026-08-04 18:15:29 +08:00
|
|
|
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")
|
2026-08-04 09:35:39 +08:00
|
|
|
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())
|