From ca8629fc96e0904d13541e813adb30800d6b683b Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sat, 10 Oct 2026 01:41:51 +0000 Subject: [PATCH 1/2] Add hollowshaft model contract --- src/models/hollowshaft/index.ts | 1 + src/models/hollowshaft/parse-model-string.ts | 50 +++++++++ src/models/hollowshaft/register.ts | 11 ++ src/models/hollowshaft/schema.ts | 101 +++++++++++++++++++ tests/hollowshaft-01-contract.test.ts | 26 +++++ tests/hollowshaft-02-units.test.ts | 27 +++++ tests/hollowshaft-03-invalid-tokens.test.ts | 35 +++++++ tests/hollowshaft-04-fit-validation.test.ts | 25 +++++ tests/hollowshaft-05-flags.test.ts | 35 +++++++ 9 files changed, 311 insertions(+) create mode 100644 src/models/hollowshaft/index.ts create mode 100644 src/models/hollowshaft/parse-model-string.ts create mode 100644 src/models/hollowshaft/register.ts create mode 100644 src/models/hollowshaft/schema.ts create mode 100644 tests/hollowshaft-01-contract.test.ts create mode 100644 tests/hollowshaft-02-units.test.ts create mode 100644 tests/hollowshaft-03-invalid-tokens.test.ts create mode 100644 tests/hollowshaft-04-fit-validation.test.ts create mode 100644 tests/hollowshaft-05-flags.test.ts 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..5c79e8d --- /dev/null +++ b/src/models/hollowshaft/parse-model-string.ts @@ -0,0 +1,50 @@ +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", +} +const literals: Record = { + style: ["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(literals, key)) { + const [literal, property] = literals[key]! + if (value.toLowerCase() !== `(${literal})`) + throw new Error(`Expected hollowshaft ${key}(${literal})`) + assign(property, 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..0a257fc --- /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 style flag; _style(roundtube) is accepted as the roadmap alias. 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..3cdebba --- /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_style(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..cccdd1b --- /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_style(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..c8c7296 --- /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_style(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..17226da --- /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_style(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..e7db672 --- /dev/null +++ b/tests/hollowshaft-05-flags.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_style(roundtube)_endchamfer1mm" +const props = { + outerDiameter: 20, + innerDiameter: 12, + length: 200, + endChamfer: 1, + roundTube: true, +} as const +test("hollowshaft value-free flags reject repeated and conflicting selectors", () => { + expect( + mp + .string("hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube") + .json(), + ).toEqual({ fn: "hollowshaft", ...props }) + expect(() => + mp + .string( + "hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube_roundtube", + ) + .json(), + ).toThrow() + expect(() => + mp + .string("hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube(true)") + .json(), + ).toThrow() + expect(() => mp.string(`${source}_roundtube`).json()).toThrow() +}) From abf3df1e775488b6e06cf44d05af608115af0c6d Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sun, 11 Oct 2026 03:41:31 +0000 Subject: [PATCH 2/2] Replace hollowshaft enum selectors with boolean flags --- src/models/hollowshaft/parse-model-string.ts | 8 ---- src/models/hollowshaft/schema.ts | 2 +- tests/hollowshaft-01-contract.test.ts | 2 +- tests/hollowshaft-02-units.test.ts | 2 +- tests/hollowshaft-03-invalid-tokens.test.ts | 2 +- tests/hollowshaft-04-fit-validation.test.ts | 2 +- tests/hollowshaft-05-flags.test.ts | 41 +++++++------------- 7 files changed, 20 insertions(+), 39 deletions(-) diff --git a/src/models/hollowshaft/parse-model-string.ts b/src/models/hollowshaft/parse-model-string.ts index 5c79e8d..9dcb32e 100644 --- a/src/models/hollowshaft/parse-model-string.ts +++ b/src/models/hollowshaft/parse-model-string.ts @@ -13,9 +13,6 @@ const aliases: Record = { const flags: Record = { roundtube: "roundTube", } -const literals: Record = { - style: ["roundtube", "roundTube"], -} export function parseHollowShaftModelParams(raw: RawModelprinterParams) { const [name, ...tokens] = splitModelStringTokens(raw.string) if (raw.fn !== "hollowshaft" || name?.toLowerCase() !== "hollowshaft") @@ -36,11 +33,6 @@ export function parseHollowShaftModelParams(raw: RawModelprinterParams) { if (value !== "") throw new Error(`hollowshaft flag "${key}" takes no value`) assign(flags[key]!, true) - } else if (Object.hasOwn(literals, key)) { - const [literal, property] = literals[key]! - if (value.toLowerCase() !== `(${literal})`) - throw new Error(`Expected hollowshaft ${key}(${literal})`) - assign(property, true) } else if (Object.hasOwn(aliases, key) && value) { const property = aliases[key]! assign(property, property === "pinCount" ? Number(value) : value) diff --git a/src/models/hollowshaft/schema.ts b/src/models/hollowshaft/schema.ts index 0a257fc..c9f4501 100644 --- a/src/models/hollowshaft/schema.ts +++ b/src/models/hollowshaft/schema.ts @@ -72,7 +72,7 @@ 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 style flag; _style(roundtube) is accepted as the roadmap alias. All normalized dimensions are millimeters. */ +/** 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 { diff --git a/tests/hollowshaft-01-contract.test.ts b/tests/hollowshaft-01-contract.test.ts index 3cdebba..f266e22 100644 --- a/tests/hollowshaft-01-contract.test.ts +++ b/tests/hollowshaft-01-contract.test.ts @@ -5,7 +5,7 @@ import { hollowShaftModelPropsSchema, getHollowShaftDimensions, } from "../src" -const source = "hollowshaft_od20mm_id12mm_l200mm_style(roundtube)_endchamfer1mm" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" const props = { outerDiameter: 20, innerDiameter: 12, diff --git a/tests/hollowshaft-02-units.test.ts b/tests/hollowshaft-02-units.test.ts index cccdd1b..c8b94ab 100644 --- a/tests/hollowshaft-02-units.test.ts +++ b/tests/hollowshaft-02-units.test.ts @@ -5,7 +5,7 @@ import { hollowShaftModelPropsSchema, getHollowShaftDimensions, } from "../src" -const source = "hollowshaft_od20mm_id12mm_l200mm_style(roundtube)_endchamfer1mm" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" const props = { outerDiameter: 20, innerDiameter: 12, diff --git a/tests/hollowshaft-03-invalid-tokens.test.ts b/tests/hollowshaft-03-invalid-tokens.test.ts index c8c7296..018d286 100644 --- a/tests/hollowshaft-03-invalid-tokens.test.ts +++ b/tests/hollowshaft-03-invalid-tokens.test.ts @@ -5,7 +5,7 @@ import { hollowShaftModelPropsSchema, getHollowShaftDimensions, } from "../src" -const source = "hollowshaft_od20mm_id12mm_l200mm_style(roundtube)_endchamfer1mm" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" const props = { outerDiameter: 20, innerDiameter: 12, diff --git a/tests/hollowshaft-04-fit-validation.test.ts b/tests/hollowshaft-04-fit-validation.test.ts index 17226da..c2c1ba4 100644 --- a/tests/hollowshaft-04-fit-validation.test.ts +++ b/tests/hollowshaft-04-fit-validation.test.ts @@ -5,7 +5,7 @@ import { hollowShaftModelPropsSchema, getHollowShaftDimensions, } from "../src" -const source = "hollowshaft_od20mm_id12mm_l200mm_style(roundtube)_endchamfer1mm" +const source = "hollowshaft_od20mm_id12mm_l200mm_roundtube_endchamfer1mm" const props = { outerDiameter: 20, innerDiameter: 12, diff --git a/tests/hollowshaft-05-flags.test.ts b/tests/hollowshaft-05-flags.test.ts index e7db672..39d6c31 100644 --- a/tests/hollowshaft-05-flags.test.ts +++ b/tests/hollowshaft-05-flags.test.ts @@ -1,11 +1,5 @@ import { expect, test } from "bun:test" -import { - mp, - modelDefinitionSchema, - hollowShaftModelPropsSchema, - getHollowShaftDimensions, -} from "../src" -const source = "hollowshaft_od20mm_id12mm_l200mm_style(roundtube)_endchamfer1mm" +import { mp } from "../src" const props = { outerDiameter: 20, innerDiameter: 12, @@ -13,23 +7,18 @@ const props = { endChamfer: 1, roundTube: true, } as const -test("hollowshaft value-free flags reject repeated and conflicting selectors", () => { - expect( - mp - .string("hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube") - .json(), - ).toEqual({ fn: "hollowshaft", ...props }) - expect(() => - mp - .string( - "hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube_roundtube", - ) - .json(), - ).toThrow() - expect(() => - mp - .string("hollowshaft_od20mm_id12mm_l200mm_endchamfer1mm_roundtube(true)") - .json(), - ).toThrow() - expect(() => mp.string(`${source}_roundtube`).json()).toThrow() +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() + } })