from __future__ import annotations import argparse import math from pathlib import Path import sys import tempfile from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCone, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus 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.step_io import _write_step def _write_cone_model(path: Path) -> None: shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape() _write_step(shape, path) def _write_sphere_model(path: Path) -> None: shape = BRepPrimAPI_MakeSphere(5.0).Shape() _write_step(shape, path) def _write_torus_model(path: Path) -> None: shape = BRepPrimAPI_MakeTorus(8.0, 2.0).Shape() _write_step(shape, path) def _first_face_by_surface(model: StepModel, surface: str) -> int: for face_id in range(len(model.faces)): if model.face_info(face_id).get("surface") == surface: return face_id raise SystemExit(f"no {surface} Face was recognized") def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int, float]: best: tuple[int, float, float] | None = None for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "sphere": continue radius = float(info.get("radius") or 0.0) score = abs(radius - target_radius) if best is None or score < best[2]: best = (face_id, radius, score) if best is None: raise SystemExit("no sphere Face remained after edit") return best[0], best[1] def _nearest_cone_semi_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, float]: best: tuple[int, float, 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, angle_degrees, score) if best is None: diameters = _thin_cap_diameters(model) if len(diameters) >= 2: small = min(diameters) large = max(diameters) height = _bbox_z_size(model) if height > 1e-9 and large > small: angle_degrees = math.degrees(math.atan(((large - small) * 0.5) / height)) return -1, angle_degrees raise SystemExit("no cone Face remained after edit and cap diameters could not recover the semi-angle") return best[0], best[1] def _nearest_torus_radii( model: StepModel, target_major: float, target_minor: float, ) -> tuple[int, float, float]: best: tuple[int, float, float, float] | None = None for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "torus": continue major = float(info.get("major_radius") or 0.0) minor = float(info.get("minor_radius") or 0.0) score = abs(major - target_major) + abs(minor - target_minor) if best is None or score < best[3]: best = (face_id, major, minor, score) if best is None: raise SystemExit("no torus Face remained after edit") return best[0], best[1], best[2] def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool: return any(abs(diameter - target_diameter) <= tolerance for diameter in _thin_cap_diameters(model)) def _thin_cap_diameters(model: StepModel) -> list[float]: diameters: list[float] = [] for face_id in range(len(model.faces)): info = model.face_info(face_id) size = info.get("bbox_size") if not isinstance(size, tuple) or len(size) != 3: continue dx, dy, dz = (float(size[0]), float(size[1]), float(size[2])) if dz > 1e-4: continue if abs(dx - dy) <= max(max(abs(dx), abs(dy)) * 1e-5, 1e-5): diameters.append((dx + dy) * 0.5) return diameters def _bbox_z_size(model: StepModel) -> float: size = model.geometry_stats().get("bbox_size") if not isinstance(size, tuple) or len(size) != 3: raise SystemExit("model bbox_size is missing") return float(size[2]) def _run_cone_case(target_reference_radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_cone_") as temp_dir: model_path = Path(temp_dir) / "cone.step" _write_cone_model(model_path) model = StepModel.load(model_path) face_id = _first_face_by_surface(model, "cone") before = model.stats() info = model.face_info(face_id) current_reference_radius = float(info.get("reference_radius") or 0.0) current_top_radius = 2.0 scale = target_reference_radius / current_reference_radius target_top_radius = current_top_radius * scale plan = model.conical_reference_radius_plan(face_id, target_reference_radius) if plan["status"] == "blocked": raise SystemExit(f"cone plan was blocked: {plan['message']}") result = model.resize_conical_reference_radius(face_id, target_reference_radius) after = model.stats() if after.solids != before.solids: raise SystemExit(f"cone resize changed solid count: before={before.solids}, after={after.solids}") if abs(_bbox_z_size(model) - 10.0) > tolerance: raise SystemExit(f"cone height changed unexpectedly: z_size={_bbox_z_size(model):g}") if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance): raise SystemExit(f"cone bottom cap diameter was not resized to {target_reference_radius * 2.0:g}") if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance): raise SystemExit(f"cone top cap diameter was not resized to {target_top_radius * 2.0:g}") print("mode=cone_reference_radius") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_reference_radius={current_reference_radius:.6f} target_reference_radius={target_reference_radius:.6f}") print(f"target_top_radius={target_top_radius:.6f}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_cone_angle_case(target_angle_degrees: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_cone_angle_") as temp_dir: model_path = Path(temp_dir) / "cone.step" _write_cone_model(model_path) model = StepModel.load(model_path) face_id = _first_face_by_surface(model, "cone") before = model.stats() info = model.face_info(face_id) current_reference_radius = float(info.get("reference_radius") or 0.0) current_semi_angle = abs(float(info.get("semi_angle") or 0.0)) current_top_radius = 2.0 scale = math.tan(math.radians(target_angle_degrees)) / math.tan(current_semi_angle) target_reference_radius = current_reference_radius * scale target_top_radius = current_top_radius * scale plan = model.conical_semi_angle_plan(face_id, target_angle_degrees) if plan["status"] == "blocked": raise SystemExit(f"cone angle plan was blocked: {plan['message']}") result = model.resize_conical_semi_angle(face_id, target_angle_degrees) after = model.stats() if after.solids != before.solids: raise SystemExit(f"cone angle resize changed solid count: before={before.solids}, after={after.solids}") if abs(_bbox_z_size(model) - 10.0) > tolerance: raise SystemExit(f"cone angle resize changed height unexpectedly: z_size={_bbox_z_size(model):g}") if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance): raise SystemExit(f"cone angle bottom cap diameter was not resized to {target_reference_radius * 2.0:g}") if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance): raise SystemExit(f"cone angle top cap diameter was not resized to {target_top_radius * 2.0:g}") verified_face, angle_degrees = _nearest_cone_semi_angle(model, target_angle_degrees) if abs(angle_degrees - target_angle_degrees) > tolerance: raise SystemExit( f"cone semi-angle verification failed: target={target_angle_degrees:g}, value={angle_degrees:g}" ) print("mode=cone_semi_angle") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_semi_angle_degrees={math.degrees(current_semi_angle):.6f}") print(f"target_semi_angle_degrees={target_angle_degrees:.6f} verified={angle_degrees:.6f}") print(f"target_reference_radius={target_reference_radius:.6f}") print(f"target_top_radius={target_top_radius:.6f}") print(f"verified_face={verified_face}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_sphere_case(target_radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir: model_path = Path(temp_dir) / "sphere.step" _write_sphere_model(model_path) model = StepModel.load(model_path) face_id = _first_face_by_surface(model, "sphere") before = model.stats() current_radius = float(model.face_info(face_id).get("radius") or 0.0) plan = model.spherical_radius_plan(face_id, target_radius) if plan["status"] == "blocked": raise SystemExit(f"sphere plan was blocked: {plan['message']}") result = model.resize_spherical_radius(face_id, target_radius) after = model.stats() verified_face, radius = _nearest_sphere_radius(model, target_radius) if after.solids != before.solids: raise SystemExit(f"sphere resize changed solid count: before={before.solids}, after={after.solids}") if abs(radius - target_radius) > tolerance: raise SystemExit(f"sphere radius verification failed: target={target_radius:g}, value={radius:g}") print("mode=sphere_radius") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_radius={current_radius:.6f} target_radius={target_radius:.6f}") print(f"verified_face={verified_face} value={radius:.6f}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_torus_case(mode: str, target_radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix=f"geom_param_torus_{mode}_") as temp_dir: model_path = Path(temp_dir) / "torus.step" _write_torus_model(model_path) model = StepModel.load(model_path) face_id = _first_face_by_surface(model, "torus") before = model.stats() info = model.face_info(face_id) current_major = float(info.get("major_radius") or 0.0) current_minor = float(info.get("minor_radius") or 0.0) current = current_major if mode == "major" else current_minor scale = target_radius / current target_major = current_major * scale target_minor = current_minor * scale plan = model.toroidal_radius_plan(face_id, target_radius, mode) if plan["status"] == "blocked": raise SystemExit(f"torus {mode} plan was blocked: {plan['message']}") result = model.resize_toroidal_radius(face_id, target_radius, mode) after = model.stats() verified_face, major, minor = _nearest_torus_radii(model, target_major, target_minor) if after.solids != before.solids: raise SystemExit(f"torus {mode} resize changed solid count: before={before.solids}, after={after.solids}") if abs(major - target_major) > tolerance or abs(minor - target_minor) > tolerance: raise SystemExit( f"torus {mode} verification failed: target=({target_major:g}, {target_minor:g}), " f"value=({major:g}, {minor:g})" ) print(f"mode=torus_{mode}_radius") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_major={current_major:.6f} current_minor={current_minor:.6f}") print(f"target_major={target_major:.6f} target_minor={target_minor:.6f}") print(f"verified_face={verified_face} major={major:.6f} minor={minor:.6f}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify analytic curved surface resize operations.") parser.add_argument( "--mode", default="all", choices=["all", "cone", "cone_angle", "sphere", "torus_major", "torus_minor"], ) parser.add_argument("--cone-reference-radius", type=float, default=5.0) parser.add_argument("--cone-semi-angle-degrees", type=float, default=16.0) parser.add_argument("--sphere-radius", type=float, default=6.25) parser.add_argument("--torus-major-radius", type=float, default=10.0) parser.add_argument("--torus-minor-radius", type=float, default=3.0) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() modes = ["cone", "cone_angle", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode] for mode in modes: if mode == "cone": _run_cone_case(args.cone_reference_radius, args.tolerance) elif mode == "cone_angle": _run_cone_angle_case(args.cone_semi_angle_degrees, args.tolerance) elif mode == "sphere": _run_sphere_case(args.sphere_radius, args.tolerance) elif mode == "torus_major": _run_torus_case("major", args.torus_major_radius, args.tolerance) elif mode == "torus_minor": _run_torus_case("minor", args.torus_minor_radius, args.tolerance) else: raise SystemExit(f"unsupported mode: {mode}") return 0 if __name__ == "__main__": raise SystemExit(main())