from __future__ import annotations import argparse import math from pathlib import Path import sys import tempfile from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse 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_half_round_slot_model(path: Path) -> None: box = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape() # A cylinder centered on the top face cuts a half-round groove open to Z+. cutter_axis = gp_Ax2(gp_Pnt(-5.0, 10.0, 10.0), gp_Dir(1.0, 0.0, 0.0)) cutter = BRepPrimAPI_MakeCylinder(cutter_axis, 3.0, 40.0).Shape() cut = BRepAlgoAPI_Cut(box, cutter) shape = _finalize_boolean_result(cut, "verify slot model cut") _write_step(shape, path) def _write_obround_slot_model(path: Path) -> None: plate = BRepPrimAPI_MakeBox(40.0, 24.0, 6.0).Shape() axis_1 = gp_Ax2(gp_Pnt(15.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) axis_2 = gp_Ax2(gp_Pnt(25.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape() cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape() connector = BRepPrimAPI_MakeBox(gp_Pnt(15.0, 9.0, -1.0), 10.0, 6.0, 8.0).Shape() fuse_1 = BRepAlgoAPI_Fuse(cylinder_1, connector) tool = _finalize_boolean_result(fuse_1, "verify obround slot tool fuse") fuse_2 = BRepAlgoAPI_Fuse(tool, cylinder_2) tool = _finalize_boolean_result(fuse_2, "verify obround slot tool second fuse") cut = BRepAlgoAPI_Cut(plate, tool) shape = _finalize_boolean_result(cut, "verify obround slot model cut") _write_step(shape, path) def _write_cross_obround_slot_model(path: Path) -> None: plate = BRepPrimAPI_MakeBox(40.0, 40.0, 6.0).Shape() def capsule_tool_x() -> object: axis_1 = gp_Ax2(gp_Pnt(14.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) axis_2 = gp_Ax2(gp_Pnt(26.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape() cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape() connector = BRepPrimAPI_MakeBox(gp_Pnt(14.0, 17.0, -1.0), 12.0, 6.0, 8.0).Shape() fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot x tool fuse") return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot x tool second fuse") def capsule_tool_y() -> object: axis_1 = gp_Ax2(gp_Pnt(20.0, 14.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) axis_2 = gp_Ax2(gp_Pnt(20.0, 26.0, -1.0), gp_Dir(0.0, 0.0, 1.0)) cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape() cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape() connector = BRepPrimAPI_MakeBox(gp_Pnt(17.0, 14.0, -1.0), 6.0, 12.0, 8.0).Shape() fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot y tool fuse") return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot y tool second fuse") cut_x = _finalize_boolean_result(BRepAlgoAPI_Cut(plate, capsule_tool_x()), "verify cross slot x cut") cut_xy = _finalize_boolean_result(BRepAlgoAPI_Cut(cut_x, capsule_tool_y()), "verify cross slot y cut") _write_step(cut_xy, path) def _slot_face_ids(model: StepModel) -> 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 feature = model.feature_info(face_id) if feature.get("slot_kind") != "partial-cylindrical-groove": continue span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0) if 1e-6 < span < math.tau * 0.92: face_ids.append(face_id) return face_ids def _first_slot_face(model: StepModel) -> int: candidates = _slot_face_ids(model) if not candidates: raise SystemExit("no partial cylindrical slot face was recognized") return candidates[0] def _slot_metric(model: StepModel, face_id: int, key: str) -> float: feature = model.feature_info(face_id) info = model.face_info(face_id) value = feature.get(key) if value is None: value = info.get(key) if value is None: raise SystemExit(f"slot metric {key} is missing on Face {face_id}") return float(value) def _slot_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"slot 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 _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 _nearest_metric(model: StepModel, key: str, target: float) -> tuple[int, float, float]: best: tuple[int, float, float] | None = None for face_id in _slot_face_ids(model): value = _slot_metric(model, face_id, key) error = abs(value - target) if best is None or error < best[2]: best = (face_id, value, error) if best is None: raise SystemExit("no slot face remained after edit") return best def _nearest_axis_center(model: StepModel, target: tuple[float, float, float]) -> tuple[int, tuple[float, float, float], float]: best: tuple[int, tuple[float, float, float], float] | None = None for face_id in _slot_face_ids(model): center = _slot_axis_center(model, face_id) error = _distance(center, target) if best is None or error < best[2]: best = (face_id, center, error) if best is None: raise SystemExit("no slot face remained after axis move") return best def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, float]]: rows: list[tuple[int, int, float, float]] = [] seen_pairs: set[tuple[int, int]] = set() for face_id in _slot_face_ids(model): info = model.face_info(face_id) diameter = float(info.get("diameter") or 0.0) if diameter <= 1e-9: continue feature = model.feature_info(face_id) try: cutter_plan = model._bounded_cylinder_cutter_plan(face_id, diameter, feature) pair_plan = model._paired_obround_slot_plan(face_id, diameter, feature, cutter_plan) except Exception: continue pair_face_id = pair_plan.get("slot_pair_face_id") center_distance = pair_plan.get("slot_pair_axis_distance") if pair_face_id in {None, ""} or center_distance in {None, ""}: continue pair_key = tuple(sorted((face_id, int(pair_face_id)))) if pair_key in seen_pairs: continue seen_pairs.add(pair_key) rows.append((face_id, int(pair_face_id), float(center_distance) + diameter, float(center_distance))) return rows def _complex_slot_face_ids(model: StepModel) -> 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 feature = model.feature_info(face_id) if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked": face_ids.append(face_id) return face_ids def _run_complex_slot_guard() -> None: with tempfile.TemporaryDirectory(prefix="geom_param_cross_slot_guard_") as temp_dir: model_path = Path(temp_dir) / "cross_obround_slot.step" _write_cross_obround_slot_model(model_path) model = StepModel.load(model_path) face_ids = _complex_slot_face_ids(model) if len(face_ids) < 4: raise SystemExit(f"cross obround slot should expose blocked slot ends, got {face_ids}") face_id = face_ids[0] feature = model.feature_info(face_id) blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "") if "交叉槽" not in blockers and "多槽组" not in blockers and "复杂草图槽" not in blockers: raise SystemExit(f"cross slot blocker should explain the unsupported reason: {feature}") if "复杂槽" not in str(feature.get("feature_type") or ""): raise SystemExit(f"cross slot should be labeled as a complex slot: {feature.get('feature_type')}") candidates = model.editable_feature_candidates(limit=80, detailed=False) forbidden_actions = { "resize_cylinder", "resize_slot_width", "resize_slot_depth", "resize_slot_arc_length", "resize_slot_angular_span", } leaked = [ (item.get("operation_key"), item.get("target_id")) for item in candidates if int(item.get("target_id", -1)) in face_ids and item.get("operation_key") in forbidden_actions ] if leaked: raise SystemExit(f"cross slot should not leak editable slot entries: {leaked}") width_plan = model.cylindrical_slot_resize_plan(face_id, 7.0, "width") angle_plan = model.cylindrical_slot_angular_span_plan(face_id, math.pi * 0.75) current_center = _slot_axis_center(model, face_id) axis_plan = model.cylindrical_slot_axis_move_plan(face_id, (current_center[0] + 1.0, current_center[1], current_center[2])) total_plan = model.cylindrical_slot_total_length_plan(face_id, 16.0) for label, plan in ( ("width", width_plan), ("angle", angle_plan), ("axis", axis_plan), ("total_length", total_plan), ): if plan.get("status") != "blocked": raise SystemExit(f"cross slot {label} plan should be blocked: {plan}") text = f"{plan.get('message')} {plan.get('blockers')}" if "交叉槽" not in text and "多槽组" not in text and "复杂草图槽" not in text: raise SystemExit(f"cross slot {label} plan should explain unsupported complex slot: {plan}") print(f"complex_slot_guard=blocked_faces {face_ids}") def _nearest_obround_total_length(model: StepModel, target_total_length: float) -> tuple[int, int, float, float, float]: best: tuple[int, int, float, float, float] | None = None for face_id, pair_face_id, total_length, center_distance in _obround_total_lengths(model): error = abs(total_length - target_total_length) if best is None or error < best[4]: best = (face_id, pair_face_id, total_length, center_distance, error) if best is None: raise SystemExit("no paired obround slot ends were recognized") return best def _nearest_obround_axis_pair( model: StepModel, target_center_1: tuple[float, float, float], target_center_2: tuple[float, float, float], ) -> tuple[int, int, tuple[float, float, float], tuple[float, float, float], float]: best: tuple[int, int, tuple[float, float, float], tuple[float, float, float], float] | None = None for face_id, pair_face_id, _total_length, _center_distance in _obround_total_lengths(model): center_1 = _slot_axis_center(model, face_id) center_2 = _slot_axis_center(model, pair_face_id) direct_error = max(_distance(center_1, target_center_1), _distance(center_2, target_center_2)) swapped_error = max(_distance(center_1, target_center_2), _distance(center_2, target_center_1)) if swapped_error < direct_error: error = swapped_error ordered_1, ordered_2 = center_2, center_1 else: error = direct_error ordered_1, ordered_2 = center_1, center_2 if best is None or error < best[4]: best = (face_id, pair_face_id, ordered_1, ordered_2, error) if best is None: raise SystemExit("no paired obround slot ends were recognized after axis move") return best def _slot_candidate_summary(model: StepModel) -> list[tuple[int, float, float, str]]: rows: list[tuple[int, float, float, str]] = [] for face_id in range(len(model.faces)): info = model.face_info(face_id) if info.get("surface") != "cylinder": continue feature = model.feature_info(face_id) span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0) diameter = float(info.get("diameter") or feature.get("diameter") or 0.0) rows.append((face_id, round(diameter, 6), round(span, 6), str(feature.get("slot_kind") or ""))) return rows def _run_case(mode: str, target: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_slot_") as temp_dir: model_path = Path(temp_dir) / "half_round_slot.step" _write_half_round_slot_model(model_path) model = StepModel.load(model_path) face_id = _first_slot_face(model) before = model.stats() if mode == "width": metric_key = "slot_chord_width_estimate" plan = model.cylindrical_slot_resize_plan(face_id, target, "width") result = model.resize_cylindrical_slot_width(face_id, target) elif mode == "depth": metric_key = "slot_sagitta_depth_estimate" plan = model.cylindrical_slot_resize_plan(face_id, target, "depth") result = model.resize_cylindrical_slot_depth(face_id, target) elif mode == "arc_length": metric_key = "slot_arc_length_estimate" plan = model.cylindrical_slot_resize_plan(face_id, target, "arc_length") result = model.resize_cylindrical_slot_arc_length(face_id, target) elif mode == "angular_span": metric_key = "slot_angular_span" plan = model.cylindrical_slot_angular_span_plan(face_id, target) result = model.resize_cylindrical_slot_angular_span(face_id, target) elif mode == "axis_center": current_center = _slot_axis_center(model, face_id) target_center = (current_center[0], current_center[1] + target, current_center[2]) plan = model.cylindrical_slot_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_slot_axis(face_id, target_center) after = model.stats() verified_face, center, error = _nearest_axis_center(model, target_center) if error > tolerance: raise SystemExit( f"axis_center verification failed: target={target_center}, nearest={center}, " f"error={error:g}; candidates={_slot_candidate_summary(model)}" ) 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} error={error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) return else: raise SystemExit(f"unsupported mode: {mode}") if plan["status"] == "blocked": raise SystemExit(f"{mode} plan was blocked: {plan['message']}") after = model.stats() verified_face, value, error = _nearest_metric(model, metric_key, target) if error > tolerance: raise SystemExit( f"{mode} verification failed: target={target:g}, nearest={value:g}, error={error:g}; " f"candidates={_slot_candidate_summary(model)}" ) print(f"mode={mode}") print(f"source_face={face_id}") print(f"strategy={plan.get('slot_resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"verified_face={verified_face}") print(f"target={target:.6f} value={value:.6f} error={error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_total_length_case(target: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_obround_") as temp_dir: model_path = Path(temp_dir) / "obround_slot.step" _write_obround_slot_model(model_path) model = StepModel.load(model_path) face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0) before = model.stats() plan = model.cylindrical_slot_total_length_plan(face_id, target) if plan["status"] == "blocked": raise SystemExit(f"total_length plan was blocked: {plan['message']}") result = model.resize_cylindrical_slot_total_length(face_id, target) after = model.stats() verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length(model, target) if error > tolerance: raise SystemExit( f"total_length verification failed: target={target:g}, nearest={total_length:g}, " f"error={error:g}; pairs={_obround_total_lengths(model)}" ) print("mode=total_length") print(f"source_face={face_id}") print(f"source_pair_face={pair_face_id}") print(f"strategy={plan.get('slot_resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_total_length={current_total_length:.6f}") print(f"current_center_distance={current_center_distance:.6f}") print(f"verified_face={verified_face}") print(f"verified_pair_face={verified_pair}") print(f"target={target:.6f} value={total_length:.6f} center_distance={center_distance:.6f} error={error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_center_distance_case(target: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_obround_center_distance_") as temp_dir: model_path = Path(temp_dir) / "obround_slot.step" _write_obround_slot_model(model_path) model = StepModel.load(model_path) face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0) slot_diameter = current_total_length - current_center_distance target_total_length = target + slot_diameter before = model.stats() plan = model.cylindrical_slot_center_distance_plan(face_id, target) if plan["status"] == "blocked": raise SystemExit(f"center_distance plan was blocked: {plan['message']}") result = model.resize_cylindrical_slot_center_distance(face_id, target) after = model.stats() verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length( model, target_total_length, ) center_error = abs(center_distance - target) if center_error > tolerance: raise SystemExit( f"center_distance verification failed: target={target:g}, nearest={center_distance:g}, " f"error={center_error:g}; pairs={_obround_total_lengths(model)}" ) print("mode=center_distance") print(f"source_face={face_id}") print(f"source_pair_face={pair_face_id}") print(f"strategy={plan.get('slot_resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_total_length={current_total_length:.6f}") print(f"current_center_distance={current_center_distance:.6f}") print(f"slot_diameter={slot_diameter:.6f}") print(f"verified_face={verified_face}") print(f"verified_pair_face={verified_pair}") print( f"target_center_distance={target:.6f} value={center_distance:.6f} " f"total_length={total_length:.6f} total_length_error={error:.6g} error={center_error:.6g}" ) print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_obround_axis_center_case(offset: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_obround_axis_") as temp_dir: model_path = Path(temp_dir) / "obround_slot.step" _write_obround_slot_model(model_path) model = StepModel.load(model_path) face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0) current_center = _slot_axis_center(model, face_id) pair_center = _slot_axis_center(model, pair_face_id) target_center = (current_center[0], current_center[1] + offset, current_center[2]) target_pair_center = (pair_center[0], pair_center[1] + offset, pair_center[2]) before = model.stats() plan = model.cylindrical_slot_axis_move_plan(face_id, target_center) if plan["status"] == "blocked": raise SystemExit(f"obround_axis_center plan was blocked: {plan['message']}") if plan.get("resize_strategy") != "paired-obround-slot-axis-prism": raise SystemExit(f"obround_axis_center did not choose paired strategy: {plan.get('resize_strategy')}") result = model.move_cylindrical_slot_axis(face_id, target_center) after = model.stats() verified_face, verified_pair, center_1, center_2, error = _nearest_obround_axis_pair( model, target_center, target_pair_center, ) if error > tolerance: raise SystemExit( f"obround_axis_center verification failed: target=({target_center}, {target_pair_center}), " f"nearest=({center_1}, {center_2}), error={error:g}; pairs={_obround_total_lengths(model)}" ) print("mode=obround_axis_center") print(f"source_face={face_id}") print(f"source_pair_face={pair_face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"current_total_length={current_total_length:.6f}") print(f"current_center_distance={current_center_distance:.6f}") print(f"verified_face={verified_face}") print(f"verified_pair_face={verified_pair}") print(f"target=({target_center}, {target_pair_center}) value=({center_1}, {center_2}) error={error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify partial cylindrical slot resize operations.") parser.add_argument( "--mode", default="all", choices=[ "all", "width", "depth", "arc_length", "angular_span", "axis_center", "obround_axis_center", "total_length", "center_distance", "complex_slot_guard", ], help="Slot metric to verify.", ) parser.add_argument("--width", type=float, default=8.0) parser.add_argument("--depth", type=float, default=4.0) parser.add_argument("--arc-length", type=float, default=12.0) parser.add_argument("--angular-span", type=float, default=2.2) parser.add_argument("--axis-center", type=float, default=1.0, help="Radial axis-center offset to verify.") parser.add_argument("--obround-axis-center", type=float, default=1.0, help="Obround slot axis-center offset to verify.") parser.add_argument("--total-length", type=float, default=20.0) parser.add_argument("--center-distance", type=float, default=14.0) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() if args.mode == "all": cases = [ ("width", args.width), ("depth", args.depth), ("arc_length", args.arc_length), ("angular_span", args.angular_span), ("axis_center", args.axis_center), ("obround_axis_center", args.obround_axis_center), ("total_length", args.total_length), ("center_distance", args.center_distance), ("complex_slot_guard", 0.0), ] elif args.mode == "complex_slot_guard": cases = [("complex_slot_guard", 0.0)] else: cases = [(args.mode, getattr(args, args.mode.replace("-", "_")))] for mode, target in cases: if mode == "complex_slot_guard": _run_complex_slot_guard() elif mode == "total_length": _run_total_length_case(float(target), args.tolerance) elif mode == "center_distance": _run_center_distance_case(float(target), args.tolerance) elif mode == "obround_axis_center": _run_obround_axis_center_case(float(target), args.tolerance) else: _run_case(mode, float(target), args.tolerance) return 0 if __name__ == "__main__": raise SystemExit(main())