From d1c96de18c288805ba2b7a46499923a79e33426d Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sat, 10 Oct 2026 01:41:51 +0000 Subject: [PATCH 1/2] Add cornerfoot model contract --- docs/cornerfoot.md | 9 ++ src/models/cornerfoot/index.ts | 1 + src/models/cornerfoot/parse-model-string.ts | 63 ++++++++++ src/models/cornerfoot/register.ts | 11 ++ src/models/cornerfoot/schema.ts | 129 ++++++++++++++++++++ tests/cornerfoot-contract-01.test.ts | 31 +++++ tests/cornerfoot-contract-02.test.ts | 40 ++++++ tests/cornerfoot-contract-03.test.ts | 30 +++++ 8 files changed, 314 insertions(+) create mode 100644 docs/cornerfoot.md create mode 100644 src/models/cornerfoot/index.ts create mode 100644 src/models/cornerfoot/parse-model-string.ts create mode 100644 src/models/cornerfoot/register.ts create mode 100644 src/models/cornerfoot/schema.ts create mode 100644 tests/cornerfoot-contract-01.test.ts create mode 100644 tests/cornerfoot-contract-02.test.ts create mode 100644 tests/cornerfoot-contract-03.test.ts diff --git a/docs/cornerfoot.md b/docs/cornerfoot.md new file mode 100644 index 0000000..e83be77 --- /dev/null +++ b/docs/cornerfoot.md @@ -0,0 +1,9 @@ +# CornerFoot + +```text +cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_base3mm_cornercup +``` + +Square or rectangular corner foot centered on XY. Base mounting face is Z=0; the supported member sits at Z=baseThickness. Two perpendicular locating walls occupy the negative-X and negative-Y outside edges and reach Z=height. The stated seat starts at their inside corner. One central fixing hole at X=Y=0 passes through the base only. Base thickness defaults to the wall thickness. No rounded exterior or inferred load rating. + +Lengths accept complete decimals with optional units and normalize to millimeters. The schema rejects unknown fields, duplicate aliases, malformed tokens and dimensions that eliminate required material. `getCornerFootDimensions` exposes the resolved dimensions and installation datums. Legacy roadmap selectors map to value-free flags; there are no enum selectors in the resolved contract. diff --git a/src/models/cornerfoot/index.ts b/src/models/cornerfoot/index.ts new file mode 100644 index 0000000..06861aa --- /dev/null +++ b/src/models/cornerfoot/index.ts @@ -0,0 +1 @@ +export * from "./schema" diff --git a/src/models/cornerfoot/parse-model-string.ts b/src/models/cornerfoot/parse-model-string.ts new file mode 100644 index 0000000..030203e --- /dev/null +++ b/src/models/cornerfoot/parse-model-string.ts @@ -0,0 +1,63 @@ +import type { RawModelprinterParams } from "../../parse-model-string" +import { splitModelStringTokens } from "../../split-model-string-tokens" +import { cornerFootModelDefinitionSchema } from "./schema" +const lengths: Record = { + w: "width", + d: "depth", + h: "height", + wall: "wallThickness", + base: "baseThickness", + seatw: "seatWidth", + seatd: "seatDepth", + hole: "holeDiameter", + width: "width", + depth: "depth", + height: "height", + wallthickness: "wallThickness", + basethickness: "baseThickness", + seatwidth: "seatWidth", + seatdepth: "seatDepth", + holediameter: "holeDiameter", +} +const flags: Record = { cornercup: "cornerCup" } +const selectors: Record = { + shape: { value: "cornercup", property: "cornerCup" }, +} +const integers: Record = {} +const tuples: Record = { + seat: ["seatWidth", "seatDepth"], +} +export function parseCornerFootModelParams(raw: RawModelprinterParams) { + const [name, ...tokens] = splitModelStringTokens(raw.string) + if (raw.fn !== "cornerfoot" || name?.toLowerCase() !== "cornerfoot") + throw new Error("Expected cornerfoot without inline values") + const props: Record = { fn: "cornerfoot" } + const assign = (property: string, value: unknown) => { + if (Object.hasOwn(props, property)) + throw new Error(`Duplicate cornerfoot property "${property}"`) + props[property] = value + } + for (const token of tokens) { + const match = token.match(/^([a-z]+)(.*)$/i) + if (!match) throw new Error(`Malformed cornerfoot token "${token}"`) + const key = match[1]!.toLowerCase(), + value = match[2]! + if (Object.hasOwn(lengths, key) && value) assign(lengths[key]!, value) + else if (Object.hasOwn(flags, key) && !value) assign(flags[key]!, true) + else if ( + Object.hasOwn(selectors, key) && + value.toLowerCase() === `(${selectors[key]!.value})` + ) + assign(selectors[key]!.property, true) + else if (Object.hasOwn(integers, key) && /^\d+$/.test(value)) + assign(integers[key]!, Number(value)) + else if (Object.hasOwn(tuples, key)) { + const pair = value.match(/^\(([^(),]+),([^(),]+)\)$/) + if (!pair) + throw new Error(`Token "${key}" requires two parenthesized lengths`) + assign(tuples[key]![0], pair[1]!.trim()) + assign(tuples[key]![1], pair[2]!.trim()) + } else throw new Error(`Unknown or malformed cornerfoot token "${token}"`) + } + return cornerFootModelDefinitionSchema.parse(props) +} diff --git a/src/models/cornerfoot/register.ts b/src/models/cornerfoot/register.ts new file mode 100644 index 0000000..80d395f --- /dev/null +++ b/src/models/cornerfoot/register.ts @@ -0,0 +1,11 @@ +import { defineModel, type ModelRegistry } from "../../model-registry" +import { cornerFootModelDefinitionSchema } from "./schema" +import { parseCornerFootModelParams } from "./parse-model-string" +export const model = defineModel({ + name: "cornerfoot", + schema: cornerFootModelDefinitionSchema, + parse: parseCornerFootModelParams, +}) +export function register(registry: ModelRegistry): void { + registry.register(model) +} diff --git a/src/models/cornerfoot/schema.ts b/src/models/cornerfoot/schema.ts new file mode 100644 index 0000000..188b258 --- /dev/null +++ b/src/models/cornerfoot/schema.ts @@ -0,0 +1,129 @@ +import { z } from "zod" +import { modelLengthSchema } from "../../model-length-schema" + +const length = z + .union([ + z.number(), + z + .string() + .regex( + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:mm|cm|m|in|inch|mil|ft|feet)?$/i, + "Expected a complete numeric length with an optional unit", + ) + .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 = { + width: positiveLength, + depth: positiveLength, + height: positiveLength, + wallThickness: positiveLength, + baseThickness: positiveLength.optional(), + seatWidth: positiveLength, + seatDepth: positiveLength, + holeDiameter: positiveLength, + cornerCup: z.literal(true).default(true), +} +const resolve =

( + p: P, +) => ({ ...p, baseThickness: p.baseThickness ?? p.wallThickness }) + +type ResolvedProps = z.output> & { + baseThickness: number +} +function validate(p: ResolvedProps, context: z.RefinementCtx) { + const issue = (message: string, path: keyof ResolvedProps) => + context.addIssue({ code: "custom", message, path: [path] }) + if (2 * p.wallThickness >= Math.min(p.width, p.depth)) + issue( + "Locating walls must leave space around the central fixing hole", + "wallThickness", + ) + if (p.baseThickness >= p.height) + issue("The base must leave positive-height locating walls", "baseThickness") + if ( + p.seatWidth > p.width - p.wallThickness || + p.seatDepth > p.depth - p.wallThickness + ) + issue( + "The seat must fit within both locating walls and the far base edges", + "seatWidth", + ) + if ( + p.holeDiameter / 2 >= + Math.min(p.width / 2, p.depth / 2) - p.wallThickness + ) + issue( + "The central fixing hole must leave material before both locating walls", + "holeDiameter", + ) + if ( + p.width / 2 - p.wallThickness + p.holeDiameter / 2 > p.seatWidth || + p.depth / 2 - p.wallThickness + p.holeDiameter / 2 > p.seatDepth + ) + issue( + "The central fixing hole must lie completely within the stated seat", + "seatWidth", + ) + if ( + !Number.isFinite( + 8 * + Math.max( + p.width, + p.depth, + p.height, + p.wallThickness, + p.baseThickness, + p.seatWidth, + p.seatDepth, + p.holeDiameter, + ) ** + 3, + ) + ) + issue("Dimensions exceed finite derived geometry limits", "width") +} +/** Square or rectangular corner foot centered on XY. Base mounting face is Z=0; the supported member sits at Z=baseThickness. Two perpendicular locating walls occupy the negative-X and negative-Y outside edges and reach Z=height. The stated seat starts at their inside corner. One central fixing hole at X=Y=0 passes through the base only. Base thickness defaults to the wall thickness. No rounded exterior or inferred load rating. */ +export const cornerFootModelPropsSchema = z + .object(shape) + .strict() + .transform(resolve) + .superRefine(validate) +export const cornerFootModelDefinitionSchema = z + .object({ fn: z.literal("cornerfoot"), ...shape }) + .strict() + .transform(resolve) + .superRefine(validate) +export type CornerFootModelPropsInput = z.input< + typeof cornerFootModelPropsSchema +> +export type CornerFootModelProps = z.output +export type CornerFootModelDefinition = z.output< + typeof cornerFootModelDefinitionSchema +> + +/** Normalized millimeter dimensions and installation datums. */ +export function getCornerFootDimensions(input: CornerFootModelPropsInput) { + const p = cornerFootModelPropsSchema.parse(input) + return { + ...p, + size: [p.width, p.depth, p.height] as [number, number, number], + bottomZ: 0, + topZ: p.height, + seatMinX: -p.width / 2 + p.wallThickness, + seatMinY: -p.depth / 2 + p.wallThickness, + seatMaxX: -p.width / 2 + p.wallThickness + p.seatWidth, + seatMaxY: -p.depth / 2 + p.wallThickness + p.seatDepth, + seatZ: p.baseThickness, + holeCenter: [0, 0, 0] as [number, number, number], + holeDepth: p.baseThickness, + } +} diff --git a/tests/cornerfoot-contract-01.test.ts b/tests/cornerfoot-contract-01.test.ts new file mode 100644 index 0000000..71b45de --- /dev/null +++ b/tests/cornerfoot-contract-01.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test" +import { mp, cornerFootModelPropsSchema, getCornerFootDimensions } from "../src" +test("cornerfoot complete strings and public dimensions agree", () => { + const props = { + width: 25, + depth: 25, + height: 12, + wallThickness: 3, + baseThickness: 3, + seatWidth: 20, + seatDepth: 20, + holeDiameter: 4, + cornerCup: true, + } as const + expect( + mp + .string( + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_base3mm_cornercup", + ) + .json(), + ).toEqual({ fn: "cornerfoot", ...props }) + expect( + mp + .string( + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_shape(cornercup)", + ) + .json(), + ).toEqual({ fn: "cornerfoot", ...props }) + expect(cornerFootModelPropsSchema.parse(props)).toEqual(props) + expect(getCornerFootDimensions(props).size).toEqual([25, 25, 12]) +}) diff --git a/tests/cornerfoot-contract-02.test.ts b/tests/cornerfoot-contract-02.test.ts new file mode 100644 index 0000000..7f35de3 --- /dev/null +++ b/tests/cornerfoot-contract-02.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { mp, cornerFootModelPropsSchema } from "../src" +test("cornerfoot unit and token case normalization preserves dimensions", () => { + expect( + cornerFootModelPropsSchema.parse({ + width: "2.5cm", + depth: "2.5cm", + height: "1.2cm", + wallThickness: "0.3cm", + baseThickness: "0.3cm", + seatWidth: "2.0cm", + seatDepth: "2.0cm", + holeDiameter: "0.4cm", + cornerCup: true, + }), + ).toEqual({ + width: 25, + depth: 25, + height: 12, + wallThickness: 3, + baseThickness: 3, + seatWidth: 20, + seatDepth: 20, + holeDiameter: 4, + cornerCup: true, + }) + expect( + mp + .string( + "CORNERFOOT_W25MM_D25MM_H12MM_WALL3MM_SEAT(20MM,20MM)_HOLE4MM_BASE3MM_CORNERCUP", + ) + .json(), + ).toEqual( + mp + .string( + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_base3mm_cornercup", + ) + .json(), + ) +}) diff --git a/tests/cornerfoot-contract-03.test.ts b/tests/cornerfoot-contract-03.test.ts new file mode 100644 index 0000000..433e5fa --- /dev/null +++ b/tests/cornerfoot-contract-03.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { mp, cornerFootModelPropsSchema } from "../src" +test("cornerfoot rejects ambiguous and physically incompatible inputs", () => { + const props = { + width: 25, + depth: 25, + height: 12, + wallThickness: 3, + baseThickness: 3, + seatWidth: 20, + seatDepth: 20, + holeDiameter: 4, + cornerCup: true, + } as const + for (const source of [ + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_base3mm_cornercup_unknown1mm", + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_base3mm_cornercup_seat(30mm,20mm)", + "cornerfoot", + ]) + expect(() => mp.string(source).json()).toThrow() + expect(() => + cornerFootModelPropsSchema.parse({ ...props, ...{ holeDiameter: 20 } }), + ).toThrow() + expect(() => + cornerFootModelPropsSchema.parse({ ...props, width: Infinity }), + ).toThrow() + expect(() => + cornerFootModelPropsSchema.parse({ ...props, width: "12mmjunk" }), + ).toThrow() +}) From 77480471d70cd3e7eb83b366b651deb5b8fde574 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Sun, 11 Oct 2026 03:41:34 +0000 Subject: [PATCH 2/2] Replace cornerfoot enum selectors with boolean flags --- src/models/cornerfoot/parse-model-string.ts | 8 -------- tests/cornerfoot-contract-01.test.ts | 2 +- tests/cornerfoot-contract-boolean-selectors.test.ts | 9 +++++++++ 3 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 tests/cornerfoot-contract-boolean-selectors.test.ts diff --git a/src/models/cornerfoot/parse-model-string.ts b/src/models/cornerfoot/parse-model-string.ts index 030203e..3e74cee 100644 --- a/src/models/cornerfoot/parse-model-string.ts +++ b/src/models/cornerfoot/parse-model-string.ts @@ -20,9 +20,6 @@ const lengths: Record = { holediameter: "holeDiameter", } const flags: Record = { cornercup: "cornerCup" } -const selectors: Record = { - shape: { value: "cornercup", property: "cornerCup" }, -} const integers: Record = {} const tuples: Record = { seat: ["seatWidth", "seatDepth"], @@ -44,11 +41,6 @@ export function parseCornerFootModelParams(raw: RawModelprinterParams) { value = match[2]! if (Object.hasOwn(lengths, key) && value) assign(lengths[key]!, value) else if (Object.hasOwn(flags, key) && !value) assign(flags[key]!, true) - else if ( - Object.hasOwn(selectors, key) && - value.toLowerCase() === `(${selectors[key]!.value})` - ) - assign(selectors[key]!.property, true) else if (Object.hasOwn(integers, key) && /^\d+$/.test(value)) assign(integers[key]!, Number(value)) else if (Object.hasOwn(tuples, key)) { diff --git a/tests/cornerfoot-contract-01.test.ts b/tests/cornerfoot-contract-01.test.ts index 71b45de..d658380 100644 --- a/tests/cornerfoot-contract-01.test.ts +++ b/tests/cornerfoot-contract-01.test.ts @@ -22,7 +22,7 @@ test("cornerfoot complete strings and public dimensions agree", () => { expect( mp .string( - "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_shape(cornercup)", + "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm_cornercup", ) .json(), ).toEqual({ fn: "cornerfoot", ...props }) diff --git a/tests/cornerfoot-contract-boolean-selectors.test.ts b/tests/cornerfoot-contract-boolean-selectors.test.ts new file mode 100644 index 0000000..50eec73 --- /dev/null +++ b/tests/cornerfoot-contract-boolean-selectors.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" +import { mp } from "../src" + +test("cornerfoot uses boolean flags and rejects enum-style selectors", () => { + const base = "cornerfoot_w25mm_d25mm_h12mm_wall3mm_seat(20mm,20mm)_hole4mm" + expect(mp.string(base + "_cornercup").json()).toEqual(mp.string(base).json()) + for (const selector of ["shape(cornercup)"]) + expect(() => mp.string(base + "_" + selector).json()).toThrow() +})