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
1 change: 1 addition & 0 deletions src/models/hexshaft/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./schema"
39 changes: 39 additions & 0 deletions src/models/hexshaft/parse-model-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { RawModelprinterParams } from "../../parse-model-string"
import { splitModelStringTokens } from "../../split-model-string-tokens"
import { hexShaftModelDefinitionSchema } from "./schema"
const aliases: Record<string, string> = {
af: "acrossFlats",
acrossflats: "acrossFlats",
l: "length",
length: "length",
endchamfer: "endChamfer",
}
const flags: Record<string, string> = {
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<string, unknown> = { 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(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)
}
11 changes: 11 additions & 0 deletions src/models/hexshaft/register.ts
Original file line number Diff line number Diff line change
@@ -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)
}
90 changes: 90 additions & 0 deletions src/models/hexshaft/schema.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodObject<typeof shape>>,
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<typeof hexShaftModelPropsSchema>
export type HexShaftModelProps = z.output<typeof hexShaftModelPropsSchema>
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 flag for the regular hexagonal geometry. 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,
}
}
25 changes: 25 additions & 0 deletions tests/hexshaft-01-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hexShaftModelPropsSchema,
getHexShaftDimensions,
} from "../src"
const source = "hexshaft_af12mm_l200mm_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)
})
26 changes: 26 additions & 0 deletions tests/hexshaft-02-units.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hexShaftModelPropsSchema,
getHexShaftDimensions,
} from "../src"
const source = "hexshaft_af12mm_l200mm_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)
})
34 changes: 34 additions & 0 deletions tests/hexshaft-03-invalid-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hexShaftModelPropsSchema,
getHexShaftDimensions,
} from "../src"
const source = "hexshaft_af12mm_l200mm_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()
})
20 changes: 20 additions & 0 deletions tests/hexshaft-04-fit-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hexShaftModelPropsSchema,
getHexShaftDimensions,
} from "../src"
const source = "hexshaft_af12mm_l200mm_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()
})
23 changes: 23 additions & 0 deletions tests/hexshaft-05-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { mp } from "../src"
const props = {
acrossFlats: 12,
length: 200,
endChamfer: 1,
regularHex: true,
} as const
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()
}
})
Loading