Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions docs/model-drawings.md
Original file line number Diff line number Diff line change
@@ -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.
<assembly.drawing.partview partRef=".stock" name="end" direction="z+"
width="80mm" height="60mm">
<assembly.drawing.dimension edgeRef=".outer_top" offset="10mm" />
</assembly.drawing.partview>
```

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
<assembly.drawing.partview partRef=".stock" name="end" direction="z+"
width="80mm" height="60mm">
<assembly.drawing.dimension widthOfView offset="10mm" />
<assembly.drawing.dimension heightOfView offset="10mm" />
</assembly.drawing.partview>
```

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.
9 changes: 9 additions & 0 deletions docs/model-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./generated/models"
export * from "./model-registry"
export * from "./parse-model-string"
export * from "./modelprinter"
export * from "./model-drawing"
141 changes: 141 additions & 0 deletions src/model-drawing.ts
Original file line number Diff line number Diff line change
@@ -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<typeof modelNamedEdgeSchema>

/** 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<typeof modelDrawingDimensionSchema>

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<typeof modelDrawingViewSchema>

/** 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<string>()
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<string>()
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<typeof modelDrawingSchema>
28 changes: 27 additions & 1 deletion src/model-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { modelDrawingSchema, type ModelDrawing } from "./model-drawing"
import type { z } from "zod"
import {
parseModelStringParams,
Expand Down Expand Up @@ -78,6 +79,8 @@ export type ModelRegistration<
readonly parse: (params: RawModelprinterParams) => z.output<NoInfer<Schema>>
/** 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<NoInfer<Schema>>) => ModelDrawing
}

/** Preserve the literal name and schema output without widening either. */
Expand All @@ -94,7 +97,12 @@ export function defineModel<

/** Explicit registrations belong to this instance, never to globalThis. */
export class ModelRegistry {
private readonly models = new Map<string, ModelRegistration>()
private readonly models = new Map<
string,
Omit<ModelRegistration, "getDrawing"> & {
getDrawing?: (definition: RegisteredModelDefinition) => ModelDrawing
}
>()

register<
const Name extends string,
Expand All @@ -117,13 +125,24 @@ 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, {
name: model.name,
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<Schema>)
: undefined,
})
}

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/modelprinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const string = (value: string) => {
return {
params: () => params,
json: () => modelParamsToJson(params),
drawing: () => registry.drawing(params),
}
}

Expand Down
Loading
Loading