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
- Id collisions inside a group are caught by the inner recorder and reported with the group's local id (
A,B), not the prefixed form — you read the error the same way you'd read it in a flat bench. - Outer ids must still be unique: the outer recorder sees prefixed ids (
stage1/A), so a second group with the sameiderrors loudly. - External ports expose only what you list in the build's return value. An inner port that isn't in
portsis unreachable from the outside — connections referencing it from outside the group fail at compile time. - Drivers inside a group are allowed but uncommon: a group with an internal driver simulates standalone. Most groups stay passive and rely on the outer bench for input.
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:
externalPorts: [{ id, componentId, portId, type }]— the same datastage.portsexposes at authoring time, persisted so renderers, inspectors, and layout solvers can address the group's I/O without re-running the compile.children: [...]— the prefixed ids of the inner components that belong to this group, materialized so consumers do not have to parse id segments.
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:
E_GROUP_PREFIX_ORPHAN— a component whose id implies a group prefix that does not match anykindId: 'group'node.E_GROUP_CHILDREN_MISMATCH— a group node whose declaredchildren[]drifted from the prefixed components actually present (for example, a GUI patch removed a child without updating the group's metadata).E_GROUP_EXTERNAL_PORT_DANGLING— a declared external port targeting a component or port id that does not exist after inlining.
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
- Not a new engine concept. The graph builder, tick loop, and viewer see a flat bench after compilation; the explicit
groupnode is metadata, not a propagation target. - Not addressable as a connection endpoint. External connections (
mech.mesh(driver, stage.ports.in)) are rewritten at compile time to the prefixed inner port (mech.mesh(driver, 'stage1/A.axis')); no edge in the document references the group node's port directly.
When to reach for a group
- You are copy-pasting the same 3-5 components and their connections in more than one bench.
- A subassembly has a clear I/O contract — usually one input shaft and one output shaft for mechanical, or a pair of power/signal terminals for electronics.
- You want to tune parameters of the subassembly (number of stages, tooth counts) from the outside without rewriting the internals.
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
- v1 (shipped): inline
group({ id, build }). - v2 (shipped): split a group into a separate file via
group({ id, source })against a multi-source bench. The ABI (ports map as return value, id prefixing, local kind scoping) is identical to inline mode. - v3: a curated hub of shared groups, versioned like kinds (
apiVersion).
See plan §17 for the full design trade-offs.