fluid-cad/fluidcad/fluidcad-assembly

Claude Code and agent skill by fluid-cad/fluidcad

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Building multi-part models in FluidCAD: assemblies, part files, inserting and mating parts, connectors, exposed geometry, part parameters, sub-assemblies, replicated instances, STEP parts, project units, and assembly export. Use this skill whenever the user wants an assembly, a mechanism, a hinge, a slider, several parts that fit or move together, a `.assembly.js` or `.part.js` file, `insert()`, `mate()`, `connector()`, `expose()`, `replicate()`, instances, or asks to split a model into parts. Trigger it alongside the FluidCAD skill any time the FluidCAD MCP tools (`mcp__FluidCAD__*`) are available and more than one part is involved.

Install

npx vskill@latest install fluid-cad/fluidcad/fluidcad-assembly

SKILL.md

Provenance: maintained in the FluidCAD repo (https://github.com/Fluid-CAD/FluidCAD), under mcp/skills/. The installed local skill files are the runtime source of truth.

Assemblies in FluidCAD

This skill layers on top of the fluidcad skill. That one owns the loop: read the docs, plan in generic CAD terms, build in increments, check render.state and objectErrors, resolve filters before writing them, screenshot when a feature earns it, roll back to debug, and report only what was checked. Follow it for every part. This skill covers only what is specific to models made of several parts: when to split, how a part publishes its interface, how an assembly places and joins instances, and how to verify the result.

The governing principle: a part is verified alone before it is inserted, and mates are added one at a time. An assembly is where three parts' mistakes meet; keep the mistakes out of it.

1. When to split a model into parts

Split when the pieces are separate physical components: things fabricated separately, bought (a bolt, a bearing, a rail), moving relative to each other (a lever on a pin, a carriage on a rail), or reused with different parameters (four standoffs of two heights). Features of one machined or printed body (holes, ribs, fillets) are never parts.

Two rules from the API decide the mechanics:

  • Inside a part(), shapes auto-fuse with each other and never with anything outside it. Two solids that touch but must stay separate go in two parts. Inside one part, .new() keeps a feature as a separate body.
  • Every part() is exported (export const x = part("X", () => { … }) at the top level of its file, never inside another part() or assembly() body) and its name is unique within the file; a part breaking either is a build error (part-rules on a write). Only an exported part can be inserted. A standalone model needs no part() at all.

Convention: one part per .part.js file, named after the thing it is (bracket.part.js exports bracket); one .assembly.js file per mechanism. Several components in one file each get their own part(), but that file cannot be inserted piecemeal with the same clarity, so prefer one file each.

2. Files and units

suffixscenecontents
.part.jspartmodelling statements, or export const x = part("X", () => { … }) (a part is always exported)
.assembly.jsassemblyexport const x = assembly("x", () => { insert(); mate(); … })
.fluid.jspartlegacy suffix, same as .part.js
.params.jsnever renderedexport const wall = param("Wall thickness", 3) — project parameters every part and assembly imports

Units belong to parts:

  • A part's numbers are in its file's unit: unit('in') directly after the imports, else the "unit" in the nearest fluidcad.json, else mm.
  • An assembly never calls unit() (the write guard refuses it, code unit-statement). Every length the assembly file owns, .translate(), mate .offset() and .limits(), is in the project unit.
  • Inserted parts are scaled from their file's unit into the project unit automatically, connectors included. An inch part in a mm project comes out at 25.4 times its inch numbers with nothing to write.
  • insert(def, { Length: 10 }) overrides are read in the part's unit, whatever the assembly's. The inch() / mm() helpers do not help here; they convert into the calling file's unit, which is the assembly's.

Before trusting any number, read unit from get_scene_summary for the file that is open.

3. Part-local origin conventions

The part's origin is its mating interface. A dimension measured from that interface then appears in the code verbatim, and the connector at the origin needs no offset.

  • A plate or base: bottom (mounting) face on z = 0, outline centered on the origin when the part is symmetric, otherwise a datum corner at the origin.
  • A pin, shaft, standoff, bolt: axis on Z, the seating face (head underside, foot) on z = 0.
  • A lever, link, bracket: the pivot bore axis on Z through the origin; the arm extends along +X.
  • A carriage or slider: the sliding face on z = 0, travel along X.
  • A symmetric part: the symmetry plane through the origin, so mirrored features are free and the connector on the symmetry plane is at x = 0.

State the convention in one line before writing the part, exactly as the core skill asks for a single part. The assembly's starting poses (.translate()) become readable when every part's origin is where it mates.

Every part gets its own colour, written as the last statement of its body (color("steelblue") with no selection paints the whole part; a multi-body part such as a bearing colours each body: color("silver", outer), color("dimgray", balls)). Instances inherit it, so the assembly screenshot names the parts by colour and a section shows where one part ends and the next begins. Grey-on-grey is how an overlap between a screw and a bearing goes unnoticed. Palette and rules in the core skill ("Colour every body that meets another body"): housings and big parts cool and light, fasteners, pins and balls warm, no two mating parts alike.

4. A part's interface: param(), connector(), expose()

statementdirectionread back as
param("Label", default, type?, opts?)values ininsert(def, { Label: value })
connector("name", geometry)mate frames outinstance.connectors.name for mate()
expose("name", sceneObject)geometry outdef.features.name (another part builds on it), instance.features.name (a tangent mate touches it)

Rules that trip up every first attempt:

  • param() is only valid inside a part() or assembly() body, or exported from the top level of a *.params.js file; at the top level of a model file it throws. Declare parameters at the top of the body, before the geometry that uses them.
  • A dimension two parts must agree on is a project parameter, not an override. The bore of the housing and the shaft diameter, the wall thickness of both halves, the bolt size of a flange and its mate: export const x = param("Label", value) in project.params.js, then import { x } from "./project.params.js" in each part. Never copy the number into the second file, and never push it through insert(def, { Label }) — an override naming a project parameter is warned about, not applied. list_global_params / the globalParams block of get_scene_summary tell what exists; a *.params.js write re-renders the open model (the file itself is never the model).
  • A named value set is a `configurations()` row, not a copy. "A compact and a wide version", "M6 and M8 variants": configurations({ Compact: { Width: 500 }, Wide: { Width: 1200 } }) as the first statement of the body, before its param() calls; a row lists only the labels it changes. Insert a row by name, insert(def, 'Wide') (or insert(def, 'Wide', { Height: 20 }) to override on top), never by copying its numbers into insert(def, { Width: 1200 }). To show a row of the open model use select_configuration; export, screenshot and pack_model take configuration for one call without switching. get_scene_summary().configurations lists the rows.
  • connector() and expose() are declared directly in the part body, not in a nested callback; names are unique within a part.
  • A connector's source must resolve to exactly one face, edge or vertex, or be a plane; a raw point is refused inside a part. resolve_selection the expression at the part's scope and confirm count is 1 before writing it.
  • A face connector sits at the face center with Z along the outward normal; a circular edge gives center plus axis; a straight edge gives midpoint plus tangent. .offset(x, y, z) and .rotate(axis, deg) move it in its own axes, in call order.
  • Face frames point Z out of the solid, and a mate places the second Z against the first by default, so connectors on the touching faces of two parts put the parts on each other with no options.
  • A sketch a feature used is only hidden, never consumed, so it can still be passed to expose().
  • A hole pattern gets one connector and a `copy()`, not one connector per hole. Declare it on the first hole, then copy it in the same part body: copy(holes, bolt) follows the repeat() that made the holes, so every copy stays on its hole when the repeat's count, spacing or axis changes (partial arcs included); copy("linear" | "circular", axis, options, bolt) patterns it on its own. repeat() refuses connectors.
  • Copy k of bolt is bolt.instance(k) in the part file and instance.connectors.bolt.instance(k) in an assembly, a mate side and replicate cell like any connector. Slots number like repeat().instance(k): a linear grid with the first axis varying slowest and the original in its own cell, a circular copy by steps from 0, the follow form by the repeat's own slots. bolt.instance(<original's slot>) is bolt.
  • A copy is the seed's frame moved rigidly, never re-derived from geometry: a linear or circular copy sits on a hole only while its pattern matches the holes', and a circular copy steps angle / count where repeat() steps a partial arc angle / (count - 1). Follow the repeat when the connector belongs on repeated geometry.
  • Copies are read-only (.offset() / .rotate() on one throws; edit the seed or the copy statement). One copy statement per connector, no copy of a copy (a grid is one two-axis linear copy), explicit targets only, and no centered on a circular copy of a connector. Changing a later grid axis's count, or a centered count, renumbers the slots and moves whatever is mated to them. Check the copies with get_scene_summary: each is a connector object named bolt.instance(k) carrying its frame.

A standoff with its mating interface at the origin:

fluid.js
import { part, param, sketch, circle, extrude, chamfer, color, connector, expose } from "fluidcad/core";
import { face } from "fluidcad/filters";
// Threaded standoff: hex bodies are a later refinement, the interface is
// what matters here. Foot on z = 0, axis on Z.
export const standoff = part("Standoff", () => {
const height = param("Height", 12, "number", { min: 4, max: 60, step: 1 });
const bodyDia = 6;
const boreDia = 3.4; // ASSUMPTION: M3 clearance, no thread modeled
sketch("xy", () => {
circle([0, 0], bodyDia);
circle([0, 0], boreDia);
});
const body = extrude(height);
chamfer(0.5, body.endEdges());
color("goldenrod"); // whole part: distinct from the plate it sits on
// Mating interface. startFaces() is the foot: Z points down, out of the
// solid, so it lands face-to-face on an upward-facing plate connector.
connector("foot", body.startFaces());
connector("top", body.endFaces());
// The bore, for a tangent mate or for another part to cut against.
expose("bore", body.sideFaces(face().cylinder(boreDia)));
});

On the plate side, one connector is the top-face frame moved along its own X and Y to the first hole's center (the offsets read like the hole sketch coordinates), and a linear copy with the holes' pitch puts a copy on each of the others:

js
// plate.part.js, inside part("Base plate", () => { … })
connector("top", select(face().planar().onPlane("xy", plateT)));
const hole = connector("hole", select(face().planar().onPlane("xy", plateT))).offset(-30, -17.5, 0);
// hole.instance(1) (-30, 17.5), hole.instance(2) (30, -17.5), hole.instance(3) (30, 17.5)
copy("linear", ["x", "y"], { count: [2, 2], offset: [60, 35] }, hole);

When the holes come from a repeat() (const holes = repeat(...) of the hole's cut()), anchor the connector on the hole itself and follow the repeat instead: const bolt = connector("bolt", hole.startEdges()); copy(holes, bolt);.

5. STEP parts via load()

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 90 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
662
Repository forks
40
7-day trend score
32

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/fluid-cad/fluidcad/fluidcad-assembly/badge)](https://verified-skill.com/skills/fluid-cad/fluidcad/fluidcad-assembly)

Something wrong with this skill or its page? Tell us.