from __future__ import annotations import argparse from pathlib import Path import sys 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 DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" def _tuple3(value: object, label: str) -> tuple[float, float, float]: if not isinstance(value, (tuple, list)) or len(value) != 3: raise SystemExit(f"{label} is missing or invalid: {value!r}") return float(value[0]), float(value[1]), float(value[2]) def _sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: return a[0] - b[0], a[1] - b[1], a[2] - b[2] def _add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: return a[0] + b[0], a[1] + b[1], a[2] + b[2] def _scale(a: tuple[float, float, float], factor: float) -> tuple[float, float, float]: return a[0] * factor, a[1] * factor, a[2] * factor def _length(a: tuple[float, float, float]) -> float: return (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) ** 0.5 def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: return _length(_sub(a, b)) def _first_line_edge_near_length(model: StepModel, length: float, tolerance: float) -> int: for edge_id in range(len(model.edges)): info = model.edge_info(edge_id) if info.get("curve") != "line": continue if abs(float(info.get("length") or 0.0) - length) <= tolerance: return edge_id raise SystemExit(f"no line Edge near length {length:g}") def _endpoint_pair_error( model: StepModel, edge_id: int, expected_start: tuple[float, float, float], expected_end: tuple[float, float, float], ) -> float: info = model.edge_info(edge_id) start = _tuple3(info.get("start_point"), f"Edge {edge_id} start") end = _tuple3(info.get("end_point"), f"Edge {edge_id} end") direct = max(_distance(start, expected_start), _distance(end, expected_end)) reversed_order = max(_distance(start, expected_end), _distance(end, expected_start)) return min(direct, reversed_order) def _nearest_expected_edge( model: StepModel, expected_start: tuple[float, float, float], expected_end: tuple[float, float, float], ) -> tuple[int, float, float]: best: tuple[int, float, float] | None = None target_length = _distance(expected_start, expected_end) for edge_id in range(len(model.edges)): info = model.edge_info(edge_id) if info.get("curve") != "line": continue endpoint_error = _endpoint_pair_error(model, edge_id, expected_start, expected_end) length_error = abs(float(info.get("length") or 0.0) - target_length) if best is None or (endpoint_error, length_error, edge_id) < (best[1], best[2], best[0]): best = (edge_id, endpoint_error, length_error) if best is None: raise SystemExit("edited model has no line Edge for endpoint verification") return best def _source_edge_frame( model: StepModel, source_length: float, tolerance: float, ) -> tuple[int, tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], float]: edge_id = _first_line_edge_near_length(model, source_length, tolerance) info = model.edge_info(edge_id) start = _tuple3(info.get("start_point"), "source start") end = _tuple3(info.get("end_point"), "source end") vector = _sub(end, start) length = _length(vector) if length <= 1e-9: raise SystemExit(f"source Edge {edge_id} has zero length") direction = _scale(vector, 1.0 / length) return edge_id, start, end, direction, length def _run_endpoint_case(role: str, target_delta: float, tolerance: float) -> None: model = StepModel.load(DEFAULT_MODEL) edge_id, start, end, direction, source_length = _source_edge_frame(model, 10.0, tolerance) if role == "start": target_start = _add(start, _scale(direction, -target_delta)) target_end = end target_point = target_start elif role == "end": target_start = start target_end = _add(end, _scale(direction, target_delta)) target_point = target_end else: raise SystemExit(f"unsupported endpoint role: {role}") before = model.stats() plan = model.edge_endpoint_move_plan(edge_id, role, target_point) if plan["status"] == "blocked": raise SystemExit(f"{role} endpoint plan was blocked: {plan['message']}") if plan.get("resize_strategy") != "local-edge-endpoint-deform": raise SystemExit(f"{role} endpoint should use local-edge-endpoint-deform, got {plan.get('resize_strategy')}") result = model.move_edge_endpoint(edge_id, role, target_point) after = model.stats() matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end) if after.solids != before.solids: raise SystemExit(f"{role} endpoint changed solid count: before={before.solids}, after={after.solids}") if endpoint_error > tolerance or length_error > tolerance: raise SystemExit( f"{role} endpoint verification failed: matched_edge={matched_edge}, " f"endpoint_error={endpoint_error:g}, length_error={length_error:g}" ) print(f"mode={role}_endpoint") print(f"source_edge={edge_id}") print(f"matched_edge={matched_edge}") print(f"source_length={source_length:.6f}") print(f"target_point={target_point}") print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def _run_center_case(center_delta: tuple[float, float, float], tolerance: float) -> None: model = StepModel.load(DEFAULT_MODEL) edge_id, start, end, _direction, source_length = _source_edge_frame(model, 10.0, tolerance) current_center = _tuple3(model.edge_info(edge_id).get("length_center"), "source center") target_center = _add(current_center, center_delta) target_start = _add(start, center_delta) target_end = _add(end, center_delta) before = model.stats() plan = model.edge_center_move_plan(edge_id, target_center) if plan["status"] == "blocked": raise SystemExit(f"center plan was blocked: {plan['message']}") if plan.get("resize_strategy") != "local-edge-center-deform": raise SystemExit(f"center move should use local-edge-center-deform, got {plan.get('resize_strategy')}") result = model.move_edge_center(edge_id, target_center) after = model.stats() matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end) if after.solids != before.solids: raise SystemExit(f"center move changed solid count: before={before.solids}, after={after.solids}") if endpoint_error > tolerance or length_error > tolerance: raise SystemExit( f"center move verification failed: matched_edge={matched_edge}, " f"endpoint_error={endpoint_error:g}, length_error={length_error:g}" ) print("mode=center") print(f"source_edge={edge_id}") print(f"matched_edge={matched_edge}") print(f"source_length={source_length:.6f}") print(f"target_center={target_center}") print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify straight Edge start, center and end coordinate edits.") parser.add_argument("--mode", default="all", choices=["all", "start", "center", "end"]) parser.add_argument("--endpoint-delta", type=float, default=3.0) parser.add_argument("--center-delta", default="0,0,2") parser.add_argument("--tolerance", type=float, default=1e-5) args = parser.parse_args() center_delta = _tuple3([part.strip() for part in str(args.center_delta).split(",")], "center delta") if args.mode in {"all", "start"}: _run_endpoint_case("start", args.endpoint_delta, args.tolerance) if args.mode in {"all", "center"}: _run_center_case(center_delta, args.tolerance) if args.mode in {"all", "end"}: _run_endpoint_case("end", args.endpoint_delta, args.tolerance) return 0 if __name__ == "__main__": raise SystemExit(main())