diff --git a/docs/keywasher.md b/docs/keywasher.md new file mode 100644 index 0000000..4ce7cf7 --- /dev/null +++ b/docs/keywasher.md @@ -0,0 +1,9 @@ +# KeyWasher + +```text +keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward +``` + +Custom annular key washer centered on XY, bottom Z=0 and top Z=thickness. One rectangular tab on +X projects inward from the nominal circular bore to X=innerDiameter/2-tabLength. Tab width is along Y and the tab root overlaps the annular body. tabLength is measured radially at the tab centerline. No supplier standard is claimed. + +Lengths accept complete decimals with optional units and normalize to millimeters. The schema rejects unknown fields, duplicate aliases, malformed tokens and dimensions that eliminate required material. `getKeyWasherDimensions` exposes the resolved dimensions and installation datums. Legacy roadmap selectors map to value-free flags; there are no enum selectors in the resolved contract. diff --git a/src/models/keywasher/index.ts b/src/models/keywasher/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/keywasher/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/keywasher/parse-model-string.ts b/src/models/keywasher/parse-model-string.ts new file mode 100644 index 0000000..5a10fbb --- /dev/null +++ b/src/models/keywasher/parse-model-string.ts @@ -0,0 +1,47 @@ +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { keyWasherModelDefinitionSchema } from "./schema" +const lengths: Record = { + id: "innerDiameter", + od: "outerDiameter", + h: "thickness", + tabw: "tabWidth", + tabl: "tabLength", + innerdiameter: "innerDiameter", + outerdiameter: "outerDiameter", + thickness: "thickness", + tabwidth: "tabWidth", + tablength: "tabLength", +} +const flags: Record = { inward: "inwardTab", custom: "custom" } +const integers: Record = { tabs: "tabCount" } +const tuples: Record = {} +export function parseKeyWasherModelParams(raw: RawModelprinterParams) { + const [name, ...tokens] = splitModelStringTokens(raw.string) + if (raw.fn !== "keywasher" || name?.toLowerCase() !== "keywasher") + throw new Error("Expected keywasher without inline values") + const props: Record = { fn: "keywasher" } + const assign = (property: string, value: unknown) => { + if (Object.hasOwn(props, property)) + throw new Error(`Duplicate keywasher property "${property}"`) + props[property] = value + } + for (const token of tokens) { + const match = token.match(/^([a-z]+)(.*)$/i) + if (!match) throw new Error(`Malformed keywasher token "${token}"`) + const key = match[1]!.toLowerCase(), + value = match[2]! + if (Object.hasOwn(lengths, key) && value) assign(lengths[key]!, value) + else if (Object.hasOwn(flags, key) && !value) assign(flags[key]!, true) + else if (Object.hasOwn(integers, key) && /^\d+$/.test(value)) + assign(integers[key]!, Number(value)) + else if (Object.hasOwn(tuples, key)) { + const pair = value.match(/^\(([^(),]+),([^(),]+)\)$/) + if (!pair) + throw new Error(`Token "${key}" requires two parenthesized lengths`) + assign(tuples[key]![0], pair[1]!.trim()) + assign(tuples[key]![1], pair[2]!.trim()) + } else throw new Error(`Unknown or malformed keywasher token "${token}"`) + } + return keyWasherModelDefinitionSchema.parse(props) +} diff --git a/src/models/keywasher/register.ts b/src/models/keywasher/register.ts new file mode 100644 index 0000000..423b6c8 --- /dev/null +++ b/src/models/keywasher/register.ts @@ -0,0 +1,11 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { keyWasherModelDefinitionSchema } from "./schema" +import { parseKeyWasherModelParams } from "./parse-model-string" +export const model = defineModel({ + name: "keywasher", + schema: keyWasherModelDefinitionSchema, + parse: parseKeyWasherModelParams, +}) +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/keywasher/schema.ts b/src/models/keywasher/schema.ts new file mode 100644 index 0000000..a443b56 --- /dev/null +++ b/src/models/keywasher/schema.ts @@ -0,0 +1,101 @@ +import { z } from "zod" +import { modelLengthSchema } 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", + ) + .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 = { + innerDiameter: positiveLength, + outerDiameter: positiveLength, + thickness: positiveLength, + tabWidth: positiveLength, + tabLength: positiveLength, + inwardTab: z.literal(true).default(true), + custom: z.literal(true).default(true), + tabCount: z.literal(1).default(1), +} + +type ResolvedProps = z.output> +function validate(p: ResolvedProps, context: z.RefinementCtx) { + const issue = (message: string, path: keyof ResolvedProps) => + context.addIssue({ code: "custom", message, path: [path] }) + if (p.innerDiameter >= p.outerDiameter) + issue("The bore must leave a positive-width annular body", "innerDiameter") + if (p.tabWidth >= p.innerDiameter) + issue("The tab must be narrower than the bore", "tabWidth") + if (p.tabLength >= p.innerDiameter / 2) + issue("The inward tab must stop before the bore center", "tabLength") + if ( + p.tabWidth < p.innerDiameter && + p.innerDiameter / 2 - p.tabLength >= + Math.sqrt((p.innerDiameter / 2) ** 2 - (p.tabWidth / 2) ** 2) + ) + issue( + "The full rectangular tab tip must project inside the circular bore", + "tabLength", + ) + if ( + !Number.isFinite( + 8 * + Math.max( + p.innerDiameter, + p.outerDiameter, + p.thickness, + p.tabWidth, + p.tabLength, + ) ** + 3, + ) + ) + issue("Dimensions exceed finite derived geometry limits", "innerDiameter") +} +/** Custom annular key washer centered on XY, bottom Z=0 and top Z=thickness. One rectangular tab on +X projects inward from the nominal circular bore to X=innerDiameter/2-tabLength. Tab width is along Y and the tab root overlaps the annular body. tabLength is measured radially at the tab centerline. No supplier standard is claimed. */ +export const keyWasherModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const keyWasherModelDefinitionSchema = z + .object({ fn: z.literal("keywasher"), ...shape }) + .strict() + .superRefine(validate) +export type KeyWasherModelPropsInput = z.input +export type KeyWasherModelProps = z.output +export type KeyWasherModelDefinition = z.output< + typeof keyWasherModelDefinitionSchema +> + +/** Normalized millimeter dimensions and installation datums. */ +export function getKeyWasherDimensions(input: KeyWasherModelPropsInput) { + const p = keyWasherModelPropsSchema.parse(input) + return { + ...p, + size: [p.outerDiameter, p.outerDiameter, p.thickness] as [ + number, + number, + number, + ], + bottomZ: 0, + topZ: p.thickness, + tabTipX: p.innerDiameter / 2 - p.tabLength, + tabRootX: Math.sqrt((p.innerDiameter / 2) ** 2 - (p.tabWidth / 2) ** 2), + tabMinY: -p.tabWidth / 2, + tabMaxY: p.tabWidth / 2, + } +} diff --git a/tests/keywasher-contract-01.test.ts b/tests/keywasher-contract-01.test.ts new file mode 100644 index 0000000..8f9e3eb --- /dev/null +++ b/tests/keywasher-contract-01.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { mp, keyWasherModelPropsSchema, getKeyWasherDimensions } from "../src" +test("keywasher complete strings and public dimensions agree", () => { + const props = { + innerDiameter: 10, + outerDiameter: 20, + thickness: 1, + tabWidth: 3, + tabLength: 2, + tabCount: 1, + inwardTab: true, + custom: true, + } as const + expect( + mp + .string("keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward") + .json(), + ).toEqual({ fn: "keywasher", ...props }) + expect( + mp + .string( + "keywasher_custom_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward", + ) + .json(), + ).toEqual({ fn: "keywasher", ...props }) + expect(keyWasherModelPropsSchema.parse(props)).toEqual(props) + expect(getKeyWasherDimensions(props).size).toEqual([20, 20, 1]) +}) diff --git a/tests/keywasher-contract-02.test.ts b/tests/keywasher-contract-02.test.ts new file mode 100644 index 0000000..d795fcf --- /dev/null +++ b/tests/keywasher-contract-02.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" +import { mp, keyWasherModelPropsSchema } from "../src" +test("keywasher unit and token case normalization preserves dimensions", () => { + expect( + keyWasherModelPropsSchema.parse({ + innerDiameter: "1.0cm", + outerDiameter: "2.0cm", + thickness: "0.1cm", + tabWidth: "0.3cm", + tabLength: "0.2cm", + tabCount: 1, + inwardTab: true, + custom: true, + }), + ).toEqual({ + innerDiameter: 10, + outerDiameter: 20, + thickness: 1, + tabWidth: 3, + tabLength: 2, + tabCount: 1, + inwardTab: true, + custom: true, + }) + expect( + mp + .string("KEYWASHER_ID10MM_OD20MM_H1MM_TABW3MM_TABL2MM_TABS1_INWARD") + .json(), + ).toEqual( + mp + .string("keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward") + .json(), + ) +}) diff --git a/tests/keywasher-contract-03.test.ts b/tests/keywasher-contract-03.test.ts new file mode 100644 index 0000000..3f82a58 --- /dev/null +++ b/tests/keywasher-contract-03.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test" +import { mp, keyWasherModelPropsSchema } from "../src" +test("keywasher rejects ambiguous and physically incompatible inputs", () => { + const props = { + innerDiameter: 10, + outerDiameter: 20, + thickness: 1, + tabWidth: 3, + tabLength: 2, + tabCount: 1, + inwardTab: true, + custom: true, + } as const + for (const source of [ + "keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward_unknown1mm", + "keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1_inward_tabs2", + "keywasher", + ]) + expect(() => mp.string(source).json()).toThrow() + expect(() => + keyWasherModelPropsSchema.parse({ ...props, ...{ tabLength: 5 } }), + ).toThrow() + expect(() => + keyWasherModelPropsSchema.parse({ ...props, innerDiameter: Infinity }), + ).toThrow() + expect(() => + keyWasherModelPropsSchema.parse({ ...props, innerDiameter: "12mmjunk" }), + ).toThrow() +}) diff --git a/tests/keywasher-contract-boolean-selectors.test.ts b/tests/keywasher-contract-boolean-selectors.test.ts new file mode 100644 index 0000000..4c8a962 --- /dev/null +++ b/tests/keywasher-contract-boolean-selectors.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { mp } from "../src" + +test("keywasher uses boolean flags and rejects enum-style selectors", () => { + const base = "keywasher_id10mm_od20mm_h1mm_tabw3mm_tabl2mm_tabs1" + expect(mp.string(base + "_custom_inward").json()).toEqual( + mp.string(base).json(), + ) + for (const selector of ["spec(custom)", "tab(inward)"]) + expect(() => mp.string(base + "_" + selector).json()).toThrow() +})