Source code for bosl2.nema_steppers

# 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: bosl2/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. :meth:`NemaSteppers.nema_stepper_motor`
#    builds a motor (body + plinth + shaft + blind screw holes) for a NEMA size; :meth:`~NemaSteppers.
#    nema_mount_mask` is the bolt-pattern-plus-plinth cutout; :meth:`~NemaSteppers.nema_motor_info`
#    returns the standard dimensions as a :class:`NemaSpec`.
#
# FileSummary: NEMA stepper-motor models and mounting masks.
# FileGroup: BOSL2

from __future__ import annotations

from dataclasses import dataclass

from bosl2._helpers import union
from bosl2.shapes3d import Bosl2Solid, cuboid, cyl

__all__ = ["NemaSteppers", "NemaSpec"]


def _union(shapes):
    return union(shapes)


@dataclass(frozen=True)
class NemaSpec:
    """Standard dimensions of a NEMA stepper motor (BOSL2 nema_motor_info())."""

    motor_width: float  # body cross-section (square)
    plinth_height: float  # raised boss around the shaft
    plinth_diam: float
    screw_spacing: float  # centre-to-centre of the mounting holes
    screw_size: float
    screw_depth: float
    shaft_diam: float


# NEMA size -> spec, transcribed from nema_steppers.scad.
_NEMA = {
    6: NemaSpec(14.0, 1.50, 11.0, 11.50, 1.6, 2.5, 4.00),
    8: NemaSpec(20.3, 1.50, 16.0, 15.40, 2.0, 2.5, 4.00),
    11: NemaSpec(28.2, 1.50, 22.0, 23.11, 2.6, 3.0, 5.00),
    14: NemaSpec(35.2, 2.00, 22.0, 26.00, 3.0, 4.5, 5.00),
    17: NemaSpec(42.3, 2.00, 22.0, 31.00, 3.0, 4.5, 5.00),
    23: NemaSpec(57.0, 1.60, 38.1, 47.00, 5.1, 4.8, 6.35),
    34: NemaSpec(86.0, 2.00, 73.0, 69.60, 6.5, 10.0, 14.00),
    42: NemaSpec(110.0, 1.50, 55.5, 88.90, 8.5, 12.7, 19.00),
}


[docs] class NemaSteppers: """NEMA stepper-motor models and mounting masks (BOSL2 nema_steppers.scad)."""
[docs] @staticmethod def nema_motor_info(size: int) -> NemaSpec: """ The :class:`NemaSpec` for a NEMA *size* (6, 8, 11, 14, 17, 23, 34 or 42) (BOSL2 nema_motor_info()). """ try: return _NEMA[int(size)] except (KeyError, ValueError): raise ValueError(f"Unsupported NEMA size: {size!r}")
[docs] @staticmethod def nema_stepper_motor( size: int = 17, height: float = 24, shaft_len: float = 20, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A model of a NEMA *size* stepper motor (BOSL2 nema_stepper_motor()). The motor's mounting face is at ``z = 0`` with the body below it and the plinth and *shaft_len* shaft projecting up; the four mounting holes are drilled into the face. Examples: A NEMA 17 motor: .. pythonscad-example:: from bosl2.nema_steppers import NemaSteppers NemaSteppers.nema_stepper_motor(size=17).show() """ s = NemaSteppers.nema_motor_info(size) if size < 23: body = cuboid( [s.motor_width, s.motor_width, height], chamfer=2 if size >= 8 else 0.5, edges="Z", ) else: body = cuboid([s.motor_width, s.motor_width, height], rounding=s.screw_size, edges="Z") body = body.down(height / 2) # mounting face at z=0, body below for sx in (-1, 1): for sy in (-1, 1): # blind mounting holes at the corners 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=shaft_len, diameter=s.shaft_diam, fn=fn, fa=fa, fs=fs).up(shaft_len / 2) return Bosl2Solid( (body | plinth | shaft).shape, size=[s.motor_width, s.motor_width, height + shaft_len], )
[docs] @staticmethod def nema_mount_mask( size: int, depth: float = 5, length: float = 5, atype: str = "full", slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """The mounting cutout for a NEMA *size* motor -- difference it from a plate (BOSL2 nema_mount_mask()). Cuts the four screw holes and (``atype="full"``) the central plinth clearance. A slot length *length* > 0 elongates each hole so the motor can be positioned (e.g. to tension a belt). """ s = NemaSteppers.nema_motor_info(size) pd = s.plinth_diam + slop sz = s.screw_size + slop ss = s.screw_spacing def slotted(d: float, cx: float = 0.0, cy: float = 0.0): if length > 0: return [ cyl(height=depth, diameter=d, fn=fn, fa=fa, fs=fs).back(length / 2).right(cx).back(cy), cyl(height=depth, diameter=d, fn=fn, fa=fa, fs=fs).forward(length / 2).right(cx).back(cy), cuboid([d, length, depth]).right(cx).back(cy), ] return [cyl(height=depth, diameter=d, fn=fn, fa=fa, fs=fs).right(cx).back(cy)] parts = [] for sx in (-1, 1): for sy in (-1, 1): parts += slotted(sz, sx * ss / 2, sy * ss / 2) if atype == "full": parts += slotted(pd) elif atype != "screws": raise ValueError('nema_mount_mask(): atype must be "full" or "screws".') w = ss + sz + (length if length > 0 else 0) return Bosl2Solid(_union(parts).shape, size=[ss + sz, w, depth])