From 70dc1094cecb8dd72761292a6f910113e89c435d Mon Sep 17 00:00:00 2001 From: Proofline Studio <215228730+proofline-studio@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:29:53 +0000 Subject: [PATCH] Add angleplate mounting contract from roadmap item 0176 --- docs/angleplate.md | 38 ++++++ src/models/angleplate/index.ts | 1 + src/models/angleplate/parse-model-string.ts | 53 ++++++++ src/models/angleplate/register.ts | 13 ++ src/models/angleplate/schema.ts | 127 ++++++++++++++++++++ tests/angleplate-01-contract.test.ts | 31 +++++ tests/angleplate-02-units.test.ts | 19 +++ tests/angleplate-03-malformed.test.ts | 27 +++++ tests/angleplate-04-fit.test.ts | 30 +++++ tests/fixtures/angleplate.ts | 16 +++ 10 files changed, 355 insertions(+) create mode 100644 docs/angleplate.md create mode 100644 src/models/angleplate/index.ts create mode 100644 src/models/angleplate/parse-model-string.ts create mode 100644 src/models/angleplate/register.ts create mode 100644 src/models/angleplate/schema.ts create mode 100644 tests/angleplate-01-contract.test.ts create mode 100644 tests/angleplate-02-units.test.ts create mode 100644 tests/angleplate-03-malformed.test.ts create mode 100644 tests/angleplate-04-fit.test.ts create mode 100644 tests/fixtures/angleplate.ts diff --git a/docs/angleplate.md b/docs/angleplate.md new file mode 100644 index 0000000..fb6a239 --- /dev/null +++ b/docs/angleplate.md @@ -0,0 +1,38 @@ +# Angle plate + +The `angleplate` contract implements the dimensioned right-angle mounting plate +in [roadmap item 0176](https://github.com/tscircuit/modelprinter/issues/13). + +```ts +mp.string( + "angleplate_w100mm_h100mm_d80mm_t12mm_innerr0mm_baseholes4_hole8mm_pitchx70mm_pitchy50mm_faceholes2_facepitch60mm_facez50mm", +).json() +``` + +All dimensions are required and normalize to millimeters. `w`, `h`, `d`, and +`t` are the overall width, height, depth, and uniform plate thickness. `innerr` +is the concave inside-corner radius; outside edges remain square. `hole` is the +diameter of all six circular through-bores. This family supports the specified +four base holes (`baseholes4`) and two upright holes (`faceholes2`). Other counts +are rejected rather than assigned an unspecified pattern. + +The right-handed local frame has X from `-width/2` to `width/2`, Y from zero to +`depth`, and Z from zero to `height`. The horizontal base occupies +`Z=0..thickness`, and the upright occupies `Y=0..thickness`. The four base holes +run along Z on a centered `pitchx` by `pitchy` grid. The grid is centered in the +flat base between `Y=thickness+innerRadius` and `Y=depth`, avoiding the upright +and inner fillet. The two upright holes run along Y at `X=+-facepitch/2` and +`Z=facez`. Hole centers returned by `getAnglePlateDimensions` lie halfway through +their respective plate thicknesses. + +For the roadmap example the base centers are `(+-35, 21, 6)` and +`(+-35, 71, 6)`, and upright centers are `(+-30, 6, 50)`. Validation requires +positive material between holes, at all edges, and around the inside corner. +Touching or overlapping bore boundaries, duplicate aliases, unknown tokens, +missing dimensions, and nonfinite derived dimensions are rejected. + +The public exports are `anglePlateModelPropsSchema`, +`anglePlateModelDefinitionSchema`, `AnglePlateModelPropsInput`, +`AnglePlateModelProps`, `AnglePlateModelDefinition`, and +`getAnglePlateDimensions`. Geometry generation belongs in jscad-electronics; +this contract does not claim a rendered or manufactured component. diff --git a/src/models/angleplate/index.ts b/src/models/angleplate/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/angleplate/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/angleplate/parse-model-string.ts b/src/models/angleplate/parse-model-string.ts new file mode 100644 index 0000000..8e1eb54 --- /dev/null +++ b/src/models/angleplate/parse-model-string.ts @@ -0,0 +1,53 @@ +import { parseGearInteger, parseGearToken } from "../../gear-parameter-schemas" +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { anglePlateModelDefinitionSchema } from "./schema" + +const aliases = { + w: "width", + width: "width", + h: "height", + height: "height", + d: "depth", + depth: "depth", + t: "thickness", + thickness: "thickness", + innerr: "innerRadius", + innerradius: "innerRadius", + baseholes: "baseHoleCount", + baseholecount: "baseHoleCount", + hole: "holeDiameter", + holediameter: "holeDiameter", + pitchx: "basePitchX", + basepitchx: "basePitchX", + pitchy: "basePitchY", + basepitchy: "basePitchY", + faceholes: "faceHoleCount", + faceholecount: "faceHoleCount", + facepitch: "facePitch", + facez: "faceHeight", + faceheight: "faceHeight", +} as const + +export function parseAnglePlateModelParams(raw: RawModelprinterParams) { + const [name, ...tokens] = splitModelStringTokens(raw.string) + if (raw.fn !== "angleplate" || name?.toLowerCase() !== "angleplate") { + throw new Error("Expected angleplate without inline values") + } + const props: Record = { fn: "angleplate" } + for (const token of tokens) { + const [key, value] = parseGearToken(token) + if (!Object.hasOwn(aliases, key) || !value) { + throw new Error(`Unknown or malformed angleplate token "${token}"`) + } + const property = aliases[key as keyof typeof aliases] + if (Object.hasOwn(props, property)) { + throw new Error(`Repeated angleplate property "${property}"`) + } + props[property] = + property === "baseHoleCount" || property === "faceHoleCount" + ? parseGearInteger(value, property) + : value + } + return anglePlateModelDefinitionSchema.parse(props) +} diff --git a/src/models/angleplate/register.ts b/src/models/angleplate/register.ts new file mode 100644 index 0000000..8022dcf --- /dev/null +++ b/src/models/angleplate/register.ts @@ -0,0 +1,13 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { anglePlateModelDefinitionSchema } from "./schema" +import { parseAnglePlateModelParams } from "./parse-model-string" + +export const model = defineModel({ + name: "angleplate", + schema: anglePlateModelDefinitionSchema, + parse: parseAnglePlateModelParams, +}) + +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/angleplate/schema.ts b/src/models/angleplate/schema.ts new file mode 100644 index 0000000..c72b28e --- /dev/null +++ b/src/models/angleplate/schema.ts @@ -0,0 +1,127 @@ +import { z } from "zod" +import { + positiveGearLengthSchema as positive, + nonnegativeGearLengthSchema as nonnegative, +} from "../../gear-parameter-schemas" + +const shape = { + width: positive, + height: positive, + depth: positive, + thickness: positive, + innerRadius: nonnegative, + baseHoleCount: z.literal(4), + holeDiameter: positive, + basePitchX: positive, + basePitchY: positive, + faceHoleCount: z.literal(2), + facePitch: positive, + faceHeight: positive, +} + +type ResolvedProps = z.output> + +function validate(props: ResolvedProps, context: z.RefinementCtx) { + const issue = (message: string, path: keyof ResolvedProps) => + context.addIssue({ code: "custom", message, path: [path] }) + const largestDimension = Math.max( + ...Object.values(props).filter( + (value): value is number => typeof value === "number", + ), + ) + if (!Number.isFinite((4 * largestDimension) ** 3)) { + issue( + "Dimensions must permit finite derived coordinates and volume", + "width", + ) + return + } + + const cornerExtent = props.thickness + props.innerRadius + if (cornerExtent >= props.depth || cornerExtent >= props.height) { + issue( + "The inside corner must leave a straight base and upright face", + "innerRadius", + ) + } + if ( + props.basePitchX <= props.holeDiameter || + props.basePitchY <= props.holeDiameter + ) { + issue("Base hole pitches must leave material between holes", "holeDiameter") + } + if (props.basePitchX + props.holeDiameter >= props.width) { + issue("Base holes must leave material at both side edges", "basePitchX") + } + if (props.basePitchY + props.holeDiameter >= props.depth - cornerExtent) { + issue("Base holes must fit wholly within the straight base", "basePitchY") + } + if (props.facePitch <= props.holeDiameter) { + issue("Face hole pitch must leave material between holes", "facePitch") + } + if (props.facePitch + props.holeDiameter >= props.width) { + issue("Face holes must leave material at both side edges", "facePitch") + } + if (props.faceHeight - props.holeDiameter / 2 <= cornerExtent) { + issue("Face holes must lie above the inside corner", "faceHeight") + } + if (props.faceHeight + props.holeDiameter / 2 >= props.height) { + issue("Face holes must leave material at the top edge", "faceHeight") + } +} + +/** + * Right-angle mounting plate in a right-handed local frame, in millimeters: + * X spans -width/2..width/2, Y spans 0..depth, and Z spans 0..height. + * The base occupies Z=0..thickness and the upright occupies Y=0..thickness. + * innerRadius is the concave YZ fillet joining their inside faces; outside + * edges are square. Four circular through-base bores run along Z, centered + * on X=0 and the straight base interval Y=thickness+innerRadius..depth. + * Two circular through-face bores run along Y at X=+-facePitch/2, + * Z=faceHeight. All dimensions and the fixed 4/2 hole counts are required. + */ +export const anglePlateModelPropsSchema = z + .object(shape) + .strict() + .superRefine(validate) +export const anglePlateModelDefinitionSchema = z + .object({ fn: z.literal("angleplate"), ...shape }) + .strict() + .superRefine(validate) + +export type AnglePlateModelPropsInput = z.input< + typeof anglePlateModelPropsSchema +> +export type AnglePlateModelProps = z.output +export type AnglePlateModelDefinition = z.output< + typeof anglePlateModelDefinitionSchema +> + +export function getAnglePlateDimensions(input: AnglePlateModelPropsInput) { + const props = anglePlateModelPropsSchema.parse(input) + const baseCenterY = (props.thickness + props.innerRadius + props.depth) / 2 + const baseHoleCenters = [-1, 1].flatMap((xSign) => + [-1, 1].map( + (ySign) => + [ + (xSign * props.basePitchX) / 2, + baseCenterY + (ySign * props.basePitchY) / 2, + props.thickness / 2, + ] as [number, number, number], + ), + ) + const faceHoleCenters = [-1, 1].map( + (xSign) => + [ + (xSign * props.facePitch) / 2, + props.thickness / 2, + props.faceHeight, + ] as [number, number, number], + ) + return { + ...props, + size: [props.width, props.depth, props.height] as [number, number, number], + baseHoleCenters, + faceHoleCenters, + } +} diff --git a/tests/angleplate-01-contract.test.ts b/tests/angleplate-01-contract.test.ts new file mode 100644 index 0000000..1c5af3f --- /dev/null +++ b/tests/angleplate-01-contract.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test" +import { + mp, + modelDefinitionSchema, + anglePlateModelPropsSchema, + getAnglePlateDimensions, +} from "../src" +import { source, props } from "./fixtures/angleplate" + +test("angleplate roadmap 0176 string, public schemas and hole layout agree", () => { + const definition = { fn: "angleplate" as const, ...props } + expect(mp.string(source).json()).toEqual(definition) + expect(modelDefinitionSchema.parse(definition)).toEqual(definition) + expect(anglePlateModelPropsSchema.parse(props)).toEqual(props) + const dimensions = getAnglePlateDimensions(props) + expect(dimensions.size).toEqual([100, 80, 100]) + expect(dimensions.baseHoleCenters).toEqual([ + [-35, 21, 6], + [-35, 71, 6], + [35, 21, 6], + [35, 71, 6], + ]) + expect(dimensions.faceHoleCenters).toEqual([ + [-30, 6, 50], + [30, 6, 50], + ]) + // A radius changes only the straight-base grid center; the face stays at facez. + expect( + getAnglePlateDimensions({ ...props, innerRadius: 2 }).baseHoleCenters[0], + ).toEqual([-35, 22, 6]) +}) diff --git a/tests/angleplate-02-units.test.ts b/tests/angleplate-02-units.test.ts new file mode 100644 index 0000000..2b4549f --- /dev/null +++ b/tests/angleplate-02-units.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { mp, anglePlateModelPropsSchema } from "../src" +import { source, props } from "./fixtures/angleplate" + +test("angleplate normalizes unit-bearing lengths and full aliases without changing other models", () => { + expect( + mp + .string( + source.replace("w100mm", "width10cm").replace("d80mm", "depth0.08m"), + ) + .json(), + ).toEqual({ fn: "angleplate", ...props }) + expect( + anglePlateModelPropsSchema.parse({ ...props, width: "4in" }).width, + ).toBeCloseTo(101.6) + expect( + mp.string("rectangularbar_w20mm_h12mm_l200mm_cornerr0mm").json(), + ).toMatchObject({ fn: "rectangularbar", width: 20, height: 12, length: 200 }) +}) diff --git a/tests/angleplate-03-malformed.test.ts b/tests/angleplate-03-malformed.test.ts new file mode 100644 index 0000000..3eca058 --- /dev/null +++ b/tests/angleplate-03-malformed.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { mp, anglePlateModelPropsSchema } from "../src" +import { source, props } from "./fixtures/angleplate" + +test("angleplate rejects incomplete, repeated, unsupported and malformed inputs", () => { + for (const invalid of [ + "angleplate", + source + "_width100mm", + source + "_unknown2", + source + "_w", + source.replace("w100mm", "w100junk"), + source.replace("baseholes4", "baseholes4mm"), + source.replace("faceholes2", "faceholes3"), + source.replace("baseholes4", "baseholes2"), + source.replace("angleplate_", "angleplate3_"), + ]) { + expect(() => mp.string(invalid).json()).toThrow() + } + for (const width of [0, -1, NaN, Infinity, 1e200, "1e2mm"]) { + expect(() => + anglePlateModelPropsSchema.parse({ ...props, width }), + ).toThrow() + } + expect(() => + anglePlateModelPropsSchema.parse({ ...props, unknown: true }), + ).toThrow() +}) diff --git a/tests/angleplate-04-fit.test.ts b/tests/angleplate-04-fit.test.ts new file mode 100644 index 0000000..182d3a5 --- /dev/null +++ b/tests/angleplate-04-fit.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { anglePlateModelPropsSchema } from "../src" +import { props } from "./fixtures/angleplate" + +test("angleplate preserves material around its six bores and inside corner", () => { + for (const change of [ + { innerRadius: 68 }, + { thickness: 80 }, + { basePitchX: 8 }, + { basePitchY: 8 }, + { basePitchX: 92 }, + { basePitchY: 60 }, + { facePitch: 8 }, + { facePitch: 92 }, + { faceHeight: 16 }, + { faceHeight: 96 }, + { innerRadius: 10, basePitchY: 50 }, + ]) { + expect(() => + anglePlateModelPropsSchema.parse({ ...props, ...change }), + ).toThrow() + } + expect( + anglePlateModelPropsSchema.parse({ + ...props, + basePitchY: 59.9, + faceHeight: 95.9, + }), + ).toMatchObject({ basePitchY: 59.9, faceHeight: 95.9 }) +}) diff --git a/tests/fixtures/angleplate.ts b/tests/fixtures/angleplate.ts new file mode 100644 index 0000000..91f27b3 --- /dev/null +++ b/tests/fixtures/angleplate.ts @@ -0,0 +1,16 @@ +export const source = + "angleplate_w100mm_h100mm_d80mm_t12mm_innerr0mm_baseholes4_hole8mm_pitchx70mm_pitchy50mm_faceholes2_facepitch60mm_facez50mm" +export const props = { + width: 100, + height: 100, + depth: 80, + thickness: 12, + innerRadius: 0, + baseHoleCount: 4 as const, + holeDiameter: 8, + basePitchX: 70, + basePitchY: 50, + faceHoleCount: 2 as const, + facePitch: 60, + faceHeight: 50, +}