From 912f09d9efb6fe617a5bab3c073f0cc53c7c6127 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Tue, 6 Oct 2026 20:47:08 -0700 Subject: [PATCH] Add beltidler model contract --- docs/beltidler.md | 43 +++++++++++++ src/models/beltidler/index.ts | 2 + src/models/beltidler/parse-model-string.ts | 51 +++++++++++++++ src/models/beltidler/register.ts | 11 ++++ src/models/beltidler/schema.ts | 73 ++++++++++++++++++++++ tests/beltidler-01-contract.test.ts | 51 +++++++++++++++ tests/beltidler-02-validation.test.ts | 36 +++++++++++ 7 files changed, 267 insertions(+) create mode 100644 docs/beltidler.md create mode 100644 src/models/beltidler/index.ts create mode 100644 src/models/beltidler/parse-model-string.ts create mode 100644 src/models/beltidler/register.ts create mode 100644 src/models/beltidler/schema.ts create mode 100644 tests/beltidler-01-contract.test.ts create mode 100644 tests/beltidler-02-validation.test.ts diff --git a/docs/beltidler.md b/docs/beltidler.md new file mode 100644 index 0000000..2983194 --- /dev/null +++ b/docs/beltidler.md @@ -0,0 +1,43 @@ +# Belt idler + +```text +beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm +``` + +A generic smooth, two-flange roller with a straight through bore for contact +with a timing belt's smooth back. Only `shape(smooth)` is supported. It is a +roller envelope with explicit dimensions, not a named bearing assembly; +no rolling elements, races, seals, shaft, hub, or tooth-meshing capability is +implied. Diameter selection and belt bending limits belong to the application. + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `od` / `outerdiameter` | 20 mm | Smooth contact surface diameter | +| `bore` / `borediameter` | 5 mm | Straight through bore | +| `beltw` / `beltwidth` | 10 mm | Intended belt width | +| `beltt` / `beltthickness` | 2.2 mm | Complete thickness outside the contact surface | +| `clearance` / `sideclearance` | 1 mm | Positive axial gap on each side of the belt | +| `flangeh` / `flangeheight` | 3 mm | Flange's radial rise above contact surface | +| `flanget` / `flangethickness` | 1 mm | Thickness of each flange | + +All lengths must be positive; the bore must be smaller than contact diameter. +Flange rise must exceed the complete belt thickness, so the teeth projecting +outward from backside contact clear the flange rim. Face width is belt width +plus twice side clearance. Default contact/flange diameters are 20 and 26 mm; +face width is 12 mm and total width is 14 mm. The 2.2 mm belt default corresponds +to the basic T5 section but may be changed for another belt. This smooth roller +has no profile or tooth pitch. + +The axis is +Z at X=Y=0. The smooth face occupies Z=0..face width; flanges lie +at -flange thickness..0 and face width..face width+flange thickness. A centered +belt spans Z=side clearance..side clearance+belt width. Square flange shoulders +and flat annular ends are fixed construction details. `getBeltIdlerDimensions` +returns contact radius, flange diameter, wall and above-belt clearance, widths +and end datum planes. + +Length tokens normalize to millimeters and accept mm, cm, m, in, inch, mil, ft, +and feet. Names/selectors are case insensitive; duplicate aliases and unknown, +malformed or unsupported tokens fail. This is an explicitly dimensioned generic +roller, not a DIN claim or manufacturer-specific part number. See +[HPC Europe's timing-belt catalog](https://www.hpceurope.com/docFichesTechniques/Belts.pdf) +for the referenced T5 basic belt thickness and smooth-back idler application. diff --git a/src/models/beltidler/index.ts b/src/models/beltidler/index.ts new file mode 100644 index 0000000..08f4886 --- /dev/null +++ b/src/models/beltidler/index.ts @@ -0,0 +1,2 @@ +export * from "./schema" +export * from "./parse-model-string" diff --git a/src/models/beltidler/parse-model-string.ts b/src/models/beltidler/parse-model-string.ts new file mode 100644 index 0000000..04a5a23 --- /dev/null +++ b/src/models/beltidler/parse-model-string.ts @@ -0,0 +1,51 @@ +import { beltIdlerModelDefinitionSchema } from "./schema" +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +const lengths: Record = { + od: "outerDiameter", + outerdiameter: "outerDiameter", + bore: "boreDiameter", + borediameter: "boreDiameter", + beltw: "beltWidth", + beltwidth: "beltWidth", + beltt: "beltThickness", + beltthickness: "beltThickness", + clearance: "sideClearance", + sideclearance: "sideClearance", + flangeh: "flangeHeight", + flangeheight: "flangeHeight", + flanget: "flangeThickness", + flangethickness: "flangeThickness", +} +const counts: Record = {} +const selectors: Record = { shape: "shape" } +export function parseBeltIdlerModelParams(raw: RawModelprinterParams) { + const tokens = splitModelStringTokens(raw.string) + if (raw.fn !== "beltidler" || !/^beltidler$/i.test(tokens[0]!)) + throw new Error("Expected beltidler without an inline value") + const props: Record = { fn: "beltidler" } + for (const token of tokens.slice(1)) { + const match = token.match(/^([a-z]+)(.*)$/i) + const name = match?.[1]?.toLowerCase(), + value = match?.[2] + if (!name || !value) throw new Error(`Invalid beltidler token "${token}"`) + let property: string, + parsed: unknown = value + if (Object.hasOwn(lengths, name)) property = lengths[name]! + else if (Object.hasOwn(counts, name)) { + property = counts[name]! + if (!/^\d+$/.test(value)) + throw new Error("Tooth count must be a complete integer") + parsed = Number(value) + } else if (Object.hasOwn(selectors, name)) { + property = selectors[name]! + const selection = value.match(/^\(([a-z][a-z0-9]*)\)$/i) + if (!selection) throw new Error("Selectors require parentheses") + parsed = selection[1]!.toLowerCase() + } else throw new Error(`Unknown beltidler token "${token}"`) + if (Object.hasOwn(props, property)) + throw new Error(`Duplicate beltidler property "${property}"`) + props[property] = parsed + } + return beltIdlerModelDefinitionSchema.parse(props) +} diff --git a/src/models/beltidler/register.ts b/src/models/beltidler/register.ts new file mode 100644 index 0000000..3457895 --- /dev/null +++ b/src/models/beltidler/register.ts @@ -0,0 +1,11 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { beltIdlerModelDefinitionSchema } from "./schema" +import { parseBeltIdlerModelParams } from "./parse-model-string" +export const model = defineModel({ + name: "beltidler", + schema: beltIdlerModelDefinitionSchema, + parse: parseBeltIdlerModelParams, +}) +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/beltidler/schema.ts b/src/models/beltidler/schema.ts new file mode 100644 index 0000000..bedd1b5 --- /dev/null +++ b/src/models/beltidler/schema.ts @@ -0,0 +1,73 @@ +import { z } from "zod" +import { positiveModelLengthSchema } from "../../model-length-schema" +const length = z + .union([ + z.number(), + z + .string() + .regex( + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:mm|cm|m|in|inch|mil|ft|feet)?$/i, + "Expected a complete numeric length with an optional unit", + ), + ]) + .pipe(positiveModelLengthSchema) + +const shape = { + shape: z.literal("smooth").default("smooth"), + outerDiameter: length.default(20), + boreDiameter: length.default(5), + beltWidth: length.default(10), + beltThickness: length.default(2.2), + sideClearance: length.default(1), + flangeHeight: length.default(3), + flangeThickness: length.default(1), +} +type Resolved = z.output> +function dimensions(p: Resolved) { + const faceWidth = p.beltWidth + 2 * p.sideClearance + return { + faceWidth, + contactRadius: p.outerDiameter / 2, + flangeDiameter: p.outerDiameter + 2 * p.flangeHeight, + totalWidth: faceWidth + 2 * p.flangeThickness, + minZ: -p.flangeThickness, + maxZ: faceWidth + p.flangeThickness, + radialWallThickness: (p.outerDiameter - p.boreDiameter) / 2, + flangeAboveBelt: p.flangeHeight - p.beltThickness, + } +} +function validate(p: Resolved, c: z.RefinementCtx) { + if (Object.values(dimensions(p)).some((n) => !Number.isFinite(n))) + c.addIssue({ + code: "custom", + message: "Derived idler dimensions must be finite", + }) + if (p.boreDiameter >= p.outerDiameter) + c.addIssue({ + code: "custom", + path: ["boreDiameter"], + message: "Bore must be smaller than the smooth contact diameter", + }) + if (p.flangeHeight <= p.beltThickness) + c.addIssue({ + code: "custom", + path: ["flangeHeight"], + message: "Flanges must extend beyond the complete belt thickness", + }) +} +export const beltIdlerModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const beltIdlerModelDefinitionSchema = z + .object({ fn: z.literal("beltidler"), ...shape }) + .strict() + .superRefine(validate) +export type BeltIdlerModelPropsInput = z.input +export type BeltIdlerModelProps = z.output +export type BeltIdlerModelDefinition = z.output< + typeof beltIdlerModelDefinitionSchema +> +export function getBeltIdlerDimensions(input: BeltIdlerModelPropsInput = {}) { + return dimensions(beltIdlerModelPropsSchema.parse(input)) +} diff --git a/tests/beltidler-01-contract.test.ts b/tests/beltidler-01-contract.test.ts new file mode 100644 index 0000000..94f24a2 --- /dev/null +++ b/tests/beltidler-01-contract.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { + mp, + modelprinter, + modelDefinitionSchema, + beltIdlerModelPropsSchema, + getBeltIdlerDimensions, + parseBeltIdlerModelParams, +} from "../src" +const example = + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm" +test("beltidler canonical string, dimensions, units and registration", () => { + const expected = { + fn: "beltidler", + shape: "smooth", + outerDiameter: 20, + boreDiameter: 5, + beltWidth: 10, + beltThickness: 2.2, + sideClearance: 1, + flangeHeight: 3, + flangeThickness: 1, + } as const + expect(mp.string(example).json()).toEqual(expected) + expect(mp.string("beltidler").json()).toEqual(expected) + expect( + mp + .string( + "BELTIDLER_SHAPE(SMOOTH)_OUTERDIAMETER2cm_BOREDIAMETER0.005m_BELTWIDTH1cm_BELTTHICKNESS0.22cm_SIDECLEARANCE1mm_FLANGEHEIGHT0.3cm_FLANGETHICKNESS1mm", + ) + .json(), + ).toEqual(expected) + expect(modelDefinitionSchema.parse(expected)).toEqual(expected) + expect(modelprinter.getModelNames()).toContain("beltidler") + const { fn, ...props } = expected + expect(beltIdlerModelPropsSchema.parse(props)).toEqual(props) + const d = getBeltIdlerDimensions(props) + expect(d).toEqual({ + faceWidth: 12, + contactRadius: 10, + flangeDiameter: 26, + totalWidth: 14, + minZ: -1, + maxZ: 13, + radialWallThickness: 7.5, + flangeAboveBelt: 0.7999999999999998, + }) + expect(() => + parseBeltIdlerModelParams({ fn: "wrong", string: example }), + ).toThrow() +}) diff --git a/tests/beltidler-02-validation.test.ts b/tests/beltidler-02-validation.test.ts new file mode 100644 index 0000000..c343544 --- /dev/null +++ b/tests/beltidler-02-validation.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { mp, beltIdlerModelPropsSchema } from "../src" +test("beltidler rejects malformed, duplicate and impossible contracts", () => { + for (const source of [ + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm_bore5mm", + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm_borediameter5mm", + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm_unknown2mm", + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm_constructor2mm", + "beltidler_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm_", + "beltidler2_shape(smooth)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shape(smooth)_od20mm_bore1.2.3mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shape(smooth)_od20mm_bore0mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shape(smooth)_od20mm_bore-1mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shape(smooth)_od20mm_bore_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shape(toothed)_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + "beltidler_shapesmooth_od20mm_bore5mm_beltw10mm_beltt2.2mm_clearance1mm_flangeh3mm_flanget1mm", + ]) + expect(() => mp.string(source).json(), source).toThrow() + const invalid: Record[] = [ + { boreDiameter: 20 }, + { boreDiameter: 21 }, + { flangeHeight: 2.2 }, + { flangeHeight: 2 }, + { sideClearance: 0 }, + { flangeThickness: 0 }, + { outerDiameter: "20oops" }, + { outerDiameter: 1e308, flangeHeight: 1e308 }, + { shape: "toothed" }, + ] + invalid.push({ beltWidth: NaN }, { beltWidth: Infinity }, { extra: true }) + for (const change of invalid) + expect( + () => beltIdlerModelPropsSchema.parse(change), + JSON.stringify(change), + ).toThrow() +})