diff --git a/src/models/hollowshaft/index.ts b/src/models/hollowshaft/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/hollowshaft/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/hollowshaft/parse-model-string.ts b/src/models/hollowshaft/parse-model-string.ts new file mode 100644 index 0000000..9dcb32e --- /dev/null +++ b/src/models/hollowshaft/parse-model-string.ts @@ -0,0 +1,42 @@ +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { hollowShaftModelDefinitionSchema } from "./schema" +const aliases: Record = { + od: "outerDiameter", + outerdiameter: "outerDiameter", + id: "innerDiameter", + innerdiameter: "innerDiameter", + l: "length", + length: "length", + endchamfer: "endChamfer", +} +const flags: Record = { + roundtube: "roundTube", +} +export function parseHollowShaftModelParams(raw: RawModelprinterParams) { + const [name, ...tokens] = splitModelStringTokens(raw.string) + if (raw.fn !== "hollowshaft" || name?.toLowerCase() !== "hollowshaft") + throw new Error("Expected hollowshaft without inline values") + const props: Record = { fn: "hollowshaft" } + const assign = (property: string, value: unknown) => { + if (Object.hasOwn(props, property)) + throw new Error(`Duplicate hollowshaft property "${property}"`) + props[property] = value + } + for (const token of tokens) { + const match = token.match(/^([a-z]+)(.*)$/i) + const key = match?.[1]?.toLowerCase() + const value = match?.[2] + if (!key || value === undefined) + throw new Error(`Malformed hollowshaft token "${token}"`) + if (Object.hasOwn(flags, key)) { + if (value !== "") + throw new Error(`hollowshaft flag "${key}" takes no value`) + assign(flags[key]!, true) + } else if (Object.hasOwn(aliases, key) && value) { + const property = aliases[key]! + assign(property, property === "pinCount" ? Number(value) : value) + } else throw new Error(`Unknown or malformed hollowshaft token "${token}"`) + } + return hollowShaftModelDefinitionSchema.parse(props) +} diff --git a/src/models/hollowshaft/register.ts b/src/models/hollowshaft/register.ts new file mode 100644 index 0000000..203513b --- /dev/null +++ b/src/models/hollowshaft/register.ts @@ -0,0 +1,11 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { hollowShaftModelDefinitionSchema } from "./schema" +import { parseHollowShaftModelParams } from "./parse-model-string" +export const model = defineModel({ + name: "hollowshaft", + schema: hollowShaftModelDefinitionSchema, + parse: parseHollowShaftModelParams, +}) +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/hollowshaft/schema.ts b/src/models/hollowshaft/schema.ts new file mode 100644 index 0000000..c9f4501 --- /dev/null +++ b/src/models/hollowshaft/schema.ts @@ -0,0 +1,101 @@ +import { z } from "zod" +import { modelLengthSchema } from "../../model-length-schema" + +// Consume the complete decimal length rather than accepting partial numbers. +const length = z + .union([ + z.number(), + z + .string() + .regex(/^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:mm|cm|m|in|inch|mil|ft|feet)?$/i) + .transform((value) => value.toLowerCase()), + ]) + .pipe(modelLengthSchema) +const positiveLength = length.refine( + (value) => value > 0, + "Length must be positive", +) +const nonnegativeLength = length.refine( + (value) => value >= 0, + "Length cannot be negative", +) +const shape = { + outerDiameter: positiveLength, + innerDiameter: positiveLength, + length: positiveLength, + endChamfer: nonnegativeLength.default(0), + roundTube: z.literal(true).default(true), +} +function validate( + p: z.output>, + context: z.RefinementCtx, +) { + const span = Math.max(p.outerDiameter, p.length) + if (!Number.isFinite(span ** 3)) + context.addIssue({ + code: "custom", + message: "Dimensions must have a finite bounding volume", + }) + if (p.innerDiameter >= p.outerDiameter) + context.addIssue({ + code: "custom", + path: ["innerDiameter"], + message: "Shaft bore must be smaller than the outer diameter", + }) + if (2 * p.endChamfer >= (p.outerDiameter - p.innerDiameter) / 2) + context.addIssue({ + code: "custom", + path: ["endChamfer"], + message: + "Inner and outer end chamfers must leave a positive annular end land", + }) + if (2 * p.endChamfer >= p.length) + context.addIssue({ + code: "custom", + path: ["endChamfer"], + message: "End chamfers must leave a positive straight shaft length", + }) +} +export const hollowShaftModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const hollowShaftModelDefinitionSchema = z + .object({ fn: z.literal("hollowshaft"), ...shape }) + .strict() + .superRefine(validate) +export type HollowShaftModelPropsInput = z.input< + typeof hollowShaftModelPropsSchema +> +export type HollowShaftModelProps = z.output +export type HollowShaftModelDefinition = z.output< + typeof hollowShaftModelDefinitionSchema +> + +/** A concentric round tubular drive shaft with a through bore and 45-degree chamfers on both the inner and outer edges of both ends. The centered shaft axis is +Z and bottom face Z=0. endChamfer is the radial and axial chamfer size; validation preserves a positive annular end land. _roundtube is the value-free flag for the round tubular geometry. All normalized dimensions are millimeters. */ +export function getHollowShaftDimensions(input: HollowShaftModelPropsInput) { + const p = hollowShaftModelPropsSchema.parse(input) + return { + ...p, + size: [p.outerDiameter, p.outerDiameter, p.length] as [ + number, + number, + number, + ], + min: [-p.outerDiameter / 2, -p.outerDiameter / 2, 0] as [ + number, + number, + number, + ], + max: [p.outerDiameter / 2, p.outerDiameter / 2, p.length] as [ + number, + number, + number, + ], + bottomZ: 0, + topZ: p.length, + wallThickness: (p.outerDiameter - p.innerDiameter) / 2, + endOuterDiameter: p.outerDiameter - 2 * p.endChamfer, + endBoreDiameter: p.innerDiameter + 2 * p.endChamfer, + } +} diff --git a/tests/hollowshaft-01-contract.test.ts b/tests/hollowshaft-01-contract.test.ts new file mode 100644 index 0000000..f266e22 --- /dev/null +++ b/tests/hollowshaft-01-contract.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hollowShaftModelPropsSchema, + getHollowShaftDimensions, +} from "../src" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft complete roadmap contract and public schema roundtrip", () => { + const expected = { fn: "hollowshaft" as const, ...props } + expect(mp.string(source).json()).toEqual(expected) + expect(modelDefinitionSchema.parse(expected)).toEqual(expected) + expect(hollowShaftModelPropsSchema.parse(props)).toEqual(props) + expect( + getHollowShaftDimensions(props).size.every( + (value) => value > 0 && Number.isFinite(value), + ), + ).toBe(true) +}) diff --git a/tests/hollowshaft-02-units.test.ts b/tests/hollowshaft-02-units.test.ts new file mode 100644 index 0000000..c8b94ab --- /dev/null +++ b/tests/hollowshaft-02-units.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hollowShaftModelPropsSchema, + getHollowShaftDimensions, +} from "../src" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft accepts full decimal units and case-insensitive strings", () => { + expect(mp.string(source.toUpperCase()).json()).toEqual({ + fn: "hollowshaft", + ...props, + }) + expect( + hollowShaftModelPropsSchema.parse({ ...props, outerDiameter: "2.0cm" }), + ).toEqual(props) + expect( + hollowShaftModelPropsSchema.parse({ ...props, outerDiameter: "+20mm" }), + ).toEqual(props) +}) diff --git a/tests/hollowshaft-03-invalid-tokens.test.ts b/tests/hollowshaft-03-invalid-tokens.test.ts new file mode 100644 index 0000000..018d286 --- /dev/null +++ b/tests/hollowshaft-03-invalid-tokens.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hollowShaftModelPropsSchema, + getHollowShaftDimensions, +} from "../src" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft rejects missing, duplicate, unknown and partially parsed tokens", () => { + for (const suffix of [ + "", + "typo1mm", + "constructor1", + "od1mm", + "outerdiameter1mm", + "od(1mm)", + "od1mmjunk", + ]) + expect(() => mp.string(`${source}_${suffix}`).json()).toThrow() + expect(() => mp.string("hollowshaft").json()).toThrow() + for (const value of [0, -1, Infinity, NaN, 1e308, "1e2", "2mmjunk", "(2mm)"]) + expect(() => + hollowShaftModelPropsSchema.parse({ ...props, outerDiameter: value }), + ).toThrow() + expect(() => + hollowShaftModelPropsSchema.parse({ ...props, unknown: true }), + ).toThrow() +}) diff --git a/tests/hollowshaft-04-fit-validation.test.ts b/tests/hollowshaft-04-fit-validation.test.ts new file mode 100644 index 0000000..c2c1ba4 --- /dev/null +++ b/tests/hollowshaft-04-fit-validation.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hollowShaftModelPropsSchema, + getHollowShaftDimensions, +} from "../src" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft rejects impossible fitting and feature dimensions", () => { + for (const invalid of [ + { innerDiameter: 20 }, + { endChamfer: 2 }, + { length: 2 }, + ]) + expect(() => + hollowShaftModelPropsSchema.parse({ ...props, ...invalid }), + ).toThrow() +}) diff --git a/tests/hollowshaft-05-flags.test.ts b/tests/hollowshaft-05-flags.test.ts new file mode 100644 index 0000000..39d6c31 --- /dev/null +++ b/tests/hollowshaft-05-flags.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { mp } from "../src" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft uses value-free flags and rejects removed selector syntax", () => { + const base = "hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm" + const expected = { fn: "hollowshaft", ...props } as const + expect(mp.string(`${base}_roundtube`).json()).toEqual(expected) + expect(mp.string(base).json()).toEqual(expected) + for (const token of [ + "roundtube_roundtube", + "roundtube(true)", + "style(roundtube)", + "STYLE(ROUNDTUBE)", + "style(unknown)", + ]) { + expect(() => mp.string(`${base}_${token}`).json()).toThrow() + } +})