from __future__ import annotations import argparse import math from pathlib import Path import sys import tempfile from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt 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.geometry_utils import _finalize_boolean_result from step_editor.model import StepModel from step_editor.step_io import _write_step def _write_through_hole_model(path: Path) -> None: plate = BRepPrimAPI_MakeBox(30.0, 20.0, 8.0).Shape() axis = gp_Ax2(gp_Pnt(15.0, 10.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 10.0).Shape() cut = BRepAlgoAPI_Cut(plate, cutter) shape = _finalize_boolean_result(cut, "verify through-hole model cut") _write_step(shape, path) def _write_blind_hole_model(path: Path) -> None: block = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape() axis = gp_Ax2(gp_Pnt(15.0, 10.0, 10.0), gp_Dir(0.0, 0.0, -1.0)) cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 6.0).Shape() cut = BRepAlgoAPI_Cut(block, cutter) shape = _finalize_boolean_result(cut, "verify blind-hole model cut") _write_step(shape, path) def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: return math.sqrt( (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2 ) def _hole_face_ids(model: StepModel, *, blind: bool | None = None) -> list[int]: face_ids: list[int] = [] for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "cylinder": continue if str(info.get("feature_guess", "")) != "hole/groove candidate": continue span = float(info.get("angular_span") or 0.0) if span < math.tau * 0.92: continue if blind is not None: is_blind = info.get("cylinder_end_type") == "blind" if is_blind != blind: continue face_ids.append(face_id) return face_ids def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int: candidates = _hole_face_ids(model, blind=blind) if not candidates: kind = "blind " if blind else "" raise SystemExit(f"no {kind}near-full cylindrical hole face was recognized") return candidates[0] def _first_circle_edge_adjacent_to_face(model: StepModel, face_id: int) -> int: for edge_id in range(len(model.edges)): info = model.edge_info(edge_id) if info.get("curve") != "circle": continue adjacent_face_ids = tuple(int(item) for item in info.get("adjacent_face_ids", ()) or ()) if face_id in adjacent_face_ids: return edge_id raise SystemExit(f"no circular edge adjacent to Face {face_id} was recognized") def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]: info = model.face_info(face_id) axis_point = info.get("axis_point") axis = info.get("axis") v_range = info.get("same_domain_v_range") or info.get("v_range") if not isinstance(axis_point, tuple) or not isinstance(axis, tuple) or not isinstance(v_range, tuple): raise SystemExit(f"axis center data is missing on Face {face_id}") v_mid = (float(v_range[0]) + float(v_range[1])) * 0.5 return ( float(axis_point[0]) + float(axis[0]) * v_mid, float(axis_point[1]) + float(axis[1]) * v_mid, float(axis_point[2]) + float(axis[2]) * v_mid, ) def _diameter(model: StepModel, face_id: int) -> float: value = model.face_info(face_id).get("diameter") if value is None: raise SystemExit(f"diameter is missing on Face {face_id}") return float(value) def _volume(model: StepModel) -> float: value = model.geometry_stats().get("volume") if not isinstance(value, (int, float)): raise SystemExit("model volume is unavailable") return float(value) def _depth(model: StepModel, face_id: int) -> float: info = model.face_info(face_id) feature = model.feature_info(face_id) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None probe = float(info.get("hole_depth_estimate") or 1.0) plan = model.cylindrical_depth_plan(face_id, probe, bottom_face_id=bottom_face_id) value = plan.get("current_depth") if value is None: raise SystemExit(f"depth is missing on Face {face_id}") return float(value) def _nearest_hole_by_diameter( model: StepModel, target_diameter: float, target_center: tuple[float, float, float] | None = None, *, blind: bool | None = None, ) -> tuple[int, float, tuple[float, float, float], float]: best: tuple[int, float, tuple[float, float, float], float] | None = None for face_id in _hole_face_ids(model, blind=blind): diameter = _diameter(model, face_id) center = _axis_center(model, face_id) diameter_error = abs(diameter - target_diameter) center_error = _distance(center, target_center) if target_center is not None else 0.0 error = diameter_error + center_error if best is None or error < best[3]: best = (face_id, diameter, center, error) if best is None: raise SystemExit("no cylindrical hole face remained after edit") return best def _nearest_blind_depth(model: StepModel, target_depth: float) -> tuple[int, float, float]: best: tuple[int, float, float] | None = None for face_id in _hole_face_ids(model, blind=True): try: depth = _depth(model, face_id) except Exception: continue error = abs(depth - target_depth) if best is None or error < best[2]: best = (face_id, depth, error) if best is None: raise SystemExit("no measurable blind hole remained after edit") return best def _run_diameter_case(target: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_hole_diam_") as temp_dir: model_path = Path(temp_dir) / "through_hole.step" _write_through_hole_model(model_path) model = StepModel.load(model_path) face_id = _first_hole_face(model, blind=False) before = model.stats() center = _axis_center(model, face_id) plan = model.cylindrical_resize_plan(face_id, target) if plan["status"] == "blocked": raise SystemExit(f"diameter plan was blocked: {plan['message']}") result = model.resize_cylindrical_hole(face_id, target) after = model.stats() verified_face, diameter, verified_center, error = _nearest_hole_by_diameter(model, target, center, blind=False) if abs(diameter - target) > tolerance: raise SystemExit(f"diameter verification failed: target={target:g}, value={diameter:g}, error={error:g}") print("mode=diameter") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"verified_face={verified_face}") print(f"target={target:.6f} value={diameter:.6f} center={verified_center} error={abs(diameter - target):.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_axis_center_case(offset: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_hole_axis_") as temp_dir: model_path = Path(temp_dir) / "through_hole.step" _write_through_hole_model(model_path) model = StepModel.load(model_path) face_id = _first_hole_face(model, blind=False) before = model.stats() current_center = _axis_center(model, face_id) target_center = (current_center[0] + offset, current_center[1], current_center[2]) current_diameter = _diameter(model, face_id) plan = model.cylindrical_axis_move_plan(face_id, target_center) if plan["status"] == "blocked": raise SystemExit(f"axis_center plan was blocked: {plan['message']}") result = model.move_cylindrical_hole_axis(face_id, target_center) after = model.stats() verified_face, diameter, center, _ = _nearest_hole_by_diameter(model, current_diameter, target_center, blind=False) center_error = _distance(center, target_center) if center_error > tolerance or abs(diameter - current_diameter) > tolerance: raise SystemExit( f"axis_center verification failed: target={target_center}, center={center}, " f"center_error={center_error:g}, diameter={diameter:g}" ) print("mode=axis_center") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"verified_face={verified_face}") print(f"target={target_center} value={center} diameter={diameter:.6f} error={center_error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_circle_edge_axis_") as temp_dir: model_path = Path(temp_dir) / "through_hole.step" _write_through_hole_model(model_path) model = StepModel.load(model_path) face_id = _first_hole_face(model, blind=False) edge_id = _first_circle_edge_adjacent_to_face(model, face_id) before = model.stats() current_axis_center = _axis_center(model, face_id) current_diameter = _diameter(model, face_id) edge_info = model.edge_info(edge_id) edge_center = edge_info.get("center") if not isinstance(edge_center, tuple): raise SystemExit(f"circle edge center is missing on Edge {edge_id}") target_edge_center = (float(edge_center[0]) + offset, float(edge_center[1]), float(edge_center[2])) target_axis_center = (current_axis_center[0] + offset, current_axis_center[1], current_axis_center[2]) plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center) if plan["status"] == "blocked": raise SystemExit(f"circle_edge_axis_center plan was blocked: {plan['message']}") result = model.move_circular_edge_axis_center(edge_id, target_edge_center) after = model.stats() verified_face, diameter, center, _ = _nearest_hole_by_diameter( model, current_diameter, target_axis_center, blind=False, ) center_error = _distance(center, target_axis_center) if center_error > tolerance or abs(diameter - current_diameter) > tolerance: raise SystemExit( f"circle_edge_axis_center verification failed: target={target_axis_center}, center={center}, " f"center_error={center_error:g}, diameter={diameter:g}" ) print("mode=circle_edge_axis_center") print(f"source_edge={edge_id}") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"delegated_mode={plan.get('circular_edge_cylinder_mode')}") print(f"before={before}") print(f"after={after}") print(f"verified_face={verified_face}") print(f"target_edge_center={target_edge_center}") print(f"target_axis_center={target_axis_center} value={center} diameter={diameter:.6f} error={center_error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_suppress_case(tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir: model_path = Path(temp_dir) / "through_hole.step" _write_through_hole_model(model_path) model = StepModel.load(model_path) face_id = _first_hole_face(model, blind=False) before = model.stats() before_volume = _volume(model) plan = model.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": raise SystemExit(f"suppress plan was blocked: {plan['message']}") result = model.suppress_cylindrical_hole(face_id) after = model.stats() after_volume = _volume(model) remaining_holes = _hole_face_ids(model) full_box_volume = 30.0 * 20.0 * 8.0 removed_hole_volume = math.pi * 3.0 * 3.0 * 8.0 volume_tolerance = max(tolerance * full_box_volume, 1e-3) if after.solids != 1: raise SystemExit(f"suppress verification failed: expected one solid, got {after.solids}") if remaining_holes: raise SystemExit(f"suppress verification failed: hole faces still detected: {remaining_holes}") if abs(after_volume - full_box_volume) > volume_tolerance: raise SystemExit( f"suppress volume verification failed: target={full_box_volume:g}, " f"value={after_volume:g}, tolerance={volume_tolerance:g}" ) if after_volume - before_volume < removed_hole_volume * 0.95: raise SystemExit( f"suppress recovered too little volume: expected_about={removed_hole_volume:g}, " f"value={after_volume - before_volume:g}" ) print("mode=suppress") print(f"source_face={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"before_volume={before_volume:.6f}") print(f"after_volume={after_volume:.6f}") print(f"full_box_volume={full_box_volume:.6f}") print(f"remaining_hole_faces={remaining_holes}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_blind_depth_case(target: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_blind_depth_") as temp_dir: model_path = Path(temp_dir) / "blind_hole.step" _write_blind_hole_model(model_path) model = StepModel.load(model_path) face_id = _first_hole_face(model, blind=True) feature = model.feature_info(face_id) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None before = model.stats() current_depth = _depth(model, face_id) plan = model.cylindrical_depth_plan(face_id, target, bottom_face_id=bottom_face_id) if plan["status"] == "blocked": raise SystemExit(f"blind_depth plan was blocked: {plan['message']}") result = model.resize_cylindrical_depth(face_id, target, bottom_face_id=bottom_face_id) after = model.stats() verified_face, depth, error = _nearest_blind_depth(model, target) if error > tolerance: raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}") print("mode=blind_depth") print(f"source_face={face_id}") print(f"bottom_face={bottom_face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_depth={current_depth:.6f}") print(f"verified_face={verified_face}") print(f"target={target:.6f} value={depth:.6f} error={error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify cylindrical hole edit operations.") parser.add_argument( "--mode", default="all", choices=[ "all", "diameter", "diameter_shrink", "axis_center", "circle_edge_axis_center", "suppress", "blind_depth", "blind_depth_shallow", ], help="Hole edit mode to verify.", ) parser.add_argument("--diameter", type=float, default=8.0) parser.add_argument("--diameter-shrink", type=float, default=4.0) parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.") parser.add_argument("--circle-edge-axis-center", type=float, default=2.0, help="Circle Edge center X offset to verify.") parser.add_argument("--blind-depth", type=float, default=8.0) parser.add_argument("--blind-depth-shallow", type=float, default=4.0) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() cases = ( [ ("diameter", args.diameter), ("diameter_shrink", args.diameter_shrink), ("axis_center", args.axis_center), ("circle_edge_axis_center", args.circle_edge_axis_center), ("suppress", None), ("blind_depth", args.blind_depth), ("blind_depth_shallow", args.blind_depth_shallow), ] if args.mode == "all" else [(args.mode, None if args.mode == "suppress" else getattr(args, args.mode.replace("-", "_")))] ) for mode, target in cases: if mode in {"diameter", "diameter_shrink"}: _run_diameter_case(float(target), args.tolerance) elif mode == "axis_center": _run_axis_center_case(float(target), args.tolerance) elif mode == "circle_edge_axis_center": _run_circle_edge_axis_center_case(float(target), args.tolerance) elif mode == "suppress": _run_suppress_case(args.tolerance) elif mode in {"blind_depth", "blind_depth_shallow"}: _run_blind_depth_case(float(target), args.tolerance) else: raise SystemExit(f"unsupported mode: {mode}") return 0 if __name__ == "__main__": raise SystemExit(main())