Source code for thread

"""

Parametric Threads

name: thread.py
by:   Gumyr
date: November 11th 2021
      June 19th 2023 - ported to bd_warehouse

desc: A parameterized thread generator.

license:

    Copyright 2023 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.

"""

# pylint has trouble with the OCP imports
# pylint: disable=no-name-in-module, import-error
# pylint: disable=too-many-lines

import copy
import re
from math import copysign, radians, tan
from typing import Literal, Optional, Tuple, Union

from build123d.build_common import IN, MM
from build123d.build_enums import Align, Keep, Mode, SortBy
from build123d.build_line import BuildLine
from build123d.build_part import BuildPart
from build123d.build_sketch import BuildSketch
from build123d.geometry import Axis, Location, Plane, Rot, RotationLike
from build123d.joints import RigidJoint
from build123d.objects_curve import Helix, Polyline
from build123d.objects_part import BasePartObject
from build123d.operations_generic import add, mirror, scale, split
from build123d.operations_part import loft
from build123d.operations_sketch import make_face
from build123d.topology import Compound, Face, Solid, Wire, tuplify
from OCP.TopoDS import TopoDS_Shape


def is_safe(value: str) -> bool:
    """Evaluate if the given string is a fractional number safe for eval()"""
    return len(value) <= 10 and all(c in "0123456789./ " for c in set(value))


def imperial_str_to_float(measure: str) -> float:
    """Convert an imperial measurement (possibly a fraction) to a float value"""
    if is_safe(measure):
        # pylint: disable=eval-used
        # Before eval() is called the string extracted from the csv file is verified as safe
        result = eval(measure.strip().replace(" ", "+")) * IN
    else:
        result = measure
    return result


[docs] class Thread(BasePartObject): """Helical thread The most general thread class used to build all of the other threads. Creates right or left hand helical thread with the given root and apex radii. Args: apex_radius: Radius at the narrow tip of the thread. apex_width: Width at the narrow tip of the thread. root_radius: Radius at the wide base of the thread. root_width: Thread base width. pitch: Length of 360° of thread rotation. length: End to end length of the thread. apex_offset: Asymmetric thread apex offset from center. Defaults to 0.0. interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. hand: Twist direction. Defaults to "right". taper_angle: Cone angle for tapered thread. Defaults to None. end_finishes: Profile of each end, one of: "raw" unfinished which typically results in the thread extended below z=0 or above z=length "fade" the thread height drops to zero over 90° of arc (or 1/4 pitch) "square" clipped by the z=0 or z=length plane "chamfer" conical ends which facilitates alignment of a bolt into a nut Defaults to ("raw","raw"). simple: Stop at thread calculation, don't create thread. Defaults to False. Raises: ValueError: if end_finishes not in ["raw", "square", "fade", "chamfer"]: """ _applies_to = [BuildPart._tag] def __init__( self, apex_radius: float, apex_width: float, root_radius: float, root_width: float, pitch: float, length: float, apex_offset: float = 0.0, interference: float = 0.2, hand: Literal["right", "left"] = "right", taper_angle: Optional[float] = None, end_finishes: Tuple[ Literal["raw", "square", "fade", "chamfer"], Literal["raw", "square", "fade", "chamfer"], ] = ("raw", "raw"), simple: bool = False, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): """Store the parameters and create the thread object""" for finish in end_finishes: if finish not in ["raw", "square", "fade", "chamfer"]: raise ValueError( 'end_finishes invalid, must be tuple() of "raw, square, fade, or chamfer"' ) if taper_angle is not None: raise ValueError("taper_angle is not currently supported") self.external = apex_radius > root_radius self.apex_radius = apex_radius self.apex_width = apex_width self.root_radius = root_radius self.root_width = root_width self.pitch = pitch self.length = length self.apex_offset = apex_offset self.interference = interference self.right_hand = hand == "right" self.end_finishes = end_finishes self.tooth_height = abs(self.apex_radius - self.root_radius) self.taper = 0 if taper_angle is None else taper_angle self.simple = simple self.thread_loops = None # Create the thread profile with BuildSketch(mode=Mode.PRIVATE) as thread_face: height = self.apex_radius - self.root_radius overlap = -interference * copysign(1, height) with BuildLine(): # thread profile if overlap == 0: Polyline( (self.root_width / 2, 0), (self.apex_width / 2 + self.apex_offset, height), (-self.apex_width / 2 + self.apex_offset, height), (-self.root_width / 2, 0), close=True, ) else: Polyline( (self.root_width / 2, overlap), (self.root_width / 2, 0), (self.apex_width / 2 + self.apex_offset, height), (-self.apex_width / 2 + self.apex_offset, height), (-self.root_width / 2, 0), (-self.root_width / 2, overlap), close=True, ) if not self.right_hand: mirror(about=Plane.XZ, mode=Mode.REPLACE) make_face() self.thread_profile = thread_face.sketch_local.faces()[0] if simple: # Initialize with a valid shape then nullify super().__init__(part=Solid.make_box(1, 1, 1)) self.wrapped = TopoDS_Shape() else: # Create base cylindrical thread number_faded_ends = self.end_finishes.count("fade") cylindrical_thread_length = length + pitch * (1 - 1 * number_faded_ends) self.thread_loops = cylindrical_thread_length / pitch if self.end_finishes[0] == "fade": cylindrical_thread_displacement = pitch / 2 else: cylindrical_thread_displacement = -pitch / 2 loops = [] if self.thread_loops >= 1.0: full_loop = self._make_thread_loop(1.0) full_loop.label = "loop" loops = [copy.copy(full_loop) for _i in range(int(self.thread_loops))] if self.thread_loops % 1 > 0.0: last_loop = self._make_thread_loop(self.thread_loops % 1) last_loop.label = "partial" loops.append(last_loop) loops[0].locate(Location((0, 0, cylindrical_thread_displacement))) for i in range(1, len(loops)): loops[i - 1].joints["1"].connect_to(loops[i].joints["0"]) bd_object = Compound(label="thread", children=loops) # Apply the end finishes. Note that it's significantly faster # to just apply the end finish to a single loop then the entire Compound # Bottom if self.end_finishes.count("chamfer") != 0: chamfer_shape = self._make_chamfer_shape() if end_finishes[0] == "fade": start_tip = self._make_fade_end(True) start_tip.label = "bottom_tip" loops[0].joints["0"].connect_to(start_tip.joints["0"]) bd_object.children = list(bd_object.children) + [start_tip] elif end_finishes[0] in ["square", "chamfer"]: children = list(bd_object.children) bottom_loop = children.pop(0) label = bottom_loop.label if end_finishes[0] == "square": bottom_loop = split(bottom_loop, bisect_by=Plane.XY, keep=Keep.TOP) else: bottom_loop = bottom_loop.intersect(chamfer_shape) if isinstance(bottom_loop, list): bottom_loop = bottom_loop[0] bottom_loop.label = label bd_object.children = [bottom_loop] + children # Top if end_finishes[1] == "fade": end_tip = self._make_fade_end(False) end_tip.label = "top_tip" loops[-1].joints["1"].connect_to(end_tip.joints["1"]) bd_object.children = list(bd_object.children) + [end_tip] elif end_finishes[1] in ["square", "chamfer"]: children = list(bd_object.children) top_loops = [] last_square = False for _ in range(3): if not children: continue top_loop = children.pop(-1) label = top_loop.label # If this loop is entirely ABOVE the cut plane # Skip the operation and do not add it to top_loops bbox = top_loop.bounding_box() if bbox.min.Z > self.length: continue if end_finishes[1] == "square": # If this loop is entirely BELOW the plane # Keep without splitting, stop checking future loops if bbox.max.Z < self.length: last_square = True else: top_loop = split( top_loop, bisect_by=Plane.XY.offset(self.length), keep=Keep.BOTTOM, ) else: top_loop = top_loop.intersect(chamfer_shape) if isinstance(top_loop, list): top_loop = top_loop[0] if top_loop.volume != 0: top_loop.label = label top_loops.append(top_loop) if last_square: break bd_object.children = children + top_loops super().__init__( part=bd_object, rotation=rotation, align=tuplify(align, 3), mode=mode, ) def _make_thread_loop(self, loop_height: float) -> Solid: """make_thread_loop Args: loop_height (float): 0.0 < height <= 1.0 Raises: ValueError: Invalid loop height Returns: Solid: One full or partial helical loop of thread """ if not 0.0 < loop_height <= 1.0: raise ValueError(f"Invalid loop_height ({loop_height})") with BuildPart() as thread_loop: with BuildLine(): # thread path thread_path_wire = Helix( pitch=self.pitch, height=loop_height * self.pitch, radius=self.root_radius, lefthand=not self.right_hand, ) for i in range(11): u_value = i / 10 with BuildSketch( Plane( thread_path_wire @ u_value, x_dir=(0, 0, 1), z_dir=thread_path_wire % u_value, ) ): add(self.thread_profile) loft() loop = thread_loop.part.solids()[0] for i in range(2): RigidJoint(str(i), loop, thread_path_wire.location_at(i)) return loop def _make_fade_end(self, bottom: bool) -> Solid: """make_fade_end Args: bottom (bool): bottom or top of the thread Returns: Solid: The tip of the thread fading to almost nothing """ dir = -1 if bottom else 1 height = min(self.pitch / 4, self.length / 2) with BuildPart() as fade_tip: with BuildLine(): fade_path_wire = Helix( pitch=self.pitch, height=dir * height, radius=self.root_radius, lefthand=not self.right_hand, ) for i in range(11): u_value = i / 10 z_dir = fade_path_wire % u_value if bottom: z_dir = z_dir.reverse() with BuildSketch( Plane(fade_path_wire @ u_value, x_dir=(0, 0, 1), z_dir=z_dir) ): add(self.thread_profile) scale(by=(11 - i) / 11) loft() tip = fade_tip.part.solids()[0] RigidJoint( "0", tip, fade_path_wire.location_at(0) * Location((0, 0, 0), (1, 0, 0), 180), ) RigidJoint("1", tip, fade_path_wire.location_at(0)) return tip def _make_chamfer_shape(self) -> Solid: """Create the shape that will intersect with the thread to chamfer ends""" inside_radius = min(self.apex_radius, self.root_radius) outside_radius = max(self.apex_radius, self.root_radius) + 0.001 if self.external: chamfer_shape = Solid.extrude( Face(Wire.make_circle(outside_radius)), (0, 0, self.length), ) else: # Decreasing inside_radius fixes the broken/missing first row chamfer # 0.01 visually cleans up the threads more than 0.005 or 0.001 on threads up # to M100 inside_radius -= 0.01 chamfer_shape = Solid.extrude( Face( Wire.make_circle(2 * outside_radius), [Wire.make_circle(inside_radius)], ), (0, 0, self.length), ) thickness = outside_radius - inside_radius for i in range(2): if self.end_finishes[i] == "chamfer": chamfer_shape = chamfer_shape.chamfer( thickness / 2, thickness / 2, chamfer_shape.edges() .group_by(Axis.Z, reverse=i != 0)[0] .sort_by( SortBy.RADIUS, reverse=self.apex_radius > self.root_radius, )[0:1], ) return chamfer_shape
[docs] class IsoThread(BasePartObject): # class IsoThread(Solid): """ISO Standard Thread Both external and internal ISO standard 60° threads as shown in the following diagram (from https://en.wikipedia.org/wiki/ISO_metric_screw_thread): .. image:: https://upload.wikimedia.org/wikipedia/commons/4/4b/ISO_and_UTS_Thread_Dimensions.svg The following is an example of an internal thread with a chamfered end as might be found inside a nut: .. image:: assets/internal_iso_thread.png Args: major_diameter (float): Primary thread diameter pitch (float): Length of 360° of thread rotation length (float): End to end length of the thread external (bool, optional): External or internal thread selector. Defaults to True. hand (Literal[, optional): Twist direction. Defaults to "right". end_finishes (Tuple[ Literal[, optional): Profile of each end, one of: "raw" unfinished which typically results in the thread extended below z=0 or above z=length "fade" the thread height drops to zero over 90° of arc (or 1/4 pitch) "square" clipped by the z=0 or z=length plane "chamfer" conical ends which facilitates alignment of a bolt into a nut Defaults to ("fade", "square"). interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. simple: Stop at thread calculation, don't create thread. Defaults to False. rotation (RotationLike, optional): object rotation. Defaults to (0, 0, 0). align (Union[None, Align, tuple[Align, Align, Align]], optional): object alignment. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. Attributes: thread_angle (int): 60 degrees h_parameter (float): Value of `h` as shown in the thread diagram min_radius (float): Inside radius of the thread diagram Raises: ValueError: if hand not in ["right", "left"]: ValueError: end_finishes not in ["raw", "square", "fade", "chamfer"] """ @property def h_parameter(self) -> float: """Calculate the h parameter""" return (self.pitch / 2) / tan(radians(self.thread_angle / 2)) @property def min_radius(self) -> float: """The radius of the root of the thread""" return (self.major_diameter - 2 * (5 / 8) * self.h_parameter) / 2 def __init__( self, major_diameter: float, pitch: float, length: float, external: bool = True, hand: Literal["right", "left"] = "right", end_finishes: Tuple[ Literal["raw", "square", "fade", "chamfer"], Literal["raw", "square", "fade", "chamfer"], ] = ("fade", "square"), interference: float = 0.2, simple: bool = False, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): self.major_diameter = major_diameter self.pitch = pitch self.length = length self.external = external self.thread_angle = 60 if hand not in ["right", "left"]: raise ValueError(f'hand must be one of "right" or "left" not {hand}') self.hand = hand for finish in end_finishes: if finish not in ["raw", "square", "fade", "chamfer"]: raise ValueError( 'end_finishes invalid, must be tuple() of "raw, square, fade, or chamfer"' ) self.end_finishes = end_finishes self.interference = interference self.simple = simple self.apex_radius = self.major_diameter / 2 if external else self.min_radius apex_width = self.pitch / 8 if external else self.pitch / 4 self.root_radius = self.min_radius if external else self.major_diameter / 2 root_width = 3 * self.pitch / 4 if external else 7 * self.pitch / 8 if simple: # Initialize with a valid shape then nullify super().__init__(part=Solid.make_box(1, 1, 1)) self.wrapped = TopoDS_Shape() else: bd_object = Thread( apex_radius=self.apex_radius, apex_width=apex_width, root_radius=self.root_radius, root_width=root_width, pitch=self.pitch, length=self.length, interference=interference, end_finishes=self.end_finishes, hand=self.hand, simple=simple, ) self.thread_profile = bd_object.thread_profile super().__init__( part=Compound(bd_object.solids()), rotation=rotation, align=tuplify(align, 3), mode=mode, )
[docs] class TrapezoidalThread(BasePartObject): """Trapezoidal Thread Base Class Trapezoidal thread forms are screw thread profiles with trapezoidal outlines. They are the most common forms used for leadscrews (power screws). They offer high strength and ease of manufacture. They are typically found where large loads are required, as in a vise or the leadscrew of a lathe. Trapezoidal Thread is a base class for Metric and Acme derived classes, or can be used to create a trapezoidal thread with arbitrary properties. Args: thread_angle (int): thread angle in degrees diameter (float): thread diameter pitch (float): thread pitch - for threads with multiple starts, pitch is the distance between adjacent threads along the axis of the screw. size (str): specified by derived class length (float): thread length external (bool, optional): external or internal thread selector. Defaults to True. starts (int, optional): the number of thread starts, which indicates how many threads run parallel to each other along the screw. Defaults to 1. hand (Literal[, optional): twist direction. Defaults to "right". end_finishes (Tuple[ Literal[, optional): Profile of each end, one of: "raw" unfinished which typically results in the thread extended below z=0 or above z=length "fade" the thread height drops to zero over 90° of arc (or 1/4 pitch) "square" clipped by the z=0 or z=length plane "chamfer" conical ends which facilitates alignment of a bolt into a nut Defaults to ("fade", "fade"). interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. rotation (RotationLike, optional): object rotation. Defaults to (0, 0, 0). align (Union[None, Align, tuple[Align, Align, Align]], optional): object alignment. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: ValueError: hand must be one of "right" or "left" ValueError: end_finishes invalid, must be tuple() of "raw, square, taper, or chamfer" Attributes: thread_angle (int): thread angle in degrees diameter (float): thread diameter pitch (float): thread pitch """ def __init__( self, diameter: float, pitch: float, thread_angle: float, length: float, external: bool = True, starts: int = 1, hand: Literal["right", "left"] = "right", end_finishes: tuple[ Literal["raw", "square", "fade", "chamfer"], Literal["raw", "square", "fade", "chamfer"], ] = ("fade", "fade"), interference: float = 0.2, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): self.thread_size = diameter self.external = external self.length = length self.diameter = diameter self.thread_angle = thread_angle self.pitch = pitch self.lead = pitch * starts shoulder_width = (self.pitch / 2) * tan(radians(self.thread_angle / 2)) apex_width = (self.pitch / 2) - shoulder_width root_width = (self.pitch / 2) + shoulder_width if self.external: self.apex_radius = self.diameter / 2 self.root_radius = self.diameter / 2 - self.pitch / 2 else: self.apex_radius = self.diameter / 2 - self.pitch / 2 self.root_radius = self.diameter / 2 if hand not in ["right", "left"]: raise ValueError(f'hand must be one of "right" or "left" not {hand}') self.hand = hand for finish in end_finishes: if not finish in ["raw", "square", "fade", "chamfer"]: raise ValueError( 'end_finishes invalid, must be tuple() of "raw, square, fade, or chamfer"' ) self.end_finishes = end_finishes self.interference = interference bd_object = Thread( apex_radius=self.apex_radius, apex_width=apex_width, root_radius=self.root_radius, root_width=root_width, pitch=self.lead, length=self.length, interference=interference, end_finishes=self.end_finishes, hand=self.hand, ) self.thread_profile = bd_object.thread_profile thread_object = Compound( children=[ copy.copy(bd_object).move(Rot(Z=i * (360 / starts))) for i in range(starts) ] ) super().__init__( part=thread_object, rotation=rotation, align=tuplify(align, 3), mode=mode, )
[docs] class AcmeThread(TrapezoidalThread): """ACME Thread The original trapezoidal thread form, and still probably the one most commonly encountered worldwide, with a 29° thread angle, is the Acme thread form. The following is the acme thread with faded ends: .. image:: assets/acme_thread.png Args: size (str): size as a string (i.e. "3/4" or "1 1/4") length (float): thread length external (bool, optional): external or internal thread selector. Defaults to True. hand (Literal[, optional): twist direction. Defaults to "right". end_finishes (Tuple[ Literal[, optional): Profile of each end, one of: "raw" unfinished which typically results in the thread extended below z=0 or above z=length "fade" the thread height drops to zero over 90° of arc (or 1/4 pitch) "square" clipped by the z=0 or z=length plane "chamfer" conical ends which facilitates alignment of a bolt into a nut Defaults to ("fade", "fade"). interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. rotation (RotationLike, optional): object rotation. Defaults to (0, 0, 0). align (Union[None, Align, tuple[Align, Align, Align]], optional): object alignment. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: ValueError: hand must be one of "right" or "left" ValueError: end_finishes invalid, must be tuple() of "raw, square, taper, or chamfer" Attributes: thread_angle (int): thread angle in degrees diameter (float): thread diameter pitch (float): thread pitch """ acme_pitch = { "1/4": (1 / 16) * IN, "5/16": (1 / 14) * IN, "3/8": (1 / 12) * IN, "1/2": (1 / 10) * IN, "5/8": (1 / 8) * IN, "3/4": (1 / 6) * IN, "7/8": (1 / 6) * IN, "1": (1 / 5) * IN, "1 1/4": (1 / 5) * IN, "1 1/2": (1 / 4) * IN, "1 3/4": (1 / 4) * IN, "2": (1 / 4) * IN, "2 1/2": (1 / 3) * IN, "3": (1 / 2) * IN, } thread_angle = 29.0 # in degrees @classmethod def sizes(cls) -> list[str]: """Return a list of the thread sizes""" return list(AcmeThread.acme_pitch.keys()) def __init__( self, size: str, length: float, external: bool = True, hand: Literal["right", "left"] = "right", end_finishes: tuple[ Literal["raw", "square", "fade", "chamfer"], Literal["raw", "square", "fade", "chamfer"], ] = ("fade", "fade"), interference: float = 0.2, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): diameter = imperial_str_to_float(size) try: pitch = AcmeThread.acme_pitch[size] except KeyError as exc: raise ValueError("Invalid screw size") from exc super().__init__( diameter=diameter, pitch=pitch, thread_angle=self.thread_angle, length=length, external=external, hand=hand, end_finishes=end_finishes, interference=interference, rotation=rotation, align=align, mode=mode, )
[docs] class MetricTrapezoidalThread(TrapezoidalThread): """Metric Trapezoidal Thread The ISO 2904 standard metric trapezoidal thread with a thread angle of 30° Args: size (str): specified as a sting with diameter x pitch in mm (i.e. "8x1.5") length (float): End to end length of the thread external (bool, optional): external or internal thread selector. Defaults to True. hand (Literal[, optional): twist direction. Defaults to "right". end_finishes (Tuple[ Literal[, optional): Profile of each end, one of: "raw" unfinished which typically results in the thread extended below z=0 or above z=length "fade" the thread height drops to zero over 90° of arc (or 1/4 pitch) "square" clipped by the z=0 or z=length plane "chamfer" conical ends which facilitates alignment of a bolt into a nut Defaults to ("fade", "fade"). interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. rotation (RotationLike, optional): object rotation. Defaults to (0, 0, 0). align (Union[None, Align, tuple[Align, Align, Align]], optional): object alignment. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: ValueError: hand must be one of "right" or "left" ValueError: end_finishes invalid, must be tuple() of "raw, square, taper, or chamfer" Attributes: thread_angle (int): thread angle in degrees diameter (float): thread diameter pitch (float): thread pitch """ # Turn off black auto-format for this array as it will be spread over hundreds of lines # fmt: off standard_sizes = [ "8x1.5","9x1.5","9x2","10x1.5","10x2","11x2","11x3","12x2","12x3","14x2", "14x3","16x2","16x3","16x4","18x2","18x3","18x4","20x2","20x3","20x4", "22x3","22x5","22x8","24x3","24x5","24x8","26x3","26x5","26x8","28x3", "28x5","28x8","30x3","30x6","30x10","32x3","32x6","32x10","34x3","34x6", "34x10","36x3","36x6","36x10","38x3","38x7","38x10","40x3","40x7","40x10", "42x3","42x7","42x10","44x3","44x7","44x12","46x3","46x8","46x12","48x3", "48x8","48x12","50x3","50x8","50x12","52x3","52x8","52x12","55x3","55x9", "55x14","60x3","60x9","60x14","65x4","65x10","65x16","70x4","70x10","70x16", "75x4","75x10","75x16","80x4","80x10","80x16","85x4","85x12","85x18","90x4", "90x12","90x18","95x4","95x12","95x18","100x4","100x12","100x20","105x4", "105x12","105x20","110x4","110x12","110x20","115x6","115x12","115x14", "115x22","120x6","120x12","120x14","120x22","125x6","125x12","125x14", "125x22","130x6","130x12","130x14","130x22","135x6","135x12","135x14", "135x24","140x6","140x12","140x14","140x24","145x6","145x12","145x14", "145x24","150x6","150x12","150x16","150x24","155x6","155x12","155x16", "155x24","160x6","160x12","160x16","160x28","165x6","165x12","165x16", "165x28","170x6","170x12","170x16","170x28","175x8","175x12","175x16", "175x28","180x8","180x12","180x18","180x28","185x8","185x12","185x18", "185x24","185x32","190x8","190x12","190x18","190x24","190x32","195x8", "195x12","195x18","195x24","195x32","200x8","200x12","200x18","200x24", "200x32","205x4","210x4","210x8","210x12","210x20","210x24","210x36","215x4", "220x4","220x8","220x12","220x20","220x24","220x36","230x4","230x8","230x12", "230x20","230x24","230x36","235x4","240x4","240x8","240x12","240x20", "240x22","240x24","240x36","250x4","250x12","250x22","250x24","250x40", "260x4","260x12","260x20","260x22","260x24","260x40","270x12","270x24", "270x40","275x4","280x4","280x12","280x24","280x40","290x4","290x12", "290x24","290x44","295x4","300x4","300x12","300x24","300x44","310x5","315x5" ] # fmt: on thread_angle = 30.0 # in degrees @classmethod def sizes(cls) -> list[str]: """Return a list of the thread sizes""" return MetricTrapezoidalThread.standard_sizes def __init__( self, size: str, length: float, external: bool = True, hand: Literal["right", "left"] = "right", end_finishes: tuple[ Literal["raw", "square", "fade", "chamfer"], Literal["raw", "square", "fade", "chamfer"], ] = ("fade", "fade"), interference: float = 0.2, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): if not size in MetricTrapezoidalThread.standard_sizes: raise ValueError( f"size invalid, must be one of {MetricTrapezoidalThread.standard_sizes}" ) (diameter, pitch) = (float(part) for part in size.split("x")) super().__init__( diameter=diameter, pitch=pitch, thread_angle=self.thread_angle, length=length, external=external, hand=hand, end_finishes=end_finishes, interference=interference, rotation=rotation, align=align, mode=mode, )
[docs] class PlasticBottleThread(BasePartObject): """ASTM D2911 Plastic Bottle Thread The `ASTM D2911 Standard <https://www.astm.org/d2911-10.html>`_ Plastic Bottle Thread. L Style: All-Purpose Thread - trapezoidal shape with 30° shoulders, metal or platsic closures M Style: Modified Buttress Thread - asymmetric shape with 10° and 40/45/50° shoulders, plastic closures .. image:: assets/plastic_thread.png Args: size (str): as defined by the ASTM is specified as [L|M][diameter(mm)]SP[100|103|110|200|400|410|415|425|444] external (bool, optional): external or internal thread selector. Defaults to True. hand (Literal[, optional): twist direction. Defaults to "right". interference: Amount the thread will overlap with nut or bolt core. Used to help create valid threaded objects where the thread must fuse with another object. For threaded objects built as Compounds, this value could be set to 0.0. Defaults to 0.2. manufacturing_compensation (float, optional): used to compensate for over-extrusion of 3D printers. A value of 0.2mm will reduce the radius of an external thread by 0.2mm (and increase the radius of an internal thread) such that the resulting 3D printed part matches the target dimensions. Defaults to 0.0. rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, Raises: ValueError: hand must be one of "right" or "left" ValueError: size invalid, must match [L|M][diameter(mm)]SP[100|103|110|200|400|410|415:425|444] ValueError: finish invalid ValueError: diameter invalid Example: .. code-block:: python thread = PlasticBottleThread( size="M38SP444", external=False, manufacturing_compensation=0.2 * MM ) """ # {TPI: [root_width,thread_height]} _l_style_thread_dimensions = { 4: [3.18, 1.57], 5: [3.05, 1.52], 6: [2.39, 1.19], 8: [2.13, 1.07], 12: [1.14, 0.76], } _m_style_thread_dimensions = { 4: [3.18, 1.57], 5: [3.05, 1.52], 6: [2.39, 1.19], 8: [2.13, 1.07], 12: [1.29, 0.76], } _thread_angles = { "L100": [30, 30], "M100": [10, 40], "L103": [30, 30], "M103": [10, 40], "L110": [30, 30], "M110": [10, 50], "L200": [30, 30], "M200": [10, 40], "L400": [30, 30], "M400": [10, 45], "L410": [30, 30], "M410": [10, 45], "L415": [30, 30], "M415": [10, 45], "L425": [30, 30], "M425": [10, 45], "L444": [30, 30], "M444": [10, 45], } # {finish:[min turns,[diameters,...]]} # fmt: off _finish_data = { 100: [1.125,[22,24,28,30,33,35,38]], 103: [1.125,[26]], 110: [1.125,[28]], 200: [1.5,[24.28]], 400: [1.0,[18,20,22,24,28,30,33,35,38,40,43,45,48,51,53,58,60,63,66,70,75,77,83,89,100,110,120]], 410: [1.5,[18,20,22,24,28]], 415: [2.0,[13,15,18,20,22,24,28,30,33]], 425: [2.0,[13,15]], 444: [1.125,[24,28,30,33,35,38,40,43,45,48,51,53,58,60,63,66,70,75,77,83]] } # fmt: on # {thread_size:[max,min,TPI]} _thread_dimensions = { 13: [13.06, 12.75, 12], 15: [14.76, 14.45, 12], 18: [17.88, 17.47, 8], 20: [19.89, 19.48, 8], 22: [21.89, 21.49, 8], 24: [23.88, 23.47, 8], 26: [25.63, 25.12, 8], 28: [27.64, 27.13, 6], 30: [28.62, 28.12, 6], 33: [32.13, 31.52, 6], 35: [34.64, 34.04, 6], 38: [37.49, 36.88, 6], 40: [40.13, 39.37, 6], 43: [42.01, 41.25, 6], 45: [44.20, 43.43, 6], 48: [47.50, 46.74, 6], 51: [49.99, 49.10, 6], 53: [52.50, 51.61, 6], 58: [56.49, 55.60, 6], 60: [59.49, 58.60, 6], 63: [62.51, 61.62, 6], 66: [65.51, 64.62, 6], 70: [69.49, 68.60, 6], 75: [73.99, 73.10, 6], 77: [77.09, 76.20, 6], 83: [83.01, 82.12, 5], 89: [89.18, 88.29, 5], 100: [100.00, 99.11, 5], 110: [110.01, 109.12, 5], 120: [119.99, 119.10, 5], } def __init__( self, size: str, external: bool = True, hand: Literal["right", "left"] = "right", interference: float = 0.2, manufacturing_compensation: float = 0.0, rotation: RotationLike = (0, 0, 0), align: Union[None, Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): self.thread_size = size self.external = external if hand not in ["right", "left"]: raise ValueError(f'hand must be one of "right" or "left" not {hand}') self.hand = hand self.interference = interference size_match = re.match(r"([LM])(\d+)SP(\d+)", size) if not size_match: raise ValueError( "size invalid, must match \ [L|M][diameter(mm)]SP[100|103|110|200|400|410|415:425|444]" ) self.style = size_match.group(1) self.diameter = int(size_match.group(2)) self.finish = int(size_match.group(3)) if self.finish not in PlasticBottleThread._finish_data: raise ValueError( f"finish ({self.finish}) invalid, must be one of" f" {list(PlasticBottleThread._finish_data.keys())}" ) if not self.diameter in PlasticBottleThread._finish_data[self.finish][1]: raise ValueError( f"diameter ({self.diameter}) invalid, must be one" f" of {PlasticBottleThread._finish_data[self.finish][1]}" ) (diameter_max, diameter_min, self.tpi) = PlasticBottleThread._thread_dimensions[ self.diameter ] if self.style == "L": ( self.root_width, thread_height, ) = PlasticBottleThread._l_style_thread_dimensions[self.tpi] else: ( self.root_width, thread_height, ) = PlasticBottleThread._m_style_thread_dimensions[self.tpi] if self.external: self.apex_radius = diameter_min / 2 - manufacturing_compensation self.root_radius = ( diameter_min / 2 - thread_height - manufacturing_compensation ) else: self.root_radius = diameter_max / 2 + manufacturing_compensation self.apex_radius = ( diameter_max / 2 - thread_height + manufacturing_compensation ) self._thread_angles = PlasticBottleThread._thread_angles[ self.style + str(self.finish) ] shoulders = [thread_height * tan(radians(a)) for a in self._thread_angles] self.apex_width = self.root_width - sum(shoulders) self.apex_offset = shoulders[0] + self.apex_width / 2 - self.root_width / 2 if not self.external: self.apex_offset = -self.apex_offset self.pitch = 25.4 * MM / self.tpi self.length = ( PlasticBottleThread._finish_data[self.finish][0] + 0.75 ) * self.pitch bd_object = Thread( apex_radius=self.apex_radius, apex_width=self.apex_width, root_radius=self.root_radius, root_width=self.root_width, pitch=self.pitch, length=self.length, apex_offset=self.apex_offset, interference=interference, hand=self.hand, end_finishes=("fade", "fade"), ) self.thread_profile = bd_object.thread_profile super().__init__( part=bd_object, rotation=rotation, align=tuplify(align, 3), mode=mode, )