from __future__ import annotations import argparse import math from pathlib import Path import sys import tempfile from OCC.Core.BRepAdaptor import BRepAdaptor_Curve from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox from OCC.Core.GeomAbs import GeomAbs_Line from OCC.Core.GProp import GProp_GProps from OCC.Core.TopoDS import TopoDS_Shape, topods from OCC.Extend.TopologyUtils import TopologyExplorer 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_builder_result from step_editor.model import StepModel from step_editor.step_io import _write_step def _write_box_model(path: Path) -> None: shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() _write_step(shape, path) def _first_line_edge(shape: TopoDS_Shape, minimum_length: float = 5.0) -> TopoDS_Shape: for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): curve = BRepAdaptor_Curve(edge) if curve.GetType() != GeomAbs_Line: continue props = GProp_GProps() brepgprop.LinearProperties(edge, props) if props.Mass() >= minimum_length: return topods.Edge(edge) raise SystemExit("no line edge found in generated box") def _line_edge_endpoint_records(shape: TopoDS_Shape) -> list[dict[str, object]]: records: list[dict[str, object]] = [] for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): curve = BRepAdaptor_Curve(edge) if curve.GetType() != GeomAbs_Line: continue props = GProp_GProps() brepgprop.LinearProperties(edge, props) start = curve.Value(curve.FirstParameter()) end = curve.Value(curve.LastParameter()) records.append( { "edge": topods.Edge(edge), "length": float(props.Mass()), "start": (round(start.X(), 6), round(start.Y(), 6), round(start.Z(), 6)), "end": (round(end.X(), 6), round(end.Y(), 6), round(end.Z(), 6)), } ) records.sort(key=lambda item: (float(item["length"]), item["start"], item["end"])) return records def _write_filleted_box_model(path: Path, radius: float) -> None: shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() maker = BRepFilletAPI_MakeFillet(shape) maker.Add(float(radius), _first_line_edge(shape)) result = _finalize_builder_result(maker, "verify source box fillet") _write_step(result, path) def _write_chained_filleted_box_model(path: Path, radius: float) -> None: shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() records = _line_edge_endpoint_records(shape) if not records: raise SystemExit("no line edges found in generated box") first = records[0] first_endpoints = {first["start"], first["end"]} second = None for candidate in records[1:]: if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints: second = candidate break if second is None: raise SystemExit("no adjacent line Edge pair found for chained fillet source") maker = BRepFilletAPI_MakeFillet(shape) maker.Add(float(radius), first["edge"]) maker.Add(float(radius), second["edge"]) result = _finalize_builder_result(maker, "verify source box fillet chain") _write_step(result, path) def _first_editable_line_edge(model: StepModel) -> int: candidates: list[tuple[float, int]] = [] for edge_id in range(len(model.edges)): info = model.edge_info(edge_id) if info.get("curve") != "line": continue if int(info.get("adjacent_face_count") or 0) < 2: continue length = float(info.get("length") or 0.0) if length <= 1e-9: continue candidates.append((-length, edge_id)) if not candidates: raise SystemExit("no editable line Edge was recognized") candidates.sort() return candidates[0][1] def _first_adjacent_reference_face(model: StepModel, edge_id: int) -> int: adjacent_face_ids = tuple(model.edge_info(edge_id).get("adjacent_face_ids") or ()) if not adjacent_face_ids: raise SystemExit(f"Edge {edge_id} has no adjacent Face for chamfer reference") return int(adjacent_face_ids[0]) def _cylindrical_faces_near_radius( model: StepModel, radius: float, tolerance: float, *, require_fillet_guess: bool = False, ) -> list[tuple[int, float, float, str]]: matches: 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 value = float(info.get("radius") or 0.0) if abs(value - radius) > tolerance: continue guess = str(info.get("feature_guess", "")) if require_fillet_guess and guess != "round/fillet candidate": continue matches.append((face_id, value, float(info.get("angular_span") or 0.0), guess)) return matches def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: float) -> int: matches = _cylindrical_faces_near_radius(model, radius, tolerance, require_fillet_guess=True) if matches: return matches[0][0] loose_matches = _cylindrical_faces_near_radius(model, radius, tolerance) detail = ", ".join( f"Face {face_id}: radius={value:g}, span={span:g}, guess={guess}" for face_id, value, span, guess in loose_matches[:8] ) raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or ''}") def _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None: if plan.get("topology_relation_depth") != 1: raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}") if plan.get("topology_relation_status") != "ready": raise SystemExit(f"existing fillet plan topology should be ready: {plan}") if plan.get("first_level_topology_status") != "ready": raise SystemExit(f"existing fillet first-level guard should be ready: {plan}") if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) < 1: raise SystemExit(f"existing fillet plan should expose boundary Edges: {plan}") if int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0) < 2: raise SystemExit(f"existing fillet plan should expose direct support Faces: {plan}") ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ()) if "second-level" not in ignored or "third-level" not in ignored: raise SystemExit(f"existing fillet plan should document ignored deeper topology: {plan}") def _run_fillet_case(radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_edge_fillet_") as temp_dir: model_path = Path(temp_dir) / "box.step" _write_box_model(model_path) model = StepModel.load(model_path) edge_id = _first_editable_line_edge(model) before = model.stats() plan = model.edge_fillet_plan(edge_id, radius) if plan["status"] == "blocked": raise SystemExit(f"fillet plan was blocked: {plan['message']}") result = model.fillet_edge(edge_id, radius) after = model.stats() matches = _cylindrical_faces_near_radius(model, radius, tolerance) if "Edge blend result check" not in result: raise SystemExit(f"fillet result did not report execution-layer result check: {result}") if after.solids != before.solids: raise SystemExit(f"fillet changed solid count: before={before.solids}, after={after.solids}") if not matches: raise SystemExit(f"fillet verification failed: no cylindrical face near radius {radius:g}") print("mode=fillet") print(f"edge_id={edge_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"target_radius={radius:.6f}") print(f"matched_faces={matches}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _verify_chamfer_topology( mode: str, edge_id: int, plan: dict[str, object], before, after, result: str, extra_lines: list[str], ) -> None: if "Edge blend result check" not in result: raise SystemExit(f"{mode} result did not report execution-layer result check: {result}") if after.solids != before.solids: raise SystemExit(f"{mode} changed solid count: before={before.solids}, after={after.solids}") if after.faces <= before.faces: raise SystemExit(f"{mode} did not add a visible planar face: before={before.faces}, after={after.faces}") if after.edges <= before.edges: raise SystemExit(f"{mode} did not add expected boundary edges: before={before.edges}, after={after.edges}") print(f"mode={mode}") print(f"edge_id={edge_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") for line in extra_lines: print(line) print(f"face_delta={after.faces - before.faces} edge_delta={after.edges - before.edges}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_chamfer_case(distance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_edge_chamfer_") as temp_dir: model_path = Path(temp_dir) / "box.step" _write_box_model(model_path) model = StepModel.load(model_path) edge_id = _first_editable_line_edge(model) before = model.stats() plan = model.edge_chamfer_plan(edge_id, distance) if plan["status"] == "blocked": raise SystemExit(f"chamfer plan was blocked: {plan['message']}") result = model.chamfer_edge(edge_id, distance) after = model.stats() _verify_chamfer_topology( "chamfer", edge_id, plan, before, after, result, [f"target_distance={distance:.6f}"], ) def _run_asymmetric_chamfer_case(distance1: float, distance2: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_edge_asym_chamfer_") as temp_dir: model_path = Path(temp_dir) / "box.step" _write_box_model(model_path) model = StepModel.load(model_path) edge_id = _first_editable_line_edge(model) reference_face_id = _first_adjacent_reference_face(model, edge_id) before = model.stats() plan = model.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) if plan["status"] == "blocked": raise SystemExit(f"asymmetric chamfer plan was blocked: {plan['message']}") result = model.chamfer_edge_asymmetric(edge_id, distance1, distance2, reference_face_id) after = model.stats() _verify_chamfer_topology( "asymmetric_chamfer", edge_id, plan, before, after, result, [ f"target_distance1={distance1:.6f}", f"target_distance2={distance2:.6f}", f"reference_face_id={reference_face_id}", ], ) def _run_distance_angle_chamfer_case(distance: float, angle_degrees: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_edge_da_chamfer_") as temp_dir: model_path = Path(temp_dir) / "box.step" _write_box_model(model_path) model = StepModel.load(model_path) edge_id = _first_editable_line_edge(model) reference_face_id = _first_adjacent_reference_face(model, edge_id) before = model.stats() plan = model.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) if plan["status"] == "blocked": raise SystemExit(f"distance-angle chamfer plan was blocked: {plan['message']}") result = model.chamfer_edge_distance_angle(edge_id, distance, angle_degrees, reference_face_id) after = model.stats() _verify_chamfer_topology( "distance_angle_chamfer", edge_id, plan, before, after, result, [ f"target_distance={distance:.6f}", f"target_angle_degrees={angle_degrees:.6f}", f"reference_face_id={reference_face_id}", ], ) def _run_existing_fillet_case(source_radius: float, target_radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_") as temp_dir: model_path = Path(temp_dir) / "filleted_box.step" _write_filleted_box_model(model_path, source_radius) model = StepModel.load(model_path) face_id = _first_existing_fillet_face(model, source_radius, tolerance) before = model.stats() plan = model.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise SystemExit(f"existing fillet plan was blocked: {plan['message']}") _assert_existing_fillet_plan_topology(plan) support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ())) if len(support_face_ids) < 2: raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}") result = model.resize_existing_fillet(face_id, target_radius) after = model.stats() matches = _cylindrical_faces_near_radius(model, target_radius, tolerance) old_matches = _cylindrical_faces_near_radius(model, source_radius, tolerance) if after.solids != before.solids: raise SystemExit(f"existing fillet resize changed solid count: before={before.solids}, after={after.solids}") if not matches: raise SystemExit(f"existing fillet verification failed: no cylindrical face near radius {target_radius:g}") if old_matches and abs(source_radius - target_radius) > tolerance: raise SystemExit(f"existing fillet still has old radius matches: {old_matches}") if "Existing fillet result check" not in result: raise SystemExit(f"existing fillet result did not report result check: {result}") if "first_level_topology_matched=True" not in result: raise SystemExit(f"existing fillet result did not verify first-level topology: {result}") print("mode=existing_fillet") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"support_face_ids={support_face_ids}") print(f"source_radius={source_radius:.6f} target_radius={target_radius:.6f}") print(f"matched_faces={matches}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_existing_fillet_arc_length_case( source_radius: float, target_arc_length: float, tolerance: float, ) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_arc_") as temp_dir: model_path = Path(temp_dir) / "filleted_box.step" _write_filleted_box_model(model_path, source_radius) model = StepModel.load(model_path) face_id = _first_existing_fillet_face(model, source_radius, tolerance) source_info = model.feature_info(face_id) angular_span = float( source_info.get("existing_fillet_angular_span") or source_info.get("angular_span") or 0.0 ) if angular_span <= 1e-6: raise SystemExit(f"existing fillet has no stable angular span: {source_info}") target_radius = float(target_arc_length) / angular_span before = model.stats() plan = model.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise SystemExit(f"existing fillet arc-length plan was blocked: {plan['message']}") _assert_existing_fillet_plan_topology(plan) result = model.resize_existing_fillet(face_id, target_radius) after = model.stats() matches = _cylindrical_faces_near_radius(model, target_radius, tolerance) if not matches: raise SystemExit( f"existing fillet arc-length verification failed: no cylindrical face near radius {target_radius:g}" ) verified_face_id = matches[0][0] verified_info = model.feature_info(verified_face_id) verified_arc = float( verified_info.get("existing_fillet_arc_length_estimate") or target_radius * angular_span ) arc_error = abs(verified_arc - target_arc_length) if after.solids != before.solids: raise SystemExit( f"existing fillet arc-length resize changed solid count: before={before.solids}, after={after.solids}" ) if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4): raise SystemExit( f"existing fillet arc-length verification failed: " f"target={target_arc_length:g}, value={verified_arc:g}, error={arc_error:g}" ) if "Existing fillet result check" not in result: raise SystemExit(f"existing fillet arc-length result did not report result check: {result}") if "first_level_topology_matched=True" not in result: raise SystemExit(f"existing fillet arc-length result did not verify first-level topology: {result}") print("mode=existing_fillet_arc_length") print(f"face_id={face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"source_radius={source_radius:.6f}") print(f"target_arc_length={target_arc_length:.6f} target_radius={target_radius:.6f}") print(f"verified_face={verified_face_id} verified_arc_length={verified_arc:.6f} error={arc_error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_") as temp_dir: model_path = Path(temp_dir) / "fillet_chain_box.step" _write_chained_filleted_box_model(model_path, source_radius) model = StepModel.load(model_path) face_id = _first_existing_fillet_face(model, source_radius, tolerance) feature = model.feature_info(face_id) chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ()) chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ()) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ()) if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked": raise SystemExit(f"fillet chain should be blocked at recognition level: {feature}") recognition_blockers = str(feature.get("recognition_blockers") or "") recognition_ready_actions = str(feature.get("recognition_ready_actions") or "") recognition_limited_actions = str(feature.get("recognition_limited_actions") or "") if feature.get("recognition_decision") != "已阻止" or "圆角链" not in recognition_blockers: raise SystemExit(f"fillet chain recognition summary should explain the blocker: {feature}") if "已有圆角半径" in recognition_ready_actions: raise SystemExit(f"fillet chain should not expose existing fillet radius as ready: {feature}") if "已有圆角半径" not in recognition_limited_actions: raise SystemExit(f"fillet chain should list existing fillet radius as a limited action: {feature}") if len(chain_face_ids) < 2 or not chain_adjacent_face_ids: raise SystemExit(f"fillet chain should expose connected fillet faces: {feature}") if set(chain_adjacent_face_ids) & set(support_face_ids): raise SystemExit( "connected fillet faces should not be counted as support faces: " f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}" ) plan = model.existing_fillet_resize_plan(face_id, target_radius) message = str(plan.get("message") or "") blockers = str(plan.get("blockers") or "") if plan.get("status") != "blocked": raise SystemExit(f"existing fillet chain resize should be blocked before geometry execution: {plan}") if "圆角链" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}": raise SystemExit(f"fillet chain blocker should explain the unsupported capability: {plan}") if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids: raise SystemExit(f"fillet chain plan should retain chain face ids: {plan}") scan_candidates = model.editable_feature_candidates(limit=20, detailed=False) leaked_chain_candidates = [ item for item in scan_candidates if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids ] if leaked_chain_candidates: raise SystemExit(f"fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}") print("mode=existing_fillet_chain_guard") print(f"face_id={face_id}") print(f"chain_face_ids={chain_face_ids}") print(f"chain_adjacent_face_ids={chain_adjacent_face_ids}") print(f"support_face_ids={support_face_ids}") print(message.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.") parser.add_argument( "--mode", default="all", choices=[ "all", "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet", "existing_fillet_arc_length", "existing_fillet_chain_guard", ], help="Edge rounding/chamfering edit mode to verify.", ) parser.add_argument("--fillet-radius", type=float, default=1.0) parser.add_argument("--chamfer-distance", type=float, default=1.0) parser.add_argument("--asymmetric-distance1", type=float, default=0.8) parser.add_argument("--asymmetric-distance2", type=float, default=1.2) parser.add_argument("--distance-angle-distance", type=float, default=1.0) parser.add_argument("--distance-angle-degrees", type=float, default=45.0) parser.add_argument("--source-fillet-radius", type=float, default=1.0) parser.add_argument("--target-fillet-radius", type=float, default=1.5) parser.add_argument("--target-fillet-arc-length", type=float, default=2.356194490192345) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() modes = ( [ "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet", "existing_fillet_arc_length", "existing_fillet_chain_guard", ] if args.mode == "all" else [args.mode] ) for mode in modes: if mode == "fillet": _run_fillet_case(args.fillet_radius, args.tolerance) elif mode == "chamfer": _run_chamfer_case(args.chamfer_distance) elif mode == "asymmetric_chamfer": _run_asymmetric_chamfer_case(args.asymmetric_distance1, args.asymmetric_distance2) elif mode == "distance_angle_chamfer": _run_distance_angle_chamfer_case(args.distance_angle_distance, args.distance_angle_degrees) elif mode == "existing_fillet": _run_existing_fillet_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance) elif mode == "existing_fillet_arc_length": _run_existing_fillet_arc_length_case( args.source_fillet_radius, args.target_fillet_arc_length, args.tolerance, ) elif mode == "existing_fillet_chain_guard": _run_existing_fillet_chain_guard_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance) else: raise SystemExit(f"unsupported mode: {mode}") return 0 if __name__ == "__main__": raise SystemExit(main())