from __future__ import annotations import math from itertools import combinations from pathlib import Path from typing import Iterable from ..cad.ocp_interference import ( InterferenceResult, OcpInterferenceError, ShapeMetrics, _bbox_overlap, _import_ocp, common_volume, read_step_shape, shape_bbox, shape_valid, shape_volume, ) from .models import BBox, ComponentPlacement, StepShapeMetrics def _normalize(vector: Iterable[float]) -> tuple[float, float, float]: values = tuple(float(value) for value in vector) length = math.sqrt(sum(value * value for value in values)) if length <= 1e-12: return (0.0, 0.0, 1.0) return tuple(value / length for value in values) # type: ignore[return-value] def dot(a: Iterable[float], b: Iterable[float]) -> float: return sum(float(x) * float(y) for x, y in zip(a, b)) def cross(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]: ax, ay, az = (float(value) for value in a) bx, by, bz = (float(value) for value in b) return (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx) def vector_add(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]: return tuple(float(x) + float(y) for x, y in zip(a, b)) # type: ignore[return-value] def vector_sub(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]: return tuple(float(x) - float(y) for x, y in zip(a, b)) # type: ignore[return-value] def vector_scale(a: Iterable[float], scale: float) -> tuple[float, float, float]: return tuple(float(x) * scale for x in a) # type: ignore[return-value] def axis_angle_between( source: Iterable[float], target: Iterable[float], ) -> tuple[float, float, float, float]: source_n = _normalize(source) target_n = _normalize(target) cross_value = cross(source_n, target_n) cross_norm = math.sqrt(dot(cross_value, cross_value)) dot_value = max(-1.0, min(1.0, dot(source_n, target_n))) if cross_norm <= 1e-12: if dot_value >= 0.0: return (0.0, 0.0, 1.0, 0.0) fallback = cross(source_n, (1.0, 0.0, 0.0)) if math.sqrt(dot(fallback, fallback)) <= 1e-12: fallback = cross(source_n, (0.0, 1.0, 0.0)) axis = _normalize(fallback) return (*axis, 180.0) axis = _normalize(cross_value) return (*axis, math.degrees(math.atan2(cross_norm, dot_value))) def rotate_vector( vector: Iterable[float], rotation_axis_angle_deg: Iterable[float], ) -> tuple[float, float, float]: vx, vy, vz = (float(value) for value in vector) ax, ay, az, angle_deg = (float(value) for value in rotation_axis_angle_deg) axis = _normalize((ax, ay, az)) angle = math.radians(angle_deg) cos_a = math.cos(angle) sin_a = math.sin(angle) ux, uy, uz = axis cross_part = cross(axis, (vx, vy, vz)) dot_part = dot(axis, (vx, vy, vz)) return ( vx * cos_a + cross_part[0] * sin_a + ux * dot_part * (1.0 - cos_a), vy * cos_a + cross_part[1] * sin_a + uy * dot_part * (1.0 - cos_a), vz * cos_a + cross_part[2] * sin_a + uz * dot_part * (1.0 - cos_a), ) def apply_placement_to_point( point: Iterable[float], placement: ComponentPlacement, ) -> tuple[float, float, float]: rotated = rotate_vector(point, placement.rotation_axis_angle_deg) return vector_add(rotated, placement.translation_mm) def apply_placement_to_direction( direction: Iterable[float], placement: ComponentPlacement, ) -> tuple[float, float, float]: return _normalize(rotate_vector(direction, placement.rotation_axis_angle_deg)) def bbox_axis_range(bbox: BBox, axis: Iterable[float]) -> tuple[float, float]: xmin, ymin, zmin, xmax, ymax, zmax = bbox axis_n = _normalize(axis) values = [] for x in [xmin, xmax]: for y in [ymin, ymax]: for z in [zmin, zmax]: values.append(dot((x, y, z), axis_n)) return (min(values), max(values)) def bbox_center(bbox: BBox) -> tuple[float, float, float]: xmin, ymin, zmin, xmax, ymax, zmax = bbox return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0) def union_bbox(bboxes: Iterable[BBox]) -> BBox: items = list(bboxes) if not items: raise ValueError("union_bbox requires at least one bbox") return ( min(item[0] for item in items), min(item[1] for item in items), min(item[2] for item in items), max(item[3] for item in items), max(item[4] for item in items), max(item[5] for item in items), ) def subshape_count(shape, kind_name: str) -> int: ocp = _import_ocp() kind = { "solid": ocp["TopAbs_SOLID"], "face": ocp["TopAbs_FACE"], }[kind_name] explorer = ocp["TopExp_Explorer"](shape, kind) count = 0 while explorer.More(): count += 1 explorer.Next() return count def face_type_counts(shape) -> tuple[int, int]: ocp = _import_ocp() explorer = ocp["TopExp_Explorer"](shape, ocp["TopAbs_FACE"]) plane_count = 0 cylinder_count = 0 while explorer.More(): try: face = ocp["TopoDS"].Face_s(explorer.Current()) surface = ocp["BRepAdaptor_Surface"](face) surface_type = surface.GetType() if surface_type == ocp["GeomAbs_Plane"]: plane_count += 1 elif surface_type == ocp["GeomAbs_Cylinder"]: cylinder_count += 1 except Exception: # noqa: BLE001 pass explorer.Next() return plane_count, cylinder_count def metrics_for_shape( *, component_id: str, step_path: Path, shape, ) -> StepShapeMetrics: plane_count, cylinder_count = face_type_counts(shape) return StepShapeMetrics( component_id=component_id, step_path=str(step_path.resolve()), valid=shape_valid(shape), volume_mm3=shape_volume(shape), bbox=shape_bbox(shape), solid_count=subshape_count(shape, "solid"), face_count=subshape_count(shape, "face"), plane_face_count=plane_count, cylinder_face_count=cylinder_count, ) def metrics_for_step(component_id: str, path: Path) -> StepShapeMetrics: shape = read_step_shape(path) return metrics_for_shape(component_id=component_id, step_path=path, shape=shape) def apply_transform(shape, placement: ComponentPlacement): ocp = _import_ocp() ax, ay, az, angle_deg = placement.rotation_axis_angle_deg transformed = shape if abs(angle_deg) > 1e-12: rotation = ocp["gp_Trsf"]() rotation.SetRotation( ocp["gp_Ax1"]( ocp["gp_Pnt"](0.0, 0.0, 0.0), ocp["gp_Dir"](float(ax), float(ay), float(az)), ), math.radians(float(angle_deg)), ) transformed = ocp["BRepBuilderAPI_Transform"](transformed, rotation, True).Shape() tx, ty, tz = placement.translation_mm if abs(tx) > 1e-12 or abs(ty) > 1e-12 or abs(tz) > 1e-12: translation = ocp["gp_Trsf"]() translation.SetTranslation(ocp["gp_Vec"](float(tx), float(ty), float(tz))) transformed = ocp["BRepBuilderAPI_Transform"](transformed, translation, True).Shape() return transformed def write_compound_step(shapes: dict[str, object], path: Path) -> None: if not shapes: raise OcpInterferenceError("cannot_write_empty_joint_compound") compound = compound_shape(shapes) ocp = _import_ocp() path.parent.mkdir(parents=True, exist_ok=True) writer = ocp["STEPControl_Writer"]() writer.Transfer(compound, ocp["STEPControl_AsIs"]) status = writer.Write(str(path)) if status != ocp["IFSelect_RetDone"]: raise OcpInterferenceError(f"step_write_failed: {path}") def compound_shape(shapes: dict[str, object]): if not shapes: raise OcpInterferenceError("cannot_build_empty_joint_compound") ocp = _import_ocp() compound = ocp["TopoDS_Compound"]() builder = ocp["BRep_Builder"]() builder.MakeCompound(compound) for shape in shapes.values(): builder.Add(compound, shape) return compound def detect_interferences_from_placed_shapes( shapes: dict[str, object], *, step_paths: dict[str, Path], check_pairs: Iterable[tuple[str, str]] | None = None, volume_tolerance_mm3: float = 1e-6, bbox_tolerance_mm: float = 1e-7, ) -> tuple[list[ShapeMetrics], list[InterferenceResult]]: if len(shapes) < 2: raise OcpInterferenceError("need_at_least_two_placed_shapes") metrics = [ ShapeMetrics( component_id=component_id, valid=shape_valid(shape), volume_mm3=shape_volume(shape), bbox=shape_bbox(shape), ) for component_id, shape in shapes.items() ] metric_by_id = {metric.component_id: metric for metric in metrics} pairs = list(check_pairs) if check_pairs is not None else list(combinations(sorted(shapes), 2)) results: list[InterferenceResult] = [] for component_a, component_b in pairs: if component_a not in shapes or component_b not in shapes: raise OcpInterferenceError(f"unknown_interference_pair: {component_a}, {component_b}") bbox_overlap = _bbox_overlap( metric_by_id[component_a].bbox, metric_by_id[component_b].bbox, tolerance_mm=bbox_tolerance_mm, ) if not bbox_overlap: results.append( InterferenceResult( component_a=component_a, component_b=component_b, common_volume_mm3=0.0, bbox_overlap=False, checked_boolean=False, interfering=False, ) ) continue volume = common_volume(shapes[component_a], shapes[component_b]) results.append( InterferenceResult( component_a=component_a, component_b=component_b, common_volume_mm3=volume, bbox_overlap=True, checked_boolean=True, interfering=volume > volume_tolerance_mm3, ) ) return metrics, results