From e6ab075e142f1385a894b49cffe990931d1ab5ec Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sat, 10 Oct 2026 01:41:51 +0000 Subject: [PATCH 1/2] Add hexshaft model contract --- src/models/hexshaft/index.ts | 1 + src/models/hexshaft/parse-model-string.ts | 47 ++++++++++++ src/models/hexshaft/register.ts | 11 +++ src/models/hexshaft/schema.ts | 90 +++++++++++++++++++++++ tests/hexshaft-01-contract.test.ts | 25 +++++++ tests/hexshaft-02-units.test.ts | 26 +++++++ tests/hexshaft-03-invalid-tokens.test.ts | 34 +++++++++ tests/hexshaft-04-fit-validation.test.ts | 20 +++++ tests/hexshaft-05-flags.test.ts | 28 +++++++ 9 files changed, 282 insertions(+) create mode 100644 src/models/hexshaft/index.ts create mode 100644 src/models/hexshaft/parse-model-string.ts create mode 100644 src/models/hexshaft/register.ts create mode 100644 src/models/hexshaft/schema.ts create mode 100644 tests/hexshaft-01-contract.test.ts create mode 100644 tests/hexshaft-02-units.test.ts create mode 100644 tests/hexshaft-03-invalid-tokens.test.ts create mode 100644 tests/hexshaft-04-fit-validation.test.ts create mode 100644 tests/hexshaft-05-flags.test.ts diff --git a/src/models/hexshaft/index.ts b/src/models/hexshaft/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/hexshaft/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/hexshaft/parse-model-string.ts b/src/models/hexshaft/parse-model-string.ts new file mode 100644 index 0000000..9a0b4de --- /dev/null +++ b/src/models/hexshaft/parse-model-string.ts @@ -0,0 +1,47 @@ +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { hexShaftModelDefinitionSchema } from "./schema" +const aliases: Record = { + af: "acrossFlats", + acrossflats: "acrossFlats", + l: "length", + length: "length", + endchamfer: "endChamfer", +} +const flags: Record = { + regularhex: "regularHex", +} +const literals: Record = { + profile: ["regularhex", "regularHex"], +} +export function parseHexShaftModelParams(raw: RawModelprinterParams) { + const [name, ...tokens] = splitModelStringTokens(raw.string) + if (raw.fn !== "hexshaft" || name?.toLowerCase() !== "hexshaft") + throw new Error("Expected hexshaft without inline values") + const props: Record = { fn: "hexshaft" } + const assign = (property: string, value: unknown) => { + if (Object.hasOwn(props, property)) + throw new Error(`Duplicate hexshaft 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 hexshaft token "${token}"`) + if (Object.hasOwn(flags, key)) { + if (value !== "") throw new Error(`hexshaft 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 hexshaft ${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 hexshaft token "${token}"`) + } + return hexShaftModelDefinitionSchema.parse(props) +} diff --git a/src/models/hexshaft/register.ts b/src/models/hexshaft/register.ts new file mode 100644 index 0000000..96b9738 --- /dev/null +++ b/src/models/hexshaft/register.ts @@ -0,0 +1,11 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { hexShaftModelDefinitionSchema } from "./schema" +import { parseHexShaftModelParams } from "./parse-model-string" +export const model = defineModel({ + name: "hexshaft", + schema: hexShaftModelDefinitionSchema, + parse: parseHexShaftModelParams, +}) +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/hexshaft/schema.ts b/src/models/hexshaft/schema.ts new file mode 100644 index 0000000..2ca5080 --- /dev/null +++ b/src/models/hexshaft/schema.ts @@ -0,0 +1,90 @@ +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 = { + acrossFlats: positiveLength, + length: positiveLength, + endChamfer: nonnegativeLength.default(0), + regularHex: z.literal(true).default(true), +} +function validate( + p: z.output>, + context: z.RefinementCtx, +) { + const span = Math.max(2 * (p.acrossFlats / Math.sqrt(3)), p.length) + if (!Number.isFinite(span ** 3)) + context.addIssue({ + code: "custom", + message: "Dimensions must have a finite bounding volume", + }) + if (2 * p.endChamfer >= p.acrossFlats) + context.addIssue({ + code: "custom", + path: ["endChamfer"], + message: "End chamfer must leave a positive hexagonal end face", + }) + if (2 * p.endChamfer >= p.length) + context.addIssue({ + code: "custom", + path: ["endChamfer"], + message: "End chamfers must leave a positive straight shaft length", + }) +} +export const hexShaftModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const hexShaftModelDefinitionSchema = z + .object({ fn: z.literal("hexshaft"), ...shape }) + .strict() + .superRefine(validate) +export type HexShaftModelPropsInput = z.input +export type HexShaftModelProps = z.output +export type HexShaftModelDefinition = z.output< + typeof hexShaftModelDefinitionSchema +> + +/** A regular hexagonal drive shaft with 45-degree chamfers on all six facets at each end. Length runs along the centered +Z axis from Z=0. acrossFlats measures the distance between the flats X=+-acrossFlats/2; vertices lie on the +/-Y axis. endChamfer offsets each flat inward by that amount over the same axial distance. _regularhex is the value-free profile flag; _profile(regularhex) is the roadmap alias. All normalized dimensions are millimeters. */ +export function getHexShaftDimensions(input: HexShaftModelPropsInput) { + const p = hexShaftModelPropsSchema.parse(input) + return { + ...p, + size: [p.acrossFlats, (2 * p.acrossFlats) / Math.sqrt(3), p.length] as [ + number, + number, + number, + ], + min: [-p.acrossFlats / 2, -p.acrossFlats / Math.sqrt(3), 0] as [ + number, + number, + number, + ], + max: [p.acrossFlats / 2, p.acrossFlats / Math.sqrt(3), p.length] as [ + number, + number, + number, + ], + bottomZ: 0, + topZ: p.length, + circumradius: p.acrossFlats / Math.sqrt(3), + endAcrossFlats: p.acrossFlats - 2 * p.endChamfer, + } +} diff --git a/tests/hexshaft-01-contract.test.ts b/tests/hexshaft-01-contract.test.ts new file mode 100644 index 0000000..92b80ea --- /dev/null +++ b/tests/hexshaft-01-contract.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hexShaftModelPropsSchema, + getHexShaftDimensions, +} from "../src" +const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const props = { + acrossFlats: 12, + length: 200, + endChamfer: 1, + regularHex: true, +} as const +test("hexshaft complete roadmap contract and public schema roundtrip", () => { + const expected = { fn: "hexshaft" as const, ...props } + expect(mp.string(source).json()).toEqual(expected) + expect(modelDefinitionSchema.parse(expected)).toEqual(expected) + expect(hexShaftModelPropsSchema.parse(props)).toEqual(props) + expect( + getHexShaftDimensions(props).size.every( + (value) => value > 0 && Number.isFinite(value), + ), + ).toBe(true) +}) diff --git a/tests/hexshaft-02-units.test.ts b/tests/hexshaft-02-units.test.ts new file mode 100644 index 0000000..ac6b6f7 --- /dev/null +++ b/tests/hexshaft-02-units.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hexShaftModelPropsSchema, + getHexShaftDimensions, +} from "../src" +const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const props = { + acrossFlats: 12, + length: 200, + endChamfer: 1, + regularHex: true, +} as const +test("hexshaft accepts full decimal units and case-insensitive strings", () => { + expect(mp.string(source.toUpperCase()).json()).toEqual({ + fn: "hexshaft", + ...props, + }) + expect( + hexShaftModelPropsSchema.parse({ ...props, acrossFlats: "1.2cm" }), + ).toEqual(props) + expect( + hexShaftModelPropsSchema.parse({ ...props, acrossFlats: "+12mm" }), + ).toEqual(props) +}) diff --git a/tests/hexshaft-03-invalid-tokens.test.ts b/tests/hexshaft-03-invalid-tokens.test.ts new file mode 100644 index 0000000..202b8bf --- /dev/null +++ b/tests/hexshaft-03-invalid-tokens.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hexShaftModelPropsSchema, + getHexShaftDimensions, +} from "../src" +const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const props = { + acrossFlats: 12, + length: 200, + endChamfer: 1, + regularHex: true, +} as const +test("hexshaft rejects missing, duplicate, unknown and partially parsed tokens", () => { + for (const suffix of [ + "", + "typo1mm", + "constructor1", + "af1mm", + "acrossflats1mm", + "af(1mm)", + "af1mmjunk", + ]) + expect(() => mp.string(`${source}_${suffix}`).json()).toThrow() + expect(() => mp.string("hexshaft").json()).toThrow() + for (const value of [0, -1, Infinity, NaN, 1e308, "1e2", "2mmjunk", "(2mm)"]) + expect(() => + hexShaftModelPropsSchema.parse({ ...props, acrossFlats: value }), + ).toThrow() + expect(() => + hexShaftModelPropsSchema.parse({ ...props, unknown: true }), + ).toThrow() +}) diff --git a/tests/hexshaft-04-fit-validation.test.ts b/tests/hexshaft-04-fit-validation.test.ts new file mode 100644 index 0000000..fe73376 --- /dev/null +++ b/tests/hexshaft-04-fit-validation.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hexShaftModelPropsSchema, + getHexShaftDimensions, +} from "../src" +const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const props = { + acrossFlats: 12, + length: 200, + endChamfer: 1, + regularHex: true, +} as const +test("hexshaft rejects impossible fitting and feature dimensions", () => { + for (const invalid of [{ endChamfer: 6 }, { length: 2 }]) + expect(() => + hexShaftModelPropsSchema.parse({ ...props, ...invalid }), + ).toThrow() +}) diff --git a/tests/hexshaft-05-flags.test.ts b/tests/hexshaft-05-flags.test.ts new file mode 100644 index 0000000..c076c47 --- /dev/null +++ b/tests/hexshaft-05-flags.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + hexShaftModelPropsSchema, + getHexShaftDimensions, +} from "../src" +const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const props = { + acrossFlats: 12, + length: 200, + endChamfer: 1, + regularHex: true, +} as const +test("hexshaft value-free flags reject repeated and conflicting selectors", () => { + expect( + mp.string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex").json(), + ).toEqual({ fn: "hexshaft", ...props }) + expect(() => + mp + .string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex_regularhex") + .json(), + ).toThrow() + expect(() => + mp.string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex(true)").json(), + ).toThrow() + expect(() => mp.string(`${source}_regularhex`).json()).toThrow() +}) From e0b30adc164bec7db7792b1fc26bfd554df60df3 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sun, 11 Oct 2026 03:41:33 +0000 Subject: [PATCH 2/2] Replace hexshaft enum selectors with boolean flags --- src/models/hexshaft/parse-model-string.ts | 8 ------ src/models/hexshaft/schema.ts | 2 +- tests/hexshaft-01-contract.test.ts | 2 +- tests/hexshaft-02-units.test.ts | 2 +- tests/hexshaft-03-invalid-tokens.test.ts | 2 +- tests/hexshaft-04-fit-validation.test.ts | 2 +- tests/hexshaft-05-flags.test.ts | 35 ++++++++++------------- 7 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/models/hexshaft/parse-model-string.ts b/src/models/hexshaft/parse-model-string.ts index 9a0b4de..17848a2 100644 --- a/src/models/hexshaft/parse-model-string.ts +++ b/src/models/hexshaft/parse-model-string.ts @@ -11,9 +11,6 @@ const aliases: Record = { const flags: Record = { regularhex: "regularHex", } -const literals: Record = { - profile: ["regularhex", "regularHex"], -} export function parseHexShaftModelParams(raw: RawModelprinterParams) { const [name, ...tokens] = splitModelStringTokens(raw.string) if (raw.fn !== "hexshaft" || name?.toLowerCase() !== "hexshaft") @@ -33,11 +30,6 @@ export function parseHexShaftModelParams(raw: RawModelprinterParams) { if (Object.hasOwn(flags, key)) { if (value !== "") throw new Error(`hexshaft 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 hexshaft ${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/hexshaft/schema.ts b/src/models/hexshaft/schema.ts index 2ca5080..7e08091 100644 --- a/src/models/hexshaft/schema.ts +++ b/src/models/hexshaft/schema.ts @@ -62,7 +62,7 @@ export type HexShaftModelDefinition = z.output< typeof hexShaftModelDefinitionSchema > -/** A regular hexagonal drive shaft with 45-degree chamfers on all six facets at each end. Length runs along the centered +Z axis from Z=0. acrossFlats measures the distance between the flats X=+-acrossFlats/2; vertices lie on the +/-Y axis. endChamfer offsets each flat inward by that amount over the same axial distance. _regularhex is the value-free profile flag; _profile(regularhex) is the roadmap alias. All normalized dimensions are millimeters. */ +/** A regular hexagonal drive shaft with 45-degree chamfers on all six facets at each end. Length runs along the centered +Z axis from Z=0. acrossFlats measures the distance between the flats X=+-acrossFlats/2; vertices lie on the +/-Y axis. endChamfer offsets each flat inward by that amount over the same axial distance. _regularhex is the value-free flag for the regular hexagonal geometry. All normalized dimensions are millimeters. */ export function getHexShaftDimensions(input: HexShaftModelPropsInput) { const p = hexShaftModelPropsSchema.parse(input) return { diff --git a/tests/hexshaft-01-contract.test.ts b/tests/hexshaft-01-contract.test.ts index 92b80ea..6aa5310 100644 --- a/tests/hexshaft-01-contract.test.ts +++ b/tests/hexshaft-01-contract.test.ts @@ -5,7 +5,7 @@ import { hexShaftModelPropsSchema, getHexShaftDimensions, } from "../src" -const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const source = "hexshaft_af12mm_l200mm_regularhex_endchamfer1mm" const props = { acrossFlats: 12, length: 200, diff --git a/tests/hexshaft-02-units.test.ts b/tests/hexshaft-02-units.test.ts index ac6b6f7..96d9e66 100644 --- a/tests/hexshaft-02-units.test.ts +++ b/tests/hexshaft-02-units.test.ts @@ -5,7 +5,7 @@ import { hexShaftModelPropsSchema, getHexShaftDimensions, } from "../src" -const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const source = "hexshaft_af12mm_l200mm_regularhex_endchamfer1mm" const props = { acrossFlats: 12, length: 200, diff --git a/tests/hexshaft-03-invalid-tokens.test.ts b/tests/hexshaft-03-invalid-tokens.test.ts index 202b8bf..c8c6e70 100644 --- a/tests/hexshaft-03-invalid-tokens.test.ts +++ b/tests/hexshaft-03-invalid-tokens.test.ts @@ -5,7 +5,7 @@ import { hexShaftModelPropsSchema, getHexShaftDimensions, } from "../src" -const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const source = "hexshaft_af12mm_l200mm_regularhex_endchamfer1mm" const props = { acrossFlats: 12, length: 200, diff --git a/tests/hexshaft-04-fit-validation.test.ts b/tests/hexshaft-04-fit-validation.test.ts index fe73376..457aabc 100644 --- a/tests/hexshaft-04-fit-validation.test.ts +++ b/tests/hexshaft-04-fit-validation.test.ts @@ -5,7 +5,7 @@ import { hexShaftModelPropsSchema, getHexShaftDimensions, } from "../src" -const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +const source = "hexshaft_af12mm_l200mm_regularhex_endchamfer1mm" const props = { acrossFlats: 12, length: 200, diff --git a/tests/hexshaft-05-flags.test.ts b/tests/hexshaft-05-flags.test.ts index c076c47..e99f528 100644 --- a/tests/hexshaft-05-flags.test.ts +++ b/tests/hexshaft-05-flags.test.ts @@ -1,28 +1,23 @@ import { expect, test } from "bun:test" -import { - mp, - modelDefinitionSchema, - hexShaftModelPropsSchema, - getHexShaftDimensions, -} from "../src" -const source = "hexshaft_af12mm_l200mm_profile(regularhex)_endchamfer1mm" +import { mp } from "../src" const props = { acrossFlats: 12, length: 200, endChamfer: 1, regularHex: true, } as const -test("hexshaft value-free flags reject repeated and conflicting selectors", () => { - expect( - mp.string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex").json(), - ).toEqual({ fn: "hexshaft", ...props }) - expect(() => - mp - .string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex_regularhex") - .json(), - ).toThrow() - expect(() => - mp.string("hexshaft_af12mm_l200mm_endchamfer1mm_regularhex(true)").json(), - ).toThrow() - expect(() => mp.string(`${source}_regularhex`).json()).toThrow() +test("hexshaft uses value-free flags and rejects removed selector syntax", () => { + const base = "hexshaft_af12mm_l200mm_endchamfer1mm" + const expected = { fn: "hexshaft", ...props } as const + expect(mp.string(`${base}_regularhex`).json()).toEqual(expected) + expect(mp.string(base).json()).toEqual(expected) + for (const token of [ + "regularhex_regularhex", + "regularhex(true)", + "profile(regularhex)", + "PROFILE(REGULARHEX)", + "profile(unknown)", + ]) { + expect(() => mp.string(`${base}_${token}`).json()).toThrow() + } })