From 78b03471e1fb4292146841bf07c5c81f18655691 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Thu, 22 Oct 2026 10:39:08 -0700 Subject: [PATCH 1/4] Add named drawing references for rectangular stock tube dimensions --- docs/model-drawings.md | 100 ++++++++++++++++++++ docs/model-registration.md | 8 ++ src/index.ts | 1 + src/model-drawing.ts | 91 ++++++++++++++++++ src/model-registry.ts | 28 +++++- src/modelprinter.ts | 1 + src/models/rectangulartube/drawing.ts | 63 ++++++++++++ src/models/rectangulartube/index.ts | 1 + src/models/rectangulartube/register.ts | 2 + tests/model-drawing-01-contract.test.ts | 97 +++++++++++++++++++ tests/model-drawing-02-validation.test.ts | 81 ++++++++++++++++ tests/model-drawing-03-registration.test.ts | 70 ++++++++++++++ 12 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 docs/model-drawings.md create mode 100644 src/model-drawing.ts create mode 100644 src/models/rectangulartube/drawing.ts create mode 100644 tests/model-drawing-01-contract.test.ts create mode 100644 tests/model-drawing-02-validation.test.ts create mode 100644 tests/model-drawing-03-registration.test.ts diff --git a/docs/model-drawings.md b/docs/model-drawings.md new file mode 100644 index 0000000..a4a65eb --- /dev/null +++ b/docs/model-drawings.md @@ -0,0 +1,100 @@ +# Model drawing references + +Modelprinter can provide semantic measurement references alongside a model's +parameter contract. This metadata contains no meshes, rendered images, paper +layout, or renderer dependencies. Geometry generation and visual demonstrations +remain 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()` is optional metadata: it +returns `undefined` for models that have no drawing contract, and validates the +model's parameters before invoking a registered drawing callback. The first +supported model 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 edges are authored analytic reference segments. Each has a stable `name` +and `start`/`end` points. Coordinates are model-local, right-handed CAD XYZ in +millimeters with +Z up; points acquire rotation and translation when core places +the model. A segment may be a nominal-envelope datum rather than an edge of the +final mesh or a B-rep topological edge. Names never depend on tessellation, +triangle order, or inferred vertex indices. + +For rectangular tube, the cross-section is centered on XY and the cut ends are +Z=0 and Z=length. The 40 × 20 × 80 mm stock above provides: + +| Reference | Start (mm) | End (mm) | Nominal measurement | +| --- | --- | --- | --- | +| `overall_width` | (−20, 10, 0) | (20, 10, 0) | 40 mm | +| `overall_height` | (−20, −10, 0) | (−20, 10, 0) | 20 mm | +| `overall_length` | (20, −10, 0) | (20, −10, 80) | 80 mm | +| `wall_thickness` | (0, 7, 0) | (0, 10, 0) | 3 mm | + +These coordinates update from the normalized parameters. Rounded tubes retain +the same semantic names and nominal envelope spans: `overall_width` and +`overall_height` do not shrink to the straight portions between rounded corners. +They measure the nominal stock dimensions, not tessellated feature lengths. + +`views` supplies suggested end (`z+`) and side (`y-`) views, with dimension intents +referencing those edge names. Each intent has `edgeRef`, `orientation`, `offset`, +and `precision`. Orientations are `horizontal`, `vertical`, or `aligned` +(default). Offset is signed paper-space mm after projection (default 8); it +positions the dimension line and never changes the measured value. Precision is +the number of decimal places from 0 to 6 (default 1). The metadata does not fix a +viewport size, projection scale, dimension text, or units label. + +The intended authoring flow lets core expose these names on the selected model +so dimension TSX can remain conventional: + +```tsx + + + + +``` + +This illustrates the dimension proposal; modelprinter does not register TSX +elements, place a model, transform references into world coordinates, or resolve +Circuit JSON IDs. Those are downstream core responsibilities. The exporter +projects resolved references and renders extension lines, arrows, and measured +values. Initial references are straight segments; circles, diameters, radii, +angular dimensions, tolerances, and mesh 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 an optional 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 that model's validated definition. Metadata must have +unique nonempty edge and view names, finite nonzero segments, and dimension +references resolving to an edge in the same contract. The registry validates and +copies the result with `modelDrawingSchema`; it snapshots the callback when +registered, so changing a descriptor cannot change another registry's contract. diff --git a/docs/model-registration.md b/docs/model-registration.md index c8d8871..46872ee 100644 --- a/docs/model-registration.md +++ b/docs/model-registration.md @@ -47,6 +47,14 @@ 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 authored named reference +segments and suggested view/dimension intents; 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/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..9d45dcf --- /dev/null +++ b/src/model-drawing.ts @@ -0,0 +1,91 @@ +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. + * A segment may be a nominal-envelope datum, not a final mesh or B-rep edge. + */ +export const modelNamedEdgeSchema = z + .object({ + name: z.string().trim().min(1), + 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 a signed distance in paper mm after projection; + * measured values come from the reference segment, never from this offset. + */ +export const modelDrawingDimensionSchema = z + .object({ + edgeRef: z.string().trim().min(1), + orientation: z + .enum(["horizontal", "vertical", "aligned"]) + .default("aligned"), + offset: z.number().finite().default(8), + precision: z.number().int().min(0).max(6).default(1), + }) + .strict() +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).min(1), + 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 (!edgeNames.has(dimension.edgeRef)) + 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..87bbd23 --- /dev/null +++ b/src/models/rectangulartube/drawing.ts @@ -0,0 +1,63 @@ +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. Overall spans use the nominal envelope; + * for rounded profiles they are dimension datums, not topological edge claims. + */ +export function getRectangularTubeDrawing( + input: RectangularTubeModelPropsInput, +): ModelDrawing { + const { width, height, length, wallThickness } = + getRectangularTubeDimensions(input) + const left = -width / 2 + const right = width / 2 + const bottom = -height / 2 + const top = height / 2 + return modelDrawingSchema.parse({ + namedEdges: [ + { + name: "overall_width", + start: { x: left, y: top, z: 0 }, + end: { x: right, y: top, z: 0 }, + }, + { + name: "overall_height", + start: { x: left, y: bottom, z: 0 }, + end: { x: left, y: top, z: 0 }, + }, + { + name: "overall_length", + start: { x: right, y: bottom, z: 0 }, + end: { x: right, y: bottom, z: length }, + }, + { + name: "wall_thickness", + start: { x: 0, y: top - wallThickness, z: 0 }, + end: { x: 0, y: top, z: 0 }, + }, + ], + views: [ + { + name: "end", + direction: "z+", + dimensions: [ + { edgeRef: "overall_width", orientation: "horizontal", offset: 10 }, + { edgeRef: "overall_height", orientation: "vertical", offset: 10 }, + { edgeRef: "wall_thickness", orientation: "vertical", offset: -8 }, + ], + }, + { + name: "side", + direction: "y-", + dimensions: [ + { edgeRef: "overall_width", orientation: "horizontal", offset: -10 }, + { edgeRef: "overall_length", orientation: "vertical", 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..ad9fa1b --- /dev/null +++ b/tests/model-drawing-01-contract.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test" +import { getRectangularTubeDrawing, mp } from "../src" + +test("stock-tube drawing references follow normalized dimensions and preserve model JSON", () => { + 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: "overall_width", + start: { x: -20, y: 10, z: 0 }, + end: { x: 20, y: 10, z: 0 }, + }, + { + name: "overall_height", + start: { x: -20, y: -10, z: 0 }, + end: { x: -20, y: 10, z: 0 }, + }, + { + name: "overall_length", + start: { x: 20, y: -10, z: 0 }, + end: { x: 20, y: -10, z: 80 }, + }, + { + name: "wall_thickness", + start: { x: 0, y: 7, z: 0 }, + end: { x: 0, y: 10, z: 0 }, + }, + ]) + expect( + drawing.views.map(({ name, direction }) => ({ name, direction })), + ).toEqual([ + { name: "end", direction: "z+" }, + { name: "side", direction: "y-" }, + ]) + expect(drawing.views[0]!.dimensions).toEqual([ + { + edgeRef: "overall_width", + orientation: "horizontal", + offset: 10, + precision: 1, + }, + { + edgeRef: "overall_height", + orientation: "vertical", + offset: 10, + precision: 1, + }, + { + edgeRef: "wall_thickness", + orientation: "vertical", + offset: -8, + 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([60, 30, 120, 4]) + expect( + mp + .string("rectangulartube_w40mm_h20mm_wall3mm_l80mm_outerr4mm_innerr1mm") + .drawing()!.namedEdges, + ).toEqual(drawing.namedEdges) + 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..4c748d9 --- /dev/null +++ b/tests/model-drawing-02-validation.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test" +import { + getRectangularTubeDrawing, + modelDrawingSchema, + modelNamedEdgeSchema, +} from "../src" + +test("drawing metadata validates finite nonzero named segments and unambiguous dimension refs", () => { + const drawing = getRectangularTubeDrawing({ + width: 40, + height: 20, + wallThickness: 3, + length: 80, + }) + const edge = drawing.namedEdges[0]! + for (const patch of [ + { name: " " }, + { 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] }, + { namedEdges: [] }, + { views: [...drawing.views, drawing.views[0]] }, + { views: [] }, + { + views: [ + { name: "end", direction: "z+", dimensions: [{ edgeRef: "missing" }] }, + ], + }, + { views: [{ name: "end", direction: "front", dimensions: [] }] }, + { + views: [ + { + name: "end", + direction: "z+", + dimensions: [{ edgeRef: edge.name, precision: 7 }], + }, + ], + }, + { + views: [ + { + name: "end", + direction: "z+", + dimensions: [{ edgeRef: edge.name, offset: NaN }], + }, + ], + }, + ]) + expect(modelDrawingSchema.safeParse({ ...drawing, ...patch }).success).toBe( + false, + ) + const defaulted = modelDrawingSchema.parse({ + namedEdges: [{ ...edge, name: " overall_width " }], + views: [ + { + name: " end ", + direction: "z+", + dimensions: [{ edgeRef: " overall_width " }], + }, + ], + }) + expect(defaulted.namedEdges[0]!.name).toBe("overall_width") + expect(defaulted.views[0]).toEqual({ + name: "end", + direction: "z+", + dimensions: [ + { + edgeRef: "overall_width", + 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() +}) From 534586912b1025f990fd8a3104a2cb4a1453cc4d Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Thu, 22 Oct 2026 10:40:52 -0700 Subject: [PATCH 2/4] Pin formatter for reproducible model contract checks --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0dd19c4..889ab86 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", @@ -58,3 +58,4 @@ "zod": "^3.25.67" } } + From 434c2c73c5cb43b2f114fbf43c7858375a6add45 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Thu, 22 Oct 2026 10:42:28 -0700 Subject: [PATCH 3/4] Normalize package manifest trailing newline --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 889ab86..19105dd 100644 --- a/package.json +++ b/package.json @@ -58,4 +58,3 @@ "zod": "^3.25.67" } } - From 1ded19fa5887ec038f4fe89b5430f4fd49605842 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Thu, 22 Oct 2026 11:01:23 -0700 Subject: [PATCH 4/4] Reference declared model edges and infer view dimensions with presets --- docs/model-drawings.md | 149 ++++++++++++++-------- docs/model-registration.md | 5 +- src/model-drawing.ts | 70 ++++++++-- src/models/rectangulartube/drawing.ts | 41 +++--- tests/model-drawing-01-contract.test.ts | 68 +++++----- tests/model-drawing-02-validation.test.ts | 57 +++++++-- 6 files changed, 256 insertions(+), 134 deletions(-) diff --git a/docs/model-drawings.md b/docs/model-drawings.md index a4a65eb..ecddcc7 100644 --- a/docs/model-drawings.md +++ b/docs/model-drawings.md @@ -1,9 +1,13 @@ # Model drawing references -Modelprinter can provide semantic measurement references alongside a model's -parameter contract. This metadata contains no meshes, rendered images, paper -layout, or renderer dependencies. Geometry generation and visual demonstrations -remain in jscad-electronics and the drawing exporter. +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" @@ -13,10 +17,10 @@ const definition = stock.json() const drawing = stock.drawing() ``` -`.json()` retains its existing output. `.drawing()` is optional metadata: it -returns `undefined` for models that have no drawing contract, and validates the -model's parameters before invoking a registered drawing callback. The first -supported model is `rectangulartube`. Its helper is also public: +`.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" @@ -29,60 +33,101 @@ const drawing = getRectangularTubeDrawing({ }) ``` -Named edges are authored analytic reference segments. Each has a stable `name` -and `start`/`end` points. Coordinates are model-local, right-handed CAD XYZ in -millimeters with +Z up; points acquire rotation and translation when core places -the model. A segment may be a nominal-envelope datum rather than an edge of the -final mesh or a B-rep topological edge. Names never depend on tessellation, -triangle order, or inferred vertex indices. +## 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. -For rectangular tube, the cross-section is centered on XY and the cut ends are -Z=0 and Z=length. The 40 × 20 × 80 mm stock above provides: +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: -| Reference | Start (mm) | End (mm) | Nominal measurement | +| Defined edge | Start (mm) | End (mm) | Edge length | | --- | --- | --- | --- | -| `overall_width` | (−20, 10, 0) | (20, 10, 0) | 40 mm | -| `overall_height` | (−20, −10, 0) | (−20, 10, 0) | 20 mm | -| `overall_length` | (20, −10, 0) | (20, −10, 80) | 80 mm | -| `wall_thickness` | (0, 7, 0) | (0, 10, 0) | 3 mm | - -These coordinates update from the normalized parameters. Rounded tubes retain -the same semantic names and nominal envelope spans: `overall_width` and -`overall_height` do not shrink to the straight portions between rounded corners. -They measure the nominal stock dimensions, not tessellated feature lengths. - -`views` supplies suggested end (`z+`) and side (`y-`) views, with dimension intents -referencing those edge names. Each intent has `edgeRef`, `orientation`, `offset`, -and `precision`. Orientations are `horizontal`, `vertical`, or `aligned` -(default). Offset is signed paper-space mm after projection (default 8); it -positions the dimension line and never changes the measured value. Precision is -the number of decimal places from 0 to 6 (default 1). The metadata does not fix a -viewport size, projection scale, dimension text, or units label. - -The intended authoring flow lets core expose these names on the selected model -so dimension TSX can remain conventional: +| `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 - - + + ``` -This illustrates the dimension proposal; modelprinter does not register TSX -elements, place a model, transform references into world coordinates, or resolve -Circuit JSON IDs. Those are downstream core responsibilities. The exporter -projects resolved references and renders extension lines, arrows, and measured -values. Initial references are straight segments; circles, diameters, radii, -angular dimensions, tolerances, and mesh topology naming remain follow-up work. +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 an optional typed callback to its registration descriptor: +public `index.ts`. Add a typed callback to its registration descriptor: ```ts export const model = defineModel({ @@ -93,8 +138,8 @@ export const model = defineModel({ }) ``` -The callback receives that model's validated definition. Metadata must have -unique nonempty edge and view names, finite nonzero segments, and dimension -references resolving to an edge in the same contract. The registry validates and +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 changing a descriptor cannot change another registry's contract. +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 46872ee..2bfebd0 100644 --- a/docs/model-registration.md +++ b/docs/model-registration.md @@ -48,8 +48,9 @@ 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 authored named reference -segments and suggested view/dimension intents; the registry validates that output +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 diff --git a/src/model-drawing.ts b/src/model-drawing.ts index 9d45dcf..8efd17c 100644 --- a/src/model-drawing.ts +++ b/src/model-drawing.ts @@ -10,11 +10,18 @@ const modelDrawingPointSchema = z /** Authored reference segment in model-local right-handed CAD coordinates, * millimeters, +Z up. Endpoints are points and pick up placement translation. - * A segment may be a nominal-envelope datum, not a final mesh or B-rep edge. + * 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().min(1), + 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, }) @@ -26,19 +33,62 @@ export const modelNamedEdgeSchema = z ) export type ModelNamedEdge = z.output -/** Dimension intent. offset is a signed distance in paper mm after projection; - * measured values come from the reference segment, never from this offset. +/** 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().min(1), - orientation: z - .enum(["horizontal", "vertical", "aligned"]) - .default("aligned"), + 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 @@ -55,7 +105,7 @@ export type ModelDrawingView = z.output */ export const modelDrawingSchema = z .object({ - namedEdges: z.array(modelNamedEdgeSchema).min(1), + namedEdges: z.array(modelNamedEdgeSchema), views: z.array(modelDrawingViewSchema).min(1), }) .strict() @@ -80,7 +130,7 @@ export const modelDrawingSchema = z }) viewNames.add(view.name) for (const [dimensionIndex, dimension] of view.dimensions.entries()) - if (!edgeNames.has(dimension.edgeRef)) + if (dimension.edgeRef && !edgeNames.has(dimension.edgeRef.slice(1))) context.addIssue({ code: "custom", path: ["views", viewIndex, "dimensions", dimensionIndex, "edgeRef"], diff --git a/src/models/rectangulartube/drawing.ts b/src/models/rectangulartube/drawing.ts index 87bbd23..f69e88f 100644 --- a/src/models/rectangulartube/drawing.ts +++ b/src/models/rectangulartube/drawing.ts @@ -5,13 +5,15 @@ import { } 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. Overall spans use the nominal envelope; - * for rounded profiles they are dimension datums, not topological edge claims. + * 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, wallThickness } = + const { width, height, length, outerRadius } = getRectangularTubeDimensions(input) const left = -width / 2 const right = width / 2 @@ -20,24 +22,19 @@ export function getRectangularTubeDrawing( return modelDrawingSchema.parse({ namedEdges: [ { - name: "overall_width", - start: { x: left, y: top, z: 0 }, - end: { x: right, y: top, z: 0 }, + name: "outer_top", + start: { x: left + outerRadius, y: top, z: 0 }, + end: { x: right - outerRadius, y: top, z: 0 }, }, { - name: "overall_height", - start: { x: left, y: bottom, z: 0 }, - end: { x: left, y: top, z: 0 }, + name: "outer_left", + start: { x: left, y: bottom + outerRadius, z: 0 }, + end: { x: left, y: top - outerRadius, z: 0 }, }, { - name: "overall_length", - start: { x: right, y: bottom, z: 0 }, - end: { x: right, y: bottom, z: length }, - }, - { - name: "wall_thickness", - start: { x: 0, y: top - wallThickness, z: 0 }, - end: { x: 0, y: top, z: 0 }, + name: "outer_right_longitudinal", + start: { x: right, y: bottom + outerRadius, z: 0 }, + end: { x: right, y: bottom + outerRadius, z: length }, }, ], views: [ @@ -45,18 +42,14 @@ export function getRectangularTubeDrawing( name: "end", direction: "z+", dimensions: [ - { edgeRef: "overall_width", orientation: "horizontal", offset: 10 }, - { edgeRef: "overall_height", orientation: "vertical", offset: 10 }, - { edgeRef: "wall_thickness", orientation: "vertical", offset: -8 }, + { widthOfView: true, offset: 10 }, + { heightOfView: true, offset: 10 }, ], }, { name: "side", direction: "y-", - dimensions: [ - { edgeRef: "overall_width", orientation: "horizontal", offset: -10 }, - { edgeRef: "overall_length", orientation: "vertical", offset: -10 }, - ], + dimensions: [{ heightOfView: true, offset: -10 }], }, ], }) diff --git a/tests/model-drawing-01-contract.test.ts b/tests/model-drawing-01-contract.test.ts index ad9fa1b..6883c05 100644 --- a/tests/model-drawing-01-contract.test.ts +++ b/tests/model-drawing-01-contract.test.ts @@ -1,32 +1,27 @@ import { expect, test } from "bun:test" import { getRectangularTubeDrawing, mp } from "../src" -test("stock-tube drawing references follow normalized dimensions and preserve model JSON", () => { +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: "overall_width", + name: "outer_top", start: { x: -20, y: 10, z: 0 }, end: { x: 20, y: 10, z: 0 }, }, { - name: "overall_height", + name: "outer_left", start: { x: -20, y: -10, z: 0 }, end: { x: -20, y: 10, z: 0 }, }, { - name: "overall_length", + name: "outer_right_longitudinal", start: { x: 20, y: -10, z: 0 }, end: { x: 20, y: -10, z: 80 }, }, - { - name: "wall_thickness", - start: { x: 0, y: 7, z: 0 }, - end: { x: 0, y: 10, z: 0 }, - }, ]) expect( drawing.views.map(({ name, direction }) => ({ name, direction })), @@ -35,24 +30,11 @@ test("stock-tube drawing references follow normalized dimensions and preserve mo { name: "side", direction: "y-" }, ]) expect(drawing.views[0]!.dimensions).toEqual([ - { - edgeRef: "overall_width", - orientation: "horizontal", - offset: 10, - precision: 1, - }, - { - edgeRef: "overall_height", - orientation: "vertical", - offset: 10, - precision: 1, - }, - { - edgeRef: "wall_thickness", - orientation: "vertical", - offset: -8, - precision: 1, - }, + { 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({ @@ -79,12 +61,32 @@ test("stock-tube drawing references follow normalized dimensions and preserve mo larger.namedEdges.map(({ start, end }) => Math.hypot(end.x - start.x, end.y - start.y, end.z - start.z), ), - ).toEqual([60, 30, 120, 4]) - expect( - mp - .string("rectangulartube_w40mm_h20mm_wall3mm_l80mm_outerr4mm_innerr1mm") - .drawing()!.namedEdges, - ).toEqual(drawing.namedEdges) + ).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, diff --git a/tests/model-drawing-02-validation.test.ts b/tests/model-drawing-02-validation.test.ts index 4c748d9..349b5b9 100644 --- a/tests/model-drawing-02-validation.test.ts +++ b/tests/model-drawing-02-validation.test.ts @@ -5,7 +5,7 @@ import { modelNamedEdgeSchema, } from "../src" -test("drawing metadata validates finite nonzero named segments and unambiguous dimension refs", () => { +test("drawing refs resolve declared edges while view presets need no invented edge", () => { const drawing = getRectangularTubeDrawing({ width: 40, height: 20, @@ -15,6 +15,7 @@ test("drawing metadata validates finite nonzero named segments and unambiguous d 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" }, @@ -24,21 +25,15 @@ test("drawing metadata validates finite nonzero named segments and unambiguous d ) for (const patch of [ { namedEdges: [...drawing.namedEdges, edge] }, - { namedEdges: [] }, { views: [...drawing.views, drawing.views[0]] }, { views: [] }, - { - views: [ - { name: "end", direction: "z+", dimensions: [{ edgeRef: "missing" }] }, - ], - }, { views: [{ name: "end", direction: "front", dimensions: [] }] }, { views: [ { name: "end", direction: "z+", - dimensions: [{ edgeRef: edge.name, precision: 7 }], + dimensions: [{ edgeRef: ".outer_top", precision: 7 }], }, ], }, @@ -47,7 +42,7 @@ test("drawing metadata validates finite nonzero named segments and unambiguous d { name: "end", direction: "z+", - dimensions: [{ edgeRef: edge.name, offset: NaN }], + dimensions: [{ edgeRef: ".outer_top", offset: NaN }], }, ], }, @@ -55,23 +50,59 @@ test("drawing metadata validates finite nonzero named segments and unambiguous d 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: " overall_width " }], + namedEdges: [{ ...edge, name: " outer_top " }], views: [ { name: " end ", direction: "z+", - dimensions: [{ edgeRef: " overall_width " }], + dimensions: [{ edgeRef: " .outer_top " }], }, ], }) - expect(defaulted.namedEdges[0]!.name).toBe("overall_width") + expect(defaulted.namedEdges[0]!.name).toBe("outer_top") expect(defaulted.views[0]).toEqual({ name: "end", direction: "z+", dimensions: [ { - edgeRef: "overall_width", + edgeRef: ".outer_top", orientation: "aligned", offset: 8, precision: 1,