DSL reference
A bench source is a JavaScript module that ends with export default defineBench(buildFn). The compiler first checks the source against a closed grammar (see §The closed grammar) and only then runs the body inside a capability-zero sandbox (no window, document, fetch, import, require, eval, Function, new, this, or Meteor). The only binding available is defineBench; Math is the sole extra global; everything else comes from the DSL context passed to your buildFn. Benches written the normal way — the examples on this page — are unaffected; the restriction only rejects code that reaches outside the modeling surface.
export default defineBench((ctx) => {
// ctx is an object with one namespace per registered category
// (e.g. ctx.mech), plus top-level helpers group, defineComponent,
// deg, rad. Destructure what you need.
const { mech, group, defineComponent, deg, rad } = ctx;
// ...
});
defineBench also accepts an object form, defineBench({ build, externalPorts }), when the bench is meant to be reused as a sub-bench via group({ source }). The externalPorts array publishes the external interface statically — see Groups for the full contract. The function form remains the right choice for top-level benches that aren't reused.
Categories and component builders
Every registered component kind is exposed as a function under its category namespace, keyed by the kind's dslName. Calling the builder records a component in the recorder and returns a handle.
export default defineBench(({ mech }) => {
const g = mech.spurGear({
id: 'G1', // optional; auto-generated if omitted
teeth: 20, // kind-specific params (see catalog)
module: 2,
width: 10,
boreDiameter: 8,
label: 'Driver', // optional free-form label for the UI
transform: {
position: [-25, 0, 0], // millimeters (canonical unit)
rotation: [0, 0, 90], // degrees; use rad(x) to pass radians
},
});
// g.ports.axis, g.id, g.kindId are available below
});
idmust be unique in the bench. Duplicates error loudly — never silently overridden.transformis optional. Components without it are positioned by the layout solver (see below).- Units: lengths in mm, angles in degrees on the DSL surface, RPM for driver input. The engine converts to radians / rad·s⁻¹ at compile time (see [/docs/dsl]() §Units below).
The statics namespace
The static-workbench kinds register under the category static. Because static is a reserved word and cannot be destructured (const { static } = ctx is a syntax error), the namespace is also exposed under the alias `statics` — always destructure that one:
export default defineBench(({ statics }) => {
const leg = statics.steelTubeSquare({
id: 'leg', width: 50, wallThickness: 2, length: 889,
transform: { position: [50, 444.5, 50], rotation: [-90, 0, 0] },
});
// The rail has no transform: the weld's 'face' hint places it flush on
// the standing leg's end face (its local +Z = 'front'), gap 0.
const rail = statics.steelTubeRectangular({ id: 'rail', w: 20, h: 40, length: 2500 });
statics.weld(leg, rail, { face: 'front', rotationDeg: [0, 90, 0] }); // rigid junction
const top = statics.woodPanel({ id: 'top', material: 'osb', length: 2600, width: 600, thickness: 11 });
statics.rest(rail, top); // gravity rest — top's bottom face onto the rail top
statics.envelope({ x: 2600, y: 900, z: 600 }); // declared outer bounds (mm)
});
The static junctions — weld, bolted, rest, inset — are backed by the assembly solver (plan §16): pass a face ('top'|'bottom'|'front'|'back'|'left'|'right', read in the anchor's own local frame) and the driven part is placed flush against that face, so you never hand-compute a mating position. The hints are strictly opt-in — a junction with no face/align keeps its original behavior. See Static workbench for the assembly guide, the placement hints, and the validator rules.
envelope({ x, y, z })
A bench-level helper (exposed on every namespace) that declares the assembly's outer bounding box in millimeters, origin at a corner. It feeds the static envelope validator: every part must fit inside it within a 25 mm tolerance. Kinematic benches simply don't call it.
importedFrom(tag)
A bench-level helper (exposed on every namespace) that stamps machine-import provenance onto the bench — e.g. statics.importedFrom('scene3d'), which the scene3d→mbl converter writes into its generated source. Its only effect is on the connectivity validator: an imported bench may lean on bare geometric contact (no declared junction) silently, whereas an authored bench relying on contact gets the soft W_STATIC_CONTACT_NO_JOINT warning nudging it to declare a weld/rest. tag is a free provenance string; surfaced as document.meta.importOrigin. You rarely write this by hand — it exists for converters.
Repetition — array(descriptor, options) and grid(descriptor, options)
Exposed on every namespace, these replicate one component kind many times so a shelf unit or a run of legs is a single call instead of dozens of hand-placed parts (the anti-runaway lever, plan §18.2). descriptor is { id, kind, params?, transform? } — kind is a builder's dslName (a string), id the base id, transform the base placement of the first replica.
array(descriptor, { count, dx, dy, dz, startIndex? })— replicakgets id${id}_${i}and offset[k·dx, k·dy, k·dz].grid(descriptor, { rows, cols, dx, dy, dz, startIndex? })— replica(r, c)gets id${id}_${r}_${c}and offset[c·dx, r·dy, r·dz].
Both return an array of handles (row-major for grid), so the result composes with junctions — e.g. statics.weld(legs[0], rail, { face: 'front' }). They cap at 512 parts and fail fast beyond it. See Static workbench for full examples.
profile({ id, points, thickness, transform? })
A static builder for a custom extruded 2D polygon (gussets, brackets, cut plates). points is an array of [x, y] pairs in mm, authored counter-clockwise, at least 3 (do not repeat the first). The outline lies in the local X/Y plane and extrudes thickness along local Z. A self-intersecting or degenerate outline is rejected with E_STATIC_PROFILE.
Connections
Each registered connection kind exposes a creator under its category, also keyed by dslName. The creator takes two handles (or port refs) plus optional per-connection params.
const G1 = mech.spurGear({ id: 'G1', teeth: 20, module: 2 });
const G2 = mech.spurGear({ id: 'G2', teeth: 40, module: 2 });
// Built-in mech connections:
mech.mesh(G1, G2, { side: 'right' }); // gear mesh
mech.couple(G1, G2, { offsetMm: 10 }); // rigid shaft coupling
mech.belt(P1, P2, { side: 'right' }); // belt between two pulleys
mech.chain(S1, S2, { side: 'right' }); // chain between two sprockets
When a handle has exactly one port (most do), you can pass the handle directly; otherwise spell out the port:
mech.mesh(G1.ports.axis, G2.ports.axis);
Drivers
mech.drive(G1, { rpm: 60 }); // 60 RPM constant
Drivers inject a fixed velocity into a component's port. At each tick the engine resets all ω to zero, applies drivers, and propagates. A bench with no drivers simulates a static mechanism.
Groups (subassemblies)
group({ id, build }) runs an inner build against a fresh recorder and inlines the result under a namespace prefix. The inner build returns a ports map that becomes the group's external ports; outside, the group is indistinguishable from a single component with those ports.
export default defineBench(({ mech, group }) => {
const reducer = group({
id: 'stage1',
build: ({ mech: m }) => {
const a = m.spurGear({ id: 'A', teeth: 20, module: 2 });
const b = m.spurGear({ id: 'B', teeth: 60, module: 2 });
m.mesh(a, b);
return { ports: { in: a.ports.axis, out: b.ports.axis } };
},
});
const driver = mech.spurGear({ id: 'D', teeth: 20, module: 2 });
mech.mesh(driver, reducer.ports.in);
mech.drive(driver, { rpm: 60 });
});
The compiled document contains stage1/A and stage1/B — the engine sees a flat graph. See Groups for the full model (namespacing, local kinds inside groups, future multi-file sources).
Inline kinds
defineComponent(...) registers a component kind scoped to this compile only. It does not leak to other benches. After registration, the kind is available at <category>.<dslName> just like a built-in.
export default defineBench(({ defineComponent, mech }) => {
defineComponent({
apiVersion: 1,
id: 'wobbly-disc',
category: 'mech',
dslName: 'wobblyDisc',
parametersSchema: { radius: { type: Number, defaultValue: 30 } },
ports: [{ id: 'axis', type: 'rotational' }],
// ... build3D, buildGraphNode, dimensions ...
});
const d = mech.wobblyDisc({ id: 'D', radius: 40 });
mech.drive(d, { rpm: 30 });
});
Full plugin contract: Custom components.
Angle helpers
The DSL surface authors rotations in degrees; the compiler converts to radians internally.
deg(x)— identity passthrough, kept for readability:rotation: [0, 0, deg(90)].rad(x)— converts radians to degrees so a radian literal survives the standard deg→rad conversion.rad(Math.PI / 2)resolves to90before compilation re-converts it.
Units
| Quantity | DSL surface | Document / engine |
|---|---|---|
| Lengths | millimeters | millimeters |
| Rotations | degrees | radians |
| Angular velocity (drivers) | RPM | rad·s⁻¹ (internal) |
| Time | seconds | seconds |
The mm → scene-meters conversion for the 3D viewer happens once via <SceneUnits>. You never multiply by 0.001 in a component. Full contract: plan §13.
The closed grammar
The DSL is a closed subset of JavaScript, enforced by an AST whitelist that runs before any execution. This makes a bench a description of a mechanism, not a program with host access — a public/shared bench, an MCP-authored bench, or an imported .mbl/.mblext can be compiled without trusting its author. Anything outside the subset is rejected with E_FORBIDDEN_SYNTAX (params: { node, line }), surfaced in the Console like any other compile error.
Allowed: const/let; arrow and function expressions/declarations; object/array/number/string/boolean/template literals; arithmetic, comparison, logical and unary operators (typeof, !, -, …); if/ternary; for, for…of, while; map/forEach/push and other method calls on the injected namespaces, on your handles, or on local variables; spread of local arrays/objects; Math.*.
Rejected (default-deny — anything not explicitly allowed): import/export (other than the single default defineBench export), require, dynamic import(); class, new, this, super; async/await, generators/yield; try/catch, throw, with, labels, switch, do…while, for…in; regex and tagged-template literals; instanceof/in, delete, comma expressions, var; any reference to a global other than defineBench and Math (so globalThis, window, self, process, fetch, XMLHttpRequest, WebSocket, Function, eval, setTimeout, JSON, Object, Array, Number, console, … are all out); constructor/prototype/__proto__ access on any object; assignment to a property of a non-local object; and dynamic computed member access that is chained or called (the obj[k1][k2]() escape). defineComponent bodies that reach for React/three primitives are outside the subset for the same reason — inline kinds that only compute geometry stay within it.
Execution is hardened on top of the whitelist (defense-in-depth): the module runs in strict mode with this = undefined and dangerous globals shadowed to undefined. Server-side compiles (import, MCP) additionally run the source through a worker-thread canary with a hard time budget, so a runaway loop can't block the server; it surfaces as E_COMPILE_TIMEOUT.
Compilation passes
The compiler runs these passes in order. Errors from every pass are returned together so you see everything wrong at once:
0. Grammar whitelist — the source is parsed and every node checked against the closed subset (above). Out-of-subset syntax surfaces as E_FORBIDDEN_SYNTAX and the source is never executed. 1. Source evaluation — the (already-whitelisted) source is wrapped in a hardened function and executed. Syntax errors and thrown exceptions surface here as E_SYNTAX/E_BUILD. 2. Kind resolution — every component and connection references a registered kind (including inline ones declared via defineComponent). 3. Parameter validation — kind-specific, using the declared parametersSchema. 4. Connection validation — acceptedPorts match + the connection kind's validate() hook. 5. Layout solve — BFS from anchors outward, applying solveLayout() per connection. Writes effectiveTransform onto every component. 6. Domain validation — runs after layout so it sees resolved transforms. For benches that use the static workbench, this is the connectivity / envelope / interference / profile guardrail (E_STATIC_DISCONNECTED, E_STATIC_ENVELOPE, E_STATIC_INTERFERENCE, E_STATIC_PROFILE); kinematic benches skip it entirely. It can also emit warnings (non-blocking) — e.g. W_STATIC_CONTACT_NO_JOINT when an authored bench holds parts together by bare contact instead of a declared junction. Same { code, params } shape as every other pass, and also reachable standalone via validateBench({ document }). 7. Graph build — produces the runtime graph the tick loop consumes.
See plan §13.2 for the layout solver details and Static workbench for the validator rules.