Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/keywasher.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/models/keywasher/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./schema"
47 changes: 47 additions & 0 deletions src/models/keywasher/parse-model-string.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
id: "innerDiameter",
od: "outerDiameter",
h: "thickness",
tabw: "tabWidth",
tabl: "tabLength",
innerdiameter: "innerDiameter",
outerdiameter: "outerDiameter",
thickness: "thickness",
tabwidth: "tabWidth",
tablength: "tabLength",
}
const flags: Record<string, string> = { inward: "inwardTab", custom: "custom" }
const integers: Record<string, string> = { tabs: "tabCount" }
const tuples: Record<string, readonly [string, string]> = {}
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<string, unknown> = { 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)
}
11 changes: 11 additions & 0 deletions src/models/keywasher/register.ts
Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions src/models/keywasher/schema.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodObject<typeof shape>>
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<typeof keyWasherModelPropsSchema>
export type KeyWasherModelProps = z.output<typeof keyWasherModelPropsSchema>
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,
}
}
28 changes: 28 additions & 0 deletions tests/keywasher-contract-01.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
34 changes: 34 additions & 0 deletions tests/keywasher-contract-02.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
)
})
29 changes: 29 additions & 0 deletions tests/keywasher-contract-03.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
11 changes: 11 additions & 0 deletions tests/keywasher-contract-boolean-selectors.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading