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() +})