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
38 changes: 38 additions & 0 deletions docs/angleplate.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/models/angleplate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./schema"
53 changes: 53 additions & 0 deletions src/models/angleplate/parse-model-string.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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)
}
13 changes: 13 additions & 0 deletions src/models/angleplate/register.ts
Original file line number Diff line number Diff line change
@@ -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)
}
127 changes: 127 additions & 0 deletions src/models/angleplate/schema.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodObject<typeof shape>>

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<typeof anglePlateModelPropsSchema>
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,
}
}
31 changes: 31 additions & 0 deletions tests/angleplate-01-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
19 changes: 19 additions & 0 deletions tests/angleplate-02-units.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
27 changes: 27 additions & 0 deletions tests/angleplate-03-malformed.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
30 changes: 30 additions & 0 deletions tests/angleplate-04-fit.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
16 changes: 16 additions & 0 deletions tests/fixtures/angleplate.ts
Original file line number Diff line number Diff line change
@@ -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,
}
Loading