diff --git a/docs/nema-motors.md b/docs/nema-motors.md index 9ec0d0a..385e6c6 100644 --- a/docs/nema-motors.md +++ b/docs/nema-motors.md @@ -65,8 +65,10 @@ These flags are mutually exclusive. Direct props use `backFaceHoles`, one true flag and false for the other rear-face flags. Rear fasteners are representative configurable details, not guaranteed by a NEMA -frame number. The square rear pitches default to 16 / 31 / 47.14 / 40 mm, -independently overridden by `backFaceHoleSpacing` / `_backholespacing`. +frame number. Rear patterns default to 16 × 16 / 31 × 31 / 50 × 32 / 40 × 40 mm. +`backFaceHoleSpacing` / `_backholespacing` still sets both axes to a square +pattern when supplied. `backFaceHoleSpacingX` / `_backholespacingx` and +`backFaceHoleSpacingY` / `_backholespacingy` independently override each axis. 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 @@ -80,13 +82,20 @@ 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. +NEMA8/17 retain their existing profiles. NEMA23 uses matching curved body/rear +profiles with 9 mm shoulders, R5 recesses and R2.5 fillets; front holes are unchanged. + +`rearStepped` / `_rearstepped` and `rearChamfered` / `_rearchamfered` override the +rear profile independently of `stepped` / `chamfered`. Body and rear arc radii +use `bodyCornerRecessRadius`, `bodyCornerFilletRadius`, `rearCornerRecessRadius` +and `rearCornerFilletRadius` (`bodyrecess`, `bodyfillet`, `rearrecess`, `rearfillet`). +`nema23_chamfered_backholespacing47.14mm_backscrewm4` retains the previous depiction. | 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 | +| Screw size | M2 | M3 | M3 | M5 | +| Bore diameter / depth | 2 / 2 | 3 / 4.5 | 3 / 4.5 | 5 / 6 | +| Head diameter / height | 3.8 / 2 | 5.5 / 3 | 5.5 / 3 | 8.5 / 5 | `backFaceScrewSize` / `_backscrewm3` selects the existing ISO 4762 bolt head/socket dimensions. `backFaceHoleDiameter` / `_backholediameter` and @@ -98,7 +107,7 @@ screws larger than the bore, and holes or heads that cross the rear-face edge. ```ts mp.string("nema17_backfaceholes").json() -mp.string("nema23_backfacescrews_backholespacing40mm_backscrewm3").json() +mp.string("nema23_backfacescrews_backholespacingx48mm_backholespacingy30mm").json() ``` ## Wire connection aliases diff --git a/src/nema-motor-profile.ts b/src/nema-motor-profile.ts new file mode 100644 index 0000000..38589a1 --- /dev/null +++ b/src/nema-motor-profile.ts @@ -0,0 +1,111 @@ +/** Analytic cap/core clearance checks; no renderer or meshes are needed. */ +type NemaProfile = { + width: number + chamfer: number + stepped: boolean + recessRadius: number + filletRadius: number +} + +export function isInsideNemaFace(x: number, y: number, p: NemaProfile) { + const h = p.width / 2 + x = Math.abs(x) + y = Math.abs(y) + if (x > h || y > h) return false + const chamfer = p.chamfer + if (!p.stepped) return x + y <= 2 * h - chamfer + const c = chamfer / Math.SQRT2 + if (c === 0) return true + const recess = p.recessRadius + const fillet = p.filletRadius + const k = h - c + recess + if (x > h - c && y > h - c) + return x <= k && y <= k && Math.hypot(x - k, y - k) >= recess + if (y > h - fillet && x > h - c - fillet) + return Math.hypot(x - (h - c - fillet), y - (h - fillet)) <= fillet + if (x > h - fillet && y > h - c - fillet) + return Math.hypot(x - (h - fillet), y - (h - c - fillet)) <= fillet + return true +} + +const segmentDistance = ( + x: number, + y: number, + ax: number, + ay: number, + bx: number, + by: number, +) => { + const dx = bx - ax + const dy = by - ay + const t = Math.max( + 0, + Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy || 1)), + ) + return Math.hypot(x - ax - t * dx, y - ay - t * dy) +} + +const arcDistance = ( + x: number, + y: number, + cx: number, + cy: number, + r: number, + start: number, + end: number, +) => { + const angle = (Math.atan2(y - cy, x - cx) + 2 * Math.PI) % (2 * Math.PI) + if (angle >= start && angle <= end) + return Math.abs(Math.hypot(x - cx, y - cy) - r) + return Math.min( + Math.hypot(x - cx - r * Math.cos(start), y - cy - r * Math.sin(start)), + Math.hypot(x - cx - r * Math.cos(end), y - cy - r * Math.sin(end)), + ) +} + +const boundaryDistance = (x: number, y: number, p: NemaProfile) => { + const h = p.width / 2 + x = Math.abs(x) + y = Math.abs(y) + const chamfer = p.chamfer + if (!p.stepped || chamfer === 0) + return Math.min( + Math.abs(h - x), + Math.abs(h - y), + Math.abs(2 * h - chamfer - x - y) / Math.SQRT2, + ) + const c = chamfer / Math.SQRT2 + const recess = p.recessRadius + const fillet = p.filletRadius + const a = h - c - fillet + const k = h - c + recess + return Math.min( + segmentDistance(x, y, -a, h, a, h), + segmentDistance(x, y, h, -a, h, a), + segmentDistance(x, y, h - c, h - fillet, h - c, k), + segmentDistance(x, y, k, h - c, h - fillet, h - c), + arcDistance(x, y, a, h - fillet, fillet, 0, Math.PI / 2), + arcDistance(x, y, k, k, recess, Math.PI, (3 * Math.PI) / 2), + arcDistance(x, y, h - fillet, a, fillet, 0, Math.PI / 2), + ) +} + +/** Reject a bore/head that touches or crosses a straight edge or either kind of arc. */ +export function fitsNemaCircle( + x: number, + y: number, + radius: number, + p: NemaProfile, +) { + return isInsideNemaFace(x, y, p) && boundaryDistance(x, y, p) > radius +} + +/** A through mounting bore must remain entirely outside the core profile. */ +export function clearsNemaCircle( + x: number, + y: number, + radius: number, + p: NemaProfile, +) { + return !isInsideNemaFace(x, y, p) && boundaryDistance(x, y, p) > radius +} diff --git a/src/nema-motor-reference-points.ts b/src/nema-motor-reference-points.ts index 4d61626..6d4e135 100644 --- a/src/nema-motor-reference-points.ts +++ b/src/nema-motor-reference-points.ts @@ -2,6 +2,7 @@ import { nemaMotorModelPropsSchema, type NemaMotorModelPropsInput, } from "./nema-motor-schema" +import { isInsideNemaFace } from "./nema-motor-profile" export interface NemaMotorReferencePoint { position: { x: number; y: number; z: number } @@ -22,32 +23,28 @@ export function getNemaMotorReferencePoints(input: NemaMotorModelPropsInput) { 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.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 - } + if (p.nemaSize !== 34 || p.stepped) { + const profile = + p.nemaSize === 34 + ? { + width: p.bodyWidth, + chamfer: p.bodyCornerChamfer, + stepped: p.stepped, + recessRadius: p.bodyCornerRecessRadius, + filletRadius: p.bodyCornerFilletRadius, + } + : { + width: p.bodyWidth, + chamfer: p.rearCornerChamfer, + stepped: p.rearStepped, + recessRadius: p.rearCornerRecessRadius, + filletRadius: p.rearCornerFilletRadius, + } let low = 0, high = p.bodyWidth for (let i = 0; i < 50; i++) { const mid = (low + high) / 2 - if (contains(mid)) low = mid + if (isInsideNemaFace(mid * x, mid * y, profile)) low = mid else high = mid } radius = low diff --git a/src/nema-motor-schema.ts b/src/nema-motor-schema.ts index 04e6f1d..425cffa 100644 --- a/src/nema-motor-schema.ts +++ b/src/nema-motor-schema.ts @@ -3,6 +3,7 @@ import { jstMotorWireConnectionSchema, } from "./jst-motor-connector" import { z } from "zod" +import { fitsNemaCircle, clearsNemaCircle } from "./nema-motor-profile" import { hexSocketBoltDimensions, metricBoltSizeSchema, @@ -79,7 +80,15 @@ export const nemaMotorDimensions = { bodyCornerChamfer: 6, }, 23: { - backFaceScrewSize: "M4", + // Representative side-tab rear pattern from STEPPERONLINE's ST-M10 adapter. + // The adapter's R5/R2.5 profile is a visual reference, not a frame guarantee: + // https://www.omc-stepperonline.com/image/catalog/dimensions/ST-M10_Dimension.svg + backFaceHoleSpacing: 50, + backFaceHoleSpacingY: 32, + rearCornerChamfer: 9 * Math.SQRT2, + rearCornerRecessRadius: 5, + rearCornerFilletRadius: 2.5, + backFaceScrewSize: "M3", backFaceHoleDepth: 4.5, bodyWidth: 56.4, bodyLength: 51, @@ -99,7 +108,10 @@ export const nemaMotorDimensions = { frontCapLength: 5, rearCapLength: 5, faceCornerChamfer: 2, - bodyCornerChamfer: 15, + bodyCornerChamfer: 9 * Math.SQRT2, + bodyCornerRecessRadius: 5, + bodyCornerFilletRadius: 2.5, + chamferedBodyCornerChamfer: 15, }, 34: { // Illustrative rear details; independent of the mounting flange. @@ -136,6 +148,9 @@ const shape = { backFaceHoles: z.boolean().optional(), backFaceScrews: z.boolean().optional(), backFaceHoleSpacing: positive.optional(), + /** Axis overrides; the legacy scalar still sets a square pattern when supplied. */ + backFaceHoleSpacingX: positive.optional(), + backFaceHoleSpacingY: positive.optional(), backFaceHoleDiameter: positive.optional(), backFaceHoleDepth: positive.optional(), backFaceScrewSize: metricBoltSizeSchema.optional(), @@ -172,8 +187,14 @@ const shape = { rearCapLength: positive.optional(), faceCornerChamfer: nonnegative.optional(), bodyCornerChamfer: nonnegative.optional(), + bodyCornerRecessRadius: nonnegative.optional(), + bodyCornerFilletRadius: nonnegative.optional(), /** Rear outline is independent of the front mounting flange. */ rearCornerChamfer: nonnegative.optional(), + rearCornerRecessRadius: nonnegative.optional(), + rearCornerFilletRadius: nonnegative.optional(), + rearChamfered: z.boolean().optional(), + rearStepped: z.boolean().optional(), chamfered: z.boolean().optional(), stepped: z.boolean().optional(), } @@ -204,7 +225,15 @@ const resolve = ( ) const corner = select( ["chamfered", "stepped"], - p.nemaSize === 34 ? "stepped" : "chamfered", + p.nemaSize === 23 || p.nemaSize === 34 ? "stepped" : "chamfered", + ) + const rearCorner = select( + ["rearChamfered", "rearStepped"], + p.chamfered === true + ? "rearChamfered" + : p.nemaSize === 23 || corner === "stepped" + ? "rearStepped" + : "rearChamfered", ) const wireSpecified = p.noWires !== undefined || @@ -221,8 +250,30 @@ const resolve = ( "Select exactly one of noWires, wireStubs or a JST wireConnection", }) const d = nemaMotorDimensions[p.nemaSize] + const bodyCornerChamfer = + p.bodyCornerChamfer ?? + (corner === "chamfered" && "chamferedBodyCornerChamfer" in d + ? d.chamferedBodyCornerChamfer + : d.bodyCornerChamfer) + const defaultBodyRadius = Math.min(4, bodyCornerChamfer / Math.SQRT2 / 4) const backFaceScrewSize = p.backFaceScrewSize ?? d.backFaceScrewSize const screw = hexSocketBoltDimensions[backFaceScrewSize] + const backFaceHoleSpacing = + p.backFaceHoleSpacing ?? + ("backFaceHoleSpacing" in d + ? d.backFaceHoleSpacing + : (p.mountingHoleSpacing ?? d.mountingHoleSpacing)) + const rearCornerChamfer = + p.rearCornerChamfer ?? + (p.nemaSize === 23 && rearCorner === "rearChamfered" + ? (p.faceCornerChamfer ?? d.faceCornerChamfer) + : "rearCornerChamfer" in d + ? d.rearCornerChamfer + : back === "backFacePlain" && + (p.mountingHoleThrough ?? d.mountingHoleThrough) + ? (p.bodyCornerChamfer ?? d.bodyCornerChamfer) + : (p.faceCornerChamfer ?? d.faceCornerChamfer)) + const defaultRearRadius = Math.min(4, rearCornerChamfer / Math.SQRT2 / 4) return { nemaSize: p.nemaSize, noWires: p.noWires === true, @@ -238,11 +289,14 @@ const resolve = ( backFacePlain: back === "backFacePlain", backFaceHoles: back === "backFaceHoles", backFaceScrews: back === "backFaceScrews", - backFaceHoleSpacing: + backFaceHoleSpacing, + backFaceHoleSpacingX: p.backFaceHoleSpacingX ?? backFaceHoleSpacing, + backFaceHoleSpacingY: + p.backFaceHoleSpacingY ?? p.backFaceHoleSpacing ?? - ("backFaceHoleSpacing" in d - ? d.backFaceHoleSpacing - : (p.mountingHoleSpacing ?? d.mountingHoleSpacing)), + ("backFaceHoleSpacingY" in d + ? d.backFaceHoleSpacingY + : backFaceHoleSpacing), backFaceHoleDiameter: p.backFaceHoleDiameter ?? screw.diameter, backFaceHoleDepth: p.backFaceHoleDepth ?? d.backFaceHoleDepth, backFaceScrewSize, @@ -264,43 +318,78 @@ const resolve = ( frontCapLength: p.frontCapLength ?? d.frontCapLength, rearCapLength: p.rearCapLength ?? d.rearCapLength, faceCornerChamfer: p.faceCornerChamfer ?? d.faceCornerChamfer, - bodyCornerChamfer: p.bodyCornerChamfer ?? d.bodyCornerChamfer, + bodyCornerChamfer, + bodyCornerRecessRadius: + p.bodyCornerRecessRadius ?? + ("bodyCornerRecessRadius" in d + ? d.bodyCornerRecessRadius + : defaultBodyRadius), + bodyCornerFilletRadius: + p.bodyCornerFilletRadius ?? + ("bodyCornerFilletRadius" in d + ? d.bodyCornerFilletRadius + : defaultBodyRadius), chamfered: corner === "chamfered", stepped: corner === "stepped", - ...("rearCornerChamfer" in d || p.rearCornerChamfer !== undefined - ? { - rearCornerChamfer: - p.rearCornerChamfer ?? - ("rearCornerChamfer" in d - ? d.rearCornerChamfer - : (p.faceCornerChamfer ?? d.faceCornerChamfer)), - } - : {}), + rearCornerChamfer, + rearStepped: rearCorner === "rearStepped", + rearChamfered: rearCorner === "rearChamfered", + rearCornerRecessRadius: + p.rearCornerRecessRadius ?? + ("rearCornerRecessRadius" in d + ? d.rearCornerRecessRadius + : defaultRearRadius), + rearCornerFilletRadius: + p.rearCornerFilletRadius ?? + ("rearCornerFilletRadius" in d + ? d.rearCornerFilletRadius + : defaultRearRadius), } } export type NemaMotorModelProps = ReturnType const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => { const issue = (message: string) => ctx.addIssue({ code: "custom", message }) + if ( + p.stepped && + p.bodyCornerRecessRadius + p.bodyCornerFilletRadius > + p.bodyCornerChamfer / Math.SQRT2 + ) + issue("Body recess and fillet radii must fit within the corner shoulder") const rearChamfer = p.rearCornerChamfer ?? (p.backFacePlain && p.mountingHoleThrough ? p.bodyCornerChamfer : p.faceCornerChamfer) - const rearEdge = p.stepped ? rearChamfer / Math.SQRT2 : rearChamfer + const rearEdge = p.rearStepped ? rearChamfer / Math.SQRT2 : rearChamfer + if ( + p.rearStepped && + p.rearCornerRecessRadius + p.rearCornerFilletRadius > rearEdge + ) + issue("Rear recess and fillet radii must fit within the corner shoulder") if (!p.backFacePlain) { const screw = hexSocketBoltDimensions[p.backFaceScrewSize] const radius = (p.backFaceScrews ? Math.max(p.backFaceHoleDiameter, screw.headDiameter) : p.backFaceHoleDiameter) / 2 - if (p.backFaceHoleSpacing <= 2 * radius) + if ( + p.backFaceHoleSpacingX <= 2 * radius || + p.backFaceHoleSpacingY <= 2 * radius + ) issue("Rear holes and screw heads must not overlap each other") if ( - p.backFaceHoleSpacing + 2 * radius >= p.bodyWidth || - (p.stepped - ? p.backFaceHoleSpacing + 2 * radius >= p.bodyWidth - 2 * rearEdge - : p.bodyWidth - rearChamfer - p.backFaceHoleSpacing <= - radius * Math.SQRT2) + !fitsNemaCircle( + p.backFaceHoleSpacingX / 2, + p.backFaceHoleSpacingY / 2, + radius, + { + width: p.bodyWidth, + chamfer: p.rearCornerChamfer, + stepped: p.rearStepped, + recessRadius: p.rearCornerRecessRadius, + filletRadius: p.rearCornerFilletRadius, + }, + ) ) issue("Rear holes and screw heads must fit entirely inside the rear face") if (p.backFaceHoleDepth >= p.rearCapLength) @@ -344,13 +433,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.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) + !clearsNemaCircle(p.mountingHoleSpacing / 2, p.mountingHoleSpacing / 2, r, { + width: p.bodyWidth, + chamfer: p.bodyCornerChamfer, + stepped: p.stepped, + recessRadius: p.bodyCornerRecessRadius, + filletRadius: p.bodyCornerFilletRadius, + }) ) issue("Body must clear through front mounting holes") if (p.shaftLength <= p.pilotLength) diff --git a/src/parse-nema-motor-model-string.ts b/src/parse-nema-motor-model-string.ts index 9447903..6f6d573 100644 --- a/src/parse-nema-motor-model-string.ts +++ b/src/parse-nema-motor-model-string.ts @@ -10,6 +10,8 @@ const lengths = { bodylength: "bodyLength", bodywidth: "bodyWidth", backholespacing: "backFaceHoleSpacing", + backholespacingx: "backFaceHoleSpacingX", + backholespacingy: "backFaceHoleSpacingY", backholediameter: "backFaceHoleDiameter", backholedepth: "backFaceHoleDepth", shaftlength: "shaftLength", @@ -25,7 +27,11 @@ const lengths = { rearcap: "rearCapLength", facechamfer: "faceCornerChamfer", bodychamfer: "bodyCornerChamfer", + bodyrecess: "bodyCornerRecessRadius", + bodyfillet: "bodyCornerFilletRadius", rearchamfer: "rearCornerChamfer", + rearrecess: "rearCornerRecessRadius", + rearfillet: "rearCornerFilletRadius", } as const const flags = { @@ -38,6 +44,8 @@ const flags = { dshaft: "dShaft", chamfered: "chamfered", stepped: "stepped", + rearstepped: "rearStepped", + rearchamfered: "rearChamfered", nowires: "noWires", wirestubs: "wireStubs", } as const diff --git a/tests/fixtures/assert-nema-motor.ts b/tests/fixtures/assert-nema-motor.ts index e11b664..c0575fe 100644 --- a/tests/fixtures/assert-nema-motor.ts +++ b/tests/fixtures/assert-nema-motor.ts @@ -23,9 +23,11 @@ export function assertNemaMotor(size: NemaSize) { expect(props.backFacePlain).toBe(size === 34) expect(props.backFaceHoles).toBe(false) expect(props.backFaceScrews).toBe(size !== 34) - expect(props.backFaceHoleSpacing).toBe(size === 34 ? 40 : expected.pitch) + expect(props.backFaceHoleSpacing).toBe( + size === 34 ? 40 : size === 23 ? 50 : expected.pitch, + ) expect(props.backFaceScrewSize).toBe( - ({ 8: "M2", 17: "M3", 23: "M4", 34: "M5" } as const)[size], + ({ 8: "M2", 17: "M3", 23: "M3", 34: "M5" } as const)[size], ) for (const [token, mode] of [ ["backfaceholes", "backFaceHoles"], diff --git a/tests/fixtures/registration-baselines.json b/tests/fixtures/registration-baselines.json index d1518ac..48b80b4 100644 --- a/tests/fixtures/registration-baselines.json +++ b/tests/fixtures/registration-baselines.json @@ -85,7 +85,16 @@ "roundShaft": true, "dShaft": false, "chamfered": true, - "stepped": false + "stepped": false, + "backFaceHoleSpacingX": 31, + "backFaceHoleSpacingY": 31, + "rearCornerChamfer": 3, + "rearChamfered": true, + "rearStepped": false, + "rearCornerRecessRadius": 0.5303300858899106, + "rearCornerFilletRadius": 0.5303300858899106, + "bodyCornerRecessRadius": 1.0606601717798212, + "bodyCornerFilletRadius": 1.0606601717798212 } }, { diff --git a/tests/nema-motor-boolean-flags.test.ts b/tests/nema-motor-boolean-flags.test.ts index cb4239c..3b00a56 100644 --- a/tests/nema-motor-boolean-flags.test.ts +++ b/tests/nema-motor-boolean-flags.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { mp, nemaMotorModelPropsSchema } from "../src" -test("NEMA geometry choices resolve to exclusive booleans with unchanged frame defaults", () => { +test("NEMA geometry choices resolve to exclusive booleans for each frame", () => { for (const size of [8, 17, 23, 34] as const) { const definition = mp.string(`nema${size}`).json() if (definition.fn !== "nema") throw new Error("Expected NEMA") @@ -10,8 +10,8 @@ test("NEMA geometry choices resolve to exclusive booleans with unchanged frame d expect(nemaMotorModelPropsSchema.parse({ nemaSize: size })).toEqual(props) expect(props.roundShaft).toBe(size === 8 || size === 34) expect(props.dShaft).toBe(size === 17 || size === 23) - expect(props.chamfered).toBe(size !== 34) - expect(props.stepped).toBe(size === 34) + expect(props.chamfered).toBe(size !== 23 && size !== 34) + expect(props.stepped).toBe(size === 23 || size === 34) expect(props.backFacePlain).toBe(size === 34) expect(props.backFaceHoles).toBe(false) expect(props.backFaceScrews).toBe(size !== 34) diff --git a/tests/nema23-rear-profile.test.ts b/tests/nema23-rear-profile.test.ts new file mode 100644 index 0000000..4d6063f --- /dev/null +++ b/tests/nema23-rear-profile.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from "bun:test" +import { + getNemaMotorReferencePoints, + modelDefinitionSchema, + mp, + nemaMotorModelPropsSchema, +} from "../src" + +test("NEMA23 rear side tabs use a rectangular pattern independent of the front flange", () => { + const definition = mp.string("nema23").json() + expect(definition).toMatchObject({ + mountingHoleSpacing: 47.14, + mountingHoleDiameter: 5, + bodyCornerChamfer: 9 * Math.SQRT2, + bodyCornerRecessRadius: 5, + bodyCornerFilletRadius: 2.5, + stepped: true, + chamfered: false, + rearStepped: true, + rearChamfered: false, + rearCornerRecessRadius: 5, + rearCornerFilletRadius: 2.5, + backFaceHoleSpacingX: 50, + backFaceHoleSpacingY: 32, + backFaceScrewSize: "M3", + }) + expect(modelDefinitionSchema.parse(definition)).toEqual(definition) + expect( + mp.string("nema23_holespacing46mm_facechamfer3mm").json(), + ).toMatchObject({ + backFaceHoleSpacingX: 50, + backFaceHoleSpacingY: 32, + }) +}) + +test("rear axis overrides and the legacy square pitch work in strings and direct props", () => { + expect(mp.string("nema23_backholespacing30mm").json()).toMatchObject({ + backFaceHoleSpacingX: 30, + backFaceHoleSpacingY: 30, + }) + const source = + "nema23_backholespacingx48mm_backholespacingy30mm_rearrecess4mm_rearfillet2mm" + const definition = mp.string(source).json() + if (definition.fn !== "nema") throw new Error("Expected NEMA") + expect(definition).toMatchObject({ + backFaceHoleSpacingX: 48, + backFaceHoleSpacingY: 30, + rearCornerRecessRadius: 4, + rearCornerFilletRadius: 2, + }) + const { fn, ...props } = definition + expect(nemaMotorModelPropsSchema.parse(props)).toEqual(props) + expect( + mp.string("nema23_rearchamfered_backholespacing47.14mm_backscrewm4").json(), + ).toMatchObject({ + rearChamfered: true, + rearStepped: false, + backFaceHoleSpacingX: 47.14, + backFaceHoleSpacingY: 47.14, + }) +}) + +test("rear bores and screw heads cannot cross recessed corners or rounded tab edges", () => { + for (const source of [ + "nema23_backfaceholes_backholespacing47.14mm", + "nema23_backholespacingy38mm", + "nema23_backscrewm4", + "nema23_backholespacingx52mm", + "nema23_rearrecess8mm", + "nema23_rearfillet5mm", + "nema23_rearstepped_rearchamfered", + "nema23_rearstepped0", + "nema23_backholespacingx50mm_backholespacingx48mm", + ]) + expect(() => mp.string(source).json()).toThrow() +}) + +test("NEMA23 diagonal wire references stay on the curved rear cap in every rear mode", () => { + const p = { nemaSize: 23 as const, wireSideAngle: 45 } + const wire = getNemaMotorReferencePoints(p).wireside + expect(wire.position.x).toBeCloseTo(24.2 - 5 / Math.SQRT2, 8) + expect(wire.position.y).toBeCloseTo(wire.position.x, 8) + expect(wire.position.z).toBe(-48.5) + for (const mode of [ + { noWires: true }, + { backFacePlain: true }, + { backFaceHoles: true }, + ]) + expect(getNemaMotorReferencePoints({ ...p, ...mode }).wireside).toEqual( + wire, + ) +}) + +test("NEMA23 body arcs can be overridden without changing the rear or front flange", () => { + const source = "nema23_bodyrecess4mm_bodyfillet2mm" + const definition = mp.string(source).json() + expect(definition).toMatchObject({ + bodyCornerRecessRadius: 4, + bodyCornerFilletRadius: 2, + rearCornerRecessRadius: 5, + rearCornerFilletRadius: 2.5, + mountingHoleSpacing: 47.14, + }) + expect(modelDefinitionSchema.parse(definition)).toEqual(definition) + expect( + mp.string("nema23_chamfered_backholespacing47.14mm_backscrewm4").json(), + ).toMatchObject({ + bodyCornerChamfer: 15, + chamfered: true, + rearChamfered: true, + }) +}) + +test("curved body shoulders must clear the complete front through-hole radius", () => { + for (const source of [ + "nema23_bodyrecess8mm", + "nema23_bodyfillet5mm", + "nema23_bodychamfer11mm", + "nema23_bodyrecess9mm_bodyfillet0mm", + ]) + expect(() => mp.string(source).json()).toThrow() + expect(mp.string("nema23_bodyrecess6mm_bodyfillet2mm").json()).toMatchObject({ + bodyCornerRecessRadius: 6, + bodyCornerFilletRadius: 2, + }) +})