diff --git a/doc/APIreference/functions.rst b/doc/APIreference/functions.rst index 298e5134..4daaaf44 100644 --- a/doc/APIreference/functions.rst +++ b/doc/APIreference/functions.rst @@ -4496,6 +4496,17 @@ Add sensor. Add flex. +.. _mjs_makeFlex: + +`mjs_makeFlex <#mjs_makeFlex>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. mujoco-include:: mjs_makeFlex + +Add flexcomp: create flex with auto-generated bodies/joints, return flex spec. + +*Nullable:* ``type``, ``dof``, ``count``, ``cellcount``, ``spacing``, ``scale``, ``pos``, ``quat``, ``origin``, ``file``, ``vfs`` + .. _mjs_addPair: `mjs_addPair <#mjs_addPair>`__ diff --git a/doc/changelog.rst b/doc/changelog.rst index b07ba2b0..699d0dd0 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -5,11 +5,16 @@ Changelog Upcoming version (not yet released) ----------------------------------- +General +^^^^^^^ +- Added :ref:`mjs_makeFlex`, a new C API function equivalent to the :ref:`flexcomp` element for + programmatically creating flex objects with auto-generated bodies, joints, and equality constraints. Exposed as + ``body.make_flex()`` in Python. + Bug fixes ^^^^^^^^^ - Fixed a bug in the ``mjz`` :ref:`decoder ` where unnormalized paths would fail to be read. - Version 3.9.0 (May 27, 2026) ---------------------------- diff --git a/doc/includes/references.h b/doc/includes/references.h index b158c3e1..60b8d699 100644 --- a/doc/includes/references.h +++ b/doc/includes/references.h @@ -3678,6 +3678,12 @@ int mjs_delete(mjSpec* spec, mjsElement* element); mjsActuator* mjs_addActuator(mjSpec* s, const mjsDefault* def); mjsSensor* mjs_addSensor(mjSpec* s); mjsFlex* mjs_addFlex(mjSpec* s); +mjsFlex* mjs_makeFlex(mjsBody* body, const char* name, const char* type, int dim, + const char* dof, const int count[3], const int cellcount[3], + const double spacing[3], const double scale[3], double radius, + double mass, double inertiabox, int equality, int rigid, int flatskin, + int elastic2d, const double pos[3], const double quat[4], + const double origin[3], const char* file, const mjVFS* vfs); mjsPair* mjs_addPair(mjSpec* s, const mjsDefault* def); mjsExclude* mjs_addExclude(mjSpec* s); mjsEquality* mjs_addEquality(mjSpec* s, const mjsDefault* def); diff --git a/include/mujoco/mujoco.h b/include/mujoco/mujoco.h index ee3ab87d..4a1384a7 100644 --- a/include/mujoco/mujoco.h +++ b/include/mujoco/mujoco.h @@ -1667,6 +1667,15 @@ MJAPI mjsSensor* mjs_addSensor(mjSpec* s); // Add flex. MJAPI mjsFlex* mjs_addFlex(mjSpec* s); +// Add flexcomp: create flex with auto-generated bodies/joints, return flex spec. +// Nullable: type, dof, count, cellcount, spacing, scale, pos, quat, origin, file, vfs +MJAPI mjsFlex* mjs_makeFlex(mjsBody* body, const char* name, const char* type, int dim, + const char* dof, const int count[3], const int cellcount[3], + const double spacing[3], const double scale[3], double radius, + double mass, double inertiabox, int equality, int rigid, int flatskin, + int elastic2d, const double pos[3], const double quat[4], + const double origin[3], const char* file, const mjVFS* vfs); + // Add contact pair. // Nullable: def MJAPI mjsPair* mjs_addPair(mjSpec* s, const mjsDefault* def); diff --git a/python/mujoco/introspect/functions.py b/python/mujoco/introspect/functions.py index c0bce30f..d2323d9e 100644 --- a/python/mujoco/introspect/functions.py +++ b/python/mujoco/introspect/functions.py @@ -10366,6 +10366,144 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([ ), doc='Add flex.', )), + ('mjs_makeFlex', + FunctionDecl( + name='mjs_makeFlex', + return_type=PointerType( + inner_type=ValueType(name='mjsFlex'), + ), + parameters=( + FunctionParameterDecl( + name='body', + type=PointerType( + inner_type=ValueType(name='mjsBody'), + ), + ), + FunctionParameterDecl( + name='name', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + FunctionParameterDecl( + name='type', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + nullable=True, + ), + FunctionParameterDecl( + name='dim', + type=ValueType(name='int'), + ), + FunctionParameterDecl( + name='dof', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + nullable=True, + ), + FunctionParameterDecl( + name='count', + type=ArrayType( + inner_type=ValueType(name='int', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='cellcount', + type=ArrayType( + inner_type=ValueType(name='int', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='spacing', + type=ArrayType( + inner_type=ValueType(name='double', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='scale', + type=ArrayType( + inner_type=ValueType(name='double', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='radius', + type=ValueType(name='double'), + ), + FunctionParameterDecl( + name='mass', + type=ValueType(name='double'), + ), + FunctionParameterDecl( + name='inertiabox', + type=ValueType(name='double'), + ), + FunctionParameterDecl( + name='equality', + type=ValueType(name='int'), + ), + FunctionParameterDecl( + name='rigid', + type=ValueType(name='int'), + ), + FunctionParameterDecl( + name='flatskin', + type=ValueType(name='int'), + ), + FunctionParameterDecl( + name='elastic2d', + type=ValueType(name='int'), + ), + FunctionParameterDecl( + name='pos', + type=ArrayType( + inner_type=ValueType(name='double', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='quat', + type=ArrayType( + inner_type=ValueType(name='double', is_const=True), + extents=(4,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='origin', + type=ArrayType( + inner_type=ValueType(name='double', is_const=True), + extents=(3,), + ), + nullable=True, + ), + FunctionParameterDecl( + name='file', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + nullable=True, + ), + FunctionParameterDecl( + name='vfs', + type=PointerType( + inner_type=ValueType(name='mjVFS', is_const=True), + ), + nullable=True, + ), + ), + doc='Add flexcomp: create flex with auto-generated bodies/joints, return flex spec.', # pylint: disable=line-too-long + )), ('mjs_addPair', FunctionDecl( name='mjs_addPair', diff --git a/python/mujoco/specs.cc b/python/mujoco/specs.cc index abe101d9..738ab73b 100644 --- a/python/mujoco/specs.cc +++ b/python/mujoco/specs.cc @@ -842,6 +842,130 @@ PYBIND11_MODULE(_specs, m) { return FindAllImpl(self, objtype, true); }, py::return_value_policy::reference_internal); + mjsBody.def( + "make_flex", + [](raw::MjsBody& self, + const std::string& name, + std::optional type, + int dim, + std::optional dof, + std::optional> count, + std::optional> cellcount, + std::optional> spacing, + std::optional> scale, + double radius, + double mass, + double inertiabox, + int equality, + int rigid, + int flatskin, + int elastic2d, + std::optional> pos, + std::optional> quat, + std::optional> origin, + std::optional file, + MjVfs* vfs) -> raw::MjsFlex* { + const char* type_str = type.has_value() ? type->c_str() : nullptr; + const char* dof_str = dof.has_value() ? dof->c_str() : nullptr; + const char* file_str = file.has_value() ? file->c_str() : nullptr; + const mjVFS* vfs_ptr = vfs ? vfs->get() : nullptr; + + int count_arr[3] = {10, 10, 10}; + if (count.has_value()) { + if (count->size() != 3) { + throw pybind11::value_error("count must have 3 elements"); + } + for (int i = 0; i < 3; i++) count_arr[i] = (*count)[i]; + } + const int* count_ptr = count.has_value() ? count_arr : nullptr; + + int cellcount_arr[3] = {-1, -1, -1}; + if (cellcount.has_value()) { + if (cellcount->size() != 3) { + throw pybind11::value_error("cellcount must have 3 elements"); + } + for (int i = 0; i < 3; i++) cellcount_arr[i] = (*cellcount)[i]; + } + const int* cellcount_ptr = + cellcount.has_value() ? cellcount_arr : nullptr; + + double spacing_arr[3] = {0.02, 0.02, 0.02}; + if (spacing.has_value()) { + if (spacing->size() != 3) { + throw pybind11::value_error("spacing must have 3 elements"); + } + for (int i = 0; i < 3; i++) spacing_arr[i] = (*spacing)[i]; + } + const double* spacing_ptr = spacing.has_value() ? spacing_arr : nullptr; + + double scale_arr[3] = {1, 1, 1}; + if (scale.has_value()) { + if (scale->size() != 3) { + throw pybind11::value_error("scale must have 3 elements"); + } + for (int i = 0; i < 3; i++) scale_arr[i] = (*scale)[i]; + } + const double* scale_ptr = scale.has_value() ? scale_arr : nullptr; + + double pos_arr[3] = {0, 0, 0}; + if (pos.has_value()) { + if (pos->size() != 3) { + throw pybind11::value_error("pos must have 3 elements"); + } + for (int i = 0; i < 3; i++) pos_arr[i] = (*pos)[i]; + } + const double* pos_ptr = pos.has_value() ? pos_arr : nullptr; + + double quat_arr[4] = {1, 0, 0, 0}; + if (quat.has_value()) { + if (quat->size() != 4) { + throw pybind11::value_error("quat must have 4 elements"); + } + for (int i = 0; i < 4; i++) quat_arr[i] = (*quat)[i]; + } + const double* quat_ptr = quat.has_value() ? quat_arr : nullptr; + + double origin_arr[3] = {0, 0, 0}; + if (origin.has_value()) { + if (origin->size() != 3) { + throw pybind11::value_error("origin must have 3 elements"); + } + for (int i = 0; i < 3; i++) origin_arr[i] = (*origin)[i]; + } + const double* origin_ptr = origin.has_value() ? origin_arr : nullptr; + + auto out = mjs_makeFlex( + &self, name.c_str(), type_str, dim, dof_str, + count_ptr, cellcount_ptr, spacing_ptr, scale_ptr, + radius, mass, inertiabox, equality, rigid, flatskin, elastic2d, + pos_ptr, quat_ptr, origin_ptr, file_str, vfs_ptr); + if (!out) { + raw::MjSpec* spec = mjs_getSpec(self.element); + throw pybind11::value_error(mjs_getError(spec)); + } + return out; + }, + py::arg("name"), + py::arg("type") = py::none(), + py::arg("dim") = 3, + py::arg("dof") = py::none(), + py::arg("count") = py::none(), + py::arg("cellcount") = py::none(), + py::arg("spacing") = py::none(), + py::arg("scale") = py::none(), + py::arg("radius") = 0.0, + py::arg("mass") = 1.0, + py::arg("inertiabox") = 0.005, + py::arg("equality") = 0, + py::arg("rigid") = 0, + py::arg("flatskin") = 0, + py::arg("elastic2d") = 0, + py::arg("pos") = py::none(), + py::arg("quat") = py::none(), + py::arg("origin") = py::none(), + py::arg("file") = py::none(), + py::arg("vfs") = py::none(), + py::return_value_policy::reference_internal); mjsBody.def( "find_child", [](raw::MjsBody& self, std::string& name) -> raw::MjsBody* { diff --git a/python/mujoco/specs_test.py b/python/mujoco/specs_test.py index 7e1baa04..8bae2be1 100644 --- a/python/mujoco/specs_test.py +++ b/python/mujoco/specs_test.py @@ -2032,5 +2032,75 @@ class SpecsTest(absltest.TestCase): with self.assertRaises(mujoco.FatalError): spec.encode(filename, model) + def test_make_flex_grid(self): + # Create a spec with a flexcomp grid. + spec = mujoco.MjSpec() + body = spec.worldbody.add_body(name='flex_body') + flex = body.make_flex( + name='test_flex', + type='grid', + dim=3, + count=[4, 4, 4], + spacing=[0.05, 0.05, 0.05], + mass=0.5, + equality=1, + ) + self.assertIsNotNone(flex) + model = spec.compile() + self.assertIsNotNone(model) + self.assertGreater(model.nflex, 0) + + # Verify elastic2d is forwarded to Make() and produces shell-mode + # strain constraints (equality=3 + elastic2d=2 triggers shell path). + spec2 = mujoco.MjSpec() + body2 = spec2.worldbody.add_body(name='shell_body') + flex2 = body2.make_flex( + name='shell_flex', + type='grid', + dim=3, + count=[3, 3, 3], + spacing=[0.1, 0.1, 0.1], + dof='trilinear', + cellcount=[2, 2, 1], + mass=0.5, + equality=3, # strain + elastic2d=2, # bend + ) + flex2.young = 1e3 + flex2.thickness = 0.01 + flex2.selfcollide = mujoco.mjtFlexSelf.mjFLEXSELF_NONE + self.assertIsNotNone(flex2) + model2 = spec2.compile() + self.assertIsNotNone(model2) + # Shell mode creates face-based constraints; verify they exist. + self.assertGreater(model2.neq, 0) + + def test_make_flex_defaults(self): + # Create a spec with minimal flexcomp args (defaults). + spec = mujoco.MjSpec() + body = spec.worldbody.add_body(name='flex_body') + flex = body.make_flex(name='default_flex', equality=1) + self.assertIsNotNone(flex) + model = spec.compile() + self.assertIsNotNone(model) + + def test_make_flex_with_pos_quat(self): + # Create a spec with flexcomp that has a pose. + spec = mujoco.MjSpec() + body = spec.worldbody.add_body(name='flex_body') + flex = body.make_flex( + name='posed_flex', + type='grid', + dim=2, + count=[3, 3, 1], + spacing=[0.1, 0.1, 0.1], + pos=[1.0, 2.0, 3.0], + quat=[1.0, 0.0, 0.0, 0.0], + equality=1, + ) + self.assertIsNotNone(flex) + model = spec.compile() + self.assertIsNotNone(model) + if __name__ == '__main__': absltest.main() diff --git a/src/user/user_api.cc b/src/user/user_api.cc index 42e0d7a6..0f657ff9 100644 --- a/src/user/user_api.cc +++ b/src/user/user_api.cc @@ -33,6 +33,7 @@ #include #include "engine/engine_support.h" #include "user/user_cache.h" +#include "user/user_flexcomp.h" #include "user/user_model.h" #include "user/user_objects.h" #include "user/user_resource.h" @@ -599,6 +600,121 @@ mjsFlex* mjs_addFlex(mjSpec* s) { +// helper: convert type string to mjtFcompType +static mjtFcompType FlexcompTypeFromStr(const char* type) { + if (!type || !strcmp(type, "grid")) return mjFCOMPTYPE_GRID; + if (!strcmp(type, "box")) return mjFCOMPTYPE_BOX; + if (!strcmp(type, "cylinder")) return mjFCOMPTYPE_CYLINDER; + if (!strcmp(type, "ellipsoid")) return mjFCOMPTYPE_ELLIPSOID; + if (!strcmp(type, "square")) return mjFCOMPTYPE_SQUARE; + if (!strcmp(type, "disc")) return mjFCOMPTYPE_DISC; + if (!strcmp(type, "circle")) return mjFCOMPTYPE_CIRCLE; + if (!strcmp(type, "mesh")) return mjFCOMPTYPE_MESH; + if (!strcmp(type, "gmsh")) return mjFCOMPTYPE_GMSH; + if (!strcmp(type, "direct")) return mjFCOMPTYPE_DIRECT; + return mjFCOMPTYPE_GRID; // default +} + +// helper: convert dof string to mjtDof +static mjtDof FlexcompDofFromStr(const char* dof) { + if (!dof || !strcmp(dof, "full")) return mjFCOMPDOF_FULL; + if (!strcmp(dof, "radial")) return mjFCOMPDOF_RADIAL; + if (!strcmp(dof, "trilinear")) return mjFCOMPDOF_TRILINEAR; + if (!strcmp(dof, "quadratic")) return mjFCOMPDOF_QUADRATIC; + if (!strcmp(dof, "2d")) return mjFCOMPDOF_2D; + return mjFCOMPDOF_FULL; // default +} + + +// add flexcomp: create flex with auto-generated bodies/joints +mjsFlex* mjs_makeFlex(mjsBody* body, const char* name, const char* type, int dim, + const char* dof, const int count[3], const int cellcount[3], + const double spacing[3], const double scale[3], double radius, + double mass, double inertiabox, int equality, int rigid, int flatskin, + int elastic2d, const double pos[3], const double quat[4], + const double origin[3], const char* file, const mjVFS* vfs) { + if (!body || !name) { + mju_error("mjs_makeFlex: body and name must not be null"); + return nullptr; + } + + mjCModel* model = static_cast(body->element)->model; + + // create temporary flexcomp with defaults + mjCFlexcomp fcomp; + fcomp.name = name; + fcomp.type = FlexcompTypeFromStr(type); + fcomp.doftype = FlexcompDofFromStr(dof); + + // topology + if (count) { + fcomp.count[0] = count[0]; + fcomp.count[1] = count[1]; + fcomp.count[2] = count[2]; + } + if (cellcount) { + fcomp.cellcount[0] = cellcount[0]; + fcomp.cellcount[1] = cellcount[1]; + fcomp.cellcount[2] = cellcount[2]; + } + if (spacing) { + fcomp.spacing[0] = spacing[0]; + fcomp.spacing[1] = spacing[1]; + fcomp.spacing[2] = spacing[2]; + } + if (scale) { + fcomp.scale[0] = scale[0]; + fcomp.scale[1] = scale[1]; + fcomp.scale[2] = scale[2]; + } + if (origin) { + fcomp.origin[0] = origin[0]; + fcomp.origin[1] = origin[1]; + fcomp.origin[2] = origin[2]; + } + + // physics + fcomp.def.spec.flex->dim = dim; + fcomp.def.spec.flex->radius = radius; + if (mass > 0) fcomp.mass = mass; + if (inertiabox > 0) fcomp.inertiabox = inertiabox; + fcomp.equality = equality; + fcomp.rigid = rigid; + fcomp.def.spec.flex->flatskin = flatskin; + fcomp.def.spec.flex->elastic2d = elastic2d; + + // pose + if (pos) { + fcomp.pos[0] = pos[0]; + fcomp.pos[1] = pos[1]; + fcomp.pos[2] = pos[2]; + } + if (quat) { + fcomp.quat[0] = quat[0]; + fcomp.quat[1] = quat[1]; + fcomp.quat[2] = quat[2]; + fcomp.quat[3] = quat[3]; + } + + // file + if (file) { + fcomp.file = file; + } + + // call Make + char error[500] = ""; + if (!fcomp.Make(body, error, sizeof(error), vfs)) { + model->SetError(mjCError(nullptr, "%s", error)); + return nullptr; + } + + // return the flex that was created (last flex in model) + mjCFlex* flex = model->Flexes().back(); + return &flex->spec; +} + + + // add frame to body mjsFrame* mjs_addFrame(mjsBody* bodyspec, mjsFrame* parentframe) { mjCFrame* parentframeC = 0; diff --git a/src/user/user_api.h b/src/user/user_api.h index 1ab17a40..53b250e7 100644 --- a/src/user/user_api.h +++ b/src/user/user_api.h @@ -118,6 +118,14 @@ MJAPI mjsSensor* mjs_addSensor(mjSpec* s); // Add flex. MJAPI mjsFlex* mjs_addFlex(mjSpec* s); +// Add flexcomp: create flex with auto-generated bodies/joints, return flex spec. +MJAPI mjsFlex* mjs_makeFlex(mjsBody* body, const char* name, const char* type, int dim, + const char* dof, const int count[3], const int cellcount[3], + const double spacing[3], const double scale[3], double radius, + double mass, double inertiabox, int equality, int rigid, int flatskin, + int elastic2d, const double pos[3], const double quat[4], + const double origin[3], const char* file, const mjVFS* vfs); + // Add contact pair. MJAPI mjsPair* mjs_addPair(mjSpec* s, const mjsDefault* def); diff --git a/wasm/codegen/generated/bindings.cc b/wasm/codegen/generated/bindings.cc index b7157df7..80d0675a 100644 --- a/wasm/codegen/generated/bindings.cc +++ b/wasm/codegen/generated/bindings.cc @@ -9952,6 +9952,22 @@ int mjs_isWarning_wrapper(MjSpec& s) { return mjs_isWarning(s.get()); } +std::optional mjs_makeFlex_wrapper(MjsBody& body, const String& name, const StringOrNull& type, int dim, const StringOrNull& dof, const NumberArray& count, const NumberArray& cellcount, const NumberArray& spacing, const NumberArray& scale, double radius, double mass, double inertiabox, int equality, int rigid, int flatskin, int elastic2d, const NumberArray& pos, const NumberArray& quat, const NumberArray& origin, const StringOrNull& file, const MjVFS& vfs) { + CHECK_VAL(name); + UNPACK_NULLABLE_ARRAY(int, count); + UNPACK_NULLABLE_ARRAY(int, cellcount); + UNPACK_NULLABLE_ARRAY(double, spacing); + UNPACK_NULLABLE_ARRAY(double, scale); + UNPACK_NULLABLE_ARRAY(double, pos); + UNPACK_NULLABLE_ARRAY(double, quat); + UNPACK_NULLABLE_ARRAY(double, origin); + mjsFlex* result = mjs_makeFlex(body.get(), name.as().data(), type.as().data(), dim, dof.as().data(), count_.data(), cellcount_.data(), spacing_.data(), scale_.data(), radius, mass, inertiabox, equality, rigid, flatskin, elastic2d, pos_.data(), quat_.data(), origin_.data(), file.as().data(), vfs.get()); + if (result == nullptr) { + return std::nullopt; + } + return MjsFlex(result); +} + int mjs_makeMesh_wrapper(MjsMesh& mesh, mjtMeshBuiltin builtin, const val& params, int nparams) { UNPACK_VALUE(double, params); return mjs_makeMesh(mesh.get(), builtin, params_.data(), nparams); @@ -13474,6 +13490,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) { function("mjs_getWrapSideSite", &mjs_getWrapSideSite_wrapper); function("mjs_getWrapTarget", &mjs_getWrapTarget_wrapper); function("mjs_isWarning", &mjs_isWarning_wrapper); + function("mjs_makeFlex", &mjs_makeFlex_wrapper); function("mjs_makeMesh", &mjs_makeMesh_wrapper); function("mjs_nextChild", &mjs_nextChild_wrapper); function("mjs_nextElement", &mjs_nextElement_wrapper); diff --git a/wasm/codegen/generators/functions.py b/wasm/codegen/generators/functions.py index bb296d12..f7939a83 100644 --- a/wasm/codegen/generators/functions.py +++ b/wasm/codegen/generators/functions.py @@ -143,7 +143,7 @@ def get_param_string(p: ast_nodes.FunctionParameterDecl) -> str: # Pointer to primitive value parameters or arrays if p.type.inner_type.name == "char": if p.nullable: - return f"const NullableString& {p.name}" + return f"const StringOrNull& {p.name}" else: return f"const String& {p.name}" elif (