yapCAD DSL Language Guide
Where this fits: This guide teaches the yapCAD DSL as a language — its mental model, execution semantics, and idioms. Read it first. When you want a worked, end-to-end example, go to the DSL Tutorial. When you need to look up a specific type or built-in function, go to the DSL Reference.
Learning path: Guide (you are here) → Tutorial → Reference.
1. What the yapCAD DSL is (and why it exists)
The yapCAD DSL is a small, statically-typed, Python-flavored language for
describing parametric CAD geometry as code. A .dsl file is not a drawing
and not a saved model — it is a program that generates geometry when you
run it with a chosen set of parameters.
You could write the same geometry directly in Python against the yapcad
modules. The DSL exists because it deliberately gives up some of Python’s power
in exchange for guarantees that matter for CAD automation and, increasingly,
for machine-generated designs:
Static type safety. Every value has a declared type (
float,solid,region2d, …). Type errors are caught bydsl checkbefore any geometry is built, so a malformed design fails fast with a clear message instead of producing a broken solid.Guaranteed termination. There are no
whileloops and no unbounded recursion (call depth is capped). Every DSL program is statically verifiable to halt — which means a generator or LLM can emit DSL without the risk of an infinite loop wedging a build farm.Provenance and reproducibility. A DSL program plus its parameters fully determines the output. Package it as a
.ycpkgand you have a signed, reproducible artifact with its source embedded.A constrained surface for automation. The DSL is small enough that tools (and language models) can author it reliably. Several shipped examples were generated from natural-language prompts.
If you have ever re-drafted a drawing because a material thickness, bolt spacing, or pipe diameter changed, the DSL is the alternative: change a parameter, re-run, done.
2. The execution model
A DSL program runs in one direction: you invoke a command with parameters,
it computes geometry, and it emits a single result. Understanding this loop
is most of understanding the language.
module my_part # 1. every file declares a module
command MAKE_BRACKET( # 2. a command is the unit of execution
width: float = 40.0, # parameters are typed, may have defaults
thickness: float = 5.0
) -> solid: # 3. the command declares its return type
require width > 0.0 # 4. validate inputs (fail fast)
plate: solid = box(width, width, thickness) # 5. build geometry into typed vars
emit plate # 6. emit exactly one result
Run it from the CLI:
python -m yapcad.dsl run my_part.dsl MAKE_BRACKET --param width=60.0 --output bracket.step
Key points about the model:
Commands are the entry points. You don’t run a “file”; you run a command in a file. One file can hold many commands.
emitis a return, not a print. A command produces its output by emitting exactly one value of its declared return type.emitcan also act as an early return inside a loop or conditional (see §5).Execution is top-down and pure-ish. There is no global mutable state shared between commands; a command’s output depends only on its parameters and the helpers it calls.
checkbeforerun.dsl checktype-checks the whole file without building geometry. Make it a habit — it catches most mistakes in milliseconds.
3. Modules, commands, and the UPPERCASE/lowercase rule
Every file begins with module <name>. Inside it you define commands. The
capitalization of a command name is semantically meaningful:
Name style |
Role |
Visible to |
|---|---|---|
|
Exported — a public entry point |
✅ Yes |
|
Helper — internal, composed by other commands |
❌ No |
This is the DSL’s module-boundary mechanism: export the few commands a user should call; keep the building-block helpers lowercase and private.
module gearbox
# Helper — internal, not callable from the CLI
command make_tooth(module_mm: float) -> solid:
emit box(module_mm, module_mm * 2.0, 5.0)
# Exported — appears in `dsl list`, callable from CLI
command MAKE_GEAR(teeth: int = 24, module_mm: float = 2.0) -> solid:
body: solid = cylinder(teeth * module_mm / 2.0, 10.0)
emit body
Idiom: Design top-down. Write one
UPPERCASEcommand that expresses the whole part, and factor repeated structure (a flange, a tooth, a rib) intolowercasehelpers that it calls.
4. Thinking in types
The DSL is statically typed and every variable declares its type. This is the single biggest adjustment for users coming from Python or OpenSCAD, and it is the source of most of the DSL’s safety.
radius: float = 12.5 # explicit annotation (preferred)
let height: float = 30.0 # 'let' form is equivalent
gear: solid = involute_gear(24, 2.0, 20.0, 10.0)
holes: list<solid> = []
A few rules that trip up newcomers:
Numbers are not interchangeable with their literal form. Write
5.0, not5, where afloatis expected. Integer literals areint; the checker will tell you when a conversion is needed.Both branches of a conditional expression must share a type.
box(...) if flag else cylinder(...)is fine (bothsolid); mixing asolidand afloatis a type error.There is no implicit negation operator surprise — but see the gotcha in §7 about
0.0 - x.
The type vocabulary (primitives, geometric types like point3d/region2d,
curve types, compound and generic types like list<T>) is cataloged in the
DSL Reference → Types. Treat the Reference as your
dictionary; this guide is the grammar.
Angles: degrees for geometry, radians for trig
This is the single most common unit mistake, so internalize it early:
Geometry/transform functions take DEGREES. The trigonometric math functions take RADIANS.
Function |
Angle unit |
|---|---|
|
degrees |
|
degrees |
|
radians |
|
return radians |
|
unit converters |
So rotating a part 45° is simply:
rotated: solid = rotate(part, 0.0, 0.0, 45.0) # 45 DEGREES — no conversion
But if you compute a position with trig, the trig call needs radians —
convert with radians(...):
# Place a hole on a bolt circle at `deg` degrees around the part:
deg: float = 30.0
x: float = bolt_circle_radius * cos(radians(deg)) # cos() needs radians
y: float = bolt_circle_radius * sin(radians(deg))
hole_at: solid = translate(hole, x, y, 0.0)
The trap: mixing the two — e.g. feeding a raw radian value into rotate,
or a raw degree value into sin — type-checks fine (both are float) but
produces silently wrong geometry. When in doubt, keep your angle variables in
degrees (matching the geometry functions) and wrap them in radians(...) only
at the moment you call a trig function.
5. Control flow — and the deliberate omissions
The DSL has the control flow you expect, minus while:
# for over a range
for i in range(bolt_count):
angle: float = i * (360.0 / bolt_count)
# ... place a hole at `angle`
# for over a list
for hole in holes:
body = difference(body, hole)
# if / elif / else
if depth <= 0:
emit base_solid
elif depth < 3:
emit small_variant
else:
emit full_variant
# ternary (conditional expression)
unit: float = 1.0 if metric else 25.4
Why no while? Unbounded loops can’t be statically proven to terminate. The
DSL trades them away so that every program is guaranteed to halt. When you
need “loop until converged,” use a bounded for with an early emit:
command SOLVE(start: float) -> float:
x: float = start
for i in range(100): # hard upper bound
if abs(error(x)) < 0.001:
emit x # early return on convergence
x = improve(x)
emit x # best effort after max iterations
Recursion is allowed but bounded. Commands may call commands (including
themselves) up to a depth limit (default 100, configurable via
--recursion-limit or YAPCAD_DSL_RECURSION_LIMIT). This makes fractal/tree
structures expressible while preserving the termination guarantee.
6. Composing geometry: the core workflow
Most DSL programs follow the same shape: build primitives, transform them, combine them with booleans, emit the result.
command MAKE_WASHER(outer_d: float = 20.0, inner_d: float = 8.0, thick: float = 2.0) -> solid:
require outer_d > inner_d
disc: solid = cylinder(outer_d / 2.0, thick)
bore: solid = cylinder(inner_d / 2.0, thick + 2.0) # over-long to cut cleanly
bore = translate(bore, 0.0, 0.0, -1.0) # straddle both faces
emit difference(disc, bore)
Two patterns worth internalizing immediately:
(a) Over-cut your subtractions. When you difference a hole through a part,
make the cutting tool slightly longer than the part and offset it so it pokes
out both faces. A bore exactly as tall as the part can leave a zero-thickness
coplanar face that confuses the kernel. The + 2.0 / translate(..., -1.0)
idiom above is the standard fix.
(b) Aggregate instead of chaining. Combining many solids by nesting
union(union(union(a, b), c), d) is hard to read and easy to get wrong. Prefer
the aggregation helpers (see Reference → Aggregation) or a for loop:
result: solid = parts[0]
for p in parts:
result = union(result, p)
emit result
7. Idioms, conventions, and gotchas
These are the DSL-specific habits that separate “fighting the language” from “fluent.” Most are not obvious from the function catalog.
requireis your contract. Putrequireassertions at the top of every exported command to validate parameters. They produce clear errors and double as executable documentation of the valid parameter envelope.emitexactly once on every path. Every code path through a command must emit a value of the declared return type — including each branch of anif/elif/elseand the fall-through after a loop.Negation: write
0.0 - x, not-xfor computed floats. Where you need the negative of a variable (e.g. mirroring a bolt position), the safe, type-clean form isneg: float = 0.0 - x. This shows up constantly in symmetric placement.Degrees for geometry, radians for trig — don’t cross the streams.
rotate,rotate_2d, andarc/ellipseangles are in degrees;sin/cos/tantake radians. Mixing them type-checks (bothfloat) but produces silently wrong geometry. Keep angle variables in degrees and wrap them inradians(...)only at the trig call. (Full rundown in §4.)Radial patterns via
for+ trig. Bolt circles, fins, and spokes are a loop overrange(count)computing an angle (in degrees), thentranslate/rotate. Remember toradians(...)the angle before anysin/cos. For common cases, prefer the built-inradial_pattern/linear_patternhelpers (Reference → Pattern helpers) over hand-rolled loops.print()is for debugging only. It writes to the console duringrun; it does not affect geometry. Use it to inspect intermediate values, then remove it.Lowercase helpers, UPPERCASE entry points. (See §3.) Keep the public surface small.
useimports are reserved for the future. You may seeuse other_modulein examples; cross-module imports are a forthcoming feature — for now keep a design within one module.Method vs. function form. Many operations are available both as functions and as methods (
translate(s, ...)≡s.translate(...)). Pick one style per file for readability; the Reference → Method Syntax section lists what’s available.
8. Annotating for tooling: @meta and @ui
Commands and parameters can carry decorators that are informational only — the evaluator ignores them, so they never change geometry or type-checking:
@ui(...)on a parameter gives the yapCAD Workbench widget hints (widget="slider", min=..., max=...).@meta(...)on a command attaches output metadata, optionally namespaced (assembly.*,operation.*) for assembly and machining workflows.
@meta(operation.kind="subtract", operation.feature_kind="pocket")
command MAKE_POCKET(
depth: float @ui(widget="slider", min=1.0, max=50.0) = 10.0
) -> solid:
...
You can write fully functional designs without ever using these; reach for them when you’re feeding the Workbench UI or the assembly/operation metadata system. Full syntax and the v1.1 namespace vocabularies are in the DSL Reference → Parameter Decorators.
9. From source to artifact: the CLI loop
The DSL ships with a CLI that covers the full lifecycle. You will use these constantly:
python -m yapcad.dsl check my_part.dsl # type-check, no build
python -m yapcad.dsl list my_part.dsl # show exported commands
python -m yapcad.dsl run my_part.dsl MAKE_PART --output part.step
python -m yapcad.dsl run my_part.dsl MAKE_PART --param width=60.0 --param thick=4.0
python -m yapcad.dsl run my_part.dsl MAKE_PART --package part.ycpkg
Typical inner loop while authoring: edit → check → run --output →
inspect → repeat, then run --package once you’re happy to produce a signed,
reproducible .ycpkg. The Tutorial walks this end-to-end on
a real part, including viewing and validating the package.
10. Where to go next
DSL Tutorial — build a parametric pipe fitting from scratch and export it. Best next step now that you have the mental model.
DSL Reference — the complete catalog: every type, built-in function, statement, method, and decorator. Your day-to-day lookup.
examples/— dozens of real.dslfiles (gears, fasteners, rockets, stands) showing idioms in context.Project Packaging — the
.ycpkgformat, signing, and provenance, for when you want reproducible, shareable design artifacts.