Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 40 additions & 25 deletions docs/nema-motors.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
# NEMA stepper motors

`mp.string("nema8").json()`, `nema17`, and `nema23` resolve to `fn: "nema"`
and `nemaSize: 8 | 17 | 23`. Lengths are millimeters or unit-bearing strings.
`mp.string("nema8").json()`, `nema17`, `nema23`, and `nema34` resolve to `fn: "nema"`
and `nemaSize: 8 | 17 | 23 | 34`. Lengths are millimeters or unit-bearing strings.
`nemaMotorModelPropsSchema` validates direct props with a required `nemaSize`.

| Default | NEMA 8 | NEMA 17 | NEMA 23 |
| --- | --- | --- | --- |
| Body width × length | 20.3 × 33 | 42.3 × 38 | 56.4 × 51 |
| Square hole pitch | 16 | 31 | 47.14 |
| Hole center coordinates | (±8, ±8) | (±15.5, ±15.5) | (±23.57, ±23.57) |
| Hole diameter / depth | 2 / 2 blind | 3 / 4.5 blind | 5 / through front flange |
| Pilot diameter × height | 15 × 1.5 | 22 × 2 | 38.1 × 1.6 |
| Shaft diameter × length from face | 4 × 15 | 5 × 24 | 6.35 × 20.6 |
| Shaft shape | round | D | D |
| D flat depth / length | 0.5 / 10 when enabled | 0.5 / 15 | 0.5 / 15 |
| Default | NEMA 8 | NEMA 17 | NEMA 23 | NEMA 34 |
| --- | --- | --- | --- | --- |
| Body width × length | 20.3 × 33 | 42.3 × 38 | 56.4 × 51 | 86 × 80 |
| Square hole pitch | 16 | 31 | 47.14 | 69.6 |
| Hole center coordinates | (±8, ±8) | (±15.5, ±15.5) | (±23.57, ±23.57) | (±34.8, ±34.8) |
| Hole diameter / depth | 2 / 2 blind | 3 / 4.5 blind | 5 / through front flange | 6.5 / through front flange |
| Pilot diameter × height | 15 × 1.5 | 22 × 2 | 38.1 × 1.6 | 73 × 1.6 |
| Shaft diameter × length from face | 4 × 15 | 5 × 24 | 6.35 × 20.6 | 14 × 32 |
| Shaft shape | round | D | D | round (keyway omitted) |
| D flat depth / length | 0.5 / 10 when enabled | 0.5 / 15 | 0.5 / 15 | 0.5 / 15 when enabled |

Mounting, pilot and shaft dimensions follow representative Nanotec drawings:
[SCA2018](https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_SCA2018.pdf),
[ST4118](https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_ST4118.pdf),
[ST5918](https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_ST5918.pdf).
NEMA34 envelope, mounting, pilot and shaft dimensions follow the
[STEPPERONLINE 34HS31-6004S1 drawing](https://www.omc-stepperonline.com/download/34HS31-6004S1.pdf).
Its keyway is omitted and the default shaft is round; `_dshaft` enables an illustrative flat.
Frame names do not guarantee body length or shaft details. D cuts, cap lengths
and corner chamfers are illustrative defaults; no screw threads or wires are modeled.

Expand All @@ -39,7 +42,7 @@ mp.string("nema23_shaftdiameter8mm_shaftlength25mm_flatdepth0.5mm_flatlength20mm
Modifiers: `l` / `length` / `bodylength`, `bodywidth`, `shaftlength`,
`shaftdiameter`, `round` / `dshaft`, `flatdepth`, `flatlength`, `flatangle`,
`holespacing`, `holediameter`, `holedepth`, `throughholes` / `blindholes`,
`pilotdiameter`, `pilotlength`, `frontcap`, `rearcap`, `facechamfer`, `bodychamfer`.
`pilotdiameter`, `pilotlength`, `frontcap`, `rearcap`, `facechamfer`, `bodychamfer`, `rearchamfer`, `stepped` / `chamfered`.
Duplicate, unknown and geometrically invalid parameters fail validation.

This package defines parameters and parses model strings; it does not generate
Expand All @@ -48,22 +51,34 @@ JSCAD solids and own the visual snapshots and geometry tests.

## Rear face

The rear face is Z=-bodyLength. Bare model strings show four installed socket-head
cap screws. `_backfaceholes` removes the screws and exposes four blind bores;
The rear face is Z=-bodyLength. NEMA8/17/23 model strings show four installed socket-head
cap screws; NEMA34 defaults to a plain rear casting. `_backfaceholes` removes the screws and exposes four blind bores;
`_backfacescrews` explicitly selects installed screws. `_plainbackface` omits both.
These flags are mutually exclusive. Direct props use `backFace: "holes" | "screws" | "plain"`.

Rear fasteners are representative configurable details, not guaranteed by a NEMA
frame number. The square rear pitch defaults to the mounting pitch (16 / 31 /
47.14 mm), independently overridden by `backFaceHoleSpacing` / `_backholespacing`.
The enabled rear face uses the front-face outline so the NEMA23 rear fasteners
have support outside the chamfered core. Front flange holes are unchanged.

| Rear default (mm) | NEMA8 | NEMA17 | NEMA23 |
| --- | --- | --- | --- |
| Screw size | M2 | M3 | M4 |
| Bore diameter / depth | 2 / 2 | 3 / 4.5 | 4 / 4.5 |
| Head diameter / height | 3.8 / 2 | 5.5 / 3 | 7 / 4 |
frame number. The square rear pitches default to 16 / 31 / 47.14 / 40 mm,
independently overridden by `backFaceHoleSpacing` / `_backholespacing`.
NEMA34 follows the rounded, stepped shoulders and plain rear casting visible in
STEPPERONLINE product photos. The rear follows the body profile instead of
repeating the mounting flange. Front assembly screws sit in edge recesses
separately from the mounting holes. The side wire bundle has a grommet, a 90 degree
default exit angle, 2 mm wires, and 25 mm visible length. Threads, laminations,
and the shaft keyway are omitted.

These visual details are photo-based approximations, not manufacturing dimensions.
`cornerProfile` (`stepped` / `chamfered`) and `rearCornerChamfer` / `rearchamfer`
configure the profiles. In stepped mode, the chamfer value divided by sqrt(2)
sets the corner shoulder depth, with fillets up to 4 mm. Default body and rear
values are both 24 mm. Rear bores/screws remain opt-in with an illustrative
40 mm square pitch and can be overridden to match a specific motor.
NEMA8/17/23 retain their existing rear profiles. Front flange holes are unchanged.

| Rear default (mm) | NEMA8 | NEMA17 | NEMA23 | NEMA34 |
| --- | --- | --- | --- | --- |
| Screw size | M2 | M3 | M4 | M5 |
| Bore diameter / depth | 2 / 2 | 3 / 4.5 | 4 / 4.5 | 5 / 6 |
| Head diameter / height | 3.8 / 2 | 5.5 / 3 | 7 / 4 | 8.5 / 5 |

`backFaceScrewSize` / `_backscrewm3` selects the existing ISO 4762 bolt
head/socket dimensions. `backFaceHoleDiameter` / `_backholediameter` and
Expand Down
37 changes: 35 additions & 2 deletions src/nema-motor-reference-points.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,40 @@ export function getNemaMotorReferencePoints(input: NemaMotorModelPropsInput) {
const x = Math.cos(radians),
y = Math.sin(radians)
// Intersect the direction with the cap's chamfered square specification.
const radius = Math.min(
let radius = Math.min(
p.bodyWidth / 2 / Math.max(Math.abs(x), Math.abs(y)),
(p.bodyWidth - p.faceCornerChamfer) / (Math.abs(x) + Math.abs(y)),
)
if (p.nemaSize === 34 && p.cornerProfile === "stepped") {
const h = p.bodyWidth / 2,
c = p.bodyCornerChamfer / Math.SQRT2,
r = Math.min(4, c / 4)
const contains = (distance: number) => {
const a = Math.abs(distance * x),
b = Math.abs(distance * y)
if (a > h || b > h) return false
if (c === 0) return true
if (a > h - c && b > h - c)
return (
a <= h - c + r &&
b <= h - c + r &&
Math.hypot(a - (h - c + r), b - (h - c + r)) >= r
)
if (b > h - r && a > h - c - r)
return Math.hypot(a - (h - c - r), b - (h - r)) <= r
if (a > h - r && b > h - c - r)
return Math.hypot(a - (h - r), b - (h - c - r)) <= r
return true
}
let low = 0,
high = p.bodyWidth
for (let i = 0; i < 50; i++) {
const mid = (low + high) / 2
if (contains(mid)) low = mid
else high = mid
}
radius = low
}
const flatRadians = (p.shaftFlatAngle * Math.PI) / 180
return {
frontface: {
Expand All @@ -40,7 +70,10 @@ export function getNemaMotorReferencePoints(input: NemaMotorModelPropsInput) {
position: {
x: radius * x,
y: radius * y,
z: -p.bodyLength + p.rearCapLength / 2,
z:
p.nemaSize === 34
? -p.bodyLength + p.rearCapLength + 3
: -p.bodyLength + p.rearCapLength / 2,
},
direction: { x, y, z: 0 },
},
Expand Down
89 changes: 75 additions & 14 deletions src/nema-motor-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const nemaSizeSchema = z.union([
z.literal(8),
z.literal(17),
z.literal(23),
z.literal(34),
])
export type NemaSize = z.infer<typeof nemaSizeSchema>

Expand All @@ -24,6 +25,9 @@ export type NemaSize = z.infer<typeof nemaSizeSchema>
* https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_SCA2018.pdf
* https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_ST4118.pdf
* https://www.nanotec.com/fileadmin/files/Baureihenuebersichten/Schrittmotoren/Product_Overview_ST5918.pdf
* NEMA 34 envelope, mounting/pilot and shaft: STEPPERONLINE 34HS31-6004S1:
* https://www.omc-stepperonline.com/download/34HS31-6004S1.pdf
* Its shaft keyway is omitted; the default shaft is round.
* Cap thicknesses, chamfers and D cuts are illustrative, configurable details.
* NEMA 8 also has 15.4 mm pitch / 16 mm pilot variants; override both together.
*/
Expand Down Expand Up @@ -94,6 +98,31 @@ export const nemaMotorDimensions = {
faceCornerChamfer: 2,
bodyCornerChamfer: 15,
},
34: {
// Illustrative rear details; independent of the mounting flange.
backFaceHoleSpacing: 40,
rearCornerChamfer: 24,
backFaceScrewSize: "M5",
backFaceHoleDepth: 6,
bodyWidth: 86,
bodyLength: 80,
mountingHoleSpacing: 69.6,
mountingHoleDiameter: 6.5,
mountingHoleDepth: 10,
mountingHoleThrough: true,
pilotDiameter: 73,
pilotLength: 1.6,
shaftDiameter: 14,
shaftLength: 32,
shaftShape: "round",
shaftFlatDepth: 0.5,
shaftFlatLength: 15,
shaftFlatAngle: 0,
frontCapLength: 10,
rearCapLength: 8,
faceCornerChamfer: 2,
bodyCornerChamfer: 24,
},
} as const

const shape = {
Expand Down Expand Up @@ -135,6 +164,9 @@ const shape = {
rearCapLength: positive.optional(),
faceCornerChamfer: nonnegative.optional(),
bodyCornerChamfer: nonnegative.optional(),
/** Rear outline is independent of the front mounting flange. */
rearCornerChamfer: nonnegative.optional(),
cornerProfile: z.enum(["chamfered", "stepped"]).optional(),
}
const resolve = (p: z.output<z.ZodObject<typeof shape>>) => {
const d = nemaMotorDimensions[p.nemaSize]
Expand All @@ -143,13 +175,17 @@ const resolve = (p: z.output<z.ZodObject<typeof shape>>) => {
return {
nemaSize: p.nemaSize,
wireConnection: p.wireConnection ?? "stubs",
wireSideAngle: (((p.wireSideAngle ?? 0) % 360) + 360) % 360,
wireLength: p.wireLength ?? 6,
wireDiameter: p.wireDiameter ?? 1.2,
wireSideAngle:
(((p.wireSideAngle ?? (p.nemaSize === 34 ? 90 : 0)) % 360) + 360) % 360,
wireLength: p.wireLength ?? (p.nemaSize === 34 ? 25 : 6),
wireDiameter: p.wireDiameter ?? (p.nemaSize === 34 ? 2 : 1.2),
wireCount: p.wireCount ?? 4,
backFace: p.backFace ?? "screws",
backFace: p.backFace ?? (p.nemaSize === 34 ? "plain" : "screws"),
backFaceHoleSpacing:
p.backFaceHoleSpacing ?? p.mountingHoleSpacing ?? d.mountingHoleSpacing,
p.backFaceHoleSpacing ??
("backFaceHoleSpacing" in d
? d.backFaceHoleSpacing
: (p.mountingHoleSpacing ?? d.mountingHoleSpacing)),
backFaceHoleDiameter: p.backFaceHoleDiameter ?? screw.diameter,
backFaceHoleDepth: p.backFaceHoleDepth ?? d.backFaceHoleDepth,
backFaceScrewSize,
Expand All @@ -171,11 +207,30 @@ const resolve = (p: z.output<z.ZodObject<typeof shape>>) => {
rearCapLength: p.rearCapLength ?? d.rearCapLength,
faceCornerChamfer: p.faceCornerChamfer ?? d.faceCornerChamfer,
bodyCornerChamfer: p.bodyCornerChamfer ?? d.bodyCornerChamfer,
...(p.nemaSize === 34 || p.cornerProfile !== undefined
? { cornerProfile: p.cornerProfile ?? ("stepped" as const) }
: {}),
...("rearCornerChamfer" in d || p.rearCornerChamfer !== undefined
? {
rearCornerChamfer:
p.rearCornerChamfer ??
("rearCornerChamfer" in d
? d.rearCornerChamfer
: (p.faceCornerChamfer ?? d.faceCornerChamfer)),
}
: {}),
}
}
export type NemaMotorModelProps = ReturnType<typeof resolve>
const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => {
const issue = (message: string) => ctx.addIssue({ code: "custom", message })
const rearChamfer =
p.rearCornerChamfer ??
(p.backFace === "plain" && p.mountingHoleThrough
? p.bodyCornerChamfer
: p.faceCornerChamfer)
const rearEdge =
p.cornerProfile === "stepped" ? rearChamfer / Math.SQRT2 : rearChamfer
if (p.backFace !== "plain") {
const screw = hexSocketBoltDimensions[p.backFaceScrewSize]
const radius =
Expand All @@ -186,8 +241,10 @@ const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => {
issue("Rear holes and screw heads must not overlap each other")
if (
p.backFaceHoleSpacing + 2 * radius >= p.bodyWidth ||
p.bodyWidth - p.faceCornerChamfer - p.backFaceHoleSpacing <=
radius * Math.SQRT2
(p.cornerProfile === "stepped"
? p.backFaceHoleSpacing + 2 * radius >= p.bodyWidth - 2 * rearEdge
: p.bodyWidth - rearChamfer - p.backFaceHoleSpacing <=
radius * Math.SQRT2)
)
issue("Rear holes and screw heads must fit entirely inside the rear face")
if (p.backFaceHoleDepth >= p.rearCapLength)
Expand All @@ -196,23 +253,21 @@ const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => {
issue("Rear holes must accommodate the screw diameter")
}
if (p.wireConnection === "stubs") {
if (
p.wireDiameter * p.wireCount * 1.5 >
p.bodyWidth - 2 * p.faceCornerChamfer
)
if (p.wireDiameter * p.wireCount * 1.5 > p.bodyWidth - 2 * rearEdge)
issue("Wire bundle must fit on the cap side")
if (p.wireDiameter >= p.rearCapLength)
issue("Wire diameter must fit within the rear cap")
}
const connector = getJstMotorConnector(p.wireConnection)
if (connector && p.bodyWidth - 2 * p.faceCornerChamfer < connector.bodyWidth)
if (connector && p.bodyWidth - 2 * rearEdge < connector.bodyWidth)
issue("JST header must fit on the motor side")
const r = p.mountingHoleDiameter / 2
if (p.frontCapLength + p.rearCapLength >= p.bodyLength)
issue("End caps must leave a positive body length")
if (
p.faceCornerChamfer >= p.bodyWidth / 2 ||
p.bodyCornerChamfer >= p.bodyWidth / 2
p.bodyCornerChamfer >= p.bodyWidth / 2 ||
rearChamfer >= p.bodyWidth / 2
)
issue("Chamfer must be smaller than half the body width")
if (
Expand All @@ -230,7 +285,13 @@ const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => {
issue("Blind mounting hole depth must be less than the front cap length")
if (
p.mountingHoleThrough &&
p.bodyWidth - p.bodyCornerChamfer - p.mountingHoleSpacing >= -r * Math.SQRT2
(p.cornerProfile === "stepped"
? p.bodyWidth / 2 -
p.bodyCornerChamfer / Math.SQRT2 +
Math.min(4, p.bodyCornerChamfer / Math.SQRT2 / 4) >=
p.mountingHoleSpacing / 2 - r
: p.bodyWidth - p.bodyCornerChamfer - p.mountingHoleSpacing >=
-r * Math.SQRT2)
)
issue("Body must clear through front mounting holes")
if (p.shaftLength <= p.pilotLength)
Expand Down
2 changes: 1 addition & 1 deletion src/normalize-nema-motor-model-string.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Accept assembly cable spellings without changing NEMA's resolved definition. */
export function normalizeNemaMotorModelString(value: string): string {
if (!/^nema(?:8|17|23)(?:_|$)/i.test(value)) return value
if (!/^nema(?:8|17|23|34)(?:_|$)/i.test(value)) return value
return value
.replace(/(^|_)jst([0-9]+)_(ph|sh)(?=_|$)/gi, "$1jst$3$2")
.replace(/(^|_)jst[_-](ph|sh)[_-]([0-9]+)(?=_|$)/gi, "$1jst$2$3")
Expand Down
9 changes: 7 additions & 2 deletions src/parse-nema-motor-model-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ const lengths = {
rearcap: "rearCapLength",
facechamfer: "faceCornerChamfer",
bodychamfer: "bodyCornerChamfer",
rearchamfer: "rearCornerChamfer",
} as const

export function parseNemaMotorModelParams(raw: RawModelprinterParams) {
const tokens = normalizeNemaMotorModelString(raw.string).split("_")
const match = tokens[0]?.match(/^nema(8|17|23)$/i)
if (!match) throw new Error("Expected nema8, nema17 or nema23")
const match = tokens[0]?.match(/^nema(8|17|23|34)$/i)
if (!match) throw new Error("Expected nema8, nema17, nema23 or nema34")
const props: Record<string, unknown> = {
fn: "nema",
nemaSize: Number(match[1]),
Expand Down Expand Up @@ -65,6 +66,10 @@ export function parseNemaMotorModelParams(raw: RawModelprinterParams) {
: name === "nowires"
? "none"
: "stubs"
} else if (name === "stepped" || name === "chamfered") {
if (value) throw new Error(`NEMA flag "${name}" does not accept a value`)
property = "cornerProfile"
parsed = name
} else if (name === "wirecount") {
property = "wireCount"
if (!/^\d+$/.test(value)) throw new Error("wirecount requires an integer")
Expand Down
7 changes: 4 additions & 3 deletions tests/fixtures/assert-nema-motor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function assertNemaMotor(size: NemaSize) {
8: { pitch: 16, diameter: 2, length: 33, shaft: 15 },
17: { pitch: 31, diameter: 3, length: 38, shaft: 24 },
23: { pitch: 47.14, diameter: 5, length: 51, shaft: 20.6 },
34: { pitch: 69.6, diameter: 6.5, length: 80, shaft: 32 },
}[size]
const definition = mp.string(`NEMA${size}`).json()
if (definition.fn !== "nema") throw new Error("Expected NEMA")
Expand All @@ -19,10 +20,10 @@ export function assertNemaMotor(size: NemaSize) {
expect(definition.mountingHoleSpacing).toBe(expected.pitch)
expect(definition.mountingHoleDiameter).toBe(expected.diameter)
const { fn, ...props } = definition
expect(props.backFace).toBe("screws")
expect(props.backFaceHoleSpacing).toBe(expected.pitch)
expect(props.backFace).toBe(size === 34 ? "plain" : "screws")
expect(props.backFaceHoleSpacing).toBe(size === 34 ? 40 : expected.pitch)
expect(props.backFaceScrewSize).toBe(
({ 8: "M2", 17: "M3", 23: "M4" } as const)[size],
({ 8: "M2", 17: "M3", 23: "M4", 34: "M5" } as const)[size],
)
for (const [token, mode] of [
["backfaceholes", "holes"],
Expand Down
2 changes: 1 addition & 1 deletion tests/nema-motor-wire-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ test("JST PH and SH variants accept canonical and legacy spellings", () => {
}
}
}
for (const size of [8, 17, 23]) {
for (const size of [8, 17, 23, 34]) {
for (const [alias, token] of [
["none", "nowires"],
["stubs", "wirestubs"],
Expand Down
2 changes: 1 addition & 1 deletion tests/nema-motor.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test } from "bun:test"
import { assertNemaMotor } from "./fixtures/assert-nema-motor"

for (const size of [8, 17, 23] as const)
for (const size of [8, 17, 23, 34] as const)
test(`NEMA${size} parameter contract`, () => assertNemaMotor(size))
Loading
Loading