"""Bearing standard assemblies built from public SimpleCAD product APIs. The factories in this module return product-level assemblies rather than a single merged solid when the standard part has meaningful internal motion. A ball bearing is a small assembly: the important kinematic relationship is the revolute axis between the outer ring and the inner ring, while direct sphere balls sit in continuous toroidal raceway grooves at authored positions. """ from __future__ import annotations import math from typing import Dict, List, Optional from ..product import Assembly, Part from ..operations import ( add_component_rassembly, add_connector_rpart, add_revolute_constraint_rassembly, apply_tag, chamfer_rsolid, forward_connector_rassembly, identity_placement_rplacement, make_assembly_rassembly, make_connector_ref_rconnectorref, make_face_connector_rconnector, make_face_from_wire_rface, make_line_redge, make_part_rpart, make_placement_rplacement, make_sphere_rsolid, make_three_point_arc_redge, make_wire_from_edges_rwire, revolve_rsolid, ) from ..core import Face, Solid __all__ = ["make_ball_bearing_rassembly"] def _validate_positive_finite(name: str, value: float) -> float: resolved = float(value) if not math.isfinite(resolved): raise ValueError(f"{name} must be finite") if resolved <= 0.0: raise ValueError(f"{name} must be positive") return resolved def _validate_non_negative_finite(name: str, value: float) -> float: resolved = float(value) if not math.isfinite(resolved): raise ValueError(f"{name} must be finite") if resolved < 0.0: raise ValueError(f"{name} must be non-negative") return resolved def _infer_ball_count(ball_pitch_radius: float, ball_diameter: float) -> int: circumference = 2.0 * math.pi * ball_pitch_radius # A conservative visual default leaves room for a printed cage/gap between # adjacent balls instead of packing tangent spheres around the pitch circle. return max(3, int(math.floor(circumference / (1.5 * ball_diameter)))) def _validate_ball_count( ball_count: Optional[int], ball_pitch_radius: float, ball_diameter: float, ) -> int: if ball_count is None: resolved = _infer_ball_count(ball_pitch_radius, ball_diameter) else: resolved = int(ball_count) if resolved < 3: raise ValueError("ball_count must be at least 3") chord_spacing = 2.0 * ball_pitch_radius * math.sin(math.pi / resolved) if chord_spacing <= ball_diameter: raise ValueError( "ball_count is too high for ball_diameter on the bearing pitch circle" ) return resolved def _candidate_chamfer_edges(solid: Solid, bearing_width: float) -> List[object]: circular_min_length = max(1e-6, bearing_width * 1.1) return [edge for edge in solid.get_edges() if edge.get_length() > circular_min_length] def _apply_edge_chamfer(solid: Solid, edge_chamfer: float, bearing_width: float) -> Solid: if edge_chamfer <= 0.0: return solid edges = _candidate_chamfer_edges(solid, bearing_width) if not edges: return solid return chamfer_rsolid(solid, edges, edge_chamfer) def _make_race_ring_solid( bore_or_inner_radius: float, outer_or_shoulder_radius: float, ball_pitch_radius: float, groove_radius: float, bearing_width: float, edge_chamfer: float, role: str, ) -> Solid: half_width = bearing_width / 2.0 if role == "inner_ring": inner_radius = bore_or_inner_radius outer_radius = outer_or_shoulder_radius mouth_offset = ball_pitch_radius - outer_radius if mouth_offset <= 0.0 or mouth_offset >= groove_radius: raise ValueError("inner ring shoulder radius must expose the raceway groove") mouth_z = math.sqrt(max(0.0, groove_radius * groove_radius - mouth_offset * mouth_offset)) points = [ (inner_radius, 0.0, -half_width), (outer_radius, 0.0, -half_width), (outer_radius, 0.0, -mouth_z), (ball_pitch_radius - groove_radius, 0.0, 0.0), (outer_radius, 0.0, mouth_z), (outer_radius, 0.0, half_width), (inner_radius, 0.0, half_width), ] arc_start = points[2] arc_mid = points[3] arc_end = points[4] edges = [ make_line_redge(points[0], points[1]), make_line_redge(points[1], arc_start), make_three_point_arc_redge(arc_start, arc_mid, arc_end), make_line_redge(arc_end, points[5]), make_line_redge(points[5], points[6]), make_line_redge(points[6], points[0]), ] elif role == "outer_ring": inner_radius = bore_or_inner_radius outer_radius = outer_or_shoulder_radius mouth_offset = inner_radius - ball_pitch_radius if mouth_offset <= 0.0 or mouth_offset >= groove_radius: raise ValueError("outer ring shoulder radius must expose the raceway groove") mouth_z = math.sqrt(max(0.0, groove_radius * groove_radius - mouth_offset * mouth_offset)) points = [ (inner_radius, 0.0, -half_width), (outer_radius, 0.0, -half_width), (outer_radius, 0.0, half_width), (inner_radius, 0.0, half_width), (inner_radius, 0.0, mouth_z), (ball_pitch_radius + groove_radius, 0.0, 0.0), (inner_radius, 0.0, -mouth_z), ] arc_start = points[4] arc_mid = points[5] arc_end = points[6] edges = [ make_line_redge(points[0], points[1]), make_line_redge(points[1], points[2]), make_line_redge(points[2], points[3]), make_line_redge(points[3], arc_start), make_three_point_arc_redge(arc_start, arc_mid, arc_end), make_line_redge(arc_end, points[0]), ] else: raise ValueError("role must be inner_ring or outer_ring") profile = make_face_from_wire_rface( make_wire_from_edges_rwire(edges), normal=(0.0, -1.0, 0.0), ) ring = revolve_rsolid( profile, axis=(0.0, 0.0, 1.0), angle=360.0, origin=(0.0, 0.0, 0.0), ) ring = apply_tag(ring, f"role.{role}") ring = apply_tag(ring, "group.ball_bearing") ring = _apply_edge_chamfer(ring, edge_chamfer, bearing_width) ring.set_metadata( "std.bearing.ring", { "role": role, "inner_radius": inner_radius, "outer_radius": outer_radius, "ball_pitch_radius": ball_pitch_radius, "groove_radius": groove_radius, "raceway_mouth_z": mouth_z, "bearing_width": bearing_width, "edge_chamfer": edge_chamfer, }, ) return ring def _axis_face(solid: Solid, target_z: float) -> Face: candidates = [] for face in solid.get_faces(): normal = face.get_normal_at() if normal.z < 0.7: continue center = face.get_center() candidates.append((abs(center.z - target_z), -face.get_area(), face)) if not candidates: raise ValueError("no +Z bearing axis face found") return min(candidates, key=lambda item: item[0:2])[2] def _part_with_axis_connector( part_id: str, body: Solid, name: str, target_z: float, ) -> Part: part = make_part_rpart(part_id, body, name=name) axis = make_face_connector_rconnector("axis", _axis_face(body, target_z)) return add_connector_rpart(part, axis) def _ball_placement(ball_pitch_radius: float, angle_degrees: float): angle = math.radians(angle_degrees) cos_a = math.cos(angle) sin_a = math.sin(angle) return make_placement_rplacement( origin=(ball_pitch_radius * cos_a, ball_pitch_radius * sin_a, 0.0), x_axis=(cos_a, sin_a, 0.0), y_axis=(-sin_a, cos_a, 0.0), ) def make_ball_bearing_rassembly( bore_diameter: float, outer_diameter: float, bearing_width: float, ball_diameter: float, ball_count: Optional[int] = None, raceway_clearance: float = 0.02, edge_chamfer: float = 0.0, assembly_id: str = "ball_bearing", drive_angle_degrees: Optional[float] = None, ) -> Assembly: """Create a parameterized radial ball bearing assembly. This factory returns an `Assembly`, not a merged `Solid`, because a bearing has useful internal structure. The returned assembly contains stable component ids `outer_ring`, `inner_ring`, and `ball_00`, `ball_01`, ... . The inner and outer rings each carry an `axis` connector, and the assembly includes one revolute constraint named `inner_outer_revolute` between those two axes. Use `bearing.get_component("inner_ring").item.body` to access the inner-ring geometry directly, or use connector refs such as `make_connector_ref_rconnectorref("inner_ring", "axis")` when adding shaft or housing constraints to the same assembly. The returned bearing assembly also forwards public assembly-level connectors `inner_axis` and `outer_axis` from `inner_ring.axis` and `outer_ring.axis`. Parent assemblies can constrain to those connectors without depending on the bearing's internal component structure. These public axes are offset to the bearing center plane. The returned bearing is not grounded. Ground the parent assembly's housing, shaft, or fixture components explicitly; the standard bearing assembly does not emit `GroundedJoint` objects that would lock a parent mechanism. Parameters use explicit SDK-style names rather than compact catalog labels: `bore_diameter` maps to common `id`, `outer_diameter` maps to `od`, `bearing_width` maps to axial bearing thickness, `ball_diameter` maps to ball size, `raceway_clearance` maps to print clearance around the balls, and `edge_chamfer` maps to edge break/chamfer. There is intentionally no Python keyword-only `*` separator in this signature so the function remains callable with either positional or keyword arguments. `ball_count=None` lets the factory infer a conservative visual ball count from the pitch circle. Explicit `ball_count` is accepted when you need to match a real bearing or a printed cage design. Balls are direct sphere primitive solids, and the inner and outer rings are revolved from arc-groove profiles to create continuous toroidal raceway grooves. Balls are visual rolling elements fixed at their authored positions; the currently modeled kinematic degree of freedom is only the inner-ring-to-outer-ring revolute joint. For printable bearings, the classic checks from many parametric generators are still useful: `((outer_diameter - bore_diameter) / 2) - ball_diameter` should leave enough radial wall thickness, and `bearing_width - ball_diameter` should be positive so balls do not protrude axially. """ bore_diameter_value = _validate_positive_finite("bore_diameter", bore_diameter) outer_diameter_value = _validate_positive_finite("outer_diameter", outer_diameter) bearing_width_value = _validate_positive_finite("bearing_width", bearing_width) ball_diameter_value = _validate_positive_finite("ball_diameter", ball_diameter) raceway_clearance_value = _validate_non_negative_finite( "raceway_clearance", raceway_clearance, ) edge_chamfer_value = _validate_non_negative_finite("edge_chamfer", edge_chamfer) if outer_diameter_value <= bore_diameter_value: raise ValueError("outer_diameter must be greater than bore_diameter") bore_radius = bore_diameter_value / 2.0 outer_radius = outer_diameter_value / 2.0 ball_radius = ball_diameter_value / 2.0 ball_pitch_radius = (bore_radius + outer_radius) / 2.0 groove_radius = ball_radius + raceway_clearance_value inner_groove_root_radius = ball_pitch_radius - groove_radius outer_groove_root_radius = ball_pitch_radius + groove_radius inner_wall_thickness = inner_groove_root_radius - bore_radius outer_wall_thickness = outer_radius - outer_groove_root_radius axial_clearance = bearing_width_value / 2.0 - groove_radius if inner_wall_thickness <= 0.0 or outer_wall_thickness <= 0.0: raise ValueError( "ball_diameter plus raceway_clearance leaves no radial wall thickness" ) if axial_clearance <= 0.0: raise ValueError( "bearing_width must be greater than ball_diameter plus raceway_clearance" ) smallest_feature = min(inner_wall_thickness, outer_wall_thickness, axial_clearance) if edge_chamfer_value >= smallest_feature: raise ValueError("edge_chamfer must be smaller than the thinnest bearing feature") resolved_ball_count = _validate_ball_count( ball_count, ball_pitch_radius, ball_diameter_value, ) outer_ring = _make_race_ring_solid( ball_pitch_radius + ball_radius * 0.75, outer_radius, ball_pitch_radius, groove_radius, bearing_width_value, edge_chamfer_value, "outer_ring", ) inner_ring = _make_race_ring_solid( bore_radius, ball_pitch_radius - ball_radius * 0.75, ball_pitch_radius, groove_radius, bearing_width_value, edge_chamfer_value, "inner_ring", ) ball = make_sphere_rsolid(radius=ball_radius, center=(0.0, 0.0, 0.0)) ball = apply_tag(ball, "role.rolling_element") ball = apply_tag(ball, "group.ball_bearing") ball.set_metadata( "std.bearing.ball", { "diameter": ball_diameter_value, "pitch_radius": ball_pitch_radius, }, ) outer_part = _part_with_axis_connector( "outer_ring", outer_ring, "Outer bearing ring", bearing_width_value / 2.0, ) inner_part = _part_with_axis_connector( "inner_ring", inner_ring, "Inner bearing ring", bearing_width_value / 2.0, ) ball_part = make_part_rpart("ball", ball, name="Bearing ball") assembly = make_assembly_rassembly(assembly_id, name="Ball bearing") assembly = add_component_rassembly( assembly, outer_part, component_id="outer_ring", placement=identity_placement_rplacement(), ) assembly = add_component_rassembly( assembly, inner_part, component_id="inner_ring", placement=identity_placement_rplacement(), ) ball_component_ids: List[str] = [] ball_angles: Dict[str, float] = {} digits = max(2, len(str(resolved_ball_count - 1))) for index in range(resolved_ball_count): component_id = f"ball_{index:0{digits}d}" angle_degrees = 360.0 * index / resolved_ball_count ball_component_ids.append(component_id) ball_angles[component_id] = angle_degrees assembly = add_component_rassembly( assembly, ball_part, component_id=component_id, placement=_ball_placement(ball_pitch_radius, angle_degrees), ) assembly = add_revolute_constraint_rassembly( assembly, "inner_outer_revolute", make_connector_ref_rconnectorref("outer_ring", "axis"), make_connector_ref_rconnectorref("inner_ring", "axis"), drive_angle_degrees=drive_angle_degrees, name="Inner ring spins in outer ring", ) public_axis_offset = make_placement_rplacement( origin=(0.0, 0.0, -bearing_width_value / 2.0), ) assembly = forward_connector_rassembly( assembly, connector_id="outer_axis", source_component_id="outer_ring", source_connector_id="axis", name="Outer ring housing axis", offset=public_axis_offset, ) assembly = forward_connector_rassembly( assembly, connector_id="inner_axis", source_component_id="inner_ring", source_connector_id="axis", name="Inner ring shaft axis", offset=public_axis_offset, ) assembly.set_metadata( "std.bearing.ball_bearing", { "bore_diameter": bore_diameter_value, "outer_diameter": outer_diameter_value, "bearing_width": bearing_width_value, "ball_diameter": ball_diameter_value, "ball_count": resolved_ball_count, "raceway_clearance": raceway_clearance_value, "edge_chamfer": edge_chamfer_value, "ball_pitch_radius": ball_pitch_radius, "groove_radius": groove_radius, "inner_groove_root_radius": inner_groove_root_radius, "outer_groove_root_radius": outer_groove_root_radius, "inner_wall_thickness": inner_wall_thickness, "outer_wall_thickness": outer_wall_thickness, "axial_clearance": axial_clearance, "outer_component_id": "outer_ring", "inner_component_id": "inner_ring", "ball_component_ids": ball_component_ids, "ball_angles_degrees": ball_angles, "axis_connector_id": "axis", "outer_axis_connector_id": "outer_axis", "inner_axis_connector_id": "inner_axis", "revolute_constraint_id": "inner_outer_revolute", }, ) return assembly