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/hollowshaft/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./schema"
42 changes: 42 additions & 0 deletions src/models/hollowshaft/parse-model-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { RawModelprinterParams } from "../../parse-model-string"
import { splitModelStringTokens } from "../../split-model-string-tokens"
import { hollowShaftModelDefinitionSchema } from "./schema"
const aliases: Record<string, string> = {
od: "outerDiameter",
outerdiameter: "outerDiameter",
id: "innerDiameter",
innerdiameter: "innerDiameter",
l: "length",
length: "length",
endchamfer: "endChamfer",
}
const flags: Record<string, string> = {
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<string, unknown> = { 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(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)
}
11 changes: 11 additions & 0 deletions src/models/hollowshaft/register.ts
Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions src/models/hollowshaft/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"

// 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<z.ZodObject<typeof shape>>,
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<typeof hollowShaftModelPropsSchema>
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 flag for the round tubular geometry. 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,
}
}
26 changes: 26 additions & 0 deletions tests/hollowshaft-01-contract.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,
hollowShaftModelPropsSchema,
getHollowShaftDimensions,
} from "../src"
const source = "hollowshaft_od20mm_id12mm_l200mm_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)
})
27 changes: 27 additions & 0 deletions tests/hollowshaft-02-units.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hollowShaftModelPropsSchema,
getHollowShaftDimensions,
} from "../src"
const source = "hollowshaft_od20mm_id12mm_l200mm_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)
})
35 changes: 35 additions & 0 deletions tests/hollowshaft-03-invalid-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import {
mp,
modelDefinitionSchema,
hollowShaftModelPropsSchema,
getHollowShaftDimensions,
} from "../src"
const source = "hollowshaft_od20mm_id12mm_l200mm_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()
})
25 changes: 25 additions & 0 deletions tests/hollowshaft-04-fit-validation.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,
hollowShaftModelPropsSchema,
getHollowShaftDimensions,
} from "../src"
const source = "hollowshaft_od20mm_id12mm_l200mm_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()
})
24 changes: 24 additions & 0 deletions tests/hollowshaft-05-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { mp } from "../src"
const props = {
outerDiameter: 20,
innerDiameter: 12,
length: 200,
endChamfer: 1,
roundTube: true,
} as const
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()
}
})
Loading