"""
Parametric Threaded Fasteners
name: fastener.py
by: Gumyr
date: August 14th 2021
March 24th 2024 - ported to bd_warehouse
desc: This python/build123d code is a parameterized threaded fastener generator.
todo: - add helix line to thread object if simple enabled
- support unthreaded sections on screw shanks
- calculate depth for thru threaded holes
- optimize recess creation when recess_taper = 0
license:
Copyright 2024 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.
"""
from __future__ import annotations
import csv
import math
from abc import ABC, abstractmethod
from math import atan, cos, pi, radians, sin, sqrt, tan
from typing import Literal, Optional, Union
import bd_warehouse
from importlib import resources
from bd_warehouse.thread import IsoThread, imperial_str_to_float, is_safe
from build123d.build_common import (
IN,
MM,
PolarLocations,
LocationList,
Locations,
validate_inputs,
)
from build123d.build_enums import Align, 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,
Color,
Location,
Plane,
Pos,
Rot,
RotationLike,
Vector,
)
from build123d.joints import RigidJoint
from build123d.objects_curve import (
JernArc,
Line,
PolarLine,
Polyline,
RadiusArc,
Spline,
)
from build123d.objects_part import BasePartObject, Cylinder
from build123d.objects_sketch import (
Polygon,
Rectangle,
RectangleRounded,
RegularPolygon,
SlotOverall,
Trapezoid,
)
from build123d.operations_generic import add, chamfer, fillet, split
from build123d.operations_part import extrude, revolve, loft
from build123d.operations_sketch import make_face
from build123d.topology import (
Compound,
Curve,
Edge,
Face,
Part,
Shell,
Sketch,
Solid,
Wire,
)
# ISO standards use single variable dimension labels which are used extensively
# pylint: disable=invalid-name
def polygon_diagonal(width: float, num_sides: Optional[int] = 6) -> float:
"""Distance across polygon diagonals given width across flats"""
return width / cos(pi / num_sides)
def read_fastener_parameters_from_csv(filename: str) -> dict:
"""Parse a csv parameter file into a dictionary of strings"""
parameters = {}
data_resource = resources.files(bd_warehouse) / f"data/{filename}"
with data_resource.open() as csvfile:
reader = csv.DictReader(csvfile)
fieldnames = reader.fieldnames
for row in reader:
key = row[fieldnames[0]]
row.pop(fieldnames[0])
parameters[key] = row
return parameters
def decode_imperial_size(size: str) -> tuple[float, float]:
"""Extract the major diameter and pitch from an imperial size"""
# Imperial # sizes to diameters
imperial_numbered_sizes = {
"#0000": 0.0210 * IN,
"#000": 0.0340 * IN,
"#00": 0.0470 * IN,
"#0": 0.0600 * IN,
"#1": 0.0730 * IN,
"#2": 0.0860 * IN,
"#3": 0.0990 * IN,
"#4": 0.1120 * IN,
"#5": 0.1250 * IN,
"#6": 0.1380 * IN,
"#8": 0.1640 * IN,
"#10": 0.1900 * IN,
"#12": 0.2160 * IN,
}
sizes = size.split("-")
if size[0] == "#":
major_diameter = imperial_numbered_sizes[sizes[0]]
else:
major_diameter = imperial_str_to_float(sizes[0])
pitch = IN / (imperial_str_to_float(sizes[1]) / IN)
return (major_diameter, pitch)
def metric_str_to_float(measure: str) -> float:
"""Convert a metric measurement 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)
else:
result = measure
return result
def evaluate_parameter_dict_of_dict(
parameters: dict,
is_metric: Optional[bool] = True,
) -> dict:
"""Convert string values in a dict of dict structure to floats based on provided units"""
measurements = {}
for key, value in parameters.items():
measurements[key] = evaluate_parameter_dict(
parameters=value, is_metric=is_metric
)
return measurements
def evaluate_parameter_dict(
parameters: dict,
is_metric: Optional[bool] = True,
) -> dict:
"""Convert the strings in a parameter dictionary into dimensions"""
measurements = {}
for params, value in parameters.items():
if is_metric:
measurements[params] = metric_str_to_float(value)
else:
measurements[params] = imperial_str_to_float(value)
return measurements
def isolate_fastener_type(target_fastener: str, fastener_data: dict) -> dict:
"""Split the fastener data 'type:value' strings into dictionary elements"""
result = {}
for size, parameters in fastener_data.items():
dimension_dict = {}
for type_dimension, value in parameters.items():
(fastener_name, dimension) = tuple(type_dimension.strip().split(":"))
if target_fastener == fastener_name and not value == "":
dimension_dict[dimension] = value
if len(dimension_dict) > 0:
result[size] = dimension_dict
return result
def read_drill_sizes() -> dict:
"""Read the drill size csv file and build a drill_size dictionary (Ah, the imperial system)"""
drill_sizes = {}
data_resource = resources.files(bd_warehouse) / "data/drill_sizes.csv"
with data_resource.open() as csvfile:
reader = csv.DictReader(csvfile)
fieldnames = reader.fieldnames
for row in reader:
drill_sizes[row[fieldnames[0]]] = float(row[fieldnames[1]]) * IN
return drill_sizes
def lookup_drill_diameters(drill_hole_sizes: dict) -> dict:
"""Return a dict of dict of drill size to drill diameter"""
drill_sizes = read_drill_sizes()
# Build a dictionary of hole diameters for these hole sizes
drill_hole_diameters = {}
for size, drill_data in drill_hole_sizes.items():
hole_data = {}
for fit, drill in drill_data.items():
try:
hole_data[fit] = drill_sizes[drill]
except KeyError:
if size[0] == "M":
hole_data[fit] = float(drill)
else:
hole_data[fit] = imperial_str_to_float(drill)
drill_hole_diameters[size] = hole_data
return drill_hole_diameters
def lookup_nominal_screw_lengths() -> dict:
"""Return a dict of dict of drill size to drill diameter"""
# Read the nominal screw length csv file and build a dictionary
nominal_screw_lengths = {}
data_resource = resources.files(bd_warehouse) / "data/nominal_screw_lengths.csv"
with data_resource.open() as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
unit_factor = MM if row["Unit"] == "mm" else IN
sizes = [
unit_factor * float(size)
for size in str(row["Nominal_Sizes"]).split(",")
]
nominal_screw_lengths[row["Screw_Type"]] = sizes
return nominal_screw_lengths
def cross_recess(size: str) -> tuple[Face, float]:
"""Type H Cross / Phillips recess for screws
size must be one of: PH0, PH1, PH2, PH3, or PH4
Note: the following dimensions are somewhat simplified to a single
value per drive size instead of unique sizes for each fastener
"""
widths = {"PH0": 1.9, "PH1": 3.1, "PH2": 5.3, "PH3": 6.8, "PH4": 10.0}
depths = {"PH0": 1.1, "PH1": 2.0, "PH2": 3.27, "PH3": 3.53, "PH4": 5.88}
try:
m = widths[size]
except KeyError as e:
raise ValueError(f"{size} is an invalid cross size {widths}") from e
with BuildSketch() as recess:
Rectangle(m, m / 6)
Rectangle(m / 6, m)
fillet(recess.vertices().group_by(SortBy.DISTANCE)[0], m / 3)
return (recess.face(), depths[size])
def hex_recess(size: float) -> Face:
"""Hexagon recess for screws
size refers to the size across the flats
"""
with BuildSketch() as plan:
RegularPolygon(radius=size / 2, side_count=6, major_radius=False)
return plan.face()
def hexalobular_recess(size: str) -> tuple[Face, float]:
"""Plan of Hexalobular recess for screws
size must be one of: T6, T8, T10, T15, T20, T25, T30, T40, T45, T50, T55, T60,
T70, T80, T90, T100
depth approximately 60% of maximum diameter
"""
try:
screw_data = evaluate_parameter_dict_of_dict(
read_fastener_parameters_from_csv("iso10664def.csv")
)[size]
except KeyError as e:
raise ValueError(f"{size} is an invalid hexalobular size") from e
(A, B, Re) = (screw_data[p] for p in ["A", "B", "Re"])
# Given the outer (A) and inner (B) diameters and the external radius (Re),
# calculate the internal radius
sqrt_3 = sqrt(3)
Ri = (A**2 - sqrt_3 * A * B - 4 * A * Re + B**2 + 2 * sqrt_3 * B * Re) / (
2 * (sqrt_3 * A - 2 * B - 2 * sqrt_3 * Re + 4 * Re)
)
center_external_arc = [
Vector(0, A / 2 - Re),
Vector(sqrt_3 * (A / 2 - Re) / 2, A / 4 - Re / 2),
]
center_internal_arc = Vector(B / 4 + Ri / 2, sqrt_3 * (B / 2 + Ri) / 2)
# Determine where the two arcs are tangent (i.e. touching)
tangent_points = [
center_external_arc[0]
+ (center_internal_arc - center_external_arc[0]).normalized() * Re,
center_external_arc[1]
+ (center_internal_arc - center_external_arc[1]).normalized() * Re,
]
# Create one sixth of the wire and repeat it
with BuildSketch() as plan:
with BuildLine(mode=Mode.PRIVATE) as arc:
RadiusArc((0, A / 2), tangent_points[0], Re)
RadiusArc(*tangent_points, -Ri)
RadiusArc(tangent_points[1], (sqrt_3 * A / 4, A / 4), Re)
with PolarLocations(0, 6):
add(arc.line)
make_face()
return (plan.face(), 0.6 * A)
def slot_recess(width: float, length: float) -> Face:
"""Slot recess for screws"""
return Face.make_rect(width, length)
def square_recess(size: str) -> tuple[Face, float]:
"""Robertson Square recess for screws
size must be one of: R00, R0, R1, R2, and R3
Note: Robertson sizes are also color coded: Orange, Yellow, Green, Red, Black
"""
widths = {"R00": 1.80, "R0": 2.31, "R1": 2.86, "R2": 3.38, "R3": 4.85}
depths = {"R00": 1.85, "R0": 2.87, "R1": 3.56, "R2": 4.19, "R3": 5.11}
try:
m = widths[size]
except KeyError as e:
raise ValueError(f"{size} is an invalid square size {widths}") from e
return (Face.make_rect(m, m), depths[size])
def select_by_size_fn(cls, size: str) -> dict:
"""Given a fastener size, return a dictionary of {class:[type,...]}"""
type_dict = {}
for fastener_class in cls.__subclasses__():
for fastener_type in fastener_class.types():
if size in fastener_class.sizes(fastener_type):
if fastener_class in type_dict.keys():
type_dict[fastener_class].append(fastener_type)
else:
type_dict[fastener_class] = [fastener_type]
return type_dict
def method_exists(cls, method: str) -> bool:
"""Did the derived class create this method"""
return hasattr(cls, method) and callable(getattr(cls, method))
[docs]
class Nut(ABC, BasePartObject):
"""Parametric Nut
Base Class used to create standard threaded nuts
Args:
size (str): standard sizes - e.g. "M6-1"
fastener_type (str): type identifier - e.g. "iso4032"
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
Raises:
ValueError: invalid size, must be formatted as size-pitch or size-TPI
ValueError: invalid fastener_type
ValueError: invalid hand, must be one of 'left' or 'right'
ValueError: invalid size
Each nut instance creates a set of instance variables that provide the CAD object as well as valuable
parameters, as follows (values intended for internal use are not shown):
"""
_applies_to = [BuildPart._tag]
# Read clearance and tap hole dimensions tables
# Close, Medium, Loose
clearance_hole_drill_sizes = read_fastener_parameters_from_csv(
"clearance_hole_sizes.csv"
)
clearance_hole_data = lookup_drill_diameters(clearance_hole_drill_sizes)
# Soft (Aluminum, Brass, & Plastics) or Hard (Steel, Stainless, & Iron)
tap_hole_drill_sizes = read_fastener_parameters_from_csv("tap_hole_sizes.csv")
tap_hole_data = lookup_drill_diameters(tap_hole_drill_sizes)
@property
def tap_drill_sizes(self):
"""A dictionary of drill sizes for tapped holes"""
try:
return self.tap_hole_drill_sizes[self.thread_size]
except KeyError as e:
raise ValueError(f"No tap hole data for size {self.thread_size}") from e
@property
def tap_hole_diameters(self):
"""A dictionary of drill diameters for tapped holes"""
try:
return self.tap_hole_data[self.thread_size]
except KeyError as e:
raise ValueError(f"No tap hole data for size {self.thread_size}") from e
@property
def clearance_drill_sizes(self):
"""A dictionary of drill sizes for clearance holes"""
try:
return self.clearance_hole_drill_sizes[self.thread_size.split("-")[0]]
except KeyError as e:
raise ValueError(
f"No clearance hole data for size {self.thread_size}"
) from e
@property
def clearance_hole_diameters(self):
"""A dictionary of drill diameters for clearance holes"""
try:
return self.clearance_hole_data[self.thread_size.split("-")[0]]
except KeyError as e:
raise ValueError(
f"No clearance hole data for size {self.thread_size}"
) from e
[docs]
@classmethod
def select_by_size(cls, size: str) -> dict:
"""Return a dictionary of list of fastener types of this size"""
return select_by_size_fn(cls, size)
@property
@abstractmethod
def fastener_data(cls): # pragma: no cover
"""Each derived class must provide a fastener_data dictionary"""
return NotImplementedError
@abstractmethod
def nut_profile(self) -> Face: # pragma: no cover
"""Each derived class must provide the profile of the nut"""
return NotImplementedError
@abstractmethod
def nut_plan(self) -> Face: # pragma: no cover
"""Each derived class must provide the plan of the nut"""
return NotImplementedError
@abstractmethod
def countersink_profile(
self, fit: Literal["Close", "Normal", "Loose"]
) -> Face: # pragma: no cover
"""Each derived class must provide the profile of a countersink cutter"""
return NotImplementedError
@property
def info(self):
"""Return identifying information"""
return f"{self.nut_class}({self.fastener_type}): {self.thread_size}"
@property
def nut_class(self):
"""Which derived class created this nut"""
return type(self).__name__
[docs]
@classmethod
def types(cls) -> set[str]:
"""Return a set of the nut types"""
return set(p.split(":")[0] for p in list(cls.fastener_data.values())[0].keys())
[docs]
@classmethod
def sizes(cls, fastener_type: str) -> list[str]:
"""Return a list of the nut sizes for the given type"""
return list(isolate_fastener_type(fastener_type, cls.fastener_data).keys())
@property
def nut_thickness(self):
"""Calculate the maximum thickness of the nut"""
return self.bounding_box().max.Z
@property
def nut_diameter(self):
"""Calculate the maximum diameter of the nut"""
bottom_vertices = self.nut_plan().vertices()
if len(bottom_vertices) < 3:
raise Exception(f"Invalid nut: {type(self).__name__},{self.__dict__}")
bottom_arc = Edge.make_three_point_arc(*bottom_vertices[0:3])
return 2 * bottom_arc.radius
def length_offset(self):
"""Screw only parameter"""
return 0
def __init__(
self,
size: str,
fastener_type: str,
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
self.hole_locations: list[Location] = [] #: custom holes locations
self.nut_thickness: float #: maximum thickness of the nut
self.nut_diameter: float #: maximum diameter of the nut
self.nut_size = size.strip()
size_parts = self.nut_size.split("-")
if 3 > len(size_parts) < 2:
raise ValueError(
f"{size_parts} invalid, must be formatted as size-pitch(-length) or size-TPI(-length) where length is optional"
)
self.thread_size = "-".join(size_parts[:2])
if len(size_parts) >= 3:
self.length_size = size_parts[2]
self.is_metric = self.thread_size[0] == "M"
if self.is_metric:
self.thread_diameter = float(size_parts[0][1:])
self.thread_pitch = float(size_parts[1])
else:
(self.thread_diameter, self.thread_pitch) = decode_imperial_size(
self.thread_size
)
if fastener_type not in self.types():
raise ValueError(f"{fastener_type} invalid, must be one of {self.types()}")
self.fastener_type = fastener_type
if hand in ["left", "right"]:
self.hand = hand
else:
raise ValueError(f"{hand} invalid, must be one of 'left' or 'right'")
self.simple = simple
self.socket_clearance = 6 * MM # Used as extra clearance when countersinking
try:
self.nut_data = evaluate_parameter_dict(
isolate_fastener_type(self.fastener_type, self.fastener_data)[
self.nut_size
],
is_metric=self.is_metric,
)
except KeyError as e:
raise ValueError(
f"{size} invalid, must be one of {self.sizes(self.fastener_type)}"
) from e
if method_exists(self.__class__, "custom_make"):
bd_object = self.custom_make()
else:
bd_object = self.make_nut()
# Unwrap the Compound if possible
if isinstance(bd_object, Compound) and len(bd_object.solids()) == 1:
super().__init__(bd_object.solid(), rotation, align, mode)
else:
super().__init__(bd_object, rotation, align, mode)
self.label = f"{self.__class__.__name__}({size}, {fastener_type})"
self.color = Color(0xC0C0C0)
RigidJoint("a", self, Location())
RigidJoint("b", self, Pos(Z=self.nut_thickness))
def make_nut(self) -> Union[Compound, Solid]:
"""Create a screw head from the 2D shapes defined in the derived class"""
# pylint: disable=no-member
profile = self.nut_profile()
max_nut_height = profile.bounding_box().max.Z
nut_thread_height = self.nut_data["m"]
# Create the basic nut shape
nut = revolve(profile, Axis.Z)
# Modify the head to conform to the shape of head_plan (e.g. hex)
# Note that some nuts (e.g. domed nuts) extend beyond the threaded section
nut_blank = extrude(
self.nut_plan(), max_nut_height, (0, 0, 1)
) - Solid.make_cylinder(self.thread_diameter / 2, nut_thread_height)
nut = nut.intersect(nut_blank)
if isinstance(nut, list):
nut = nut[0]
# Add a flange as it exists outside of the head plan
if method_exists(self.__class__, "flange_profile"):
flange = revolve(
split(
self.flange_profile(),
Plane.YZ.offset(self.thread_diameter / 2 + 0.1),
),
Axis.Z,
)
nut = nut.fuse(flange)
nut.label = "body"
# Add the thread to the nut body
if not self.simple:
# Create the thread
thread = IsoThread(
major_diameter=self.thread_diameter,
pitch=self.thread_pitch,
length=self.nut_data["m"],
external=False,
end_finishes=("fade", "fade"),
hand=self.hand,
)
thread.label = "thread"
nut = Part(children=[nut, thread])
return nut
def default_nut_profile(self) -> Face:
"""Create 2D profile of hex nuts with double chamfers"""
(m, s) = (self.nut_data[p] for p in ["m", "s"])
e = polygon_diagonal(s, 6)
# Chamfer angle must be between 15 and 30 degrees
cs = (e - s) * tan(radians(15)) / 2
# Note that when intersecting a revolved shape with a extruded polygon the OCCT
# core may fail unless the polygon is slightly larger than the circle so
# all profiles must be reduced by a small fudge factor
with BuildSketch(Plane.XZ) as profile:
Polygon(
(0, 0),
(s / 2, 0),
(e / 2 - 0.001, cs),
(e / 2 - 0.001, m - cs),
(s / 2, m),
(0, m),
(0, 0),
align=None,
)
return profile.sketch.face()
def default_nut_plan(self) -> Face:
"""Create a hexagon solid"""
with BuildSketch() as plan:
RegularPolygon(self.nut_data["s"] / 2, 6, major_radius=False)
return plan.face()
def default_countersink_profile(self, fit) -> Face:
"""A simple rectangle with gets revolved into a cylinder with an
extra socket_clearance (defaults to 6mm across the diameter) for a socket wrench
"""
# Note that fit is only used for some flanged nuts but is here for uniformity
del fit
(m, s) = (self.nut_data[p] for p in ["m", "s"])
width = polygon_diagonal(s, 6) + self.socket_clearance
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, m, align=Align.MIN)
return profile.sketch.face()
[docs]
class DomedCapNut(Nut):
"""Domed Cap Nut
DIN 1587 domed cap nuts, also known as acorn nuts, feature a high, rounded, closed-end top.
These nuts are typically used to provide a finished appearance while also protecting exposed
bolt threads from damage or corrosion. The dome prevents the entry of dirt and moisture, making
them suitable for applications where hygiene, safety, or aesthetics are important. Domed cap nuts
are often found in furniture, machinery, automotive, and architectural applications.
These nuts are tightened like standard hex nuts but offer the added benefit of thread protection
and a smoother exterior that reduces the risk of snagging or injury.
Args:
size (str): size specification, e.g. "M6-1"
fastener_type (Literal["din1587"], optional): Defaults to "din1587".
din1587 Hexagon domed cap nuts
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("domed_cap_nut_parameters.csv")
def __init__(
self,
size: str,
fastener_type: Literal["din1587"] = "din1587",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
def nut_profile(self) -> Face:
"""Create 2D profile of hex nuts with double chamfers"""
(dk, m, s) = (self.nut_data[p] for p in ["dk", "m", "s"])
e = polygon_diagonal(s, 6)
# Chamfer angle must be between 15 and 30 degrees
cs = (e - s) * tan(radians(15)) / 2
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
Polyline(
(1 * MM, 0),
(s / 2, 0),
(e / 2, cs),
(e / 2, m - cs),
(s / 2, m),
(dk / 2, m),
)
RadiusArc((dk / 2, m), (0, m + dk / 2), -dk / 2)
Line((0, m + dk / 2), (0, m + dk / 2 - 1 * MM))
RadiusArc(
(0, m + dk / 2 - 1 * MM), (dk / 2 - 1 * MM, m), (dk / 2 - 1 * MM)
)
Polyline((dk / 2 - 1 * MM, m), (1 * MM, m), (1 * MM, 0))
make_face()
return profile.sketch.face()
def countersink_profile(self, fit) -> Face:
"""A simple rectangle with gets revolved into a cylinder with an
extra socket_clearance (defaults to 6mm across the diameter) for a socket wrench
"""
# Note that fit is only used for some flanged nuts but is here for uniformity
del fit
(dk, m, s) = (self.nut_data[p] for p in ["dk", "m", "s"])
width = polygon_diagonal(s, 6) + self.socket_clearance
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, m + dk / 2, align=Align.MIN)
return profile.sketch.face()
nut_plan = Nut.default_nut_plan
# class BradTeeNut(Nut):
# """Brad Tee Nut
# A Brad Tee Nut - a large flanged nut fastened with multiple screws countersunk
# into the flange.
# Args:
# size (str): nut size, e.g. M6-1
# fastener_type (str): "Hilitchi"
# hand (Literal["right", "left"]): thread direction. Defaults to "right".
# simple (bool): omit the thread from the nut. Defaults to True.
# """
# fastener_data = read_fastener_parameters_from_csv("brad_tee_nut_parameters.csv")
# def custom_make(self) -> Compound:
# """A build123d Compound nut as defined by class attributes"""
# brad = CounterSunkScrew(
# size=self.nut_data["brad_size"],
# length=2 * self.nut_data["c"],
# fastener_type="iso10642",
# )
# return (
# self.make_nut()
# .faces(">Z")
# .workplane()
# .polarArray(self.nut_data["bcd"] / 2, 0, 360, self.nut_data["brad_num"])
# .clearanceHole(fastener=brad)
# .val()
# )
# def nut_profile(self):
# (dc, s, m, c) = (self.nut_data[p] for p in ["dc", "s", "m", "c"])
# return (
# cq.Workplane("XZ")
# .vLine(m)
# .hLine(dc / 2)
# .vLine(-c)
# .hLineTo(s)
# .vLineTo(0)
# .hLineTo(0)
# .close()
# )
# def nut_plan(self):
# return cq.Workplane("XY").circle(self.nut_data["dc"] / 2)
# def countersink_profile(
# self, fit: Literal["Close", "Normal", "Loose"]
# ) -> cq.Workplane:
# """A enlarged cavity allowing the nut to be countersunk"""
# try:
# clearance_hole_diameter = self.clearance_hole_diameters[fit]
# except KeyError as e:
# raise ValueError(
# f"{fit} invalid, must be one of {list(self.clearance_hole_diameters.keys())}"
# ) from e
# (dc, s, m, c) = (self.nut_data[p] for p in ["dc", "s", "m", "c"])
# clearance = (clearance_hole_diameter - self.thread_diameter) / 2
# return (
# cq.Workplane("XZ")
# .vLine(m)
# .hLine(dc / 2 + clearance)
# .vLine(-c - clearance)
# .hLineTo(s + clearance)
# .vLineTo(-clearance)
# .hLineTo(0)
# .close()
# )
[docs]
class HeatSetNut(Nut):
"""Heat Set Nut
Heat set insert nuts are specially designed threaded inserts used in thermoplastics to provide
durable, reusable threads in 3D printed or injection-molded parts. These inserts are installed
by heating them—typically with a soldering iron—and pressing them into a pre-formed hole in the
plastic. As the insert heats the surrounding plastic, it melts slightly and flows into the
knurled or slotted features of the insert, forming a strong mechanical bond upon cooling.
Heat set nuts are ideal for applications where threaded fasteners are frequently assembled and
disassembled, helping to prevent wear and stripping in plastic components. They are widely used
in prototyping, enclosures, robotics, and other applications where lightweight, modular designs
are needed.
Args:
size (str): nut size, e.g. M5-0.8-Standard
fastener_type (str): standard or manufacturer that defines the nut ["McMaster-Carr"]
hand (Literal["right", "left"], optional): direction of thread. Defaults to "right".
simple (bool): omit the thread from the nut. Defaults to True.
Attributes:
fill_factor (float): Fraction of insert hole filled with heatset nut
"""
fastener_data = read_fastener_parameters_from_csv("heatset_nut_parameters.csv")
@property
def nut_diameter(self):
"""Calculate the maximum diameter of the nut"""
return float(self.fastener_data[self.nut_size][self.fastener_type + ":s"])
def __init__(
self,
size: str,
fastener_type: Literal["McMaster-Carr", "Hilitchi"] = "McMaster-Carr",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
@staticmethod
def knurled_cylinder_faces(
diameter: float,
bottom_hole_radius: float,
top_hole_radius: float,
height: float,
knurl_depth: float,
pitch: float,
tip_count: int,
hand: Literal["right", "left"] = "right",
) -> list[Face]:
"""Faces of Knurled Cylinder
Generate a list of Faces on a knurled cylinder with a central hole. These faces are
used to build a knurled HeatSet insert with maximal performance.
Args:
diameter (float): outside diameter of knurled cylinder
bottom_hole_radius (float): size of bottom hole
top_hole_radius (float): size of top hole
height (float): end-to-end height
knurl_depth (float): size of the knurling
pitch (float): knurling helical pitch
tip_count (int): number of knurled tips
hand (Literal["right", "left"], optional): direction of knurling. Defaults to "right".
Returns:
list[Face]: Faces of the knurled cylinder except for central hole
"""
# Start by creating helical edges for the inside and outside of the knurling
lefthand = hand == "left"
inside_edges = [
Edge.make_helix(
pitch, height, diameter / 2 - knurl_depth, lefthand=lefthand
).rotate(Axis.Z, i * 360 / tip_count)
for i in range(tip_count)
]
outside_edges = [
Edge.make_helix(pitch, height, diameter / 2, lefthand=lefthand).rotate(
Axis.Z, (i + 0.5) * 360 / tip_count
)
for i in range(tip_count)
]
# Connect the bottoms of the helical edges into a star shaped bottom face
bottom_edges = [
(
Edge.make_line(
inside_edges[i].position_at(0), outside_edges[i].position_at(0)
),
Edge.make_line(
outside_edges[i].position_at(0),
inside_edges[(i + 1) % tip_count].position_at(0),
),
)
for i in range(tip_count)
]
# Flatten the list of tuples to a list
bottom_edges = list(sum(bottom_edges, ()))
top_edges = [
(
Edge.make_line(
inside_edges[i].position_at(1), outside_edges[i].position_at(1)
),
Edge.make_line(
outside_edges[i].position_at(1),
inside_edges[(i + 1) % tip_count].position_at(1),
),
)
for i in range(tip_count)
]
top_edges = list(sum(top_edges, ()))
# Build the faces from the edges
outside_faces = [
(
Face.make_surface(
[
inside_edges[i],
outside_edges[i],
bottom_edges[2 * i],
top_edges[2 * i],
],
),
Face.make_surface(
[
outside_edges[i],
inside_edges[(i + 1) % tip_count],
bottom_edges[2 * i + 1],
top_edges[2 * i + 1],
],
),
)
for i in range(tip_count)
]
outside_faces = list(sum(outside_faces, ()))
# Create the top and bottom faces with holes in them
bottom_face = Face(
Wire(bottom_edges), [Wire(Edge.make_circle(bottom_hole_radius))]
)
top_face = Face(
Wire(top_edges),
[Wire(Edge.make_circle(top_hole_radius, Plane.XY.offset(height)))],
)
return [bottom_face, top_face] + outside_faces
@property
def fill_factor(self) -> float:
"""Relative size of nut vs hole
Returns:
float: Fraction of insert hole filled with heatset nut
"""
drill_sizes = read_drill_sizes()
hole_radius = drill_sizes[self.nut_data["drill"].strip()] / 2
heatset_volume = (
self.volume + self.nut_data["m"] * pi * (self.thread_diameter / 2) ** 2
)
hole_volume = self.nut_data["m"] * pi * hole_radius**2
return heatset_volume / hole_volume
def make_nut(self) -> Solid:
"""Build heatset nut object
Create the heatset nut with a flanged bottom and two knurled sections
with opposite twist direction which locks into the plastic when heated
and inserted into an appropriate hole.
To maximize performance, the nut is created by building assembling Faces
into a Shell which is converted to a Solid. No extrusions or boolean
operations are used until the thread is added to the nut.
Returns:
Solid: the heatset nut
"""
nut_base = Cylinder(
self.nut_data["dc"] / 2,
0.11 * self.nut_data["m"],
align=(Align.CENTER, Align.CENTER, Align.MIN),
) + Cylinder(
0.425 * self.nut_data["dc"],
0.24 * self.nut_data["m"],
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
base_bottom_face = (
nut_base.faces()
.sort_by(Axis.Z)[0]
.make_holes([Wire(Edge.make_circle(self.thread_diameter / 2))])
)
base_outside_faces = nut_base.faces().sort_by(Axis.Z)[1:-1]
# Create the lower knurled section Faces
lower_knurl_faces = HeatSetNut.knurled_cylinder_faces(
self.nut_data["s"],
0.425 * self.nut_data["dc"],
0.425 * self.nut_data["dc"],
height=0.33 * self.nut_data["m"],
knurl_depth=0.1 * self.nut_data["s"],
pitch=3 * self.nut_data["m"],
tip_count=self.nut_data["knurls"],
hand="right",
)
lower_knurl_faces = [
f.translate(Vector(0, 0, 0.24 * self.nut_data["m"]))
for f in lower_knurl_faces
]
# Create the Face in the gap between the knurled sections
nut_middle_face = Face.extrude(
Edge.make_circle(
0.425 * self.nut_data["dc"], Plane.XY.offset(0.57 * self.nut_data["m"])
),
(0, 0, 0.1 * self.nut_data["m"]),
)
# Create the Faces in the upper knurled section
upper_knurl_faces = HeatSetNut.knurled_cylinder_faces(
self.nut_data["s"],
0.425 * self.nut_data["dc"],
self.thread_diameter / 2,
height=0.33 * self.nut_data["m"],
knurl_depth=0.1 * self.nut_data["s"],
pitch=3 * self.nut_data["m"],
tip_count=20,
hand="left",
)
upper_knurl_faces = [
f.translate(Vector(0, 0, 0.67 * self.nut_data["m"]))
for f in upper_knurl_faces
]
# Create the Face for the inside of the nut
thread_hole_face = Face.extrude(
Edge.make_circle(self.thread_diameter / 2),
(0, 0, self.nut_data["m"]),
)
# Build a Shell of the nut from all of the Faces
nut_shell = Shell(
[base_bottom_face, nut_middle_face, thread_hole_face]
+ base_outside_faces
+ lower_knurl_faces
+ upper_knurl_faces
)
# Finally create the Solid from the Shell
nut = Solid(nut_shell)
nut.label = "body"
# Add the thread to the nut body
if not self.simple:
# Create the thread
thread = IsoThread(
major_diameter=self.thread_diameter,
pitch=self.thread_pitch,
length=self.nut_data["m"],
external=False,
end_finishes=("fade", "fade"),
hand=self.hand,
)
nut = Part(children=[nut, thread])
return nut
def nut_profile(self) -> Face: # pragma: no cover
"""Not used but required by the abstract base class"""
pass
def nut_plan(self) -> Face: # pragma: no cover
"""Not used but required by the abstract base class"""
pass
def countersink_profile(self, manufacturing_compensation: float = 0.0) -> Face:
"""countersink_profile
Create the profile for a cavity allowing the heatset nut to be countersunk into the plastic.
Args:
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.
Returns:
Face: The countersink hole profile
"""
drill_sizes = read_drill_sizes()
hole_radius = (
drill_sizes[self.nut_data["drill"].strip()] / 2 + manufacturing_compensation
)
# chamfer_size = self.nut_data["s"] / 2 - hole_radius
with BuildSketch(Plane.XZ) as profile:
Rectangle(hole_radius, self.nut_data["m"], align=Align.MIN).face()
return profile.sketch
[docs]
class HexNut(Nut):
"""Hex Nut
Hex nuts are the most commonly used type of fastening nut, featuring a six-sided profile that
provides a strong grip and easy installation with standard tools. They are used in conjunction
with bolts, screws, and other externally threaded fasteners to secure components in mechanical
assemblies.
ISO 4032, ISO 4033, and ISO 4035 define different variants of metric hex nuts:
- ISO 4032 specifies a regular height hex nut with a standard width across flats.
- ISO 4033 defines a heavy series hex nut, typically used for larger or more heavily loaded assemblies.
- ISO 4035 specifies a thin (jam) nut, used where space is limited or as a locknut against a standard nut.
These nuts are widely used across industries including automotive, aerospace, machinery, and
construction. They are available in various grades and finishes to suit different strength,
corrosion resistance, and environmental requirements.
Args:
size (str): size specification, e.g. "M6-1"
fastener_type (Literal["iso4032", "iso4033", "iso4035"], optional):
Defaults to "iso4032".
iso4032 Hexagon nuts, Style 1
iso4033 Hexagon nuts, Style 2
iso4035 Hexagon thin nuts, chamfered
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("hex_nut_parameters.csv")
def __init__(
self,
size: str,
fastener_type: Literal["iso4032", "iso4033", "iso4035"] = "iso4032",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
nut_profile = Nut.default_nut_profile
nut_plan = Nut.default_nut_plan
countersink_profile = Nut.default_countersink_profile
[docs]
class HexNutWithFlange(Nut):
"""Hex Nut With Flange
A hex nut with flange combines a standard six-sided nut with an integrated flange at the base,
which acts like a built-in washer. This flange helps distribute the clamping load over a larger
surface area, reducing surface pressure and the risk of damage to softer materials. It also
improves resistance to loosening due to vibration by increasing friction at the mating surface.
DIN 1665 specifies metric hex flange nuts used in general-purpose and structural applications.
These nuts are often used in automotive, machinery, and assembly applications where ease of use,
improved load distribution, and vibration resistance are desired. The flanged base eliminates the
need for a separate washer in many cases, simplifying assembly and reducing part count.
Args:
size (str): size specification, e.g. "M6-1"
fastener_type (Literal["din1665"], optional): Defaults to "din1665".
din1665 Hexagon nuts with flange
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"hex_nut_with_flange_parameters.csv"
)
def __init__(
self,
size: str,
fastener_type: Literal["din1665"] = "din1665",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
nut_profile = Nut.default_nut_profile
nut_plan = Nut.default_nut_plan
def flange_profile(self) -> Face:
"""Flange for hexagon Bolts"""
(dc, c) = (self.nut_data[p] for p in ["dc", "c"])
flange_angle = 25
tangent_point = Vector(
(c / 2) * cos(radians(90 - flange_angle)),
(c / 2) * sin(radians(90 - flange_angle)),
) + Vector((dc - c) / 2, c / 2)
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
l1 = Line((0, 0), (dc / 2 - c / 2, 0))
RadiusArc(l1 @ 1, tangent_point, -c / 2)
l3 = PolarLine(tangent_point, dc / 2 - c / 2, 180 - flange_angle)
Polyline(l3 @ 1, (0, l3.position_at(1).Y), (0, 0))
make_face()
return profile.sketch.face()
def countersink_profile(self, fit: Literal["Close", "Normal", "Loose"]) -> Face:
"""A simple rectangle with gets revolved into a cylinder with
at least socket_clearance (default 6mm across the diameter) for a socket wrench
"""
try:
clearance_hole_diameter = self.clearance_hole_diameters[fit]
except KeyError as e:
raise ValueError(
f"{fit} invalid, must be one of {list(self.clearance_hole_diameters.keys())}"
) from e
(dc, s, m) = (self.nut_data[p] for p in ["dc", "s", "m"])
clearance = clearance_hole_diameter - self.thread_diameter
width = max(dc + clearance, polygon_diagonal(s, 6) + self.socket_clearance)
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, m, align=Align.MIN)
return profile.sketch.face()
nut_plan = Nut.default_nut_plan
[docs]
class UnchamferedHexagonNut(Nut):
"""Unchamfered Hexagon Nut
ISO 4036 defines a thin, unchamfered hexagon nut, typically used in non-critical applications
or where space and weight constraints are a priority. Unlike standard hex nuts, these nuts lack
a chamfered edge and are manufactured with reduced height, which makes them suitable for
low-stress assemblies, locking applications (e.g., as a jam nut), or secondary fastening positions.
Due to their minimal height and absence of chamfers, they are generally not intended for high-load
structural use. Instead, they are ideal for internal assemblies, compact enclosures, or when used
in combination with standard nuts to resist loosening under vibration. ISO 4036 nuts are most often
found in light mechanical assemblies and electronics hardware.
Args:
size (str): size specification, e.g. "M6-1"
fastener_type (Literal["iso4036"], optional): Defaults to "iso4036".
iso4036 Hexagon thin nuts, unchamfered
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"unchamfered_hex_nut_parameters.csv"
)
def __init__(
self,
size: str,
fastener_type: Literal["iso4036"] = "iso4036",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
def nut_profile(self):
"""Create 2D profile of hex nuts with double chamfers"""
(m, s) = (self.nut_data[p] for p in ["m", "s"])
with BuildSketch(Plane.XZ) as profile:
Rectangle(polygon_diagonal(s, 6) / 2 - 0.001, m, align=Align.MIN)
return profile.sketch.face()
nut_plan = Nut.default_nut_plan
countersink_profile = Nut.default_countersink_profile
[docs]
class SquareNut(Nut):
"""Square Nut
Square nuts, as defined by DIN 557, are four-sided nuts commonly used in older machinery,
woodworking, and applications where a flat bearing surface and greater resistance to loosening
are beneficial. Their larger surface area compared to hex nuts provides increased grip and load
distribution, especially when used with flat washers or in slots.
DIN 557 specifies standard square nuts with a flat top and bottom and sharp or slightly chamfered
corners. These nuts are well-suited for tightening by hand or with simple tools, and are often
found in applications where ease of alignment or aesthetics are not critical. Their geometry makes
them less prone to rounding and easier to weld or lock in place.
Args:
size (str): size specification, e.g. "M6-1"
fastener_type (Literal["din557"], optional): Defaults to "din557".
din557 - Square Nuts
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("square_nut_parameters.csv")
def __init__(
self,
size: str,
fastener_type: Literal["din557"] = "din557",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, hand, simple, rotation, align, mode)
def nut_profile(self) -> Face:
"""Create 2D profile of hex nuts with double chamfers"""
(m, s) = (self.nut_data[p] for p in ["m", "s"])
e = polygon_diagonal(s, 4)
# Chamfer angle must be between 15 and 30 degrees
cs = (e - s) * tan(radians(15)) / 2
with BuildSketch(Plane.XZ) as profile:
Polygon(
(0, 0),
(e / 2 - 0.001, 0),
(e / 2 - 0.001, m - cs),
(s / 2, m),
(0, m),
(0, 0),
align=None,
)
return profile.sketch.face()
def nut_plan(self) -> Face:
"""Simple square for the plan"""
# return cq.Workplane("XY").rect(self.nut_data["s"], self.nut_data["s"])
return Face.make_rect(self.nut_data["s"], self.nut_data["s"])
def countersink_profile(self, fit) -> Face:
"""A simple rectangle with gets revolved into a cylinder with an
extra socket_clearance (defaults to 6mm across the diameter) for a socket wrench
"""
# Note that fit is only used for some flanged nuts but is here for uniformity
del fit
(m, s) = (self.nut_data[p] for p in ["m", "s"])
width = polygon_diagonal(s, 4) + self.socket_clearance
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, m, align=Align.MIN)
return profile.sketch.face()
[docs]
class Screw(ABC, BasePartObject):
"""Parametric Screw
Base class for a set of threaded screws or bolts
Args:
size (str): standard sizes - e.g. "M6-1"
length (float): distance from base of head to tip of thread
fastener_type (str): type identifier - e.g. "iso4014"
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
socket_clearance (float, optional): gap around screw with no recess (e.g. hex head)
which allows a socket wrench to be inserted. Defaults to 6mm.
Raises:
ValueError: invalid size, must be formatted as size-pitch or size-TPI
ValueError: invalid fastener_type
ValueError: invalid hand, must be one of 'left' or 'right'
ValueError: invalid size
"""
_applies_to = [BuildPart._tag]
# Read clearance and tap hole dimesions tables
# Close, Medium, Loose
clearance_hole_drill_sizes = read_fastener_parameters_from_csv(
"clearance_hole_sizes.csv"
)
clearance_hole_data = lookup_drill_diameters(clearance_hole_drill_sizes)
# Soft (Aluminum, Brass, & Plastics) or Hard (Steel, Stainless, & Iron)
tap_hole_drill_sizes = read_fastener_parameters_from_csv("tap_hole_sizes.csv")
tap_hole_data = lookup_drill_diameters(tap_hole_drill_sizes)
# Build a dictionary of nominal screw lengths keyed by screw type
nominal_length_range = lookup_nominal_screw_lengths()
@property
def tap_drill_sizes(self) -> dict[str:float]:
"""A dictionary of drill sizes for tapped holes"""
try:
return self.tap_hole_drill_sizes[self.thread_size]
except KeyError as e:
raise ValueError(f"No tap hole data for size {self.thread_size}") from e
@property
def tap_hole_diameters(self) -> dict[str:float]:
"""A dictionary of drill diameters for tapped holes"""
try:
return self.tap_hole_data[self.thread_size]
except KeyError as e:
raise ValueError(f"No tap hole data for size {self.thread_size}") from e
@property
def clearance_drill_sizes(self) -> dict[str:float]:
"""A dictionary of drill sizes for clearance holes"""
try:
return self.clearance_hole_drill_sizes[self.thread_size.split("-")[0]]
except KeyError as e:
raise ValueError(
f"No clearance hole data for size {self.thread_size}"
) from e
@property
def clearance_hole_diameters(self) -> dict[str:float]:
"""A dictionary of drill diameters for clearance holes"""
try:
return self.clearance_hole_data[self.thread_size.split("-")[0]]
except KeyError as e:
raise ValueError(
f"No clearance hole data for size {self.thread_size}"
) from e
@property
@abstractmethod
def fastener_data(cls): # pragma: no cover
"""Each derived class must provide a fastener_data dictionary"""
return NotImplementedError
@abstractmethod
def countersink_profile(
self, fit: Literal["Close", "Normal", "Loose"]
) -> Face: # pragma: no cover
"""Each derived class must provide the profile of a countersink cutter"""
return NotImplementedError
@classmethod
def select_by_size(cls, size: str) -> dict[str:float]:
"""Return a dictionary of list of fastener types of this size"""
return select_by_size_fn(cls, size)
@classmethod
def types(cls) -> list[str]:
"""Return a set of the screw types"""
return set(p.split(":")[0] for p in list(cls.fastener_data.values())[0].keys())
@classmethod
def sizes(cls, fastener_type: str) -> list[str]:
"""Return a list of the screw sizes for the given type"""
return list(isolate_fastener_type(fastener_type, cls.fastener_data).keys())
def length_offset(self) -> float:
"""
To enable screws to include the head height in their length (e.g. Countersunk),
allow each derived class to override this length_offset calculation to the
appropriate head height.
"""
return 0
def min_hole_depth(self, counter_sunk: bool = True) -> float:
"""Minimum depth of a hole able to accept the screw"""
countersink_profile = self.countersink_profile("Loose")
if countersink_profile is None: # SetScrew
return 0
head_offset = countersink_profile.vertices().sort_by(Axis.Z)[-1].Z
if counter_sunk:
result = self.length + head_offset - self.length_offset()
else:
result = self.length - self.length_offset()
return result
@property
def nominal_lengths(self) -> list[float] | None:
"""A list of nominal screw lengths for this screw"""
try:
range_min = self.screw_data["short"]
except KeyError:
range_min = None
try:
range_max = self.screw_data["long"]
except KeyError:
range_max = None
if (
range_min is None
or range_max is None
or not self.fastener_type in Screw.nominal_length_range.keys()
):
result = None
else:
result = [
size
for size in Screw.nominal_length_range[self.fastener_type]
if range_min <= size <= range_max
]
return result
@property
def info(self) -> str:
"""Return identifying information"""
return f"{self.screw_class}({self.fastener_type}): {self.thread_size}x{self.length}{' left hand thread' if self.hand=='left' else ''}"
@property
def screw_class(self) -> str:
"""Which derived class created this screw"""
return type(self).__name__
def __init__(
self,
size: str,
length: float,
fastener_type: str,
hand: Optional[Literal["right", "left"]] = "right",
simple: Optional[bool] = True,
socket_clearance: Optional[float] = 6 * MM,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
self.hole_locations: list[Location] = [] #: custom holes locations
self.screw_size = size
self.head_height: float #: maximum height of the screw head
self.head_diameter: float #: maximum diameter of the screw head
size_parts = size.strip().split("-")
if not len(size_parts) == 2:
raise ValueError(
f"{size_parts} invalid, must be formatted as size-pitch or size-TPI"
)
self.thread_size = size
self.is_metric = self.thread_size[0] == "M"
if self.is_metric:
self.thread_diameter = float(size_parts[0][1:])
self.thread_pitch = float(size_parts[1])
else:
(self.thread_diameter, self.thread_pitch) = decode_imperial_size(
self.thread_size
)
self.length = length
if fastener_type not in self.types():
raise ValueError(f"{fastener_type} invalid, must be one of {self.types()}")
self.fastener_type = fastener_type
if hand in ["left", "right"]:
self.hand = hand
else:
raise ValueError(f"{hand} invalid, must be one of 'left' or 'right'")
self.simple = simple
try:
self.screw_data = evaluate_parameter_dict(
isolate_fastener_type(self.fastener_type, self.fastener_data)[
self.thread_size
],
is_metric=self.is_metric,
)
except KeyError as e:
raise ValueError(
f"{size} invalid, must be one of {self.sizes(self.fastener_type)}"
) from e
self.socket_clearance = socket_clearance # Only used for hex head screws
length_offset = self.length_offset()
if length_offset >= self.length:
raise ValueError(
f"Screw length {self.length} is <= countersunk screw head {length_offset}"
)
self.max_thread_length = self.length - length_offset
self.thread_length = length - length_offset
head = self.make_head()
if head is None: # A fully custom screw
screw = None
self.head_height = 0
self.head_diameter = 0
ends = ("fade", "fade")
else:
head_bb = head.bounding_box()
self.head_height = head_bb.max.Z
self.head_diameter = 2 * max(head_bb.max.X, head_bb.max.Y)
ends = ("fade", "raw")
head = head.translate((0, 0, -self.length_offset()))
thread = IsoThread(
major_diameter=self.thread_diameter,
pitch=self.thread_pitch,
length=self.thread_length,
external=True,
hand=self.hand,
end_finishes=ends,
simple=self.simple,
# ).locate(Pos(Z=-self.length))
)
if not self.simple:
thread = thread.locate(Pos(Z=-self.length))
thread.label = "thread"
shank = Solid.make_cylinder(
thread.min_radius, self.thread_length, Plane.XY.offset(-self.length)
)
if method_exists(self.__class__, "custom_make"):
screw = self.custom_make()
elif head is not None:
screw = head.fuse(shank)
else:
screw = shank
# Unwrap the Compound as it's unnecessary
if isinstance(screw, Compound):
screw = screw.unwrap(fully=True)
screw.label = "body"
if not self.simple:
screw = Part(children=[screw, thread])
super().__init__(screw, rotation, align, mode)
self.label = (
f"{self.__class__.__name__}({size}, {length:0.2f}, {fastener_type})"
)
self.color = Color(0xC0C0C0)
RigidJoint("a", self, Location())
def make_head(self) -> Solid:
"""Create a screw head from the 2D shapes defined in the derived class"""
# Determine what shape creation methods have been defined
has_profile = method_exists(self.__class__, "head_profile")
has_plan = method_exists(self.__class__, "head_plan")
has_recess = method_exists(self.__class__, "head_recess")
has_flange = method_exists(self.__class__, "flange_profile")
# raise RuntimeError
if has_profile:
# pylint: disable=no-member
profile = self.head_profile()
profile_bbox = profile.bounding_box()
max_head_height = profile_bbox.size.Z
max_head_radius = profile_bbox.max.X
min_head_height = profile_bbox.min.Z
# Create the basic head shape
head = revolve(profile)
if has_plan:
# pylint: disable=no-member
head_plan = self.head_plan()
else:
# Ensure this default plan is outside of the maximum profile dimension.
# As the slot cuts across the entire head it must go outside of the top
# face. By creating an overly large head plan the slot can be safely
# contained within and it doesn't clip the revolved profile.
head_plan = Face.make_rect(
3 * max_head_radius,
3 * max_head_radius,
Plane.XY.offset(min_head_height),
)
# Potentially modify the head to conform to the shape of head_plan
# (e.g. hex) and/or to add an engagement recess
if has_recess:
# pylint: disable=no-member
(recess_plan, recess_depth, recess_taper) = self.head_recess()
recess = Solid.extrude_taper(
recess_plan,
(0, 0, -recess_depth),
taper=recess_taper,
).translate((0, 0, max_head_height))
head_blank = extrude(head_plan, max_head_height, (0, 0, 1)) - recess
head = head.intersect(head_blank)
elif has_plan:
head_blank = extrude(head_plan, max_head_height)
head = head.intersect(head_blank)
if isinstance(head, list):
head = head[0]
# Add a flange as it exists outside of the head plan
if has_flange:
# pylint: disable=no-member
head = head.fuse(revolve(self.flange_profile()))
return head
def default_head_recess(self) -> tuple[Face, float, float]:
"""Return the plan of the recess, its depth and taper"""
recess_plan = None
# Slot Recess
try:
(dk, n, t) = (self.screw_data[p] for p in ["dk", "n", "t"])
recess_plan = slot_recess(dk, n)
recess_depth = t
recess_taper = 0
except KeyError:
pass
# Hex Recess
try:
(s, t) = (self.screw_data[p] for p in ["s", "t"])
recess_plan = hex_recess(s)
recess_depth = t
recess_taper = 0
except KeyError:
pass
# Philips, Torx or Robertson Recess
try:
recess = self.screw_data["recess"]
recess = str(recess).upper()
if recess.startswith("PH"):
(recess_plan, recess_depth) = cross_recess(recess)
recess_taper = 30 # TODO
recess_taper = 20
elif recess.startswith("T"):
(recess_plan, recess_depth) = hexalobular_recess(recess)
recess_taper = 0
recess_taper = 5
elif recess.startswith("R"):
(recess_plan, recess_depth) = square_recess(recess)
recess_taper = 0
except KeyError:
pass
if recess_plan is None:
raise ValueError(f"Recess data missing from screw_data{self.screw_data}")
return (recess_plan, recess_depth, recess_taper)
def default_countersink_profile(
self, fit: Literal["Close", "Normal", "Loose"]
) -> Face:
"""A simple rectangle with gets revolved into a cylinder"""
try:
clearance_hole_diameter = self.clearance_hole_diameters[fit]
except KeyError as e:
raise ValueError(
f"{fit} invalid, must be one of {list(self.clearance_hole_diameters.keys())}"
) from e
width = clearance_hole_diameter - self.thread_diameter + self.screw_data["dk"]
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, self.screw_data["k"], align=Align.MIN)
return profile.sketch.face()
[docs]
class CheeseHeadScrew(Screw):
"""Cheese Head Screw
Cheese head screws are cylindrical-head fasteners with vertical sides and a flat top, offering a
clean, compact profile. The head has a smaller diameter and taller profile than pan or button head
screws, providing a deep drive socket or slot for strong torque transmission. These screws are
commonly used where head space is limited or where components are recessed into counterbores.
Multiple ISO standards define variations:
- ISO 1207: Slotted cheese head machine screws for general applications.
- ISO 7048: Cross-recessed (Phillips) cheese head screws.
- ISO 14580: Hex socket cheese head screws, often used in precision assemblies.
Cheese head screws are widely used in electrical components, enclosures, and machinery where a tall
head is acceptable but a compact footprint is desired. Their straight vertical sides also make them
ideal for components that require precise guidance or centering in assembly features.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso1207", "iso7048", "iso14580"], optional):
Defaults to "iso7048".
iso1207 - Slotted cheese head screws
iso7048 - Cross-recessed cheese head screws
iso14580 - Hexalobular socket cheese head screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("cheese_head_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso1207", "iso7048", "iso14580"] = "iso7048",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self) -> Face:
"""cheese head screws"""
(k, dk) = (self.screw_data[p] for p in ["k", "dk"])
with BuildSketch(Plane.XZ) as profile:
Trapezoid(dk, k, 90 - 5, align=(Align.CENTER, Align.MIN))
fillet(profile.vertices().group_by(Axis.Z)[-1], k * 0.25)
split(bisect_by=Plane.YZ)
return profile.sketch.face()
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class CounterSunkScrew(Screw):
"""CounterSunk Screw
Countersunk screws are flat-head fasteners designed to sit flush with or below the surface of the
material they are installed into. The conical underside of the head matches a countersunk hole,
allowing for a clean finish and reduced interference in assembled products. These screws are used
in applications where appearance, clearance, or aerodynamics are important, such as in enclosures,
electronics, automotive panels, and structural components.
Multiple ISO standards define specific variations:
- ISO 2009: Slotted countersunk head screws.
- ISO 7046: Cross-recessed (Phillips) countersunk screws for general use.
- ISO 10642: Hex socket countersunk screws, often used in machinery and precision equipment.
- ISO 14581: Low-profile, cross-recessed countersunk screws for space-constrained applications.
- ISO 14582: Hex socket, low-profile countersunk screws for compact, high-strength fastening.
The variety of head heights and drive types enables countersunk screws to meet the aesthetic,
mechanical, and space requirements of a wide range of assemblies.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal[ "iso2009", "iso7046", "iso10642", "iso14581", "iso14582"], optional):
Defaults to "iso10642".
iso2009 - Slotted countersunk head screws
iso7046 - Cross recessed countersunk flat head screws
iso10642 - Hexagon socket countersunk head cap screws
iso14581 - Hexalobular socket countersunk flat head screws
iso14582 - Hexalobular socket countersunk flat head screws, high head
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("countersunk_head_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal[
"iso2009", "iso7046", "iso10642", "iso14581", "iso14582"
] = "iso10642",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def length_offset(self):
"""Countersunk screws include the head in the total length"""
return self.screw_data["k"]
def head_profile(self) -> Face:
"""Create 2D profile of countersunk screw heads"""
(a, k, dk) = (self.screw_data[p] for p in ["a", "k", "dk"])
with BuildSketch(Plane.XZ) as profile:
Trapezoid(dk, k, 90 - a / 2, align=(Align.CENTER, Align.MAX), rotation=180)
fillet(profile.vertices().group_by(Axis.Y)[-1], k * 0.075)
split(bisect_by=Plane.YZ)
return profile.sketch.face()
head_recess = Screw.default_head_recess
def countersink_profile(self, fit: Literal["Close", "Normal", "Loose"]) -> Face:
"""Create 2D profile of countersink profile"""
(a, dk, k) = (self.screw_data[p] for p in ["a", "dk", "k"])
with BuildSketch(Plane.XZ) as profile:
Trapezoid(dk, k, 90 - a / 2, align=(Align.CENTER, Align.MIN), rotation=180)
split(bisect_by=Plane.YZ)
return profile.sketch.face()
[docs]
class HexHeadScrew(Screw):
"""Hex Head Screw
Hex head screws, also referred to as hex bolts or hex cap screws, are externally threaded fasteners
with a six-sided head designed for use with wrenches or sockets. They are widely used in
construction, machinery, automotive, and industrial applications where strong, reliable bolted joints
are required.
Two main ISO standards define their dimensional properties:
- ISO 4014: Hex head screws with a partially threaded shank, typically used where shear strength is
needed along the unthreaded portion.
- ISO 4017: Fully threaded hex head screws, used for general-purpose fastening where full thread
engagement is desired.
These screws are available in a range of material grades and finishes, and are often used with
corresponding hex nuts and washers to ensure uniform clamping force and load distribution. Their
standardized geometry ensures compatibility with automated assembly tools and industry-standard hardware.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso4014", "iso4017"], optional): Defaults to "iso4014".
iso4014 - Hexagon head bolt
iso4017 - Hexagon head screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("hex_head_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso4014", "iso4017"] = "iso4014",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self) -> Face:
"""Create 2D profile of hex head screws"""
(k, s) = (self.screw_data[p] for p in ["k", "s"])
e = polygon_diagonal(s, 6)
# Chamfer angle must be between 15 and 30 degrees
cs = (e - s) * tan(radians(15)) / 2
with BuildSketch(Plane.XZ) as profile:
Polygon(
(0, 0),
(e / 2, 0),
(e / 2, k - cs),
(s / 2, k),
(0, k),
(0, 0),
align=None,
)
return profile.sketch.face()
def head_plan(self) -> Face:
"""Create a hexagon solid"""
with BuildSketch() as plan:
RegularPolygon(self.screw_data["s"] / 2, 6, major_radius=False)
return plan.sketch.face()
def countersink_profile(self, fit: Literal["Close", "Normal", "Loose"]) -> Face:
"""A simple rectangle with gets revolved into a cylinder with an
extra socket_clearance (defaults to 6mm across the diameter) for a socket wrench
"""
# Note that fit isn't used but remains for uniformity in the workplane hole methods
del fit
(k, s) = (self.screw_data[p] for p in ["k", "s"])
e = polygon_diagonal(s, 6)
width = e + self.socket_clearance + e
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, k, align=Align.MIN)
return profile.sketch.face()
[docs]
class HexHeadWithFlangeScrew(Screw):
"""Hex Head With Flange Screw
Hex head screws with flanges combine a standard hexagonal head with an integrated washer-like
flange at the base. The flange increases the bearing surface area under the head, distributing
clamping forces more evenly and reducing surface damage to the assembled material. This design
also improves resistance to loosening caused by vibration, especially when used without a separate
washer.
DIN 1662 and DIN 1665 define common types of flanged hex screws:
- DIN 1662: Hex flange screws with a partially threaded shank.
- DIN 1665: Hex flange screws that are fully threaded, offering continuous engagement over the
entire shaft.
These screws are frequently used in automotive, structural, and industrial applications where
secure fastening, reduced part count, and simplified assembly are important. The integrated flange
simplifies the design and assembly process by eliminating the need for a separate washer while
enhancing load distribution.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["din1662", "din1665"], optional): Defaults to "din1662".
din1662 - Hexagon bolts with flange small series
din1665 - Hexagon head bolts with flange
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"hex_head_with_flange_parameters.csv"
)
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["din1662", "din1665"] = "din1662",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
head_profile = HexHeadScrew.head_profile
head_plan = HexHeadScrew.head_plan
def flange_profile(self) -> Face:
"""Flange for hexagon Bolts"""
(dc, c) = (self.screw_data[p] for p in ["dc", "c"])
flange_angle = 25
tangent_point = Vector(
(c / 2) * cos(radians(90 - flange_angle)),
(c / 2) * sin(radians(90 - flange_angle)),
) + Vector((dc - c) / 2, c / 2)
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
l1 = Line((0, 0), (dc / 2 - c / 2, 0))
RadiusArc(l1 @ 1, tangent_point, -c / 2)
l3 = PolarLine(tangent_point, dc / 2 - c / 2, 180 - flange_angle)
Polyline(l3 @ 1, (0, (l3 @ 1).Y), (0, 0))
make_face()
return profile.sketch.face()
def countersink_profile(self, fit: Literal["Close", "Normal", "Loose"]) -> Face:
"""A simple rectangle with gets revolved into a cylinder with
at least socket_clearance (default 6mm across the diameter) for a socket wrench
"""
try:
clearance_hole_diameter = self.clearance_hole_diameters[fit]
except KeyError as e:
raise ValueError(
f"{fit} invalid, must be one of {list(self.clearance_hole_diameters.keys())}"
) from e
(dc, s, k) = (self.screw_data[p] for p in ["dc", "s", "k"])
shaft_clearance = clearance_hole_diameter - self.thread_diameter
width = max(
dc + shaft_clearance, polygon_diagonal(s, 6) + self.socket_clearance
)
with BuildSketch(Plane.XZ) as profile:
Rectangle(width / 2, k, align=Align.MIN)
return profile.sketch.face()
[docs]
class PanHeadScrew(Screw):
"""Pan Head Screw
Pan head screws feature a broad, low-profile head with gently curved sides and a flat bearing
surface underneath. This shape provides a large contact area, reducing the likelihood of damage
to the fastened material and allowing for a clean, finished appearance. The rounded sides offer a
smoother look than hex or cheese heads while still accommodating a variety of drive types.
Several standards define pan head screws:
- ISO 1580: Slotted pan head machine screws for general mechanical use.
- ISO 14583: Pan head screws with a hexalobular (Torx) drive for improved torque transfer and
reduced cam-out.
- ASME B18.6.3: The U.S. standard for slotted, cross-recessed (Phillips), or combination-drive
pan head machine screws.
Pan head screws are commonly used in enclosures, electronics, mechanical assemblies, and
applications where aesthetics, low head height, and easy access are important. Their wide head
allows for secure fastening without requiring countersinking or additional washers.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso1580", "iso14583", "asme_b_18.6.3"], optional):
Defaults to "iso14583".
iso1580 - Slotted pan head screws
iso14583 - Hexalobular socket pan head screws
asme_b_18.6.3 - Type 1 Cross Recessed Pan Head Machine Screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("pan_head_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso1580", "iso14583", "asme_b_18.6.3"] = "iso14583",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self) -> Face:
"""Slotted pan head screws"""
(k, dk) = (self.screw_data[p] for p in ["k", "dk"])
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
l1 = Line((0, 0), (dk / 2, 0))
l2 = Spline(
l1 @ 1,
(dk * 0.25, k),
tangents=[(-sin(radians(5)), cos(radians(5))), (-1, 0)],
)
Polyline(l2 @ 1, (0, (l2 @ 1).Y), (0, 0))
make_face()
return profile.sketch.face()
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class PanHeadWithCollarScrew(Screw):
"""Pan Head With Collar Screw
DIN 967 defines a pan head screw with an integrated collar or washer-like flange beneath the head.
The collar increases the bearing surface area, improving load distribution and reducing surface
indentation on the clamped material. The pan head retains a low-profile, rounded appearance,
while the collar eliminates the need for a separate washer in many applications.
These screws typically feature a slotted or cross-recessed (Phillips) drive and are widely used
in automotive, appliance, and light mechanical assemblies where compactness, aesthetics, and
vibration resistance are important. The combination of pan head geometry and an integral flange
makes them especially useful when fastening to softer materials such as plastics or thin sheet metal.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["din967"], optional): Defaults to "din967".
din967 - Cross recessed pan head screws with collar
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"pan_head_with_collar_parameters.csv"
)
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["din967"] = "din967",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self) -> Face:
"""Cross recessed pan head screws with collar"""
(rf, k, dk, c) = (self.screw_data[p] for p in ["rf", "k", "dk", "c"])
flat = sqrt(k - c) * sqrt(2 * rf - (k - c))
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
Polyline((0, 0), (dk / 2, 0), (dk / 2, c), (flat, c))
RadiusArc((flat, c), (0, k), -rf)
Line((0, k), (0, 0))
make_face()
return profile.sketch.face()
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class RaisedCheeseHeadScrew(Screw):
"""Raised Cheese Head Screw
ISO 7045 defines raised cheese head screws with a cylindrical, slightly domed head and a flat
underside. These screws combine the deep drive engagement and tall profile of standard cheese head
screws with a subtle domed surface for improved aesthetics and reduced edge sharpness. The result
is a screw that offers high torque capability while maintaining a more refined appearance.
Raised cheese head screws are typically available with slotted or cross-recessed (Phillips) drives,
and are used in mechanical assemblies, consumer electronics, and enclosures where clearance is
limited but drive reliability is important. The tall head allows for secure tool engagement, while
the rounded top helps reduce snagging and cosmetic impact in visible assemblies.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso7045"], optional): Defaults to "iso7045".
iso7045 - Cross recessed raised cheese head screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"raised_cheese_head_parameters.csv"
)
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso7045"] = "iso7045",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self) -> Face:
"""raised cheese head screws"""
(dk, k, rf) = (self.screw_data[p] for p in ["dk", "k", "rf"])
oval_height = rf - sqrt(4 * rf**2 - dk**2) / 2
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
Line((0, 0), (0, k))
RadiusArc((0, k), (dk / 2, k - oval_height), rf)
Polyline((dk / 2, k - oval_height), (dk / 2, 0), (0, 0))
make_face()
return profile.sketch.face()
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class RaisedCounterSunkOvalHeadScrew(Screw):
"""Raised CounterSunk Oval Head Screw
Raised countersunk screws—also known as oval head screws—feature a conical bearing surface like
standard countersunk screws, but with a gently domed top. This combination provides a flush fit
with a slightly protruding decorative finish, making them suitable for applications where appearance
and smooth contours are important.
Multiple ISO standards define oval head screws with different drive types:
- ISO 2010: Slotted raised countersunk head screws for general-purpose use.
- ISO 7047: Cross-recessed (Phillips) version for improved alignment and automation.
- ISO 14584: Hexalobular (Torx) drive version for high torque applications with reduced cam-out.
These screws are commonly used in electronics, appliance housings, mechanical assemblies, and
consumer products where flush mounting is required but a low-profile dome provides a more refined
look and reduced snagging.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso2010", "iso7047", "iso14584"], optional):
Defaults to "iso14584".
iso2010 - Slotted raised countersunk oval head screws
iso7047 - Cross recessed raised countersunk head screws
iso14584 - Hexalobular socket raised countersunk head screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"raised_countersunk_oval_head_parameters.csv"
)
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso2010", "iso7047", "iso14584"] = "iso14584",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def length_offset(self):
"""Raised countersunk oval head screws include the head but not oval
in the total length"""
return self.screw_data["k"]
def head_profile(self):
"""raised countersunk oval head screws"""
(a, k, rf, dk) = (self.screw_data[p] for p in ["a", "k", "rf", "dk"])
side_length = k / cos(radians(a / 2))
oval_height = rf - sqrt(4 * rf**2 - dk**2) / 2
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
l1 = Line((0, 0), (0, k + oval_height))
l2 = RadiusArc(l1 @ 1, (dk / 2, k), rf)
l3 = PolarLine(l2 @ 1, side_length, -90 - a / 2)
Line(l3 @ 1, (0, 0))
make_face()
fillet(
profile.vertices().group_by(Axis.Z)[-1].sort_by(Axis.X)[-1], k * 0.075
)
return profile.sketch.face()
head_recess = Screw.default_head_recess
def countersink_profile(self, fit: Literal["Close", "Normal", "Loose"]) -> Face:
"""A flat bottomed cone"""
(a, k, dk) = (self.screw_data[p] for p in ["a", "k", "dk"])
side_length = k / cos(radians(a / 2))
with BuildSketch(Plane.XZ) as profile:
with BuildLine():
l1 = Polyline((0, 0), (0, k), (dk / 2, k))
l2 = PolarLine(l1 @ 1, side_length, -90 - a / 2)
Line(l2 @ 1, (0, 0))
make_face()
return profile.sketch.face()
[docs]
class SetScrew(Screw):
"""Set Screw
ISO 4026 defines set screws with a flat point and a hexagon socket drive. These screws are fully
threaded and lack a head, allowing them to sit flush or recessed within a mating part. Set screws
are primarily used to secure one component against another—most commonly to fix a rotating part
such as a gear or pulley onto a shaft.
The flat point provides secure contact without damaging the mating surface, making it ideal for
use in applications where frequent adjustments or disassembly may be required. Set screws are
commonly used in mechanical assemblies, couplings, collars, and linkages where space is limited
and unobtrusive fastening is essential.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso4026"], optional): Defaults to "iso4026".
iso4026 - Hexagon socket set screws with flat point
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("setscrew_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso4026"] = "iso4026",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def custom_make(self):
"""Setscrews are custom builds"""
return self.make_setscrew()
def make_setscrew(self) -> Solid:
"""Construct set screw shape"""
(s, t) = (self.screw_data[p] for p in ["s", "t"])
thread = IsoThread(
major_diameter=self.thread_diameter * 1,
pitch=self.thread_pitch,
length=self.length,
external=True,
end_finishes=("fade", "fade"),
hand=self.hand,
simple=self.simple,
)
with BuildPart() as screw:
# Core
Cylinder(
thread.min_radius,
self.length,
align=(Align.CENTER, Align.CENTER, Align.MAX),
)
# if not self.simple:
# with Locations((0, 0, -self.length)):
# add(thread)
# Recess
with BuildSketch():
RegularPolygon(s / 2, 6, major_radius=False)
extrude(amount=-t, mode=Mode.SUBTRACT)
return screw.part.solid()
def make_head(self):
"""There is no head on a setscrew"""
return None
def countersink_profile(self, fit):
"""There is no head on a setscrew"""
return None
[docs]
class SocketHeadCapScrew(Screw):
"""Socket Head Cap Screw
Socket head cap screws are high-strength fasteners with a cylindrical head and an internal
hexagonal drive. Designed for use where external wrench clearance is limited, these screws are
tightened with a hex key (Allen wrench) and provide excellent torque transfer and holding power.
The tall head allows for deep socket engagement, reducing the risk of stripping.
- ISO 4762 specifies metric socket head cap screws for general and precision mechanical use.
- ASME B18.3 defines the inch-based counterpart, widely used in North American engineering standards.
Socket head cap screws are commonly used in machinery, robotics, automotive assemblies, and
structural applications where compactness and reliability are important. Their strong clamping force
and clean geometry make them ideal for pre-tapped holes and locations with tight access.
Args:
size (str): size specification, e.g. "M6-1"
length (float): screw length
fastener_type (Literal["iso4762","asme_b18.3"], optional): Defaults to "iso4762".
iso4762 - Hexagon socket head cap screws
asme_b18.3 - Imperial hexagon socket head cap screws
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("socket_head_cap_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["iso4762", "asme_b18.3"] = "iso4762",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self):
"""Socket Head Cap Screws"""
(dk, k) = (self.screw_data[p] for p in ["dk", "k"])
with BuildSketch(Plane.XZ) as profile:
Rectangle(dk / 2, k, align=Align.MIN)
fillet(
profile.vertices().group_by(Axis.Y)[-1].sort_by(Axis.X)[-1], k * 0.075
)
return profile.sketch.face()
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class LowProfileScrew(Screw):
"""Low Profile Screw
Low profile screws are specialized fasteners designed with a wide, flat, and shallow head that
minimizes protrusion above the mating surface. These screws are commonly used in aluminum
extrusion-based assemblies—such as those built with OpenBuilds V-slot and C-beam systems—where
clearance is limited and flush mounting is desired.
The large diameter head offers generous bearing area and good load distribution, while the low
height ensures smooth movement of linear components and avoids interference with adjacent parts.
These screws typically feature an internal hex drive and come in standard metric thread sizes (e.g., M5).
Low profile screws are ideal for use in DIY CNC machines, 3D printers, linear motion assemblies,
and other modular hardware systems where both mechanical performance and compactness are essential.
Args:
size (str): size specification, e.g. "M5-0.8"
length (float): screw length
fastener_type (Literal["OpenBuilds"], optional): Defaults to "OpenBuilds".
OpenBuilds - OpenBuilds custom low profile socket head cap screw
hand (Literal["right","left"], optional): thread direction. Defaults to "right".
simple (bool, optional): simplify by not creating thread. Defaults to True.
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.
"""
fastener_data = read_fastener_parameters_from_csv("low_profile_parameters.csv")
def __init__(
self,
size: str,
length: float,
fastener_type: Literal["OpenBuilds"] = "OpenBuilds",
hand: Literal["right", "left"] = "right",
simple: bool = True,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(
size,
length,
fastener_type,
hand,
simple,
rotation=rotation,
align=align,
mode=mode,
)
def head_profile(self):
"""Low Profile Screws"""
(dk, k) = (self.screw_data[p] for p in ["dk", "k"])
with BuildSketch(Plane.XZ) as profile:
SlotOverall(dk, 2)
Rectangle(dk, k, mode=Mode.INTERSECT)
split(bisect_by=Plane.YZ)
return profile.sketch.face().move(Pos(0, 0, k / 2))
head_recess = Screw.default_head_recess
countersink_profile = Screw.default_countersink_profile
[docs]
class Washer(ABC, BasePartObject):
"""Parametric Washer
Base class used to create standard washers
Args:
size (str): standard sizes - e.g. "M6"
fastener_type (str): type identifier - e.g. "iso4032"
Raises:
ValueError: invalid fastener_type
ValueError: invalid size
Each washer instance creates a set of properties that provide the Solid CAD object
as well as valuable parameters, as follows (values intended for internal use are not shown):
"""
_applies_to = [BuildPart._tag]
# Read clearance and tap hole dimesions tables
# Close, Normal, Loose
clearance_hole_drill_sizes = read_fastener_parameters_from_csv(
"clearance_hole_sizes.csv"
)
clearance_hole_data = lookup_drill_diameters(clearance_hole_drill_sizes)
@property
def clearance_hole_diameters(self):
"""A dictionary of drill diameters for clearance holes"""
try:
return self.clearance_hole_data[self.thread_size.split("-")[0]]
except KeyError as e:
raise ValueError(
f"No clearance hole data for size {self.thread_size}"
) from e
@property
@abstractmethod
def fastener_data(cls): # pragma: no cover
"""Each derived class must provide a fastener_data dictionary"""
return NotImplementedError
@abstractmethod
def washer_profile(self) -> Face: # pragma: no cover
"""Each derived class must provide the profile of the washer"""
return NotImplementedError
@property
def info(self):
"""Return identifying information"""
return f"{self.washer_class}({self.fastener_type}): {self.thread_size}"
@property
def washer_class(self):
"""Which derived class created this washer"""
return type(self).__name__
[docs]
@classmethod
def types(cls) -> list[str]:
"""Return a set of the washer types"""
return set(p.split(":")[0] for p in list(cls.fastener_data.values())[0].keys())
[docs]
@classmethod
def sizes(cls, fastener_type: str) -> list[str]:
"""Return a list of the washer sizes for the given type"""
return list(isolate_fastener_type(fastener_type, cls.fastener_data).keys())
@classmethod
def select_by_size(cls, size: str) -> dict:
"""Return a dictionary of list of fastener types of this size"""
return select_by_size_fn(cls, size)
@property
def washer_thickness(self) -> float:
"""Calculate the maximum thickness of the washer"""
return self.bounding_box().size.Z
@property
def washer_diameter(self):
"""Calculate the maximum diameter of the washer"""
radii = [(Vector(0, 0, v.Z) - Vector(v)).length for v in self.vertices()]
return 2 * max(radii)
def __init__(
self,
size: str,
fastener_type: str,
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
self.hole_locations: list[Location] = [] #: custom holes locations
self.washer_size = size
self.thread_size = size
self.is_metric = self.thread_size[0] == "M"
# Used only for clearance gap calculations
if self.is_metric:
self.thread_diameter = float(size[1:])
else:
self.thread_diameter = imperial_str_to_float(size)
if fastener_type not in self.types():
raise ValueError(f"{fastener_type} invalid, must be one of {self.types()}")
self.fastener_type = fastener_type
try:
self.washer_data = evaluate_parameter_dict(
isolate_fastener_type(self.fastener_type, self.fastener_data)[
self.thread_size
],
is_metric=self.is_metric,
)
except KeyError as e:
raise ValueError(
f"{size} invalid, must be one of {self.sizes(self.fastener_type)}"
) from e
bd_object = self.make_washer()
super().__init__(bd_object, rotation, align, mode)
self.label = f"{self.__class__.__name__}({size}, {fastener_type})"
self.color = Color(0xC0C0C0)
RigidJoint("a", self, Location())
RigidJoint("b", self, Pos(Z=self.washer_thickness))
def make_washer(self) -> Solid:
"""Create a screw head from the 2D shapes defined in the derived class"""
# Create the basic washer shape
# pylint: disable=no-member
return revolve(self.washer_profile()).solid()
def default_washer_profile(self) -> Face:
"""Create 2D profile of hex washers with double chamfers"""
(d1, d2, h) = (self.washer_data[p] for p in ["d1", "d2", "h"])
with BuildSketch(Plane.XZ) as profile:
with Locations((d1 / 2, 0)):
Rectangle((d2 - d1) / 2, h, align=Align.MIN)
return profile.sketch.face()
def default_countersink_profile(
self, fit: Literal["Close", "Normal", "Loose"]
) -> Face:
"""A simple rectangle with gets revolved into a cylinder"""
try:
clearance_hole_diameter = self.clearance_hole_diameters[fit]
except KeyError as e:
raise ValueError(
f"{fit} invalid, must be one of {list(self.clearance_hole_diameters.keys())}"
) from e
gap = clearance_hole_diameter - self.thread_diameter
(d2, h) = (self.washer_data[p] for p in ["d2", "h"])
with BuildSketch(Plane.XZ) as profile:
Rectangle(d2 / 2 + gap, h, align=Align.MIN)
return profile.sketch.face()
[docs]
class PlainWasher(Washer):
"""Plain Washer
Plain washers are flat, disc-shaped components used under the head of a screw or nut to distribute
the clamping load and protect the mating surface from damage. They also help reduce surface
deformation and prevent fasteners from loosening due to vibration or movement.
Several ISO standards define plain washers of different series:
- ISO 7089: Normal series washers with standard outer diameter and thickness.
- ISO 7091: Normal series washers with slightly tighter tolerances and more controlled flatness.
- ISO 7093: Large series washers with greater outer diameter for use with oversized or slotted holes.
- ISO 7094: Extra-large series washers, offering the greatest surface area and ideal for use with
soft materials or wide clearances.
These washers are used in nearly all types of bolted assemblies across mechanical, structural,
automotive, and industrial applications.
Args:
size (str): size specification, e.g. "M6"
fastener_type (Literal["iso7089", "iso7091", "iso7093", "iso7094"]):
iso7089 - Plain washers, Form A
iso7091 - Plain washers
iso7093 - Plain washers — Large series
iso7094 - Plain washers - Extra large series
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.
"""
def __init__(
self,
size: str,
fastener_type: Literal["iso7089", "iso7091", "iso7093", "iso7094"],
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, rotation, align, mode)
fastener_data = read_fastener_parameters_from_csv("plain_washer_parameters.csv")
washer_profile = Washer.default_washer_profile
countersink_profile = Washer.default_countersink_profile
[docs]
class ChamferedWasher(Washer):
"""Chamfered Washer
ISO 7090 defines chamfered washers, which are plain washers with a conical or beveled underside
designed to match the chamfer on fasteners such as countersunk or chamfered-head bolts. The chamfer
provides full surface contact between the washer and fastener head, improving load distribution and
alignment, especially in high-stress or structural applications.
These washers help prevent damage to the mating surface by reducing point loading and ensuring
even contact. They are typically used with screws or bolts that have a 120° chamfer under the head—
such as ISO 7379 shoulder screws—or in assemblies requiring enhanced axial alignment and bearing
surface support.
Chamfered washers are commonly found in heavy machinery, construction, tooling fixtures, and
high-precision assemblies where joint integrity is critical.
Args:
size (str): size specification, e.g. "M6"
fastener_type (Literal["iso7090"], optional): Defaults to "iso7090".
iso7090 - Plain washers, Form B
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.
"""
fastener_data = read_fastener_parameters_from_csv("chamfered_washer_parameters.csv")
def __init__(
self,
size: str,
fastener_type: Literal["iso7090"] = "iso7090",
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, rotation, align, mode)
def washer_profile(self) -> Face:
"""Create 2D profile of hex washers with double chamfers"""
(d1, d2, h) = (self.washer_data[p] for p in ["d1", "d2", "h"])
with BuildSketch(Plane.XZ) as profile:
with Locations((d1 / 2, 0)):
Rectangle((d2 - d1) / 2, h, align=Align.MIN)
chamfer(
profile.vertices().group_by(Axis.Y)[-1].sort_by(Axis.X)[-1], 0.25 * h
)
return profile.sketch.face()
countersink_profile = Washer.default_countersink_profile
[docs]
class CheeseHeadWasher(Washer):
"""Cheese Head Washer
ISO 7092 defines plain washers specifically designed for use with cheese head screws. These washers
have a reduced outer diameter and a thickness tailored to the narrow, cylindrical profile of cheese
head fasteners, ensuring proper seating and load distribution under the head without protruding
beyond its edges.
Cheese head washers help prevent surface damage, distribute clamping forces more evenly, and improve
appearance in precision assemblies. Their compact size makes them ideal for use in electronics,
instrumentation, and machine components where space is limited and a clean, flush appearance is desired.
These washers are especially useful when paired with ISO 1207 or ISO 14580 cheese head screws in
counterbored holes or recessed applications.
Args:
size (str): size specification, e.g. "M6"
fastener_type (Literal["iso7092"], optional): Defaults to "iso7092".
iso7092 - Washers for cheese head screws
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"cheese_head_washer_parameters.csv"
)
def __init__(
self,
size: str,
fastener_type: Literal["iso7092"] = "iso7092",
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, rotation, align, mode)
def washer_profile(self) -> Face:
"""Create 2D profile of hex washers with double chamfers"""
(d1, d2, h) = (self.washer_data[p] for p in ["d1", "d2", "h"])
with BuildSketch(Plane.XZ) as profile:
with Locations((d1 / 2, 0)):
Rectangle((d2 - d1) / 2, h, align=Align.MIN)
chamfer(profile.vertices().group_by(Axis.X)[0], 0.25 * h)
return profile.sketch.face()
countersink_profile = Washer.default_countersink_profile
class InternalToothLockWasher(Washer):
"""Internal Tooth Lock Washer
Internal tooth lock washers are circular washers with multiple sharp, radially inward-facing teeth
designed to bite into the surface of the mating part and the underside of the fastener head. This
toothed profile increases friction and provides mechanical resistance to loosening caused by
vibration or rotation.
- DIN 6797 defines metric internal tooth lock washers commonly used in precision electrical and
mechanical assemblies.
- ASME B18.21.1 provides similar specifications for inch-based applications and general-purpose use.
Because the teeth are located on the inner circumference, internal tooth lock washers are ideal
for use under round or pan head screws, where edge clearance is limited or appearance is important.
They are commonly used in electronics, appliance housings, and light-duty machinery to maintain
tight assemblies without chemical threadlockers or locking nuts.
Args:
size (str): size specification, e.g. "M6"
fastener_type (Literal["din6797", "asme_b18.21.1"], optional): Defaults to "din6797".
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.
"""
fastener_data = read_fastener_parameters_from_csv(
"internal_tooth_lock_washer_parameters.csv"
)
def __init__(
self,
size: str,
fastener_type: Literal["din6797", "asme_b18.21.1"] = "din6797",
rotation: RotationLike = (0, 0, 0),
align: Union[None, Align, tuple[Align, Align, Align]] = None,
mode: Mode = Mode.ADD,
):
super().__init__(size, fastener_type, rotation, align, mode)
def make_washer(self):
(d1, d2, h) = (self.washer_data[p] for p in ["d1", "d2", "h"])
n = round(self.washer_data["n"] / (IN if not self.is_metric else MM))
# tooth outer diameter
dt = d1 + (d2 - d1) / 2
# tooth arc angle
at = ({6: 30, 8: 27, 9: 22.5, 10: 20, 12: 20, 14: 16, 16: 12}).get(n)
# tooth inner and outer widths
w1, w2 = (d1 * pi * at / 360, dt * pi * at / 360)
# solve for angle to rotate inner face of tooth -> overall height == 2*h per DIN 6797
# +---------+
# | | h
# +---------+
# w1
# ref: https://math.stackexchange.com/questions/213545/solving-trigonometric-equations-of-the-form-a-sin-x-b-cos-x-c
a, b = w1 / 2, h / 2
c, d = h, sqrt((w1 / 2) ** 2 + (h / 2) ** 2 - h**2)
angle = (-atan(d / c) + atan(a / b)) * 180 / pi
# tooth inner and outer faces pre projection
f1 = (Rot(Y=angle) * (Plane.XZ * Rectangle(w1, h))).faces()[0]
f2 = (Plane.XZ * Rectangle(w2, h)).faces()[0]
return Pos(Z=h / 2) * (
Cylinder(d2 / 2, h)
- Cylinder(dt / 2, h, mode=Mode.SUBTRACT)
+ PolarLocations(0, n)
* loft(
[
f1.project_to_shape(Cylinder(d1 / 2, 10 * h), (0, -1, 0)),
f2.project_to_shape(Cylinder(dt / 2, 10 * h), (0, -1, 0)),
]
)
)
washer_profile = Washer.default_washer_profile
countersink_profile = Washer.default_countersink_profile
#
# Holes
#
def _make_fastener_hole(
hole_diameters: dict,
fastener: Union[Nut, Screw],
countersink_profile: Face,
depth: float,
fit: Literal["Close", "Normal", "Loose"] = None,
material: Literal["Soft", "Hard"] = None,
counter_sunk: bool = True,
captive_nut: bool = False,
threaded_hole: bool = False,
update_hole_locations: bool = False,
) -> Part:
"""_make_fastener_hole
Makes a counterbore clearance, tap or threaded hole for the given screw for each item
on the stack. The surface of the hole is at the current workplane.
Args:
hole_diameters (dict): either clearance or tap hole diameter specifications
fastener (Union[Nut, Screw]): A nut or screw instance
countersink_profile (Face): the 2D side profile of the fastener (not including a
screw's shaft)
depth (float): hole depth
fit (Literal["Close", "Normal", "Loose"], optional): determines clearance hole
diameter. Defaults to None.
material (Literal["Soft", "Hard"], optional): determines tap hole size.
Defaults to None.
counter_sunk (bool, optional): Is the fastener countersunk into the part?.
Defaults to True.
captive_nut (bool, optional): Countersink with a rectangular, filleted, hole..
Defaults to False.
threaded_hole (bool, optional): Does the hole have threads. Defaults to False.
update_hole_locations (bool, optional): If in Builder mode. Defaults to False.
Raises:
ValueError: fit or material not in hole_diameters dictionary
Returns:
Part: the hole to be subtracted from the base object
"""
bore_direction = Vector(0, 0, -1)
origin = Vector(0, 0, 0)
# Setscrews' countersink_profile is None so check if it exists
# countersink_profile = fastener.countersink_profile(fit)
if captive_nut:
clearance = fastener.clearance_hole_diameters[fit] - fastener.thread_diameter
head_offset = countersink_profile.vertices().sort_by(Axis.Z)[-1].Z
if isinstance(fastener, (DomedCapNut, HexNut, UnchamferedHexagonNut)):
fillet_radius = fastener.nut_diameter / 4
rect_width = fastener.nut_diameter + clearance
rect_height = fastener.nut_diameter * math.sin(math.pi / 3) + clearance
elif isinstance(fastener, SquareNut):
fillet_radius = fastener.nut_diameter / 8
rect_height = fastener.nut_diameter * math.sqrt(2) / 2 + clearance
rect_width = rect_height + 2 * fillet_radius + clearance
with BuildPart(mode=Mode.PRIVATE) as countersink_cutter_builder:
with BuildSketch():
RectangleRounded(rect_width, rect_height, fillet_radius)
extrude(amount=-head_offset)
countersink_cutter = countersink_cutter_builder.part
elif counter_sunk and not countersink_profile is None:
head_offset = countersink_profile.vertices().sort_by(Axis.Z)[-1].Z
countersink_cutter = revolve(countersink_profile, mode=Mode.PRIVATE).moved(
Pos(0, 0, -head_offset)
)
else:
head_offset = 0
if threaded_hole:
hole_radius = fastener.thread_diameter / 2
else:
key = fit if material is None else material
try:
hole_radius = hole_diameters[key] / 2
except KeyError as e:
raise ValueError(
f"{key} invalid, must be one of {list(hole_diameters.keys())}"
) from e
shank_hole = Solid.make_cylinder(
radius=hole_radius, height=depth, plane=Plane(origin, z_dir=bore_direction)
)
if counter_sunk and not countersink_profile is None:
fastener_hole = countersink_cutter.fuse(shank_hole)
else:
fastener_hole = shank_hole
csk_angle = 180 - 82 # 82 is a common tip angle
h = hole_radius / math.tan(math.radians(csk_angle / 2.0))
drill_tip = Solid.make_cone(
hole_radius, 0.0, h, plane=Plane(bore_direction * depth, z_dir=bore_direction)
)
fastener_hole = fastener_hole.fuse(drill_tip)
# Update the hole location list for this fastener
# Countersunk screws shouldn't be lowered into the hole
if isinstance(fastener, (CounterSunkScrew, RaisedCounterSunkOvalHeadScrew)):
head_offset -= head_offset
if update_hole_locations:
fastener.hole_locations.extend(
[
location * Pos(0, 0, -head_offset)
for location in LocationList._get_context().locations
]
)
return fastener_hole
[docs]
class ClearanceHole(BasePartObject):
"""Part Object: ClearanceHole
A clearance hole is a hole that is drilled through one of two (or more) parts to be
assembled together with a screw or bolt. The diameter of the clearance hole is larger
than the diameter of the screw or bolt so that it can pass through freely without
engaging the threads. The purpose of a clearance hole is to ensure that the screw
or bolt can be tightened into a threaded hole on another part without being obstructed
by the part it passes through. This allows the head of the screw or bolt to clamp
the parts together.
Args:
fastener (Union[Nut, Screw]): A nut or screw instance
fit (Optional[Literal["Close", "Normal", "Loose"]], optional): Control hole diameter.
Defaults to "Normal".
depth (float, optional): hole depth - None implies through part. Defaults to None.
counter_sunk (bool, optional): Is the fastener countersunk into the part?.
Defaults to True.
captive_nut (bool, optional): Is rotation of the nut disabled?. Defaults to False.
rotation (RotationLike, optional): object rotation. Defaults to (0, 0, 0).
mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT.
Raises:
ValueError: Use InsertHole for HeatSetNut
ValueError: Invalid nut given
ValueError: No depth provided
"""
_applies_to = [BuildPart._tag]
def __init__(
self,
fastener: Union[Nut, Screw],
fit: Literal["Close", "Normal", "Loose"] = "Normal",
depth: float = None,
counter_sunk: bool = True,
captive_nut: bool = False,
rotation: RotationLike = (0, 0, 0),
mode: Mode = Mode.SUBTRACT,
):
context: BuildPart = BuildPart._get_context(self)
validate_inputs(context, self)
if isinstance(fastener, HeatSetNut):
raise ValueError(
"ClearanceHole doesn't accept fasteners of type HeatSetNut - use insertHole instead"
)
if captive_nut and not isinstance(
fastener, (DomedCapNut, HexNut, UnchamferedHexagonNut, SquareNut)
):
raise ValueError(
"Only DomedCapNut, HexNut, UnchamferedHexagonNut or SquareNut can be captive"
)
if depth is not None:
self.hole_depth = depth
elif depth is None and context is not None:
self.hole_depth = 2 * context.max_dimension
else:
raise ValueError("No depth provided")
hole_part = _make_fastener_hole(
hole_diameters=fastener.clearance_hole_diameters,
fastener=fastener,
countersink_profile=fastener.countersink_profile(fit),
depth=self.hole_depth,
fit=fit,
counter_sunk=counter_sunk,
captive_nut=captive_nut,
update_hole_locations=context is not None,
)
super().__init__(
part=hole_part,
align=None,
rotation=rotation,
mode=mode,
)
[docs]
class TapHole(BasePartObject):
"""Part Object: TapHole
A tap hole is precisely drilled through a component, sized optimally for a subsequent
operation where a tap is employed to cut threads into the material. The diameter of
this pre-drilled hole is critical and is influenced by the specifications of the
fastener that will be threaded into it, as well as the properties of the base
material. This preparatory step ensures that the final tapped hole accurately
accommodates the intended fastener, providing a secure and reliable fit.
Args:
fastener (Union[Nut, Screw]): A nut or screw instance
material (Literal["Soft", "Hard"], optional): Determines tap hole size.
Defaults to "Soft".
fit (Optional[Literal["Close", "Normal", "Loose"]], optional): Control hole diameter
for a countersunk fastener head. Defaults to "Normal".
depth (float, optional): hole depth - None implies through part. Defaults to None.
counter_sunk (bool, optional): Is the fastener countersunk into the part?.
Defaults to True.
mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT.
Raises:
ValueError: Use InsertHole for HeatSetNut
ValueError: No depth provided
"""
_applies_to = [BuildPart._tag]
def __init__(
self,
fastener: Union[Nut, Screw],
material: Literal["Soft", "Hard"] = "Soft",
fit: Literal["Close", "Normal", "Loose"] = "Normal",
depth: float = None,
counter_sunk: bool = True,
mode: Mode = Mode.SUBTRACT,
):
context: BuildPart = BuildPart._get_context(self)
validate_inputs(context, self)
if isinstance(fastener, HeatSetNut):
raise ValueError(
"TapHole doesn't accept fasteners of type HeatSetNut - use insertHole instead"
)
if depth is not None:
self.hole_depth = depth
elif depth is None and context is not None:
self.hole_depth = 2 * context.max_dimension
else:
raise ValueError("No depth provided")
hole_part = _make_fastener_hole(
hole_diameters=fastener.tap_hole_diameters,
fastener=fastener,
countersink_profile=fastener.countersink_profile(fit),
depth=self.hole_depth,
fit=fit,
material=material,
counter_sunk=counter_sunk,
update_hole_locations=context is not None,
)
super().__init__(
part=hole_part,
align=None,
rotation=(0, 0, 0),
mode=mode,
)
[docs]
class ThreadedHole(BasePartObject):
"""Part Object: ThreadedHole
A threaded hole refers to a hole that has been provided with internal threads,
either through cutting (tapping) or forming, to allow a bolt or screw to be
screwed into it. Threaded holes are used in a wide range of applications where
secure fastening is required. They can be found in various sizes and thread
patterns to match the corresponding screws or bolts.
Args:
fastener (Union[Nut, Screw]): A nut or screw instance
material (Literal["Soft", "Hard"], optional): Determines tap hole size.
Defaults to "Soft".
fit (Optional[Literal["Close", "Normal", "Loose"]], optional): Control hole diameter.
Defaults to "Normal".
depth (float, optional): hole depth - None implies through part. Defaults to None.
counter_sunk (bool, optional): Is the fastener countersunk into the part?.
Defaults to True.
simple (bool, optional): simplify by not creating thread. Defaults to True.
mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT.
Raises:
ValueError: Use InsertHole for HeatSetNut
ValueError: No depth provided
"""
_applies_to = [BuildPart._tag]
def __init__(
self,
fastener: Union[Nut, Screw],
material: Literal["Soft", "Hard"] = "Soft",
fit: Literal["Close", "Normal", "Loose"] = "Normal",
depth: float = None,
counter_sunk: bool = True,
simple: bool = True,
mode: Mode = Mode.SUBTRACT,
):
context: BuildPart = BuildPart._get_context(self)
validate_inputs(context, self)
if isinstance(fastener, HeatSetNut):
raise ValueError(
"ThreadedHole doesn't accept fasteners of type HeatSetNut - use InsertHole instead"
)
if depth is not None:
self.hole_depth = depth
elif depth is None and context is not None:
self.hole_depth = 2 * context.max_dimension
else:
raise ValueError("No depth provided")
hole_part = _make_fastener_hole(
hole_diameters=fastener.clearance_hole_diameters,
fastener=fastener,
countersink_profile=fastener.countersink_profile(fit),
depth=self.hole_depth,
fit=fit,
material=material,
counter_sunk=counter_sunk,
threaded_hole=True,
update_hole_locations=context is not None,
)
if not simple:
with BuildPart(mode=Mode.PRIVATE):
thread = IsoThread(
major_diameter=fastener.thread_diameter + 0.01,
pitch=fastener.thread_pitch,
length=min(fastener.length, self.hole_depth),
external=False,
end_finishes=("fade", "fade"),
hand=fastener.hand,
).move(Pos(Z=-self.hole_depth))
super().__init__(
part=hole_part,
align=None,
rotation=(0, 0, 0),
mode=mode,
)
self.thread = None if simple else thread
self.thread_locations = LocationList._get_context().locations
[docs]
class InsertHole(BasePartObject):
"""Part Object: InsertHole
An insert hole is precisely sized to accommodate a heat-set nut, which is embedded
into the base component. This process creates a robust connection with the base
material, offering a durable metal threaded interface for the fastener. Heat-set
nuts are particularly favored for use with 3D-printed objects, as they ensure
strong and reliable connections between components, enhancing the structural
integrity of the assembled parts.
Args:
fastener (Union[Nut, Screw]): A nut or screw instance
fit (Optional[Literal["Close", "Normal", "Loose"]], optional): Control hole diameter.
Defaults to "Normal".
depth (float, optional): hole depth - None implies through part. Defaults to None.
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.
mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT.
Raises:
ValueError: Use InsertHole for HeatSetNut
ValueError: No depth provided
"""
_applies_to = [BuildPart._tag]
def __init__(
self,
fastener: HeatSetNut,
fit: Literal["Close", "Normal", "Loose"] = "Normal",
depth: float = None,
manufacturing_compensation: float = 0.0,
mode: Mode = Mode.SUBTRACT,
):
context: BuildPart = BuildPart._get_context(self)
validate_inputs(context, self)
if depth is not None:
self.hole_depth = depth
elif depth is None and context is not None:
self.hole_depth = 2 * context.max_dimension
else:
raise ValueError("No depth provided")
hole_part = _make_fastener_hole(
hole_diameters=fastener.clearance_hole_diameters,
fastener=fastener,
countersink_profile=fastener.countersink_profile(
manufacturing_compensation
),
depth=self.hole_depth,
fit=fit,
update_hole_locations=context is not None,
)
super().__init__(
part=hole_part,
align=None,
rotation=(0, 0, 0),
mode=mode,
)