MyBenchLab Documentation Open editor

Groups — subassemblies with explicit ports

A group is a self-contained piece of a bench — a reducer stage, a differential, a sensor cluster — that exposes only a handful of external ports. From the outside it looks like a single component: you wire its ports, you don't care what's inside.

Internally, a group is just a sub-graph. The compiler flattens it by prefixing every child id with the group's id, so the engine, graph builder, and renderer never see a hierarchy. This keeps the kinematic core simple while giving authors a real reuse primitive.

API

export default defineBench(({ mech, group }) => {
  const stage = 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);
      // The build function must return a ports map. Keys are the
      // external port names; values are inner port refs. Only ports
      // listed here are reachable from outside the group.
      return { ports: { in: a.ports.axis, out: b.ports.axis } };
    },
  });

  const driver = mech.spurGear({
    id: 'D',
    teeth: 20,
    module: 2,
    transform: { position: [0, 0, 0] },
  });
  mech.mesh(driver, stage.ports.in);
  mech.drive(driver, { rpm: 60 });
});

group({ id, build }) does three things:

1. Runs build against a fresh recorder. The inner DSL context (mech, elec, group, defineComponent, deg, rad) is the same shape as the outer one, so the inner code does not know it is inside a group. 2. Returns a handle that behaves like a component: stage.id, stage.ports.<name>. You pass it to connections exactly like mech.mesh(driver, stage.ports.in). 3. Inlines the inner components, connections, and drivers into the outer document, prefixing every inner id with ${groupId}/. In the example above, the compiled document contains stage1/A and stage1/B — not a nested "stage1" node.

Namespacing rules

Local kinds inside a group

You can register defineComponent(...) inside a group's build. The kind is scoped to the innermost compile and visible in that group's build context only — it does not leak to the outer bench, to sibling groups, or to future compiles of the same source. This is the same ergonomics as defineComponent at the top level, just narrower.

Multi-file benches

Inline build is the default, but a bench can also split its groups across multiple source files. Each file ends with export default defineBench(...) and declares its exposed ports the same way an inline build does. From the entry source you reference another file by name:

// stageA
export default defineBench(({ mech }) => {
  const g = mech.spurGear({ id: 'gear', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  return { ports: { out: g.ports.axis } };
});

// main
export default defineBench(({ group, mech }) => {
  const stage1 = group({ id: 'stage1', source: 'stageA' });
  const driver = mech.spurGear({ id: 'D', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  mech.mesh(driver, stage1.ports.out);
  mech.drive(driver, { rpm: 30 });
});

The compiler treats source: 'name' exactly like an inline build — it resolves the named file, runs its build against a fresh recorder, and inlines the results with the stage1/ prefix. Id prefixing, local-kind scoping, and the ports contract are identical.

source and build are mutually exclusive. Outside the multi-source model (a bench stored as a single source string), group({ source }) errors loudly — only benches.sources[] enables the resolver.

In the Workspace, each file shows up as its own tab; the tab marked entry is the one the compiler starts from.

Declarative external ports

A reusable sub-bench can publish its external interface statically via the object form of defineBench:

// stageA
export default defineBench({
  build: ({ mech }) => {
    mech.spurGear({ id: 'gear', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  },
  externalPorts: [
    { id: 'out', internal: 'gear.axis' },
  ],
});

When a parent's group({ source }) resolves a sub-bench that declares externalPorts, the declared list wins — the build's returned { ports } is ignored. Each entry has { id, internal: '<componentId>.<portId>', type? }. The compiler validates that every internal reference points at an actual inner component and fails loudly otherwise.

Why bother when the legacy "build returns ports" form works? Because the declarative form makes the contract readable without running the body — useful for documentation, type generation, and future tooling that wants to inspect a bench's I/O without compiling it. Inline groups (group({ build })) have nowhere to declare it and continue using the legacy form.

Both forms of defineBench keep working everywhere a bench is the *entry* source — declared externalPorts are simply ignored when there is no parent group to consume them.

The group node in the document

Alongside the flattened children, the compiler emits one component per group with kindId: 'group' and the group's authored id. The node is kinematically inert — its kind has ports: [], so the graph builder creates no nodes for it and propagation runs only over the inner children. What the node carries is metadata:

The TreePanel and the Inspector look for these explicit nodes first and fall back to id-prefix detection when a document predates this format. The DSL palette, the AI system prompt, and the auto-generated /docs catalog continue to hide the group kind because authors do not pick it from a list — api.group(...) stays the only authoring entry point.

Namespace convention

Inner component ids carry the group's authored id as a prefix joined by /: a child A inside stage1 becomes stage1/A in the compiled document; a deeper child B inside a nested group sub of stage1 becomes stage1/sub/B. The compiler enforces three invariants on this convention via the expandGroups pass that runs right after validation and before the layout solver:

When the DSL is the only authoring path, none of these errors fire. They are guard rails for any future code path that mutates the document directly (the GUI param-edit pipeline already syncs back to the source via AST surgery, but downstream tools that bypass the source could otherwise produce silently inconsistent documents).

What groups are not

When to reach for a group

When the I/O contract isn't clear or the components really are one-offs, inline them directly — groups add a layer of indirection, and indirection without reuse is just noise.

Roadmap

See plan §17 for the full design trade-offs.