diff --git a/docs/solid-rivet.md b/docs/solid-rivet.md new file mode 100644 index 0000000..b5cf72d --- /dev/null +++ b/docs/solid-rivet.md @@ -0,0 +1,68 @@ +# Solid rivet + +`solidrivet` describes an unset, solid cylindrical rivet with a spherical-cap +head and a flat tail (roadmap #13, item 0036). This is a custom nominal geometry, +not a claim of conformance to a fastener standard. It has no hole, mandrel, +threads, chamfers, head fillet, or deformed closing head. Geometry generation +belongs in jscad-electronics. + +```ts +import { getSolidRivetDimensions, mp } from "@tscircuit/modelprinter" + +mp.string("solidrivet_d3mm_l10mm_headod5.5mm_headh2.2mm_roundhead_flattail_unset").json() + +// Fixed construction options are bare boolean flags. +mp.string("solidrivet_custom_d3mm_l10mm_roundhead_headod5.5mm_headh2.2mm_flattail_unset").json() + +getSolidRivetDimensions({ diameter: 3, length: 10, headDiameter: 5.5, headHeight: 2.2 }) +``` + +| Property | Default | Tokens | Meaning | +| --- | --- | --- | --- | +| `diameter` | Required | `d`, `diameter` | Solid shank diameter | +| `length` | Required | `l`, `length` | Under-head length, excluding the head | +| `headDiameter` | Required | `headod`, `headdiameter` | Head diameter at the bearing plane | +| `headHeight` | Required | `headh`, `headheight` | Spherical-cap height above the bearing plane | +| `custom` | `true` | `custom` | Explicitly dimensioned custom construction | +| `roundHead` | `true` | `roundhead` | Spherical-cap head | +| `flatTail` | `true` | `flattail` | Flat unpeened tail | +| `unset` | `true` | `unset` | Rivet before installation/deformation | + +All four dimensions are required; no fastener size is inferred. Lengths accept +finite numbers in millimeters or complete numeric strings with optional `mm`, +`cm`, `m`, `in`, `inch`, `mil`, `ft`, or `feet` units. Outputs are millimeters. +Token names, unit suffixes, and selector values are case insensitive. Direct +schema property names and selector values use the casing in the table. + +The exported strict props and definition schemas reject unknown properties, +nonpositive/nonfinite dimensions, and any alternative head/tail/state. The head +diameter must exceed the shank diameter. The head height must not exceed half +the head diameter: the allowed spherical caps range from a shallow dome through +a hemisphere, without an overhanging bulb. Derived dimensions must remain finite. +Duplicate dimensions, aliases, flags, and equivalent flag/selector combinations +are rejected, even when their values agree. + +The local frame is right handed, in millimeters, with the shank concentric with +Z. Its bearing plane is at Z=0, the flat tail at Z=-`length`, and the head tip at +Z=`headHeight`. The shank and head are joined at the bearing plane. X and Y span +the circular cross-section; no angular orientation is preferred. + +`getSolidRivetDimensions` returns the four normalized dimensions plus: + +| Dimension | Definition | +| --- | --- | +| `overallLength` | `length + headHeight` | +| `headSphereRadius` | `headHeight / 2 + headDiameter² / (8 × headHeight)` | +| `headSphereCenterZ` | `headHeight - headSphereRadius` | +| `shankBottomZ` | `-length` | +| `headTopZ` | `headHeight` | + +The spherical cap is the portion of this sphere at Z≥0; its intersection with +the bearing plane is a circle of diameter `headDiameter`. These Z values are +positions in the stated local frame, not directions. Tolerances, load capacity, +material, and installation forces are outside this parameter contract. + +`SolidRivetModelPropsInput` accepts unit strings and optional defaults; +`SolidRivetModelProps` contains normalized lengths and resolved flags. +`SolidRivetModelDefinition` adds `fn: "solidrivet"` and participates in the +generated public model-definition union. diff --git a/src/models/solidrivet/index.ts b/src/models/solidrivet/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/solidrivet/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/solidrivet/parse-model-string.ts b/src/models/solidrivet/parse-model-string.ts new file mode 100644 index 0000000..67aa383 --- /dev/null +++ b/src/models/solidrivet/parse-model-string.ts @@ -0,0 +1,53 @@ +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { solidRivetModelDefinitionSchema } from "./schema" + +const lengths = { + d: "diameter", + diameter: "diameter", + l: "length", + length: "length", + headod: "headDiameter", + headdiameter: "headDiameter", + headh: "headHeight", + headheight: "headHeight", +} as const + +const flags = { + custom: "custom", + roundhead: "roundHead", + flattail: "flatTail", + unset: "unset", +} as const + +export function parseSolidRivetModelParams(raw: RawModelprinterParams) { + const tokens = splitModelStringTokens(raw.string) + if (raw.fn !== "solidrivet" || tokens[0]?.toLowerCase() !== "solidrivet") + throw new Error("Expected solidrivet without an inline value") + + const props: Record = { fn: "solidrivet" } + for (const token of tokens.slice(1)) { + const match = token.match(/^([a-z]+)(.*)$/i) + if (!match) throw new Error(`Invalid solid rivet token "${token}"`) + const name = match[1]!.toLowerCase() + const value = match[2]! + let property: string + let parsed: unknown + + if (Object.hasOwn(lengths, name)) { + property = lengths[name as keyof typeof lengths] + parsed = value + } else if (Object.hasOwn(flags, name)) { + if (value) throw new Error(`Solid rivet flag "${name}" takes no value`) + property = flags[name as keyof typeof flags] + parsed = true + } else throw new Error(`Unknown solid rivet token "${token}"`) + + if (Object.hasOwn(props, property)) + throw new Error( + `Solid rivet property "${property}" is set more than once`, + ) + props[property] = parsed + } + return solidRivetModelDefinitionSchema.parse(props) +} diff --git a/src/models/solidrivet/register.ts b/src/models/solidrivet/register.ts new file mode 100644 index 0000000..61b58a6 --- /dev/null +++ b/src/models/solidrivet/register.ts @@ -0,0 +1,13 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { parseSolidRivetModelParams } from "./parse-model-string" +import { solidRivetModelDefinitionSchema } from "./schema" + +export const model = defineModel({ + name: "solidrivet", + schema: solidRivetModelDefinitionSchema, + parse: parseSolidRivetModelParams, +}) + +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/solidrivet/schema.ts b/src/models/solidrivet/schema.ts new file mode 100644 index 0000000..e1e70f8 --- /dev/null +++ b/src/models/solidrivet/schema.ts @@ -0,0 +1,94 @@ +import { z } from "zod" +import { modelLengthSchema } from "../../model-length-schema" + +const positiveLength = 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) + .refine((value) => value > 0, "Length must be positive") + +const shape = { + custom: z.literal(true).default(true), + diameter: positiveLength, + /** Under-head shank length; the head is additional to this length. */ + length: positiveLength, + headDiameter: positiveLength, + /** Height of a spherical cap above its flat bearing plane. */ + headHeight: positiveLength, + roundHead: z.literal(true).default(true), + flatTail: z.literal(true).default(true), + unset: z.literal(true).default(true), +} + +type ResolvedProps = z.output> + +const getHeadSphereRadius = (props: ResolvedProps) => + props.headHeight / 2 + + (props.headDiameter / 2) * (props.headDiameter / props.headHeight / 4) + +function validate(props: ResolvedProps, context: z.RefinementCtx) { + if (props.headDiameter <= props.diameter) + context.addIssue({ + code: "custom", + path: ["headDiameter"], + message: "Head diameter must exceed the shank diameter", + }) + if (props.headHeight > props.headDiameter / 2) + context.addIssue({ + code: "custom", + path: ["headHeight"], + message: + "The round head must be a spherical cap no taller than a hemisphere", + }) + if ( + !Number.isFinite(getHeadSphereRadius(props)) || + !Number.isFinite(props.length + props.headHeight) + ) + context.addIssue({ + code: "custom", + message: "Derived head radius and overall length must be finite", + }) +} + +export const solidRivetModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const solidRivetModelDefinitionSchema = z + .object({ fn: z.literal("solidrivet"), ...shape }) + .strict() + .superRefine(validate) + +export type SolidRivetModelPropsInput = z.input< + typeof solidRivetModelPropsSchema +> +export type SolidRivetModelProps = z.output +export type SolidRivetModelDefinition = z.output< + typeof solidRivetModelDefinitionSchema +> + +/** + * Nominal dimensions in a right-handed local XYZ frame, in millimeters. + * The shank axis is Z, the bearing plane is Z=0, the head points toward +Z, + * and the flat unpeened tail is at Z=-length. Returned Z values are positions. + */ +export function getSolidRivetDimensions(input: SolidRivetModelPropsInput) { + const props = solidRivetModelPropsSchema.parse(input) + const headSphereRadius = getHeadSphereRadius(props) + return { + diameter: props.diameter, + length: props.length, + headDiameter: props.headDiameter, + headHeight: props.headHeight, + overallLength: props.length + props.headHeight, + headSphereRadius, + headSphereCenterZ: props.headHeight - headSphereRadius, + shankBottomZ: -props.length, + headTopZ: props.headHeight, + } +} diff --git a/tests/solid-rivet-boolean-selectors.test.ts b/tests/solid-rivet-boolean-selectors.test.ts new file mode 100644 index 0000000..368d953 --- /dev/null +++ b/tests/solid-rivet-boolean-selectors.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test" +import { mp, solidRivetModelPropsSchema } from "../src" + +test("solid rivet selectors are boolean flags with no enum aliases", () => { + const base = "solidrivet_d3mm_l10mm_headod5.5mm_headh2.2mm" + const model = mp.string(`${base}_custom_roundhead_flattail_unset`).json() + expect(model).toMatchObject({ + custom: true, + roundHead: true, + flatTail: true, + unset: true, + }) + expect(model).not.toHaveProperty("specification") + expect(mp.string(base).json()).toEqual(model) + for (const selector of [ + "spec(custom)", + "head(round)", + "tail(flat)", + "state(unset)", + "custom_custom", + "custom(true)", + ]) + expect(() => mp.string(`${base}_${selector}`).json()).toThrow() + expect(() => + solidRivetModelPropsSchema.parse({ + diameter: 3, + length: 10, + headDiameter: 5.5, + headHeight: 2.2, + specification: "custom", + }), + ).toThrow() +}) diff --git a/tests/solid-rivet.test.ts b/tests/solid-rivet.test.ts new file mode 100644 index 0000000..60a6499 --- /dev/null +++ b/tests/solid-rivet.test.ts @@ -0,0 +1,195 @@ +import { expect, test } from "bun:test" +import { + getSolidRivetDimensions, + modelDefinitionSchema, + modelprinter, + mp, + parseModelString, + solidRivetModelDefinitionSchema, + solidRivetModelPropsSchema, + type SolidRivetModelDefinition, +} from "../src" + +const required = { diameter: 3, length: 10, headDiameter: 5.5, headHeight: 2.2 } +const base = "solidrivet_d3mm_l10mm_headod5.5mm_headh2.2mm" + +test("solid rivet roadmap example, flags, public schemas and registry agree", () => { + const roadmap = + "solidrivet_custom_d3mm_l10mm_roundhead_headod5.5mm_headh2.2mm_flattail_unset" + const expected: SolidRivetModelDefinition = { + fn: "solidrivet", + ...required, + custom: true, + roundHead: true, + flatTail: true, + unset: true, + } + expect(mp.string(roadmap).json()).toEqual(expected) + expect(mp.string(base).json()).toEqual(expected) + expect(mp.string(`${base}_roundhead_flattail_unset`).json()).toEqual(expected) + expect(mp.string(roadmap).params()).toMatchObject({ + fn: "solidrivet", + d: "3mm", + roundhead: true, + }) + expect(parseModelString(roadmap)).toEqual(expected) + expect(modelDefinitionSchema.parse(expected)).toEqual(expected) + expect(solidRivetModelDefinitionSchema.parse(expected)).toEqual(expected) + expect(modelprinter.getModelNames()).toContain("solidrivet") +}) + +test("solid rivet dimensions define the bearing circle and under-head length", () => { + const dimensions = getSolidRivetDimensions(required) + expect(dimensions.overallLength).toBeCloseTo(12.2) + expect(dimensions.shankBottomZ).toBe(-10) + expect(dimensions.headTopZ).toBe(2.2) + expect(dimensions.headSphereRadius).toBeCloseTo(2.81875) + expect(dimensions.headSphereCenterZ).toBeCloseTo(-0.61875) + // Intersect the head sphere with Z=0 to recover the specified bearing circle. + expect( + 2 * + Math.sqrt( + dimensions.headSphereRadius ** 2 - dimensions.headSphereCenterZ ** 2, + ), + ).toBeCloseTo(5.5) + expect( + dimensions.headSphereCenterZ + dimensions.headSphereRadius, + ).toBeCloseTo(dimensions.headHeight) + const hemisphere = getSolidRivetDimensions({ ...required, headHeight: 2.75 }) + expect(hemisphere.headSphereRadius).toBe(2.75) + expect(hemisphere.headSphereCenterZ).toBe(0) + const largeHemisphere = getSolidRivetDimensions({ + diameter: 1, + length: 1, + headDiameter: 1.6e308, + headHeight: 8e307, + }) + expect(largeHemisphere.headSphereRadius).toBe(8e307) + expect(largeHemisphere.headSphereCenterZ).toBe(0) +}) + +test("solid rivet normalizes units and case without mutating props", () => { + const props = Object.freeze({ + diameter: "0.3cm", + length: "0.01m", + headDiameter: "5.5MM", + headHeight: "0.22cm", + }) + expect(solidRivetModelPropsSchema.parse(props)).toMatchObject(required) + expect(props.length).toBe("0.01m") + expect( + mp + .string( + "SOLIDRIVET_DIAMETER0.3CM_LENGTH0.01M_HEADDIAMETER5.5MM_HEADHEIGHT2.2MM_ROUNDHEAD_FLATTAIL_UNSET_CUSTOM", + ) + .json(), + ).toEqual(mp.string(base).json()) + const inches = getSolidRivetDimensions({ + diameter: "0.125in", + length: "1in", + headDiameter: "0.25in", + headHeight: "0.1in", + }) + expect(inches.diameter).toBeCloseTo(3.175) + expect(inches.length).toBeCloseTo(25.4) + expect(inches.overallLength).toBeCloseTo(27.94) +}) + +test("solid rivet rejects duplicate aliases, malformed tokens and unsupported constructions", () => { + for (const suffix of [ + "d3mm", + "diameter3mm", + "l10mm", + "length10mm", + "headod5.5mm", + "headdiameter5.5mm", + "headh2.2mm", + "headheight2.2mm", + "roundhead_roundhead", + "roundhead_head(round)", + "head(round)_roundhead", + "flattail_tail(flat)", + "tail(flat)_flattail", + "unset_state(unset)", + "state(unset)_unset", + "spec(custom)_spec(custom)", + "roundhead1", + "roundhead(true)", + "flattail(false)", + "unset0", + "head(flat)", + "head(round)junk", + "head(round)(round)", + "head", + "tail(peened)", + "state(set)", + "spec(iso1051)", + "specCUSTOM", + "constructor1", + "chamfer0.3mm", + "unknown", + "", + ]) + expect(() => mp.string(`${base}_${suffix}`).json()).toThrow() + for (const source of [ + "solidrivet", + "solidrivet_d3mm_l10mm_headod5.5mm", + "solidrivet3_d3mm_l10mm_headod5.5mm_headh2.2mm", + "solidrivet(3)_d3mm_l10mm_headod5.5mm_headh2.2mm", + "solidrivet_d3mmjunk_l10mm_headod5.5mm_headh2.2mm", + "solidrivet_d(3mm)_l10mm_headod5.5mm_headh2.2mm", + "solidrivet_d3mm_l1e2_headod5.5mm_headh2.2mm", + `${base}__unset`, + `${base}_head(round`, + ]) + expect(() => mp.string(source).json()).toThrow() +}) + +test("solid rivet validates every direct-schema and model-string dimension", () => { + for (const property of ["diameter", "length", "headDiameter", "headHeight"]) { + for (const value of [0, -1, Infinity, NaN, "3mmjunk", "1e2", ""]) { + const props = { ...required, [property]: value } + expect(() => solidRivetModelPropsSchema.parse(props)).toThrow() + expect(() => + solidRivetModelDefinitionSchema.parse({ fn: "solidrivet", ...props }), + ).toThrow() + expect(() => + modelDefinitionSchema.parse({ fn: "solidrivet", ...props }), + ).toThrow() + } + } + for (const extra of [ + { headDiameter: 3 }, + { headDiameter: 2 }, + { headHeight: 2.751 }, + { roundHead: false }, + { flatTail: false }, + { unset: false }, + { specification: "standard" }, + { unknown: 1 }, + { diameter: 1, headDiameter: 1e308, headHeight: 1 }, + { diameter: 1, headDiameter: 1.6e308, headHeight: 8e307, length: 1.6e308 }, + ]) { + expect(() => + solidRivetModelPropsSchema.parse({ ...required, ...extra }), + ).toThrow() + expect(() => + solidRivetModelDefinitionSchema.parse({ + fn: "solidrivet", + ...required, + ...extra, + }), + ).toThrow() + } + for (const source of [ + "solidrivet_d0_l10_headod5.5_headh2.2", + "solidrivet_d3_l0_headod5.5_headh2.2", + "solidrivet_d3_l10_headod3_headh1", + "solidrivet_d3_l10_headod5.5_headh0", + "solidrivet_d3_l10_headod5.5_headh3", + ]) + expect(() => mp.string(source).json()).toThrow() + expect(() => + getSolidRivetDimensions({ ...required, headHeight: 3 }), + ).toThrow() +})