diff --git a/docs/model-drawings.md b/docs/model-drawings.md
new file mode 100644
index 0000000..ecddcc7
--- /dev/null
+++ b/docs/model-drawings.md
@@ -0,0 +1,145 @@
+# Model drawing references
+
+Modelprinter supplies optional drawing metadata alongside a model's parameter
+contract. Models explicitly name their geometric edges. Separate boolean view
+presets request inferred overall measurements; an inferred width is never an
+imaginary edge named `overall_width`.
+
+This metadata contains no meshes, rendered images, paper layout, or renderer
+dependencies. Geometry generation and visual demonstrations remain downstream in
+jscad-electronics and the drawing exporter.
+
+```ts
+import { mp } from "@tscircuit/modelprinter"
+
+const stock = mp.string("rectangulartube_w40mm_h20mm_wall3mm_l80mm")
+const definition = stock.json()
+const drawing = stock.drawing()
+```
+
+`.json()` retains its existing output. `.drawing()` returns `undefined` for models
+that have no drawing contract. It validates the model's parameters before
+invoking its registered callback. The first provider is `rectangulartube`; its
+helper is also public:
+
+```ts
+import { getRectangularTubeDrawing } from "@tscircuit/modelprinter"
+
+const drawing = getRectangularTubeDrawing({
+ width: 40,
+ height: 20,
+ wallThickness: 3,
+ length: 80,
+})
+```
+
+## Named model edges
+
+Every named edge has a stable `name` and `start`/`end` points explicitly defined
+by the model. Names are identifiers such as `outer_top`; a dimension references
+one with the scoped selector `.outer_top`. References resolve only against the
+selected model's declared names. Bare labels such as `outer_top`, undeclared
+selectors, duplicate names, and zero-length segments are rejected.
+
+Coordinates are model-local, right-handed CAD XYZ in millimeters with +Z up.
+Points acquire rotation and translation when core places the model. The
+rectangular tube's cross-section is centered on XY and its cut ends lie at Z=0
+and Z=length. For sharp 40 × 20 × 80 mm stock:
+
+| Defined edge | Start (mm) | End (mm) | Edge length |
+| --- | --- | --- | --- |
+| `outer_top` | (−20, 10, 0) | (20, 10, 0) | 40 mm |
+| `outer_left` | (−20, −10, 0) | (−20, 10, 0) | 20 mm |
+| `outer_right_longitudinal` | (20, −10, 0) | (20, −10, 80) | 80 mm |
+
+The model updates these edges from normalized parameters. A rounded profile's
+cross-section straight edges stop at the corner tangencies. With a 4 mm outer
+radius, `outer_top` is (−16, 10, 0) → (16, 10, 0), a 32 mm edge; `outer_left` is
+(−20, −6, 0) → (−20, 6, 0), a 12 mm edge. The longitudinal reference follows the
+outer-right lower tangent at X=20, Y=−6, from Z=0 to Z=80. It is an authored
+analytic surface-boundary reference, not a promise about triangle indices or a
+particular renderer's B-rep topology.
+
+There is no named wall-thickness midpoint span: that would be a measurement datum
+rather than a model edge. No overall envelope span is presented as an actual
+straight edge when rounded corners shorten it.
+
+The declaration and usage are distinct:
+
+```ts
+// The model explicitly defines this edge in its drawing metadata.
+const namedEdge = {
+ name: "outer_top",
+ start: { x: -20, y: 10, z: 0 },
+ end: { x: 20, y: 10, z: 0 },
+}
+```
+
+```tsx
+// Future core resolves this selector against the selected stock model.
+
+
+
+```
+
+This illustrates the dimension proposal. Modelprinter does not register TSX
+components or resolve Circuit JSON IDs; those remain downstream core work.
+
+## Inferred view dimensions
+
+Each dimension intent must select exactly one of:
+
+| Intent | Meaning |
+| --- | --- |
+| `edgeRef: ".outer_top"` | Measure a named edge the selected model explicitly defines. |
+| `widthOfView: true` | Infer the horizontal span of the complete projected view. |
+| `heightOfView: true` | Infer the vertical span of the complete projected view. |
+
+A `false` preset, multiple selectors, or no selector is invalid. The stock-tube
+provider suggests width and height presets for its end (`z+`) view, plus a height
+preset for its side (`y-`) view. These infer the nominal projected envelope even
+when a rounded profile has shorter straight edges.
+
+```tsx
+
+
+
+
+```
+
+Metadata canonicalizes width to horizontal orientation and height to vertical.
+The schema accepts those matching canonical orientations on re-parse, and rejects
+conflicting orientations. Explicit edge intents support `horizontal`, `vertical`,
+or `aligned` (default). Offset is signed paper-space mm after projection (default
+8); it positions the dimension line without changing its value. Precision is 0–6
+decimal places (default 1). Suggested views do not fix viewport size, paper scale,
+measurement text, or units labels. A provider may expose presets with an empty
+`namedEdges` array; view inference does not require inventing reference names.
+
+Core will transform the explicitly declared edges to world coordinates and emit
+resolved dimension records. The drawing exporter projects edges or view bounds
+and draws extension lines, arrows, and measured values. Initial edge references
+are straight segments; circles, diameters, radii, angular dimensions, tolerances,
+and automatic topology naming remain follow-up work.
+
+## Add metadata to a model
+
+Implement a pure helper beside the model's schema and export it from the model's
+public `index.ts`. Add a typed callback to its registration descriptor:
+
+```ts
+export const model = defineModel({
+ name: "rectangulartube",
+ schema: rectangularTubeModelDefinitionSchema,
+ parse: parseRectangularTubeModelParams,
+ getDrawing: ({ fn, ...props }) => getRectangularTubeDrawing(props),
+})
+```
+
+The callback receives the model's validated definition. Metadata must have unique
+nonempty edge/view names, finite nonzero segments, and explicit edge selectors
+that resolve to a declared name in that contract. The registry validates and
+copies the result with `modelDrawingSchema`; it snapshots the callback when
+registered so modifying a descriptor does not change another registry's contract.
diff --git a/docs/model-registration.md b/docs/model-registration.md
index c8d8871..2bfebd0 100644
--- a/docs/model-registration.md
+++ b/docs/model-registration.md
@@ -47,6 +47,15 @@ idempotent. Registrations without a hook preserve their existing raw parameters.
The registry snapshots the hook alongside the parser; adding a model-specific
normalizer does not require another central-parser edit.
+A descriptor may also supply `getDrawing(definition)`, a pure metadata callback
+whose input is its schema's validated output. It returns explicitly named model
+edges and suggested view/dimension intents; inferred width/height dimensions use
+boolean presets rather than invented edge names. The registry validates that output
+with `modelDrawingSchema`. Models without the hook retain their existing JSON
+contracts and return `undefined` from `.drawing()`. See
+[model drawing references](model-drawings.md) for the coordinate frame, datum
+semantics, rectangular-tube example, and optional callback contract.
+
Keep the public barrel focused on the model's API:
```ts
diff --git a/package.json b/package.json
index 0dd19c4..19105dd 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"@tscircuit/mm": "^0.0.9"
},
"devDependencies": {
- "@biomejs/biome": "^2.5.9",
+ "@biomejs/biome": "2.5.15",
"bun-types": "^1.4.0",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
diff --git a/src/index.ts b/src/index.ts
index d00f1bd..40d39a9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,3 +2,4 @@ export * from "./generated/models"
export * from "./model-registry"
export * from "./parse-model-string"
export * from "./modelprinter"
+export * from "./model-drawing"
diff --git a/src/model-drawing.ts b/src/model-drawing.ts
new file mode 100644
index 0000000..8efd17c
--- /dev/null
+++ b/src/model-drawing.ts
@@ -0,0 +1,141 @@
+import { z } from "zod"
+
+const modelDrawingPointSchema = z
+ .object({
+ x: z.number().finite(),
+ y: z.number().finite(),
+ z: z.number().finite(),
+ })
+ .strict()
+
+/** Authored reference segment in model-local right-handed CAD coordinates,
+ * millimeters, +Z up. Endpoints are points and pick up placement translation.
+ * Models explicitly define these analytic features; names are not inferred
+ * measurements and never depend on mesh triangle or vertex indices.
+ */
+export const modelNamedEdgeSchema = z
+ .object({
+ name: z
+ .string()
+ .trim()
+ .regex(
+ /^[A-Za-z_][A-Za-z0-9_-]*$/,
+ "Named edge names must be identifiers usable in a scoped selector",
+ ),
+ start: modelDrawingPointSchema,
+ end: modelDrawingPointSchema,
+ })
+ .strict()
+ .refine(
+ ({ start, end }) =>
+ start.x !== end.x || start.y !== end.y || start.z !== end.z,
+ { message: "Named edges must have distinct endpoints", path: ["end"] },
+ )
+export type ModelNamedEdge = z.output
+
+/** Dimension intent. offset is signed paper mm after projection, not a value.
+ * Explicit edge selectors name a model-defined edge; booleans request inferred
+ * width/height of the complete projected view instead of inventing an edge name.
+ */
+export const modelDrawingDimensionSchema = z
+ .object({
+ edgeRef: z
+ .string()
+ .trim()
+ .regex(
+ /^\.[A-Za-z_][A-Za-z0-9_-]*$/,
+ "edgeRef must be a scoped named-edge selector such as .outer_top",
+ )
+ .optional(),
+ widthOfView: z.literal(true).optional(),
+ heightOfView: z.literal(true).optional(),
+ orientation: z.enum(["horizontal", "vertical", "aligned"]).optional(),
+ offset: z.number().finite().default(8),
+ precision: z.number().int().min(0).max(6).default(1),
+ })
+ .strict()
+ .superRefine((dimension, context) => {
+ const choices = [
+ dimension.edgeRef,
+ dimension.widthOfView,
+ dimension.heightOfView,
+ ].filter((choice) => choice !== undefined).length
+ if (choices !== 1)
+ context.addIssue({
+ code: "custom",
+ message: "Specify exactly one of edgeRef, widthOfView or heightOfView",
+ })
+ const inferredOrientation = dimension.widthOfView
+ ? "horizontal"
+ : dimension.heightOfView
+ ? "vertical"
+ : undefined
+ if (
+ inferredOrientation &&
+ dimension.orientation !== undefined &&
+ dimension.orientation !== inferredOrientation
+ )
+ context.addIssue({
+ code: "custom",
+ path: ["orientation"],
+ message: `This view dimension requires ${inferredOrientation} orientation`,
+ })
+ })
+ .transform((dimension) => ({
+ ...dimension,
+ orientation: dimension.widthOfView
+ ? ("horizontal" as const)
+ : dimension.heightOfView
+ ? ("vertical" as const)
+ : (dimension.orientation ?? ("aligned" as const)),
+ }))
+export type ModelDrawingDimension = z.output
+
+export const modelDrawingViewSchema = z
+ .object({
+ name: z.string().trim().min(1),
+ direction: z.enum(["x+", "x-", "y+", "y-", "z+", "z-", "isometric"]),
+ dimensions: z.array(modelDrawingDimensionSchema),
+ })
+ .strict()
+export type ModelDrawingView = z.output
+
+/** Semantic drawing metadata; geometry generation and paper rendering are
+ * downstream responsibilities. Reference names remain stable as parameters vary.
+ */
+export const modelDrawingSchema = z
+ .object({
+ namedEdges: z.array(modelNamedEdgeSchema),
+ views: z.array(modelDrawingViewSchema).min(1),
+ })
+ .strict()
+ .superRefine((drawing, context) => {
+ const edgeNames = new Set()
+ for (const [index, edge] of drawing.namedEdges.entries()) {
+ if (edgeNames.has(edge.name))
+ context.addIssue({
+ code: "custom",
+ path: ["namedEdges", index, "name"],
+ message: `Named edge "${edge.name}" is duplicated`,
+ })
+ edgeNames.add(edge.name)
+ }
+ const viewNames = new Set()
+ for (const [viewIndex, view] of drawing.views.entries()) {
+ if (viewNames.has(view.name))
+ context.addIssue({
+ code: "custom",
+ path: ["views", viewIndex, "name"],
+ message: `Drawing view "${view.name}" is duplicated`,
+ })
+ viewNames.add(view.name)
+ for (const [dimensionIndex, dimension] of view.dimensions.entries())
+ if (dimension.edgeRef && !edgeNames.has(dimension.edgeRef.slice(1)))
+ context.addIssue({
+ code: "custom",
+ path: ["views", viewIndex, "dimensions", dimensionIndex, "edgeRef"],
+ message: `Dimension references unknown edge "${dimension.edgeRef}"`,
+ })
+ }
+ })
+export type ModelDrawing = z.output
diff --git a/src/model-registry.ts b/src/model-registry.ts
index 285c337..2195d58 100644
--- a/src/model-registry.ts
+++ b/src/model-registry.ts
@@ -1,3 +1,4 @@
+import { modelDrawingSchema, type ModelDrawing } from "./model-drawing"
import type { z } from "zod"
import {
parseModelStringParams,
@@ -78,6 +79,8 @@ export type ModelRegistration<
readonly parse: (params: RawModelprinterParams) => z.output>
/** Optional model-local expansion of shorthand into a canonical string. */
readonly normalizeString?: (value: string) => string
+ /** Optional pure metadata callback, given this model's validated definition. */
+ readonly getDrawing?: (definition: z.output>) => ModelDrawing
}
/** Preserve the literal name and schema output without widening either. */
@@ -94,7 +97,12 @@ export function defineModel<
/** Explicit registrations belong to this instance, never to globalThis. */
export class ModelRegistry {
- private readonly models = new Map()
+ private readonly models = new Map<
+ string,
+ Omit & {
+ getDrawing?: (definition: RegisteredModelDefinition) => ModelDrawing
+ }
+ >()
register<
const Name extends string,
@@ -117,6 +125,12 @@ export class ModelRegistry {
typeof model.normalizeString !== "function"
)
throw new Error("Model string normalizer must be a function")
+ if (
+ model.getDrawing !== undefined &&
+ typeof model.getDrawing !== "function"
+ )
+ throw new Error("Model drawing callback must be a function")
+ const getDrawing = model.getDrawing
// Snapshot the descriptor so later changes to its object cannot replace
// an instance's registered parser or name.
this.models.set(model.name, {
@@ -124,6 +138,11 @@ export class ModelRegistry {
schema: model.schema,
parse: model.parse,
normalizeString: model.normalizeString,
+ // Only this descriptor's parser result reaches its callback. Erase the
+ // model-specific schema at registry storage, not in the public descriptor.
+ getDrawing: getDrawing
+ ? (definition) => getDrawing(definition as z.output)
+ : undefined,
})
}
@@ -146,6 +165,13 @@ export class ModelRegistry {
return normalized
}
+ drawing(params: RawModelprinterParams): ModelDrawing | undefined {
+ const definition = this.parse(params)
+ const getDrawing = this.models.get(params.fn)!.getDrawing
+ if (!getDrawing) return undefined
+ return modelDrawingSchema.parse(getDrawing(definition))
+ }
+
parse(params: RawModelprinterParams): RegisteredModelDefinition {
const model = this.models.get(params.fn)
if (!model) {
diff --git a/src/modelprinter.ts b/src/modelprinter.ts
index ac9b078..cc19a80 100644
--- a/src/modelprinter.ts
+++ b/src/modelprinter.ts
@@ -18,6 +18,7 @@ export const string = (value: string) => {
return {
params: () => params,
json: () => modelParamsToJson(params),
+ drawing: () => registry.drawing(params),
}
}
diff --git a/src/models/rectangulartube/drawing.ts b/src/models/rectangulartube/drawing.ts
new file mode 100644
index 0000000..f69e88f
--- /dev/null
+++ b/src/models/rectangulartube/drawing.ts
@@ -0,0 +1,56 @@
+import { modelDrawingSchema, type ModelDrawing } from "../../model-drawing"
+import {
+ getRectangularTubeDimensions,
+ type RectangularTubeModelPropsInput,
+} from "./schema"
+
+/** Parametric stock-tube references in model-local CAD mm, +Z up, centered on
+ * XY with cut ends at Z=0 and Z=length. Cross-section edges stop at the corner
+ * tangencies; rounded profiles never expose an overall envelope span as an edge.
+ * The longitudinal reference follows the outer-right lower tangent. Width/height
+ * presets measure projected view bounds independently of these explicit features.
+ */
+export function getRectangularTubeDrawing(
+ input: RectangularTubeModelPropsInput,
+): ModelDrawing {
+ const { width, height, length, outerRadius } =
+ getRectangularTubeDimensions(input)
+ const left = -width / 2
+ const right = width / 2
+ const bottom = -height / 2
+ const top = height / 2
+ return modelDrawingSchema.parse({
+ namedEdges: [
+ {
+ name: "outer_top",
+ start: { x: left + outerRadius, y: top, z: 0 },
+ end: { x: right - outerRadius, y: top, z: 0 },
+ },
+ {
+ name: "outer_left",
+ start: { x: left, y: bottom + outerRadius, z: 0 },
+ end: { x: left, y: top - outerRadius, z: 0 },
+ },
+ {
+ name: "outer_right_longitudinal",
+ start: { x: right, y: bottom + outerRadius, z: 0 },
+ end: { x: right, y: bottom + outerRadius, z: length },
+ },
+ ],
+ views: [
+ {
+ name: "end",
+ direction: "z+",
+ dimensions: [
+ { widthOfView: true, offset: 10 },
+ { heightOfView: true, offset: 10 },
+ ],
+ },
+ {
+ name: "side",
+ direction: "y-",
+ dimensions: [{ heightOfView: true, offset: -10 }],
+ },
+ ],
+ })
+}
diff --git a/src/models/rectangulartube/index.ts b/src/models/rectangulartube/index.ts
index 06861aa..d9d772a 100644
--- a/src/models/rectangulartube/index.ts
+++ b/src/models/rectangulartube/index.ts
@@ -1 +1,2 @@
export * from "./schema"
+export * from "./drawing"
diff --git a/src/models/rectangulartube/register.ts b/src/models/rectangulartube/register.ts
index 8b35544..002e0c3 100644
--- a/src/models/rectangulartube/register.ts
+++ b/src/models/rectangulartube/register.ts
@@ -1,3 +1,4 @@
+import { getRectangularTubeDrawing } from "./drawing"
import { defineModel, type ModelRegistry } from "../../model-registry"
import { rectangularTubeModelDefinitionSchema } from "./schema"
import { parseRectangularTubeModelParams } from "./parse-model-string"
@@ -5,6 +6,7 @@ export const model = defineModel({
name: "rectangulartube",
schema: rectangularTubeModelDefinitionSchema,
parse: parseRectangularTubeModelParams,
+ getDrawing: ({ fn, ...props }) => getRectangularTubeDrawing(props),
})
export function register(registry: ModelRegistry): void {
registry.register(model)
diff --git a/tests/model-drawing-01-contract.test.ts b/tests/model-drawing-01-contract.test.ts
new file mode 100644
index 0000000..6883c05
--- /dev/null
+++ b/tests/model-drawing-01-contract.test.ts
@@ -0,0 +1,99 @@
+import { expect, test } from "bun:test"
+import { getRectangularTubeDrawing, mp } from "../src"
+
+test("stock-tube drawing declares actual edges and infers overall measurements from views", () => {
+ const model = mp.string("rectangulartube_w40mm_h20mm_wall3mm_l80mm")
+ const definition = model.json()
+ const original = structuredClone(definition)
+ const drawing = model.drawing()!
+ expect(drawing.namedEdges).toEqual([
+ {
+ name: "outer_top",
+ start: { x: -20, y: 10, z: 0 },
+ end: { x: 20, y: 10, z: 0 },
+ },
+ {
+ name: "outer_left",
+ start: { x: -20, y: -10, z: 0 },
+ end: { x: -20, y: 10, z: 0 },
+ },
+ {
+ name: "outer_right_longitudinal",
+ start: { x: 20, y: -10, z: 0 },
+ end: { x: 20, y: -10, z: 80 },
+ },
+ ])
+ expect(
+ drawing.views.map(({ name, direction }) => ({ name, direction })),
+ ).toEqual([
+ { name: "end", direction: "z+" },
+ { name: "side", direction: "y-" },
+ ])
+ expect(drawing.views[0]!.dimensions).toEqual([
+ { widthOfView: true, orientation: "horizontal", offset: 10, precision: 1 },
+ { heightOfView: true, orientation: "vertical", offset: 10, precision: 1 },
+ ])
+ expect(drawing.views[1]!.dimensions).toEqual([
+ { heightOfView: true, orientation: "vertical", offset: -10, precision: 1 },
+ ])
+ expect(
+ getRectangularTubeDrawing({
+ width: "4cm",
+ height: "2cm",
+ wallThickness: "0.3cm",
+ length: "8cm",
+ }),
+ ).toEqual(drawing)
+ expect(model.json()).toEqual(original)
+ expect(definition).toEqual(original)
+ const larger = getRectangularTubeDrawing({
+ width: 60,
+ height: 30,
+ wallThickness: 4,
+ length: 120,
+ outerRadius: 5,
+ innerRadius: 1,
+ })
+ expect(larger.namedEdges.map((edge) => edge.name)).toEqual(
+ drawing.namedEdges.map((edge) => edge.name),
+ )
+ expect(
+ larger.namedEdges.map(({ start, end }) =>
+ Math.hypot(end.x - start.x, end.y - start.y, end.z - start.z),
+ ),
+ ).toEqual([50, 20, 120])
+ const rounded = mp
+ .string("rectangulartube_w40mm_h20mm_wall3mm_l80mm_outerr4mm_innerr1mm")
+ .drawing()!
+ expect(rounded.namedEdges).toEqual([
+ {
+ name: "outer_top",
+ start: { x: -16, y: 10, z: 0 },
+ end: { x: 16, y: 10, z: 0 },
+ },
+ {
+ name: "outer_left",
+ start: { x: -20, y: -6, z: 0 },
+ end: { x: -20, y: 6, z: 0 },
+ },
+ {
+ name: "outer_right_longitudinal",
+ start: { x: 20, y: -6, z: 0 },
+ end: { x: 20, y: -6, z: 80 },
+ },
+ ])
+ // Bounds dimensions remain view presets, not imaginary model edges.
+ expect(rounded.views).toEqual(drawing.views)
+ expect(drawing.namedEdges.some(({ name }) => name === "wall_thickness")).toBe(
+ false,
+ )
+ expect(() =>
+ getRectangularTubeDrawing({
+ width: 40,
+ height: 20,
+ wallThickness: 10,
+ length: 80,
+ }),
+ ).toThrow()
+ expect(mp.string("ballbearing625zz").drawing()).toBeUndefined()
+})
diff --git a/tests/model-drawing-02-validation.test.ts b/tests/model-drawing-02-validation.test.ts
new file mode 100644
index 0000000..349b5b9
--- /dev/null
+++ b/tests/model-drawing-02-validation.test.ts
@@ -0,0 +1,112 @@
+import { expect, test } from "bun:test"
+import {
+ getRectangularTubeDrawing,
+ modelDrawingSchema,
+ modelNamedEdgeSchema,
+} from "../src"
+
+test("drawing refs resolve declared edges while view presets need no invented edge", () => {
+ const drawing = getRectangularTubeDrawing({
+ width: 40,
+ height: 20,
+ wallThickness: 3,
+ length: 80,
+ })
+ const edge = drawing.namedEdges[0]!
+ for (const patch of [
+ { name: " " },
+ { name: ".outer_top" },
+ { start: { x: Infinity, y: 10, z: 0 } },
+ { end: edge.start },
+ { extra: "unsupported" },
+ ])
+ expect(modelNamedEdgeSchema.safeParse({ ...edge, ...patch }).success).toBe(
+ false,
+ )
+ for (const patch of [
+ { namedEdges: [...drawing.namedEdges, edge] },
+ { views: [...drawing.views, drawing.views[0]] },
+ { views: [] },
+ { views: [{ name: "end", direction: "front", dimensions: [] }] },
+ {
+ views: [
+ {
+ name: "end",
+ direction: "z+",
+ dimensions: [{ edgeRef: ".outer_top", precision: 7 }],
+ },
+ ],
+ },
+ {
+ views: [
+ {
+ name: "end",
+ direction: "z+",
+ dimensions: [{ edgeRef: ".outer_top", offset: NaN }],
+ },
+ ],
+ },
+ ])
+ expect(modelDrawingSchema.safeParse({ ...drawing, ...patch }).success).toBe(
+ false,
+ )
+ // Models may provide inferred dimensions before they define named edges.
+ expect(
+ modelDrawingSchema.parse({ ...drawing, namedEdges: [] }).namedEdges,
+ ).toEqual([])
+ for (const dimension of [
+ {},
+ { edgeRef: "outer_top" },
+ { edgeRef: ".missing" },
+ { edgeRef: ".overall_width" },
+ { edgeRef: ".outer_top", widthOfView: true },
+ { widthOfView: true, heightOfView: true },
+ { widthOfView: false },
+ { widthOfView: true, orientation: "vertical" },
+ { heightOfView: true, orientation: "aligned" },
+ ])
+ expect(
+ modelDrawingSchema.safeParse({
+ namedEdges: drawing.namedEdges,
+ views: [{ name: "end", direction: "z+", dimensions: [dimension] }],
+ }).success,
+ ).toBe(false)
+ const presets = modelDrawingSchema.parse({
+ namedEdges: [],
+ views: [
+ {
+ name: "end",
+ direction: "z+",
+ dimensions: [{ widthOfView: true }, { heightOfView: true }],
+ },
+ ],
+ })
+ expect(presets.views[0]!.dimensions).toEqual([
+ { widthOfView: true, orientation: "horizontal", offset: 8, precision: 1 },
+ { heightOfView: true, orientation: "vertical", offset: 8, precision: 1 },
+ ])
+ expect(modelDrawingSchema.parse(presets)).toEqual(presets)
+ const defaulted = modelDrawingSchema.parse({
+ namedEdges: [{ ...edge, name: " outer_top " }],
+ views: [
+ {
+ name: " end ",
+ direction: "z+",
+ dimensions: [{ edgeRef: " .outer_top " }],
+ },
+ ],
+ })
+ expect(defaulted.namedEdges[0]!.name).toBe("outer_top")
+ expect(defaulted.views[0]).toEqual({
+ name: "end",
+ direction: "z+",
+ dimensions: [
+ {
+ edgeRef: ".outer_top",
+ orientation: "aligned",
+ offset: 8,
+ precision: 1,
+ },
+ ],
+ })
+})
diff --git a/tests/model-drawing-03-registration.test.ts b/tests/model-drawing-03-registration.test.ts
new file mode 100644
index 0000000..5c2b338
--- /dev/null
+++ b/tests/model-drawing-03-registration.test.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "bun:test"
+import { z } from "zod"
+import {
+ defineModel,
+ ModelRegistry,
+ getRectangularTubeDrawing,
+ type ModelDrawing,
+} from "../src"
+import { parseModelStringParams } from "../src/parse-model-string"
+
+test("drawing hooks receive validated model definitions and remain isolated per registry", () => {
+ const schema = z.object({
+ fn: z.literal("stock"),
+ length: z.number().default(80),
+ })
+ let parses = 0
+ let callbacks = 0
+ const descriptor = defineModel({
+ name: "stock",
+ schema,
+ parse: () => {
+ parses++
+ return schema.parse({ fn: "stock" })
+ },
+ getDrawing: (definition) => {
+ callbacks++
+ expect(definition).toEqual({ fn: "stock", length: 80 })
+ return getRectangularTubeDrawing({
+ width: 40,
+ height: 20,
+ wallThickness: 3,
+ length: definition.length,
+ })
+ },
+ })
+ const first = new ModelRegistry()
+ first.register(descriptor)
+ const second = new ModelRegistry()
+ second.register({ name: "stock", schema, parse: descriptor.parse })
+ const mutable = descriptor as {
+ getDrawing?: (definition: z.output) => ModelDrawing
+ }
+ mutable.getDrawing = () => {
+ throw new Error("replaced")
+ }
+ const raw = parseModelStringParams("stock")
+ expect(first.drawing(raw)!.namedEdges[2]!.end.z).toBe(80)
+ expect(parses).toBe(1)
+ expect(callbacks).toBe(1)
+ expect(second.drawing(raw)).toBeUndefined()
+ expect(() => first.drawing(parseModelStringParams("unknown"))).toThrow(
+ "Unsupported modelprinter function",
+ )
+ const invalid = new ModelRegistry()
+ expect(() =>
+ invalid.register({
+ name: "stock",
+ schema,
+ parse: descriptor.parse,
+ getDrawing: 1 as unknown as typeof descriptor.getDrawing,
+ }),
+ ).toThrow("drawing callback must be a function")
+ invalid.register({
+ name: "stock",
+ schema,
+ parse: descriptor.parse,
+ getDrawing: () => ({ namedEdges: [], views: [] }),
+ })
+ expect(() => invalid.drawing(raw)).toThrow()
+})