# 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.

```js
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](/docs/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**.

```js
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
});
```

- `id` must be unique in the bench. Duplicates error loudly — never silently overridden.
- `transform` is 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:

```js
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](/docs/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? })` — replica `k` gets 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](/docs/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.

```js
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:

```js
mech.mesh(G1.ports.axis, G2.ports.axis);
```

## Drivers

```js
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.

```js
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](/docs/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.

```js
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](/docs/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 to `90` before 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](/docs/static-workbench) for the validator rules.
