// Copyright 2021 DeepMind Technologies Limited // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #include "user/user_util.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "engine/engine_crossplatform.h" // workaround with locale bug on some MacOS machines #if defined (__APPLE__) && defined (__MACH__) #include #include #define strtof(X, Y) strtof_l((X), (Y), _c_locale) #define strtod(X, Y) strtod_l((X), (Y), _c_locale) #endif // check if numeric variable is defined bool mjuu_defined(double num) { return !std::isnan(num); } // compute address of M[g1][g2] where M is triangular n-by-n int mjuu_matadr(int g1, int g2, int n) { if (g1<0 || g2<0 || g1>=n || g2>=n) { return -1; } if (g1>g2) { int tmp = g1; g1 = g2; g2 = tmp; } return g1*n + g2; } // set 4D vector void mjuu_setvec(double* dest, double x, double y, double z, double w) { dest[0] = x; dest[1] = y; dest[2] = z; dest[3] = w; } void mjuu_setvec(float* dest, double x, double y, double z, double w) { dest[0] = (float)x; dest[1] = (float)y; dest[2] = (float)z; dest[3] = (float)w; } // set 3D vector void mjuu_setvec(double* dest, double x, double y, double z) { dest[0] = x; dest[1] = y; dest[2] = z; } void mjuu_setvec(float* dest, double x, double y, double z) { dest[0] = (float)x; dest[1] = (float)y; dest[2] = (float)z; } // set 2D vector void mjuu_setvec(double* dest, double x, double y) { dest[0] = x; dest[1] = y; } // add to double array void mjuu_addtovec(double* dest, const double* src, int n) { for (int i=0; i mjEPS) { for (int i=0; i mjEPS) { for (int i=0; i0) { quat[0] = 0.5 * sqrt(1 + mat[0][0] + mat[1][1] + mat[2][2]); quat[1] = 0.25 * (mat[1][2] - mat[2][1]) / quat[0]; quat[2] = 0.25 * (mat[2][0] - mat[0][2]) / quat[0]; quat[3] = 0.25 * (mat[0][1] - mat[1][0]) / quat[0]; } // q1 largest else if (mat[0][0]>mat[1][1] && mat[0][0]>mat[2][2]) { quat[1] = 0.5 * sqrt(1 + mat[0][0] - mat[1][1] - mat[2][2]); quat[0] = 0.25 * (mat[1][2] - mat[2][1]) / quat[1]; quat[2] = 0.25 * (mat[1][0] + mat[0][1]) / quat[1]; quat[3] = 0.25 * (mat[2][0] + mat[0][2]) / quat[1]; } // q2 largest else if (mat[1][1]>mat[2][2]) { quat[2] = 0.5 * sqrt(1 - mat[0][0] + mat[1][1] - mat[2][2]); quat[0] = 0.25 * (mat[2][0] - mat[0][2]) / quat[2]; quat[1] = 0.25 * (mat[1][0] + mat[0][1]) / quat[2]; quat[3] = 0.25 * (mat[2][1] + mat[1][2]) / quat[2]; } // q3 largest else { quat[3] = 0.5 * sqrt(1 - mat[0][0] - mat[1][1] + mat[2][2]); quat[0] = 0.25 * (mat[0][1] - mat[1][0]) / quat[3]; quat[1] = 0.25 * (mat[2][0] + mat[0][2]) / quat[3]; quat[2] = 0.25 * (mat[2][1] + mat[1][2]) / quat[3]; } mjuu_normvec(quat, 4); } // invert frame transformation void mjuu_frameinvert(double newpos[3], double newquat[4], const double oldpos[3], const double oldquat[4]) { // position mjuu_localaxis(newpos, oldpos, oldquat); newpos[0] = -newpos[0]; newpos[1] = -newpos[1]; newpos[2] = -newpos[2]; // orientation newquat[0] = oldquat[0]; newquat[1] = -oldquat[1]; newquat[2] = -oldquat[2]; newquat[3] = -oldquat[3]; } // accumulate frame transformations (forward kinematics) void mjuu_frameaccum(double pos[3], double quat[4], const double childpos[3], const double childquat[4]) { double mat[9], vec[3], qtmp[4]; mjuu_quat2mat(mat, quat); mjuu_mulvecmat(vec, childpos, mat); pos[0] += vec[0]; pos[1] += vec[1]; pos[2] += vec[2]; mjuu_mulquat(qtmp, quat, childquat); mjuu_copyvec(quat, qtmp, 4); } // accumulate frame transformation in second frame void mjuu_frameaccumChild(const double pos[3], const double quat[4], double childpos[3], double childquat[4]) { double p[] = {pos[0], pos[1], pos[2]}; double q[] = {quat[0], quat[1], quat[2], quat[3]}; mjuu_frameaccum(p, q, childpos, childquat); mjuu_copyvec(childpos, p, 3); mjuu_copyvec(childquat, q, 4); } // invert frame accumulation void mjuu_frameaccuminv(double pos[3], double quat[4], const double childpos[3], const double childquat[4]) { double mat[9], vec[3], qtmp[4]; double qneg[4] = {childquat[0], -childquat[1], -childquat[2], -childquat[3]}; mjuu_mulquat(qtmp, quat, qneg); mjuu_copyvec(quat, qtmp, 4); mjuu_quat2mat(mat, quat); mjuu_mulvecmat(vec, childpos, mat); pos[0] -= vec[0]; pos[1] -= vec[1]; pos[2] -= vec[2]; } // convert local_inertia[3] to global_inertia[6] void mjuu_globalinertia(double* global, const double* local, const double* quat) { double mat[9]; mjuu_quat2mat(mat, quat); double tmp[9] = { mat[0]*local[0], mat[3]*local[0], mat[6]*local[0], mat[1]*local[1], mat[4]*local[1], mat[7]*local[1], mat[2]*local[2], mat[5]*local[2], mat[8]*local[2] }; global[0] = mat[0]*tmp[0] + mat[1]*tmp[3] + mat[2]*tmp[6]; global[1] = mat[3]*tmp[1] + mat[4]*tmp[4] + mat[5]*tmp[7]; global[2] = mat[6]*tmp[2] + mat[7]*tmp[5] + mat[8]*tmp[8]; global[3] = mat[0]*tmp[1] + mat[1]*tmp[4] + mat[2]*tmp[7]; global[4] = mat[0]*tmp[2] + mat[1]*tmp[5] + mat[2]*tmp[8]; global[5] = mat[3]*tmp[2] + mat[4]*tmp[5] + mat[5]*tmp[8]; } // compute off-center correction to inertia matrix // mass * [y^2+z^2, -x*y, -x*z; -x*y, x^2+z^2, -y*z; -x*z, -y*z, x^2+y^2] void mjuu_offcenter(double* res, const double mass, const double* vec) { res[0] = mass*(vec[1]*vec[1] + vec[2]*vec[2]); res[1] = mass*(vec[0]*vec[0] + vec[2]*vec[2]); res[2] = mass*(vec[0]*vec[0] + vec[1]*vec[1]); res[3] = -mass*vec[0]*vec[1]; res[4] = -mass*vec[0]*vec[2]; res[5] = -mass*vec[1]*vec[2]; } // compute viscosity coefficients from mass and inertia void mjuu_visccoef(double* visccoef, double mass, const double* inertia, double scl) { // compute equivalent box double ebox[3]; ebox[0] = sqrt(mjMAX(mjEPS, (inertia[1] + inertia[2] - inertia[0])) / mass * 6.0); ebox[1] = sqrt(mjMAX(mjEPS, (inertia[0] + inertia[2] - inertia[1])) / mass * 6.0); ebox[2] = sqrt(mjMAX(mjEPS, (inertia[0] + inertia[1] - inertia[2])) / mass * 6.0); // apply formula for box (or rather cross) viscosity // torque components visccoef[0] = scl * 4.0 / 3.0 * ebox[0] * (ebox[1]*ebox[1]*ebox[1] + ebox[2]*ebox[2]*ebox[2]); visccoef[1] = scl * 4.0 / 3.0 * ebox[1] * (ebox[0]*ebox[0]*ebox[0] + ebox[2]*ebox[2]*ebox[2]); visccoef[2] = scl * 4.0 / 3.0 * ebox[2] * (ebox[0]*ebox[0]*ebox[0] + ebox[1]*ebox[1]*ebox[1]); // force components visccoef[3] = scl * 4*ebox[1]*ebox[2]; visccoef[4] = scl * 4*ebox[0]*ebox[2]; visccoef[5] = scl * 4*ebox[0]*ebox[1]; } // convert axisAngle to quaternion static void mjuu_axisAngle2Quat(double res[4], const double axis[3], double angle) { // zero angle: identity quat if (angle == 0) { res[0] = 1; res[1] = 0; res[2] = 0; res[3] = 0; } // regular processing else { double s = sin(angle*0.5); res[0] = cos(angle*0.5); res[1] = axis[0]*s; res[2] = axis[1]*s; res[3] = axis[2]*s; } } // rotate vector by quaternion void mjuu_rotVecQuat(double res[3], const double vec[3], const double quat[4]) { // zero vec: zero res if (vec[0] == 0 && vec[1] == 0 && vec[2] == 0) { res[0] = res[1] = res[2] = 0; } // null quat: copy vec else if (quat[0] == 1 && quat[1] == 0 && quat[2] == 0 && quat[3] == 0) { mjuu_copyvec(res, vec, 3); } // regular processing else { // tmp = q_w * v + cross(q_xyz, v) double tmp[3] = { quat[0]*vec[0] + quat[2]*vec[2] - quat[3]*vec[1], quat[0]*vec[1] + quat[3]*vec[0] - quat[1]*vec[2], quat[0]*vec[2] + quat[1]*vec[1] - quat[2]*vec[0] }; // res = v + 2 * cross(q_xyz, t) res[0] = vec[0] + 2 * (quat[2]*tmp[2] - quat[3]*tmp[1]); res[1] = vec[1] + 2 * (quat[3]*tmp[0] - quat[1]*tmp[2]); res[2] = vec[2] + 2 * (quat[1]*tmp[1] - quat[2]*tmp[0]); } } // update moving frame along a curve or initialize it, returns edge length // inputs: // normal - normal vector computed by a previous call to the function // edge - edge vector (non-unit tangent vector) // tprv - unit tangent vector of previous body // tnxt - unit tangent vector of next body // first - 1 if the frame requires initialization // outputs: // quat - frame orientation // normal - unit normal vector double mjuu_updateFrame(double quat[4], double normal[3], const double edge[3], const double tprv[3], const double tnxt[3], int first) { double tangent[3], binormal[3]; // normalize tangent mjuu_copyvec(tangent, edge, 3); mjuu_normvec(tangent, 3); // compute moving frame if (first) { // use the first vertex binormal for the first edge mjuu_crossvec(binormal, tangent, tnxt); mjuu_normvec(binormal, 3); // compute edge normal given tangent and binormal mjuu_crossvec(normal, binormal, tangent); mjuu_normvec(normal, 3); } else { double darboux[4]; // rotate edge normal about the vertex binormal mjuu_crossvec(binormal, tprv, tangent); double angle = atan2(mjuu_normvec(binormal, 3), mjuu_dot3(tprv, tangent)); mjuu_axisAngle2Quat(darboux, binormal, angle); mjuu_rotVecQuat(normal, normal, darboux); mjuu_normvec(normal, 3); // compute edge binormal given tangent and normal mjuu_crossvec(binormal, tangent, normal); mjuu_normvec(binormal, 3); } // global orientation of the frame mjuu_frame2quat(quat, tangent, normal, binormal); // return edge length return sqrt(mjuu_dot3(edge, edge)); } // eigenvalue decomposition of symmetric 3x3 matrix static const double kEigEPS = 1E-12; int mjuu_eig3(double eigval[3], double eigvec[9], double quat[4], const double mat[9]) { double D[9], tmp[9], tmp2[9]; double tau, t, c; int iter, rk, ck, rotk; // initialize with unit quaternion quat[0] = 1; quat[1] = quat[2] = quat[3] = 0; // Jacobi iteration for (iter=0; iter < 500; iter++) { // make quaternion matrix eigvec, compute D = eigvec'*mat*eigvec mjuu_quat2mat(eigvec, quat); mjuu_transposemat(tmp2, eigvec); mjuu_mulmat(tmp, tmp2, mat); mjuu_mulmat(D, tmp, eigvec); // assign eigenvalues eigval[0] = D[0]; eigval[1] = D[4]; eigval[2] = D[8]; // find max off-diagonal element, set indices if (std::abs(D[1]) > std::abs(D[2]) && std::abs(D[1]) > std::abs(D[5])) { rk = 0; // row ck = 1; // column rotk = 2; // rotation axis } else if (std::abs(D[2]) > std::abs(D[5])) { rk = 0; ck = 2; rotk = 1; } else { rk = 1; ck = 2; rotk = 0; } // terminate if max off-diagonal element too small if (std::abs(D[3*rk+ck]) < kEigEPS) { break; } // 2x2 symmetric Schur decomposition tau = (D[4*ck]-D[4*rk])/(2*D[3*rk+ck]); if (tau >= 0) { t = 1.0/(tau + sqrt(1 + tau*tau)); } else { t = -1.0/(-tau + sqrt(1 + tau*tau)); } c = 1.0/sqrt(1 + t*t); // terminate if cosine too close to 1 if (c > 1.0-kEigEPS) { break; } // express rotation as quaternion tmp[1] = tmp[2] = tmp[3] = 0; tmp[rotk+1] = (tau >= 0 ? -sqrt(0.5-0.5*c) : sqrt(0.5-0.5*c)); if (rotk == 1) { tmp[rotk+1] = -tmp[rotk+1]; } tmp[0] = sqrt(1.0 - tmp[rotk+1]*tmp[rotk+1]); mjuu_normvec(tmp, 4); // accumulate quaternion rotation mjuu_mulquat(quat, quat, tmp); mjuu_normvec(quat, 4); } // sort eigenvalues in decreasing order (bubblesort: 0, 1, 0) for (int j=0; j < 3; j++) { int j1 = j%2; // lead index // only swap if the eigenvalues are different if (eigval[j1]+kEigEPS < eigval[j1+1]) { // swap eigenvalues t = eigval[j1]; eigval[j1] = eigval[j1+1]; eigval[j1+1] = t; // rotate quaternion tmp[0] = 0.707106781186548; // cos(pi/4) = sin(pi/4) tmp[1] = tmp[2] = tmp[3] = 0; tmp[(j1+2)%3+1] = tmp[0]; mjuu_mulquat(quat, quat, tmp); mjuu_normvec(quat, 4); } } // recompute eigvec mjuu_quat2mat(eigvec, quat); return iter; } // transform vector by pose void mjuu_trnVecPose(double res[3], const double pos[3], const double quat[4], const double vec[3]) { // res = quat*vec + pos mjuu_rotVecQuat(res, vec, quat); res[0] += pos[0]; res[1] += pos[1]; res[2] += pos[2]; } // strip directory from filename std::string mjuu_strippath(std::string filename) { // find last pathsymbol size_t start = filename.find_last_of("/\\"); // no path found: return original if (start==std::string::npos) { return filename; } // return name without path else { return filename.substr(start+1, filename.size()-start-1); } } // compute frame quat and diagonal inertia from full inertia matrix, return error if any const char* mjuu_fullInertia(double quat[4], double inertia[3], const double fullinertia[6]) { if (!mjuu_defined(fullinertia[0])) { return nullptr; } double eigval[3], eigvec[9], quattmp[4]; double full[9] = { fullinertia[0], fullinertia[3], fullinertia[4], fullinertia[3], fullinertia[1], fullinertia[5], fullinertia[4], fullinertia[5], fullinertia[2] }; mjuu_eig3(eigval, eigvec, quattmp, full); // check mimimal eigenvalue if (eigval[2] 0 && path1[n - 1] != '\\' && path1[n - 1] != '/') { return path1 + "/" + path2; } return path1 + path2; } // assemble three file paths std::string mjuu_combinePaths(const std::string& path1, const std::string& path2, const std::string& path3) { return mjuu_combinePaths(path1, mjuu_combinePaths(path2, path3)); } // return true if the text is in a valid content type format: // {type}/{subtype}[;{parameter}={value}] static bool mjuu_isValidContentType(std::string_view text) { // find a forward slash that's not the last character size_t n = text.find('/'); if (n == std::string::npos || n == text.size() - 1) { return false; } size_t m = text.find(';'); if (m == std::string::npos) { return true; } if (m + 1 <= n) { return false; } // just check if there's an equal sign; this isn't robust enough for general // validation, but works for our scope, hence this is a private helper // function size_t s = text.find('='); if (s == std::string::npos || s + 1 <= m) { return false; } return true; } // return type from content_type format {type}/{subtype}[;{parameter}={value}] // return empty string on invalid format std::optional mjuu_parseContentTypeAttrType(std::string_view text) { if (!mjuu_isValidContentType(text)) { return std::nullopt; } return { text.substr(0, text.find('/')) }; } // return subtype from content_type format {type}/{subtype}[;{parameter}={value}] // return empty string on invalid format std::optional mjuu_parseContentTypeAttrSubtype(std::string_view text) { if (!mjuu_isValidContentType(text)) { return std::nullopt; } size_t n = text.find('/'); size_t m = text.find(';', n + 1); if (m == std::string::npos) { return { text.substr(n+1) }; } return { text.substr(n + 1, m - n - 1) }; } // convert filename extension to content type; return empty string if not found std::string mjuu_extToContentType(std::string_view filename) { std::string ext = mjuu_getext(filename); if (!strcasecmp(ext.c_str(), ".stl")) { return "model/stl"; } else if (!strcasecmp(ext.c_str(), ".obj")) { return "model/obj"; } else if (!strcasecmp(ext.c_str(), ".ply")) { return "model/ply"; } else if (!strcasecmp(ext.c_str(), ".msh")) { return "model/vnd.mujoco.msh"; } else if (!strcasecmp(ext.c_str(), ".png")) { return "image/png"; } else { return ""; } } // get the length of the dirname portion of a given path int mjuu_dirnamelen(const char* path) { if (!path) { return 0; } int pos = -1; for (int i = 0; path[i]; ++i) { if (path[i] == '/' || path[i] == '\\') { pos = i; } } return pos + 1; } namespace mujoco::user { std::string FilePath::Combine(const std::string& s1, const std::string& s2) { // str2 has absolute path if (!AbsPrefix(s2).empty()) { return s2; } std::size_t n = s1.size(); if (n > 0 && s1[n - 1] != '\\' && s1[n - 1] != '/') { return s1 + "/" + s2; } return s1 + s2; } std::string FilePath::PathReduce(const std::string& str) { std::vector dirs; std::string abs_prefix = AbsPrefix(str); int j = abs_prefix.size(); for (int i = j; i < str.size(); ++i) { if (IsSeperator(str[i])) { std::string temp = str.substr(j, i - j); j = i + 1; if (temp == ".." && !dirs.empty() && dirs.back() != "..") { dirs.pop_back(); } else if (temp != ".") { dirs.push_back(std::move(temp)); } } } // push the rest of the string dirs.push_back(str.substr(j, str.size() - j)); // join the path std::stringstream path; auto it = dirs.begin(); path << abs_prefix << *it++; for (; it != dirs.end(); ++it) { path << "/" << *it; } return path.str(); } FilePath FilePath::operator+(const FilePath& path) const { return FilePath(path_, path.path_); } std::string FilePath::Ext() const { std::size_t n = path_.find_last_of('.'); if (n == std::string::npos) { return ""; } return path_.substr(n, path_.size() - n); } FilePath FilePath::StripExt() const { size_t n = path_.find_last_of('.'); // no extension if (n == std::string::npos) { return FilePathFast(path_); } // return path without extension return FilePathFast(path_.substr(0, n)); } // is directory absolute path std::string FilePath::AbsPrefix(const std::string& str) { // empty: not absolute if (str.empty()) { return ""; } // path is scheme:filename which we consider an absolute path // e.g. file URI's are always absolute paths const mjpResourceProvider* provider = mjp_getResourceProvider(str.c_str()); if (provider != nullptr) { std::size_t n = std::strlen(provider->prefix); return str.substr(0, n + 1); } // check first char if (str[0] == '\\' || str[0] == '/') { return str.substr(0, 1); } // find ":/" or ":\" std::size_t pos = str.find(":/"); if (pos != std::string::npos) { return str.substr(0, pos + 2); } pos = str.find(":\\"); if (pos != std::string::npos) { return str.substr(0, pos + 2); } return ""; } FilePath FilePath::StripPath() const { // find last path symbol std::size_t n = path_.find_last_of("/\\"); // no path if (n == std::string::npos) { return FilePathFast(path_); } return FilePathFast(path_.substr(n + 1, path_.size() - (n + 1))); } std::string FilePath::StrLower() const { std::string str = path_; std::transform(str.begin(), str.end(), str.begin(), [](unsigned char c) { return std::tolower(c); }); return str; } // read file into memory buffer std::vector FileToMemory(const char* filename) { FILE* fp = fopen(filename, "rb"); if (!fp) { return {}; } // find size if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); mju_warning("Failed to calculate size for '%s'", filename); return {}; } // ensure file size fits in int long long_filesize = ftell(fp); // NOLINT(runtime/int) if (long_filesize > INT_MAX) { fclose(fp); mju_warning("File size over 2GB is not supported. File: '%s'", filename); return {}; } else if (long_filesize < 0) { fclose(fp); mju_warning("Failed to calculate size for '%s'", filename); return {}; } std::vector buffer(long_filesize); // go back to start of file if (fseek(fp, 0, SEEK_SET) != 0) { fclose(fp); mju_warning("Read error while reading '%s'", filename); return {}; } // allocate and read std::size_t bytes_read = fread(buffer.data(), 1, buffer.size(), fp); // check that read data matches file size if (bytes_read != buffer.size()) { // SHOULD NOT OCCUR if (ferror(fp)) { fclose(fp); mju_warning("Read error while reading '%s'", filename); return {}; } else if (feof(fp)) { buffer.resize(bytes_read); } } // close file, return contents fclose(fp); return buffer; } // convert vector to string separating elements by whitespace template std::string VectorToString(const std::vector& v) { std::stringstream ss; for (const T& t : v) { ss << t << " "; } std::string s = ss.str(); if (!s.empty()) s.pop_back(); // remove trailing space return s; } template std::string VectorToString(const std::vector& v); template std::string VectorToString(const std::vector& v); template std::string VectorToString(const std::vector& v); template std::string VectorToString(const std::vector& v); namespace { template T StrToNum(char* str, char** c); template<> int StrToNum(char* str, char** c) { long n = std::strtol(str, c, 10); if (n < INT_MIN || n > INT_MAX) errno = ERANGE; return n; } template<> float StrToNum(char* str, char** c) { float f = strtof(str, c); if (std::isnan(f)) errno = EDOM; return f; } template<> double StrToNum(char* str, char** c) { double d = strtod(str, c); if (std::isnan(d)) errno = EDOM; return d; } template<> unsigned char StrToNum(char* str, char** c) { long n = std::strtol(str, c, 10); if (n < 0 || n > UCHAR_MAX) errno = ERANGE; return n; } inline bool IsNullOrSpace(char* c) { return std::isspace(static_cast(*c)) || *c == '\0'; } inline char* SkipSpace(char* c) { for (; *c != '\0'; c++) { if (!IsNullOrSpace(c)) { break; } } return c; } } // namespace template std::vector StringToVector(char* cs) { std::vector v; char* ch = cs; errno = 0; // reserve worst case v.reserve((std::strlen(cs) >> 1) + 1); for (;;) { cs = SkipSpace(ch); // skip leading spaces if (*cs == '\0') break; // end of string T num = StrToNum(cs, &ch); // parse number if (!IsNullOrSpace(ch)) errno = EINVAL; // invalid separator if (cs == ch) errno = EINVAL; // failed to parse number if (errno && errno != EDOM) break; // NaNs are quietly ignored v.push_back(num); } v.shrink_to_fit(); return v; } template<> std::vector StringToVector(const std::string& s) { std::vector v; std::stringstream ss(s); std::string word; while (ss >> word) { v.push_back(word); } return v; } template std::vector StringToVector(char* cs); template std::vector StringToVector(char* cs); template std::vector StringToVector(char* cs); template std::vector StringToVector(const std::string& s) { return StringToVector(const_cast(s.c_str())); } template std::vector StringToVector(const std::string& s); template std::vector StringToVector(const std::string& s); template std::vector StringToVector(const std::string& s); template std::vector StringToVector(const std::string& s); } // namespace mujoco::user