Source code for retaining_ring

"""

Parametric Retaining Rings

name: retaining_ring.py
by:   Gumyr
date: June 27th 2026

desc: This python/build123d code provides parameterized retaining rings.

license:

    Copyright 2026 Gumyr

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.

"""

import csv
from abc import ABC, abstractmethod
from importlib import resources
from typing import ClassVar

from build123d.build_enums import Align, GeomType, Mode, Tangency
from build123d.geometry import Axis, Color, Location, Plane, Pos, RotationLike
from build123d.joints import RigidJoint
from build123d.objects_curve import (
    CenterArc,
    ConstrainedArcs,
    ConstrainedLines,
    Line,
    Polyline,
    RadiusArc,
    ThreePointArc,
)
from build123d.objects_part import BasePartObject
from build123d.objects_sketch import Circle, Rectangle, SlotOverall
from build123d.operations_generic import fillet, mirror, split
from build123d.operations_part import extrude
from build123d.topology import Edge, Face, Part, ShapeList, Solid, Wire

import bd_warehouse


def read_retaining_ring_parameters_from_csv(
    filename: str,
) -> dict[str, dict[str, float]]:
    """Parse a metric retaining ring CSV parameter file."""
    parameters = {}
    data_resource = resources.files(bd_warehouse) / f"data/{filename}"

    with data_resource.open(encoding="utf-8", newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        if not reader.fieldnames:
            raise ValueError(f"No header found in {filename}")
        size_field = reader.fieldnames[0]
        for row in reader:
            size = row.pop(size_field)
            if not size:
                continue
            parameters[size] = {
                name: float(value)
                for name, value in row.items()
                if name is not None and value not in (None, "")
            }
            parameters[size][size_field] = float(size)
    return parameters


[docs] class RetainingRing(ABC, BasePartObject): """Base class for standardized retaining rings. Args: size: Nominal shaft or bore diameter as listed in the applicable standard. rotation: Sequence of angles about the X, Y, and Z axes. Defaults to (0, 0, 0). align: Align MIN, CENTER, or MAX of object. Defaults to None, which places the minimum Z face on the placement plane while remaining centered in XY. mode: Combination mode. Defaults to Mode.ADD. Raises: ValueError: Invalid retaining ring size. """ ring_data: ClassVar[dict[str, dict[str, float]]] standard: ClassVar[str]
[docs] @classmethod def sizes(cls) -> list[str]: """Return the available nominal sizes for this retaining ring class.""" return list(cls.ring_data.keys())
[docs] @classmethod def parameters(cls, size: str) -> dict[str, float]: """Return the evaluated metric parameters for a nominal size.""" normalized_size = size.strip() try: parameters = cls.ring_data[normalized_size] except KeyError as e: raise ValueError(f"{size} invalid, must be one of {cls.sizes()}") from e return parameters.copy()
@property def info(self) -> str: """Return identifying information for this retaining ring.""" return f"{self.__class__.__name__}({self.standard}): {self.ring_size}"
[docs] @classmethod def select_by_size(cls, size: str) -> dict[type["RetainingRing"], list[str]]: """Return retaining ring classes and standards available in the given size.""" ring_classes = cls.__subclasses__() if cls is RetainingRing else [cls] return { ring_class: [ring_class.standard] for ring_class in ring_classes if size.strip() in ring_class.sizes() }
@abstractmethod def make_ring(self) -> Part: """Create the retaining ring CAD object.""" raise NotImplementedError # pragma: no cover def __init__( self, size: str, rotation: RotationLike = (0, 0, 0), align: Align | tuple[Align, Align, Align] | None = None, mode: Mode = Mode.ADD, ): self.ring_size = size.strip() self.ring_dict = self.parameters(self.ring_size) super().__init__( part=self.make_ring(), rotation=rotation, align=align, mode=mode, ) self.label = f"{self.__class__.__name__}-{self.ring_size}" self.color = Color(0xC0C0C0) # Fine the two largest planar faces at "top" and "bottom" top_bottom = self.faces().group_by(Face.area)[-1] center_index = -2 if isinstance(self, ExternalSnapRing) else -1 center_locs = [] for i, face in enumerate(top_bottom): arc_center = ( face.edges() .filter_by(GeomType.CIRCLE) .sort_by(Edge.radius)[center_index] .arc_center ) normal = face.normal_at(arc_center) center_locs.append( Location(Plane(arc_center, z_dir=(-1 if i == 0 else 1) * normal)) ) RigidJoint("a", self, center_locs[0]) RigidJoint("b", self, center_locs[1])
[docs] class ExternalSnapRing(RetainingRing): """External retaining ring for shafts as defined by DIN 471.""" standard = "DIN471" ring_data = read_retaining_ring_parameters_from_csv("din471.csv") def make_ring(self) -> Part: """Create the external retaining ring using the DIN 471 dimensions. DIN 471 specifies the critical dimensions but leaves the transitions between the ring body and lugs illustrative. This construction creates a smooth, symmetric approximation that honors ``d3``, ``a``, ``b``, ``d5``, and ``s``. """ d3, b, a, d5, s = (self.ring_dict[p] for p in ["d3", "b", "a", "d5", "s"]) # Sizes 3-9 need a compact overlapping-slot construction because their lug # proportions do not leave enough room for the general tangent-arc profile. if int(self.ring_size) <= 9: ring_plan = Pos(Y=b / 4) * Circle((d3 + 3 * b / 2) / 2) ring_plan += Pos(Y=-d3 / 2 - a / 2) * SlotOverall(3 * d5, a) ring_plan = fillet(ring_plan.vertices().group_by(Axis.Y)[1], 2 * d5) ring_plan -= Circle(d3 / 2) ring_plan -= Rectangle(d5, 2 * d3, align=(Align.CENTER, Align.MAX)) ring_plan -= Pos(Y=-d3 / 2 - a / 2) * SlotOverall(2 * d5, d5) else: # The helper circles locate a smooth tangent transition from the ring body # to one plier lug. Only half is retained and mirrored for exact symmetry. c1_cntr = CenterArc((0, 0), d3 / 2 + b, 0, 90).intersect( Axis((d3 / 5, 0), (0, 1)) )[0] c2_cntr = CenterArc((0, 0), d3 / 2 + a, 270, 90).intersect( Axis((a / 1.5 + 2.5 * d5, 0), (0, 1)) )[0] cntr_path = ThreePointArc(c2_cntr, ((d3 + a + b) / 2, 0), c1_cntr) c1 = CenterArc(c1_cntr, b / 5, 0, 360) c2 = CenterArc(c2_cntr, a / 1.5, 0, 360) a1 = ( ConstrainedArcs( (c1, Tangency.OUTSIDE), c2, radius=cntr_path.radius - a / 2 ) .edges() .sort_by(Axis.X)[-1] ) a1_top = a1.vertices().sort_by(Axis.Y)[-1] a1_bot = a1.vertices().sort_by(Axis.Y)[0] p = Polyline( a1_top, a1_top + (0, b), a1_top + (d3, b), a1_bot + (d3, -a), a1_bot + (0, -a), a1_bot, ) ring_plan = Pos(Y=(b - a) / 2) * Circle((d3 + a + b) / 2) ring_plan -= [Face(Wire(c)) for c in (c1, c2)] ring_plan -= Face(Wire(p.edges() + [a1])) ring_plan = fillet(ring_plan.vertices().sort_by(Axis.Y)[0], a / 2) ring_plan -= Circle(d3 / 2) ring_plan -= Rectangle(d5, d3, align=(Align.CENTER, Align.MAX)) ring_plan -= Pos( CenterArc((0, 0), (d3 + a) / 2, 270, 90).intersect( Axis((1.5 * d5, 0), (0, 1)) )[0] ) * Circle(d5 / 2) ring_plan = split(ring_plan, Plane.YZ) ring_plan += mirror(ring_plan, Plane.YZ) # Create the Solid ring = extrude(ring_plan, s) return ring
[docs] class InternalSnapRing(RetainingRing): """Internal retaining ring for bores as defined by DIN 472.""" standard = "DIN472" ring_data = read_retaining_ring_parameters_from_csv("din472.csv") def make_ring(self) -> Part: """Create the canonical DIN 472 internal retaining ring. This uses the ``d1 <= 300 mm`` Detail X lug profile for every tabulated size. DIN 472's optional manufacturer-selected lug profiles are not modeled. The resulting symmetric approximation honors ``d3``, ``a``, ``b``, ``d5``, and ``s``. """ d3, b, a, d5, s = (self.ring_dict[p] for p in ["d3", "b", "a", "d5", "s"]) c1 = CenterArc((0, 0), (d3 - a) / 2, 270, 90) # Sizes 8-11 need the smaller offset to keep their compact lug connected; # larger rings have enough material for a two-hole-diameter offset. a1 = Axis((1.5 * d5 if int(self.ring_size) < 12 else 2 * d5, 0), (0, 1)) i = c1.intersect(a1)[0] ring_plan = base = Circle(d3 / 2) ring_plan -= (hole := Pos(Y=-b / 4) * Circle((d3 - 3 * b / 2) / 2)) ring_plan = split(ring_plan, -Plane((0, 0), x_dir=i, y_dir=(0, 0, 1))) ring_plan += (end_loop := Pos(i) * Circle(a / 2)) # Fillet the internal sharp corner interior_corner = (end_loop & hole).vertices().sort_by(Axis.Y)[-1] ring_plan = fillet( ring_plan.vertices().sort_by_distance(interior_corner)[0], a / 3 ) # Build the squarish corner l1 = ConstrainedLines((0, 0), end_loop.edge()).edges().sort_by(Axis.X) i2 = ShapeList(Axis(l1[0]).intersect(base.edge())).sort_by(Axis.Y)[0] l2 = Line(i2, l1[0].vertices().sort_by(Axis.Y)[0]) i3 = ShapeList(Axis(l1[1]).intersect(base.edge())).sort_by(Axis.X)[-1] a2 = RadiusArc(i2, i3, -d3 / 2) l3 = Line(i, i3) l4 = Line(l1[0] @ 0, i) ring_plan += -Face(Wire([l2, a2, l3, l4])) ring_plan -= Pos(i) * Circle(d5 / 2) # Create the other side ring_plan += mirror(ring_plan, Plane.YZ) # Create the Solid ring = extrude(ring_plan, s) return ring
[docs] class RetainingRingGroove(BasePartObject): """Groove cutter for a DIN 471 or DIN 472 retaining ring. The groove uses the nominal diameter ``d2`` and width ``m`` specified for the supplied ring. The standard's clearance diameter ``d4`` extends the cutter beyond the nominal shaft or bore surface to provide reliable Boolean overlap. Args: ring: External or internal retaining ring that defines the groove dimensions. rotation: Sequence of angles about the X, Y, and Z axes. Defaults to (0, 0, 0). align: Align MIN, CENTER, or MAX of the cutter. Defaults to None, which places the minimum Z face on the placement plane while remaining centered in XY. mode: Combination mode. Defaults to Mode.SUBTRACT. Raises: ValueError: ``ring`` is not an ExternalSnapRing or InternalSnapRing. """ def __init__( self, ring: RetainingRing, rotation: RotationLike = (0, 0, 0), align: Align | tuple[Align, Align, Align] | None = None, mode: Mode = Mode.SUBTRACT, ): if not isinstance(ring, (ExternalSnapRing, InternalSnapRing)): raise ValueError( "RetainingRingGroove only accepts ExternalSnapRing or InternalSnapRing" ) self.ring = ring self.groove_diameter = ring.ring_dict["d2"] self.groove_width = ring.ring_dict["m"] self.clearance_diameter = ring.ring_dict["d4"] if isinstance(ring, ExternalSnapRing): inner_diameter = self.groove_diameter outer_diameter = self.clearance_diameter else: inner_diameter = self.clearance_diameter outer_diameter = self.groove_diameter groove = Solid.make_cylinder(outer_diameter / 2, self.groove_width) groove -= Solid.make_cylinder(inner_diameter / 2, self.groove_width) super().__init__( part=groove, rotation=rotation, align=align, mode=mode, ) self.label = f"RetainingRingGroove-{ring.standard}-{ring.ring_size}"
if __name__ == "__main__": from ocp_vscode import show_all external_ring = ExternalSnapRing("40") # rings = [] # stack_height = 0.0 # for size in reversed(ExternalSnapRing.sizes()): # try: # ring = Pos(Z=stack_height) * ExternalSnapRing(str(size)) # rings.append(ring) # stack_height += ring.ring_dict["s"] # except Exception as error: # pylint: disable=broad-exception-caught # print(f"{size} failed: {error}") internal_ring = InternalSnapRing("30") # for size in reversed(InternalSnapRing.sizes()): # try: # ring = Pos(Z=stack_height) * InternalSnapRing(str(size)) # rings.append(ring) # stack_height += ring.ring_dict["s"] # except Exception as error: # pylint: disable=broad-exception-caught # print(f"{size} failed: {error}") show_all()