From ea88878a2f7575bc4ac75704cf72746503f62b5c Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Wed, 14 Oct 2026 19:43:51 +0530 Subject: [PATCH 1/3] fix: give NEMA23 an independent curved rear profile --- docs/nema-motors.md | 34 ++++++-- src/nema-motor-rear-profile.ts | 97 +++++++++++++++++++++ src/nema-motor-reference-points.ts | 11 +++ src/nema-motor-schema.ts | 98 +++++++++++++++++----- src/parse-nema-motor-model-string.ts | 6 ++ tests/fixtures/assert-nema-motor.ts | 6 +- tests/fixtures/registration-baselines.json | 9 +- tests/nema23-rear-profile.test.ts | 89 ++++++++++++++++++++ 8 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 src/nema-motor-rear-profile.ts create mode 100644 tests/nema23-rear-profile.test.ts diff --git a/docs/nema-motors.md b/docs/nema-motors.md index 9ec0d0a..93f2bda 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,31 @@ 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 rear profiles. NEMA23 has a separate stepped rear +cap, with R5 concave recesses and R2.5 convex fillets; its front 47.14 mm flange +pattern and core remain unchanged. The rear shape and 50 × 32 mm side-tab pattern +use [STEPPERONLINE’s ST-M10 rear adapter drawing](https://www.omc-stepperonline.com/image/catalog/dimensions/ST-M10_Dimension.svg) +as a representative reference. The adapter itself is 56 × 56 mm with Ø4 clearance +holes. The motor model retains its 56.4 mm envelope and uses illustrative M3 +socket screws/bores; the adapter drawing does not establish the motor's thread, +screw style, or exact rear-cover radii. These are not universal NEMA23 dimensions. + +`rearStepped` / `_rearstepped` and `rearChamfered` / `_rearchamfered` select +only the rear profile. Explicit `stepped` / `chamfered` still select both +profiles unless a rear selector is supplied. `rearCornerChamfer` / `_rearchamfer` +divided by sqrt(2) sets the stepped shoulder depth (9 mm for NEMA23). +`rearCornerRecessRadius` / `_rearrecess` and `rearCornerFilletRadius` / +`_rearfillet` set its independent arc radii; their sum must fit that shoulder. +Validation checks bores and heads against the actual straight edges and arcs. +Use `_rearchamfered_backholespacing47.14mm_backscrewm4` to request the previous +NEMA23 rear depiction explicitly. Diagonal wire references follow the rear +profile even when wires are hidden. | 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 +118,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-rear-profile.ts b/src/nema-motor-rear-profile.ts new file mode 100644 index 0000000..45e117a --- /dev/null +++ b/src/nema-motor-rear-profile.ts @@ -0,0 +1,97 @@ +/** Analytic rear-face clearance checks; no renderer or meshes are needed. */ +type RearProfile = { + bodyWidth: number + rearCornerChamfer?: number + rearStepped: boolean + rearCornerRecessRadius: number + rearCornerFilletRadius: number +} + +export function isInsideNemaRearFace(x: number, y: number, p: RearProfile) { + const h = p.bodyWidth / 2 + x = Math.abs(x) + y = Math.abs(y) + if (x > h || y > h) return false + const chamfer = p.rearCornerChamfer ?? 0 + if (!p.rearStepped) return x + y <= 2 * h - chamfer + const c = chamfer / Math.SQRT2 + if (c === 0) return true + const recess = p.rearCornerRecessRadius + const fillet = p.rearCornerFilletRadius + 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)), + ) +} + +/** Reject a bore/head that touches or crosses a straight edge or either kind of arc. */ +export function fitsNemaRearCircle( + x: number, + y: number, + radius: number, + p: RearProfile, +) { + if (!isInsideNemaRearFace(x, y, p)) return false + const h = p.bodyWidth / 2 + x = Math.abs(x) + y = Math.abs(y) + const chamfer = p.rearCornerChamfer ?? 0 + if (!p.rearStepped || chamfer === 0) + return ( + Math.min(h - x, h - y, (2 * h - chamfer - x - y) / Math.SQRT2) > radius + ) + const c = chamfer / Math.SQRT2 + const recess = p.rearCornerRecessRadius + const fillet = p.rearCornerFilletRadius + const a = h - c - fillet + const k = h - c + recess + const distance = 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), + ) + return distance > radius +} diff --git a/src/nema-motor-reference-points.ts b/src/nema-motor-reference-points.ts index 4d61626..07b312d 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 { isInsideNemaRearFace } from "./nema-motor-rear-profile" export interface NemaMotorReferencePoint { position: { x: number; y: number; z: number } @@ -52,6 +53,16 @@ export function getNemaMotorReferencePoints(input: NemaMotorModelPropsInput) { } radius = low } + if (p.nemaSize !== 34) { + let low = 0, + high = p.bodyWidth + for (let i = 0; i < 50; i++) { + const mid = (low + high) / 2 + if (isInsideNemaRearFace(mid * x, mid * y, p)) low = mid + else high = mid + } + radius = low + } const flatRadians = (p.shaftFlatAngle * Math.PI) / 180 return { frontface: { diff --git a/src/nema-motor-schema.ts b/src/nema-motor-schema.ts index 04e6f1d..6849df2 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 { fitsNemaRearCircle } from "./nema-motor-rear-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, @@ -136,6 +145,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(), @@ -174,6 +186,10 @@ const shape = { bodyCornerChamfer: 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(), } @@ -206,6 +222,14 @@ const resolve = ( ["chamfered", "stepped"], 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 || p.wireStubs !== undefined || @@ -223,6 +247,22 @@ const resolve = ( const d = nemaMotorDimensions[p.nemaSize] 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 +278,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, @@ -267,15 +310,19 @@ const resolve = ( bodyCornerChamfer: p.bodyCornerChamfer ?? d.bodyCornerChamfer, 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 @@ -286,21 +333,30 @@ const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => { (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) + !fitsNemaRearCircle( + p.backFaceHoleSpacingX / 2, + p.backFaceHoleSpacingY / 2, + radius, + p, + ) ) issue("Rear holes and screw heads must fit entirely inside the rear face") if (p.backFaceHoleDepth >= p.rearCapLength) diff --git a/src/parse-nema-motor-model-string.ts b/src/parse-nema-motor-model-string.ts index 9447903..92603ee 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", @@ -26,6 +28,8 @@ const lengths = { facechamfer: "faceCornerChamfer", bodychamfer: "bodyCornerChamfer", rearchamfer: "rearCornerChamfer", + rearrecess: "rearCornerRecessRadius", + rearfillet: "rearCornerFilletRadius", } as const const flags = { @@ -38,6 +42,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..b76e948 100644 --- a/tests/fixtures/registration-baselines.json +++ b/tests/fixtures/registration-baselines.json @@ -85,7 +85,14 @@ "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 } }, { diff --git a/tests/nema23-rear-profile.test.ts b/tests/nema23-rear-profile.test.ts new file mode 100644 index 0000000..af937dd --- /dev/null +++ b/tests/nema23-rear-profile.test.ts @@ -0,0 +1,89 @@ +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: 15, + chamfered: true, + 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, + ) +}) From 9660ca92b1f081298823a1d4c19860c250575bfa Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Wed, 14 Oct 2026 20:48:35 +0530 Subject: [PATCH 2/3] fix: match NEMA23 body to rear profile without side overhang --- docs/nema-motors.md | 17 +++-- ...-rear-profile.ts => nema-motor-profile.ts} | 74 +++++++++++-------- src/nema-motor-reference-points.ts | 52 +++++-------- src/nema-motor-schema.ts | 59 +++++++++++---- src/parse-nema-motor-model-string.ts | 2 + tests/fixtures/registration-baselines.json | 4 +- tests/nema-motor-boolean-flags.test.ts | 6 +- tests/nema23-rear-profile.test.ts | 41 +++++++++- 8 files changed, 168 insertions(+), 87 deletions(-) rename src/{nema-motor-rear-profile.ts => nema-motor-profile.ts} (62%) diff --git a/docs/nema-motors.md b/docs/nema-motors.md index 93f2bda..e75d8ad 100644 --- a/docs/nema-motors.md +++ b/docs/nema-motors.md @@ -82,15 +82,22 @@ 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 retain their existing rear profiles. NEMA23 has a separate stepped rear -cap, with R5 concave recesses and R2.5 convex fillets; its front 47.14 mm flange -pattern and core remain unchanged. The rear shape and 50 × 32 mm side-tab pattern +NEMA8/17 retain their existing profiles. NEMA23 defaults to matching stepped +body and rear-cap profiles, with R5 concave recesses and R2.5 convex fillets, +so the rear shoulders do not overhang the body. Its front 47.14 mm flange +pattern remains unchanged. The rear shape and 50 × 32 mm side-tab pattern use [STEPPERONLINE’s ST-M10 rear adapter drawing](https://www.omc-stepperonline.com/image/catalog/dimensions/ST-M10_Dimension.svg) as a representative reference. The adapter itself is 56 × 56 mm with Ø4 clearance holes. The motor model retains its 56.4 mm envelope and uses illustrative M3 socket screws/bores; the adapter drawing does not establish the motor's thread, screw style, or exact rear-cover radii. These are not universal NEMA23 dimensions. +`bodyCornerRecessRadius` / `bodyCornerFilletRadius` (`bodyrecess` / `bodyfillet`) +configure body arcs independently of the rear. The default NEMA23 body and rear +shoulder depths are both 9 mm; other frames retain their existing body shapes. +Arc radii must fit the shoulder, and the complete front through-hole radius must +clear the body, including the concave recess. + `rearStepped` / `_rearstepped` and `rearChamfered` / `_rearchamfered` select only the rear profile. Explicit `stepped` / `chamfered` still select both profiles unless a rear selector is supplied. `rearCornerChamfer` / `_rearchamfer` @@ -98,8 +105,8 @@ divided by sqrt(2) sets the stepped shoulder depth (9 mm for NEMA23). `rearCornerRecessRadius` / `_rearrecess` and `rearCornerFilletRadius` / `_rearfillet` set its independent arc radii; their sum must fit that shoulder. Validation checks bores and heads against the actual straight edges and arcs. -Use `_rearchamfered_backholespacing47.14mm_backscrewm4` to request the previous -NEMA23 rear depiction explicitly. Diagonal wire references follow the rear +Use `_chamfered_backholespacing47.14mm_backscrewm4` to request the previous +NEMA23 body and rear depiction explicitly. Diagonal wire references follow the rear profile even when wires are hidden. | Rear default (mm) | NEMA8 | NEMA17 | NEMA23 | NEMA34 | diff --git a/src/nema-motor-rear-profile.ts b/src/nema-motor-profile.ts similarity index 62% rename from src/nema-motor-rear-profile.ts rename to src/nema-motor-profile.ts index 45e117a..38589a1 100644 --- a/src/nema-motor-rear-profile.ts +++ b/src/nema-motor-profile.ts @@ -1,23 +1,23 @@ -/** Analytic rear-face clearance checks; no renderer or meshes are needed. */ -type RearProfile = { - bodyWidth: number - rearCornerChamfer?: number - rearStepped: boolean - rearCornerRecessRadius: number - rearCornerFilletRadius: number +/** 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 isInsideNemaRearFace(x: number, y: number, p: RearProfile) { - const h = p.bodyWidth / 2 +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.rearCornerChamfer ?? 0 - if (!p.rearStepped) return x + y <= 2 * h - chamfer + 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.rearCornerRecessRadius - const fillet = p.rearCornerFilletRadius + 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 @@ -63,28 +63,23 @@ const arcDistance = ( ) } -/** Reject a bore/head that touches or crosses a straight edge or either kind of arc. */ -export function fitsNemaRearCircle( - x: number, - y: number, - radius: number, - p: RearProfile, -) { - if (!isInsideNemaRearFace(x, y, p)) return false - const h = p.bodyWidth / 2 +const boundaryDistance = (x: number, y: number, p: NemaProfile) => { + const h = p.width / 2 x = Math.abs(x) y = Math.abs(y) - const chamfer = p.rearCornerChamfer ?? 0 - if (!p.rearStepped || chamfer === 0) - return ( - Math.min(h - x, h - y, (2 * h - chamfer - x - y) / Math.SQRT2) > radius + 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.rearCornerRecessRadius - const fillet = p.rearCornerFilletRadius + const recess = p.recessRadius + const fillet = p.filletRadius const a = h - c - fillet const k = h - c + recess - const distance = Math.min( + 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), @@ -93,5 +88,24 @@ export function fitsNemaRearCircle( arcDistance(x, y, k, k, recess, Math.PI, (3 * Math.PI) / 2), arcDistance(x, y, h - fillet, a, fillet, 0, Math.PI / 2), ) - return distance > radius +} + +/** 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 07b312d..6d4e135 100644 --- a/src/nema-motor-reference-points.ts +++ b/src/nema-motor-reference-points.ts @@ -2,7 +2,7 @@ import { nemaMotorModelPropsSchema, type NemaMotorModelPropsInput, } from "./nema-motor-schema" -import { isInsideNemaRearFace } from "./nema-motor-rear-profile" +import { isInsideNemaFace } from "./nema-motor-profile" export interface NemaMotorReferencePoint { position: { x: number; y: number; z: number } @@ -23,42 +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 - } - 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 - } - if (p.nemaSize !== 34) { + 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 (isInsideNemaRearFace(mid * x, mid * y, p)) 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 6849df2..425cffa 100644 --- a/src/nema-motor-schema.ts +++ b/src/nema-motor-schema.ts @@ -3,7 +3,7 @@ import { jstMotorWireConnectionSchema, } from "./jst-motor-connector" import { z } from "zod" -import { fitsNemaRearCircle } from "./nema-motor-rear-profile" +import { fitsNemaCircle, clearsNemaCircle } from "./nema-motor-profile" import { hexSocketBoltDimensions, metricBoltSizeSchema, @@ -108,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. @@ -184,6 +187,8 @@ 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(), @@ -220,7 +225,7 @@ 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"], @@ -245,6 +250,12 @@ 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 = @@ -307,7 +318,17 @@ 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, @@ -328,6 +349,12 @@ const resolve = ( 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 @@ -351,11 +378,17 @@ const validate = (p: NemaMotorModelProps, ctx: z.RefinementCtx) => { ) issue("Rear holes and screw heads must not overlap each other") if ( - !fitsNemaRearCircle( + !fitsNemaCircle( p.backFaceHoleSpacingX / 2, p.backFaceHoleSpacingY / 2, radius, - p, + { + 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") @@ -400,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 92603ee..6f6d573 100644 --- a/src/parse-nema-motor-model-string.ts +++ b/src/parse-nema-motor-model-string.ts @@ -27,6 +27,8 @@ const lengths = { rearcap: "rearCapLength", facechamfer: "faceCornerChamfer", bodychamfer: "bodyCornerChamfer", + bodyrecess: "bodyCornerRecessRadius", + bodyfillet: "bodyCornerFilletRadius", rearchamfer: "rearCornerChamfer", rearrecess: "rearCornerRecessRadius", rearfillet: "rearCornerFilletRadius", diff --git a/tests/fixtures/registration-baselines.json b/tests/fixtures/registration-baselines.json index b76e948..48b80b4 100644 --- a/tests/fixtures/registration-baselines.json +++ b/tests/fixtures/registration-baselines.json @@ -92,7 +92,9 @@ "rearChamfered": true, "rearStepped": false, "rearCornerRecessRadius": 0.5303300858899106, - "rearCornerFilletRadius": 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 index af937dd..4d6063f 100644 --- a/tests/nema23-rear-profile.test.ts +++ b/tests/nema23-rear-profile.test.ts @@ -11,8 +11,11 @@ test("NEMA23 rear side tabs use a rectangular pattern independent of the front f expect(definition).toMatchObject({ mountingHoleSpacing: 47.14, mountingHoleDiameter: 5, - bodyCornerChamfer: 15, - chamfered: true, + bodyCornerChamfer: 9 * Math.SQRT2, + bodyCornerRecessRadius: 5, + bodyCornerFilletRadius: 2.5, + stepped: true, + chamfered: false, rearStepped: true, rearChamfered: false, rearCornerRecessRadius: 5, @@ -87,3 +90,37 @@ test("NEMA23 diagonal wire references stay on the curved rear cap in every rear 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, + }) +}) From 43a0c1969694880fb61072b093a26170835d25ea Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Wed, 14 Oct 2026 20:56:11 +0530 Subject: [PATCH 3/3] docs: keep NEMA23 usage notes minimal --- docs/nema-motors.md | 34 ++++++++-------------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/docs/nema-motors.md b/docs/nema-motors.md index e75d8ad..385e6c6 100644 --- a/docs/nema-motors.md +++ b/docs/nema-motors.md @@ -82,32 +82,14 @@ 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 retain their existing profiles. NEMA23 defaults to matching stepped -body and rear-cap profiles, with R5 concave recesses and R2.5 convex fillets, -so the rear shoulders do not overhang the body. Its front 47.14 mm flange -pattern remains unchanged. The rear shape and 50 × 32 mm side-tab pattern -use [STEPPERONLINE’s ST-M10 rear adapter drawing](https://www.omc-stepperonline.com/image/catalog/dimensions/ST-M10_Dimension.svg) -as a representative reference. The adapter itself is 56 × 56 mm with Ø4 clearance -holes. The motor model retains its 56.4 mm envelope and uses illustrative M3 -socket screws/bores; the adapter drawing does not establish the motor's thread, -screw style, or exact rear-cover radii. These are not universal NEMA23 dimensions. - -`bodyCornerRecessRadius` / `bodyCornerFilletRadius` (`bodyrecess` / `bodyfillet`) -configure body arcs independently of the rear. The default NEMA23 body and rear -shoulder depths are both 9 mm; other frames retain their existing body shapes. -Arc radii must fit the shoulder, and the complete front through-hole radius must -clear the body, including the concave recess. - -`rearStepped` / `_rearstepped` and `rearChamfered` / `_rearchamfered` select -only the rear profile. Explicit `stepped` / `chamfered` still select both -profiles unless a rear selector is supplied. `rearCornerChamfer` / `_rearchamfer` -divided by sqrt(2) sets the stepped shoulder depth (9 mm for NEMA23). -`rearCornerRecessRadius` / `_rearrecess` and `rearCornerFilletRadius` / -`_rearfillet` set its independent arc radii; their sum must fit that shoulder. -Validation checks bores and heads against the actual straight edges and arcs. -Use `_chamfered_backholespacing47.14mm_backscrewm4` to request the previous -NEMA23 body and rear depiction explicitly. Diagonal wire references follow the rear -profile even when wires are hidden. +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 | | --- | --- | --- | --- | --- |