Generated Parts and Assemblies
A custom feature builds into a part. Some designs are better inserted into an assembly whole: a gear on its axis, a wardrobe whose doors swing and whose drawers slide. Two definitions make them insertable:
- A generated part (
definePart) turns a feature into a part. The feature's typed-in values are the part's interface, and the frames the feature sets are its connectors. - A generated assembly (
defineAssembly) makes a sub-assembly by code: its parts, their connectors and the joints between them, from one set of values.
An assembly inserts both with insert(definition, values), like a part() or an assembly(). The assembly toolbar's Generate Parts dropdown and the Insert dialog list them, and open a dialog built from their values, with the result live in the viewport. Generated Parts and Assemblies in the Assembly section shows that side. This page writes them.
A generated part
The gear from Named Faces and Edges stands on a face of a part. Two changes to the feature, and a short file, make it a part of its own on an axis an assembly can mate.
1. Let the picked values be left out
A generated part takes the feature's typed-in values only. It leaves the picked values out, so each must be optional, and the feature must build without them. The gear stands on the XY plane when no face is picked:
face: { picker: 'face', label: 'On face', surface: 'planar', optional: true },
let plane = Plane.XY();
let origin = plane.origin;
if (this.values.face !== undefined) {
const faces = this.facesOf(this.values.face);
// … one planar face, as before
plane = faces[0].getPlane();
origin = plane.projectPoint(faces[0].center());
}
A feature with a required pick is refused where the part's module loads: definePart(Gear): 'face' is a picked value the feature requires — a generated part takes typed-in values only, so each picked value must be optional (optional: true); the part leaves it out.
2. Publish the axis as a frame
A frame is a named coordinate frame the feature sets as it builds. Declare it beside the buckets, the second argument of Feature.with(), and set it in buildTool() with setFrames(), as setBuckets() sets the buckets:
}, {
buckets: { /* … */ },
frames: {
axis: { label: 'Gear axis' },
},
}) {
this.setFrames({ axis: new Plane(origin, plane.xDirection, plane.normal.negate()) });
- A frame is a
Plane: its origin, its X direction, and its normal, which is the frame's Z. - The gear's frame sits on its axis, on the face the gear stands on, with Z out of the gear, the way a connector on that face points. A plain revolute mate on a plate's or a shaft's connector seats the gear and turns it about its axis. Point every frame a part stands on out of its material, so a mate needs no Flip.
- A frame's name is an identifier, as a connector's is. Its declaration takes only a
label. - A part file reads the frame too:
connector('axis', pinion.frame('axis'))puts a connector on a gear the part builds. On a repeated gear,r.instance(1).frame('axis')reads a copy's.
3. Define the part
gear.part.js
// The gear as a part an assembly inserts. The feature's typed-in values are
// the part's interface, by id: insert(gearPart, { teeth: 30, bore: 10 }).
// The face it stands on is a picked value, so the part leaves it out; the
// gear stands on the part's XY plane, and the assembly places the part.
import { definePart } from 'fluidcad/sdk';
import { Gear } from './gear.feature.js';
export const gearPart = definePart(Gear, {
label: 'Gear', // the menu entry, the dialog's title and the part's name
description: 'A spur gear with a shaft bore, on its axis connector',
icon: './gear.svg', // relative to this file
});
// The feature's `axis` frame is the part's `axis` connector: a revolute
// mate turns the gear about it.
definePart(FeatureClass, { … })returns a part definition. Export it by name from a*.part.jsfile. It follows the part rules of apart(): exported, and one name per file.- The values are the part's interface, by id. An assembly writes
insert(gearPart, { teeth: 30, bore: 10 }), with the values' ids, not their labels. They are checked as the feature's call checks them. - The part runs the feature as a new solid, at the part's own origin, and makes one connector per frame:
gear1.connectors.axis. - Equal values share one part. Inserts whose values are equal once the defaults are filled in build once.
insert(gearPart)andinsert(gearPart, { teeth: 20 })are one part, since 20 is the default. - Opened on its own, the file renders the gear with every value at its default.
The second argument describes the part for the menus and its dialog:
| Field | Meaning |
|---|---|
label | Required. The entry in the Generate Parts dropdown and the Insert dialog, the dialog's title, and the part's name. |
name | The part's name, when it should differ from the label. |
description | The tooltip in the dropdown. |
icon | The picture, relative to this file. It may climb out of the folder ('../features/gear.svg'), never out of the project or the package. |
layout | The dialog's rows over the part's values. Left out, the part borrows the feature dialog's rows, less the picked values. |
width | 'wide' or 'wider'. Left out, the feature dialog's. |
aside | Notes and pictures beside the rows, relative to this file. Left out, the feature dialog's. |
layout, width and aside take what a feature's take: see Laying out the dialog.
An insert the part can't read fails that insert's row, with a sentence naming the part and the value. The rest of the assembly builds:
| The insert | Its row |
|---|---|
insert(gearPart, { teeth: 4 }) | part 'Gear': 'teeth' must be at least 10, got 4 |
insert(gearPart, { face: … }) | part 'Gear': 'face' is a picked value of the feature — a generated part takes its typed-in values only, and leaves the picked ones out |
A generated assembly
A wardrobe is a carcass, a pair of doors on hinges and a stack of drawers on runners. As a generated assembly, each is a part, and the doors and drawers move on joints. Its values are its size, the number of drawers and how far the doors swing.
A generated assembly is two modules: a generator, a class that lays out the parts and builds them, and the definition, which makes the class insertable.
The generator
wardrobe-unit.js
// A wardrobe made by code: a carcass, a pair of doors on hinges and a stack
// of drawers on runners, each a part of its own, from one set of values.
// wardrobe.assembly.js turns it into a definition an assembly inserts.
import { Generator } from 'fluidcad/sdk';
import { ShapeFactory } from 'fluidcad/sdk/kernel';
/** The thickness of every board, and the gap between neighbouring fronts. */
const BOARD = 18;
const GAP = 3;
// The values are declared as a feature's are, typed-in only. They are the
// insert's interface, by id: insert(wardrobeAssembly, { width: 1200, drawers: 3 }).
export class WardrobeUnit extends Generator.with({
width: { type: 'length', default: 1000, min: 500, max: 1400, hint: 'The carcass, outside' },
height: { type: 'length', default: 2000, min: 900, max: 2600 },
depth: { type: 'length', default: 600, min: 350, max: 800 },
drawers: { type: 'integer', display: 'stepper', default: 2, min: 0, max: 4, hint: 'Under the doors' },
drawerFront: { type: 'length', default: 200, min: 120, max: 400, label: 'Drawer front' },
doorSwing: { type: 'angle', default: 110, min: 90, max: 170, label: 'Door swing', hint: 'How far a door opens' },
}) {
static id = 'wardrobe-unit';
// Phase 1, for every insert and every change in the dialog: plain
// JavaScript, no geometry. It declares the parts, where they start and
// the joints between them, in the carcass's coordinates: x across, y from
// the front edge back, z up.
layout(g) {
const { width, height, depth, drawers, drawerFront, doorSwing } = this.values;
const doorsFrom = drawers * drawerFront;
const doorHeight = height - doorsFrom - GAP;
if (doorHeight < height / 3) {
// The insert's row fails with this sentence; nothing of the layout is kept.
throw new Error(`wardrobe: ${drawers} drawers of ${drawerFront} leave no room for the doors`);
}
// A connector is a frame: an origin, a Z axis and an X axis. The left
// door's hinge points down and the right one's up, so both doors open
// outward by a positive angle.
const leftHinge = { origin: [0, 0, doorsFrom], z: [0, 0, -1], x: [1, 0, 0] };
const rightHinge = { origin: [width, 0, doorsFrom], z: [0, 0, 1], x: [1, 0, 0] };
const runners = {};
for (let i = 1; i <= drawers; i++) {
// Each runner points out of the front: a drawer slides along its Z.
runners[`drawer${i}`] = { origin: [0, 0, (i - 1) * drawerFront], z: [0, -1, 0], x: [1, 0, 0] };
}
// The carcass: one part, fixed in the wardrobe's frame, so it needs no joint.
const carcass = g.part('carcass', {
name: 'Carcass',
inputs: { width, height, depth },
connectors: { leftHinge, rightHinge, ...runners },
appearance: 'plastic',
}).grounded();
// The doors are mirror images: other inputs, so two templates. Each
// starts closed (its pose) and turns on a revolute joint about its hinge.
// .flip() lays the door's frame onto the carcass's as it is, instead of
// face to face, so 0° is closed.
const doorInputs = { width: width / 2 - GAP / 2, height: doorHeight };
const leftDoor = g.part('door', {
name: 'Left door',
inputs: { ...doorInputs, side: 'left' },
connectors: { hinge: { ...leftHinge, origin: [0, 0, 0] } },
appearance: 'oak',
}).pose({ position: leftHinge.origin });
g.mate('revolute', carcass.connectors.leftHinge, leftDoor.connectors.hinge).flip().limits(0, doorSwing).name('Left door');
const rightDoor = g.part('door', {
name: 'Right door',
inputs: { ...doorInputs, side: 'right' },
connectors: { hinge: { ...rightHinge, origin: [0, 0, 0] } },
appearance: 'oak',
}).pose({ position: rightHinge.origin });
g.mate('revolute', carcass.connectors.rightHinge, rightDoor.connectors.hinge).flip().limits(0, doorSwing).name('Right door');
// The drawers have equal inputs, so they share one template, built once.
const drawerParts = [];
for (let i = 1; i <= drawers; i++) {
const drawer = g.part('drawer', {
name: `Drawer ${i}`,
inputs: { width, height: drawerFront - GAP, depth: depth - BOARD - 30 },
connectors: { slide: { origin: [0, 0, 0], z: [0, -1, 0], x: [1, 0, 0] } },
appearance: 'oak',
}).pose({ position: runners[`drawer${i}`].origin });
g.mate('slider', carcass.connectors[`drawer${i}`], drawer.connectors.slide).flip().limits(0, 0.75 * depth).name(`Drawer ${i}`);
drawerParts.push(drawer);
}
// What the inserting assembly reaches as wardrobe.parts.carcass, …
return { carcass, doors: { left: leftDoor, right: rightDoor }, drawers: drawerParts };
}
// Phase 2, once per part template the engine holds no build of: the
// shapes of one part, in its own coordinates. Static, so it reads only
// its type and inputs, and a build is kept for as long as they are equal.
static build(type, inputs, ctx) {
// 'preview' while a control in the dialog is still moving: leave out the
// small parts. 'full' once it settles.
const pulls = ctx.detail === 'full';
if (type === 'carcass') {
return WardrobeUnit.#carcass(ctx, inputs);
}
if (type === 'door') {
return WardrobeUnit.#door(ctx, inputs, pulls);
}
if (type === 'drawer') {
return WardrobeUnit.#drawer(ctx, inputs, pulls);
}
// A throw fails that part's row; the rest of the wardrobe builds.
throw new Error(`wardrobe: no part of type '${type}'`);
}
static #carcass(ctx, { width: w, height: h, depth: d }) {
const t = BOARD;
return WardrobeUnit.#boxes(ctx, [
[[0, 0, 0], [t, d, h]], // left side
[[w - t, 0, 0], [w, d, h]], // right side
[[t, 0, 0], [w - t, d, t]], // bottom
[[t, 0, h - t], [w - t, d, h]], // top
[[t, d - t, t], [w - t, d, h - t]], // back
]);
}
// A slab in front of the carcass (y from -BOARD to 0), from its hinge across.
static #door(ctx, { width: w, height: h, side }, pulls) {
const t = BOARD;
const [x0, x1] = side === 'left' ? [0, w] : [-w, 0];
const boxes = [[[x0, -t, 0], [x1, 0, h]]];
if (pulls) {
// A bar pull near the opening edge.
const at = side === 'left' ? x1 - 50 : x0 + 38;
boxes.push([[at, -t - 25, h / 2 - 150], [at + 12, -t, h / 2 + 150]]);
}
return WardrobeUnit.#boxes(ctx, boxes);
}
// A front across the carcass, and an open box behind it between the sides.
static #drawer(ctx, { width: w, height: h, depth: d }, pulls) {
const t = BOARD;
const s = 12;
const [x0, x1, z0, z1] = [t + 15, w - t - 15, 20, h - 40];
const boxes = [
[[0, -t, 0], [w, 0, h]], // front
[[x0, 0, z0], [x0 + s, d, z1]], // left side
[[x1 - s, 0, z0], [x1, d, z1]], // right side
[[x0 + s, 0, z0], [x1 - s, d - s, z0 + s]], // bottom
[[x0 + s, d - s, z0], [x1 - s, d, z1]], // back
];
if (pulls) {
boxes.push([[w / 2 - 80, -t - 25, h / 2 - 6], [w / 2 + 80, -t, h / 2 + 6]]);
}
return WardrobeUnit.#boxes(ctx, boxes);
}
// One solid per [low corner, high corner] pair, built with raw OpenCascade.
// ctx.occ() frees everything made inside it except what it returns.
static #boxes(ctx, corners) {
return ctx.occ(({ oc, track }) => corners.map(([lo, hi]) => ShapeFactory.fromShape(
track(new oc.BRepPrimAPI_MakeBox(new oc.gp_Pnt(...lo), new oc.gp_Pnt(...hi)).Shape()),
)));
}
}
Generator.with({ … }) declares the values as Feature.with() does: types, defaults (and defaults that follow another value), ranges, options with pictures, visibleWhen, groups. A generator takes typed-in values only, and refuses a picked one by name. static id is its type, as a feature's is.
The work happens in two phases:
layout(g)runs for every insert, and on every change while the dialog is open. It readsthis.valuesand declares the assembly withg: its parts, where they start, and the joints between them. It is plain JavaScript and builds no geometry, so keep it quick. What it returns is the occurrence's parts: an assembly reaches the carcass aswardrobe1.parts.carcass, and its connectors aswardrobe1.parts.carcass.connectors.leftHinge.static build(type, inputs, ctx)runs once for each part the layout declared that the engine holds no build of. It returns the part's shapes, in the part's own coordinates.
build is static so that it can read only what the layout passed it: a part's type and its inputs. Whatever changes a part's geometry must be in its inputs. That is what lets FluidCAD tell a part is unchanged, and keep its build.
Parts
g.part(type, { … }) adds a part and returns its handle:
| Field | Meaning |
|---|---|
name | The part's name in the Parts panel. Left out, the type. |
inputs | Everything the part's geometry depends on: what build receives. Plain JSON only (below). |
connectors | The part's connectors by name, each a frame in the part's own coordinates: { origin: [x, y, z], z: [x, y, z], x: [x, y, z] }. x is optional. |
appearance | An appearance id, such as 'oak' or 'plastic'. |
material | A material id, for the part's mass. |
| On the handle | Effect |
|---|---|
.grounded() | Fixes the part in the generated assembly's frame. The carcass needs no joint. Whether the whole wardrobe is grounded is up to the assembly that inserts it. |
.pose({ position, rotation }) | Where the part starts: its origin at position, turned by rotation in degrees about X, then Y, then Z. A starting point, never a constraint: the joints decide where a jointed part goes. |
.name('…') | Renames the part. |
.connectors.<name> | A connector, for g.mate(), or for the inserting assembly's mate() once the layout returns the handle. |
Equal parts are one template. A part's template is its type, inputs, connectors, appearance and material. Two g.part() calls that agree on all five share one part, built once, even across several wardrobes in one assembly. The wardrobe's drawers are one template. Its doors are mirror images, with side in their inputs, so they are two. The name is each part's own and plays no part in this.
Inputs are plain JSON: numbers, strings, booleans, null, arrays and plain objects. A function, an object of a class, undefined or a number that isn't finite is refused, naming where it sits in the inputs.
Joints
g.mate(type, a, b) joins two of the assembly's parts, as mate() does: the same types, the same connector sides, and the same chain, .flip(), .rotate(), .offset(), .limits() and .name(). g.relation(type, a, b, ratio) couples two of its joints, as a gear relation does.
- Name every moving joint. The Joints panel lists a joint by its
.name(): Left door, Drawer 1. - A mate lays its two frames face to face by default, which turns the second part over. The wardrobe gives the carcass and each door the same frame, so
.flip()lays one onto the other as it is, and 0° is the door closed. - Driving a joint writes nothing: a door swung open in the viewport or with Animate… leaves the file as it was.
The definition
wardrobe.assembly.js
// The wardrobe made insertable: an assembly definition whose body is the
// generator's layout, run with the insert's values. The assembly bar's
// Generate Parts dropdown and the Insert dialog list it; its dialog asks for the
// generator's values.
import { defineAssembly } from 'fluidcad/sdk';
import { WardrobeUnit } from './wardrobe-unit.js';
export const wardrobeAssembly = defineAssembly(WardrobeUnit, {
label: 'Wardrobe', // the menu entry, the dialog's title and the sub-assembly's name
description: 'A wardrobe as an assembly: the carcass, two hinged doors and drawers on runners',
icon: './wardrobe.svg', // images are relative to this file
// The dialog: as a feature's metadata lays it out.
width: 'wide',
layout: [
{ row: ['width', 'height', 'depth'] },
{ section: 'Drawers', items: [{ row: ['drawers', 'drawerFront'] }] },
{ section: 'Doors', items: ['doorSwing'] },
],
aside: [{ image: './wardrobe-size.svg', alt: 'The width, height and depth of the carcass' }],
});
defineAssembly(GeneratorClass, { … })returns an assembly definition. Export it by name from a*.assembly.jsfile.- Its fields are a generated part's (above). There is no feature dialog to borrow from: left out, the layout is derived from the values'
groupandtab, the dialog opens at the default width, and it has no aside. Aconnectorsfield is refused: the connectors are the parts'. - The values are the insert's, by id:
insert(wardrobeAssembly, { width: 1200, drawers: 3 }). - Opened on its own, the file renders the wardrobe with every value at its default.
Units
A generator writes in its defining module's unit: the project's, or for a package, the package's. Its values, inputs, frames, poses, mate offsets and slider limits are all lengths in that unit. Inserted into an assembly of another unit, its parts are scaled and its poses, offsets and limits converted. The values themselves are not converted: an inch generator's width: 40 is 40 in.
Builds are kept
A part is built again only when its template changes. In the wardrobe:
| Change | Built again |
|---|---|
| Depth | The carcass and the drawers. The doors' inputs hold no depth, so they are kept. |
| Door swing | Nothing: only the joints' limits change. |
| A value set back to where it was a moment ago | Nothing: the engine still holds those builds. |
| The generator's code, or a module it imports, saved or not | Everything. |
The engine keeps recent builds across renders and lets the least recently used go first, so dragging a slider back and forth builds each value once.
Detail tiers. ctx.detail says how much to build. It is 'preview' while a control in the dialog is moving, a slider being dragged or a number being typed, and 'full' once it settles, a moment later, and for every other render: Apply, an export, a save. Each tier is built and kept on its own. Use 'preview' to leave out what is slow and small, drill holes, hinges, pulls, but never to build something else: the wardrobe leaves out its pulls.
Never keep shapes in the module from one render to the next: FluidCAD frees a render's shapes once it is done with them.
When something fails
| What went wrong | Where it shows |
|---|---|
| An id the definition doesn't take | The insert's row fails: assembly 'Wardrobe': 'widht' is not one of its values — did you mean 'width'? its values are width, height, depth, drawers, drawerFront, doorSwing |
| A value its declaration refuses | The insert's row fails: assembly 'Wardrobe': 'width' must be at least 500, got 100 |
layout() throws | The insert's row fails with the message, assembly 'Wardrobe': wardrobe: 4 drawers of 300 leave no room for the doors, and nothing of the layout is kept. |
build() throws | That part's rows fail, WardrobeUnit.build('door'): …. The other parts build. |
A bad declaration: a picked value in Generator.with(), no static id, no layout or build, a field defineAssembly() doesn't know | The module fails to load. A model that imports it fails to compile with a sentence naming the call (Generator.with(), defineAssembly(WardrobeUnit)) and the fix, and the Generate Parts dropdown leaves the definition out. |
Where the modules live
In a project, a definePart export lives in a *.part.js file and a defineAssembly export in a *.assembly.js file. The generator class may be in any module. In a package, they go in the folders the package shares:
my-package/
├── features/
│ ├── gear.feature.js
│ ├── gear.svg
│ ├── wardrobe-unit.js ← the generator class
│ └── img/ ← the pictures of its values' options
├── parts/
│ └── gear.part.js ← definePart: import { Gear } from '../features/gear.feature.js'
└── assemblies/
└── wardrobe.assembly.js ← defineAssembly: import { WardrobeUnit } from '../features/wardrobe-unit.js'
- A value's pictures (its options' images, its own
aside) are found next to the module that declares the values, the one that callsGenerator.with()orFeature.with(). Keep a generator class beside its pictures, as a feature file is. - The definition's pictures (
icon, and the images in itslayoutandaside) are found next to the definition's module, and may climb with../tofeatures/.
Limits
- Values only: a generated definition takes no picked values. Its parts are placed by its layout, and the assembly that inserts it mates them.
- No configurations: a generated definition is inserted with its values, and a configuration name in
insert()fails the row. - The parts a generated definition makes are read-only in the assembly. Their names, appearances and materials come from the generator. Change them through its values.