# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: pybosl2/parts/nema_steppers.py
# Pure-Python port of BOSL2's nema_steppers.scad: models of NEMA-standard stepper motors and the
# masks that cut their mounting-hole pattern into a plate. :class:`NemaMotor`
# builds a motor (body + plinth + shaft + blind screw holes) for a NEMA size; :class:`NemaMountMask`
# is the bolt-pattern-plus-plinth cutout; :class:`NemaSpec`
# returns the standard dimensions.
#
# FileSummary: NEMA stepper-motor models and mounting masks.
# DocCategory: Parts library
# FileGroup: BOSL2
"""NEMA stepper-motor models and mounting masks."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pybosl2._edges_lang import Anchor
from pybosl2._helpers import union
from pybosl2.shapes3d import Bosl2Solid, cuboid, cyl
__all__ = ["NemaMotor", "NemaMountMask", "NemaMaskType", "NemaSpec"]
[docs]
class NemaMaskType(StrEnum):
"""Mounting mask cutout type for NEMA stepper motors."""
FULL = "full"
SCREWS = "screws"
def _union(shapes: list[Bosl2Solid]) -> Bosl2Solid:
return union(shapes) # type: ignore[no-any-return]
[docs]
@dataclass(frozen=True)
class NemaSpec:
"""Standard dimensions of a NEMA stepper motor (BOSL2 nema_motor_info()).
Construct with the NEMA size directly: ``NemaSpec(17)``.
"""
motor_width: float
plinth_height: float
plinth_diam: float
screw_spacing: float
screw_size: float
screw_depth: float
shaft_diam: float
def __init__(self, size: int) -> None:
"""Look up the NEMA motor dimensions for the given size.
Args:
size: NEMA motor frame size (6, 8, 11, 14, 17, 23, 34, or 42).
Returns:
None
Raises:
ValueError: If the size is not one of the supported NEMA sizes.
"""
try:
spec = _NEMA[int(size)]
except (KeyError, ValueError):
raise ValueError(f"Unsupported NEMA size: {size!r}") from None
object.__setattr__(self, "motor_width", spec.motor_width)
object.__setattr__(self, "plinth_height", spec.plinth_height)
object.__setattr__(self, "plinth_diam", spec.plinth_diam)
object.__setattr__(self, "screw_spacing", spec.screw_spacing)
object.__setattr__(self, "screw_size", spec.screw_size)
object.__setattr__(self, "screw_depth", spec.screw_depth)
object.__setattr__(self, "shaft_diam", spec.shaft_diam)
@dataclass(frozen=True)
class _NemaSpecRaw:
"""Internal storage for NEMA dimension tables (used before NemaSpec is constructed)."""
motor_width: float
plinth_height: float
plinth_diam: float
screw_spacing: float
screw_size: float
screw_depth: float
shaft_diam: float
# NEMA size -> spec, transcribed from nema_steppers.scad.
_NEMA = {
6: _NemaSpecRaw(14.0, 1.50, 11.0, 11.50, 1.6, 2.5, 4.00),
8: _NemaSpecRaw(20.3, 1.50, 16.0, 15.40, 2.0, 2.5, 4.00),
11: _NemaSpecRaw(28.2, 1.50, 22.0, 23.11, 2.6, 3.0, 5.00),
14: _NemaSpecRaw(35.2, 2.00, 22.0, 26.00, 3.0, 4.5, 5.00),
17: _NemaSpecRaw(42.3, 2.00, 22.0, 31.00, 3.0, 4.5, 5.00),
23: _NemaSpecRaw(57.0, 1.60, 38.1, 47.00, 5.1, 4.8, 6.35),
34: _NemaSpecRaw(86.0, 2.00, 73.0, 69.60, 6.5, 10.0, 14.00),
42: _NemaSpecRaw(110.0, 1.50, 55.5, 88.90, 8.5, 12.7, 19.00),
}
[docs]
class NemaMotor:
"""A model of a NEMA stepper motor.
The motor's mounting face is at ``z = 0`` with the body below it and the
plinth and shaft projecting up; the four mounting holes are drilled into
the face.
Examples:
A NEMA 17 motor:
.. pythonscad-example::
from pybosl2.parts.nema_steppers import NemaMotor
NemaMotor(size=17).show()
"""
def __init__(
self,
size: int = 17,
height: float = 24,
shaft_len: float = 20,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a NEMA stepper motor model.
Args:
size: NEMA motor frame size (6, 8, 11, 14, 17, 23, 34, or 42). Defaults to 17.
height: Motor body height in mm. Defaults to 24.
shaft_len: Shaft projection length in mm. Defaults to 20.
fn: Number of fragments for cylinder resolution. Passed to the geometry primitives.
fa: Minimum fragment angle. Passed to the geometry primitives.
fs: Minimum fragment size. Passed to the geometry primitives.
Returns:
None
"""
self._spec: NemaSpec = NemaSpec(size)
self._height: float = height
self._shaft_len: float = shaft_len
self._size: int = size
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._solid: Bosl2Solid | None = None
@property
def spec(self) -> NemaSpec:
"""The resolved :class:`NemaSpec`."""
return self._spec
@property
def size(self) -> int:
"""NEMA size (6, 8, 11, 14, 17, 23, 34 or 42)."""
return self._size
@property
def height(self) -> float:
"""Motor body height in mm."""
return self._height
@property
def shaft_len(self) -> float:
"""Shaft projection length in mm."""
return self._shaft_len
[docs]
def shape(self) -> Bosl2Solid:
"""Build and return the motor geometry (cached)."""
if self._solid is not None:
return self._solid
s = self._spec
ssz = self._size
fn, fa, fs = self._fn, self._fa, self._fs
if ssz < 23:
body = cuboid(
[s.motor_width, s.motor_width, self._height],
chamfer=2 if ssz >= 8 else 0.5,
edges=Anchor.Z,
fn=fn,
fa=fa,
fs=fs,
)
else:
body = cuboid(
[s.motor_width, s.motor_width, self._height],
rounding=s.screw_size,
edges=Anchor.Z,
fn=fn,
fa=fa,
fs=fs,
)
body = body.down(self._height / 2)
for sx in (-1, 1):
for sy in (-1, 1):
hole = (
cyl(height=s.screw_depth * 2, diameter=s.screw_size, fn=fn, fa=fa, fs=fs)
.right(sx * s.screw_spacing / 2)
.back(sy * s.screw_spacing / 2)
)
body = body - hole
plinth = cyl(height=s.plinth_height, diameter=s.plinth_diam, fn=fn, fa=fa, fs=fs).up(s.plinth_height / 2) - cyl(
height=s.plinth_height * 3,
diameter=s.shaft_diam + 0.75,
fn=fn,
fa=fa,
fs=fs,
)
shaft = cyl(height=self._shaft_len, diameter=s.shaft_diam, fn=fn, fa=fa, fs=fs).up(self._shaft_len / 2)
self._solid = Bosl2Solid(
(body | plinth | shaft).shape,
size=[s.motor_width, s.motor_width, self._height + self._shaft_len],
)
return self._solid
[docs]
def show(self) -> None:
"""Display the motor in the viewer."""
self.shape().show()
[docs]
class NemaMountMask:
"""The mounting cutout for a NEMA stepper motor -- difference it from a plate.
Cuts the four screw holes and (``atype=NemaMaskType.FULL``) the central plinth
clearance. A slot *length* > 0 elongates each hole so the motor can be
positioned (e.g. to tension a belt).
Examples:
A NEMA 17 mount mask:
.. pythonscad-example::
from pybosl2.parts.nema_steppers import NemaMountMask, NemaMaskType
NemaMountMask(size=17, atype=NemaMaskType.FULL).show()
"""
def __init__(
self,
size: int,
depth: float = 5,
length: float = 5,
atype: NemaMaskType = NemaMaskType.FULL,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a NEMA mounting mask cutout.
Args:
size: NEMA motor frame size (6, 8, 11, 14, 17, 23, 34, or 42).
depth: Depth of the mask cutout in mm. Defaults to 5.
length: Slot elongation length in mm; values > 0 create slots instead of round holes. Defaults to 5.
atype: Mask cutout type, either FULL (holes + plinth clearance) or SCREWS (holes only). Defaults to FULL.
slop: Additional clearance added to hole diameters. Defaults to 0.0.
fn: Number of fragments for cylinder resolution. Passed to the geometry primitives.
fa: Minimum fragment angle. Passed to the geometry primitives.
fs: Minimum fragment size. Passed to the geometry primitives.
Returns:
None
Raises:
ValueError: If atype is not NemaMaskType.FULL or NemaMaskType.SCREWS.
"""
if atype not in (NemaMaskType.FULL, NemaMaskType.SCREWS):
raise ValueError(f"nema_mount_mask: atype must be FULL or SCREWS, got {atype!r}")
self._spec: NemaSpec = NemaSpec(size)
self._depth: float = depth
self._length: float = length
self._atype: NemaMaskType = atype
self._slop: float = slop
self._size: int = size
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._solid: Bosl2Solid | None = None
@property
def spec(self) -> NemaSpec:
"""The resolved :class:`NemaSpec`."""
return self._spec
@property
def size(self) -> int:
"""NEMA size."""
return self._size
@property
def mask_type(self) -> NemaMaskType:
"""Mask cutout type."""
return self._atype
[docs]
def shape(self) -> Bosl2Solid:
"""Build and return the mount mask geometry (cached)."""
if self._solid is not None:
return self._solid
s = self._spec
fn, fa, fs = self._fn, self._fa, self._fs
pd = s.plinth_diam + self._slop
sz = s.screw_size + self._slop
ss = s.screw_spacing
def slotted(d: float, cx: float = 0.0, cy: float = 0.0) -> list[Bosl2Solid]:
if self._length > 0:
return [
cyl(height=self._depth, diameter=d, fn=fn, fa=fa, fs=fs).back(self._length / 2).right(cx).back(cy),
cyl(height=self._depth, diameter=d, fn=fn, fa=fa, fs=fs)
.forward(self._length / 2)
.right(cx)
.back(cy),
cuboid([d, self._length, self._depth], fn=fn, fa=fa, fs=fs).right(cx).back(cy),
]
return [cyl(height=self._depth, diameter=d, fn=fn, fa=fa, fs=fs).right(cx).back(cy)]
parts: list[Bosl2Solid] = []
for sx in (-1, 1):
for sy in (-1, 1):
parts += slotted(sz, sx * ss / 2, sy * ss / 2)
if self._atype == NemaMaskType.FULL:
parts += slotted(pd)
elif self._atype != NemaMaskType.SCREWS:
raise ValueError(f"nema_mount_mask: atype must be FULL or SCREWS, got {self._atype!r}")
w = ss + sz + (self._length if self._length > 0 else 0)
self._solid = Bosl2Solid(_union(parts).shape, size=[ss + sz, w, self._depth])
return self._solid
[docs]
def show(self) -> None:
"""Display the mount mask in the viewer."""
self.shape().show()