638c9a6994
PiperOrigin-RevId: 489980760 Change-Id: I1c21e736a86f6637a00e2062727d2e6fd5694c42
652 lines
17 KiB
C++
652 lines
17 KiB
C++
// 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 <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <mujoco/mjmodel.h>
|
|
#include "user/user_model.h"
|
|
#include "user/user_objects.h"
|
|
#include "user/user_util.h"
|
|
#include "xml/xml_native_reader.h"
|
|
#include "xml/xml_urdf.h"
|
|
|
|
#include "tinyxml2.h"
|
|
|
|
using tinyxml2::XMLElement;
|
|
|
|
// URDF joint type
|
|
static const int urJoint_sz = 6;
|
|
static const mjMap urJoint_map[urJoint_sz] = {
|
|
{"revolute", 0},
|
|
{"continuous", 1},
|
|
{"prismatic", 2},
|
|
{"fixed", 3},
|
|
{"floating", 4},
|
|
{"planar", 5}
|
|
};
|
|
|
|
|
|
|
|
//---------------------------------- class mjXURDF -------------------------------------------------
|
|
|
|
// constructor
|
|
mjXURDF::mjXURDF() {
|
|
Clear();
|
|
}
|
|
|
|
|
|
|
|
// destructor
|
|
mjXURDF::~mjXURDF() {
|
|
Clear();
|
|
}
|
|
|
|
|
|
// clear internal variables
|
|
void mjXURDF::Clear(void) {
|
|
model = 0;
|
|
|
|
urName.clear();
|
|
urParent.clear();
|
|
urChildren.clear();
|
|
urMat.clear();
|
|
urRGBA.clear();
|
|
}
|
|
|
|
|
|
|
|
// actual parser
|
|
void mjXURDF::Parse(XMLElement* root) {
|
|
std::string name, text;
|
|
XMLElement *elem, *temp;
|
|
int id_parent, id_child, i;
|
|
|
|
// set compiler defaults suitable for URDF
|
|
model->strippath = true;
|
|
model->discardvisual = true;
|
|
model->fusestatic = true;
|
|
|
|
// parse MuJoCo sections (not part of URDF)
|
|
XMLElement* mjc = FindSubElem(root, "mujoco");
|
|
if (mjc) {
|
|
XMLElement *section;
|
|
if ((section = FindSubElem(mjc, "compiler"))) {
|
|
mjXReader::Compiler(section, model);
|
|
}
|
|
|
|
if ((section = FindSubElem(mjc, "option"))) {
|
|
mjXReader::Option(section, &model->option);
|
|
}
|
|
|
|
if ((section = FindSubElem(mjc, "size"))) {
|
|
mjXReader::Size(section, model);
|
|
}
|
|
}
|
|
|
|
// enforce required compiler defaults for URDF
|
|
model->global = false;
|
|
model->degree = false;
|
|
|
|
// get model name
|
|
ReadAttrTxt(root, "name", model->modelname);
|
|
|
|
// find and register all materials
|
|
MakeMaterials(root);
|
|
|
|
// find all links/bodies, save names
|
|
elem = root->FirstChildElement();
|
|
while (elem) {
|
|
// identify link elements
|
|
name = elem->Value();
|
|
if (name=="link") {
|
|
ReadAttrTxt(elem, "name", text, true);
|
|
AddBody(text);
|
|
}
|
|
|
|
// advance to next element
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
|
|
// find all joints, assign parent and child pointers
|
|
elem = root->FirstChildElement();
|
|
while (elem) {
|
|
// identify joint elements
|
|
name = elem->Value();
|
|
if (name=="joint") {
|
|
// find parent, get name and id
|
|
temp = FindSubElem(elem, "parent", true);
|
|
ReadAttrTxt(temp, "link", text, true);
|
|
id_parent = FindName(text, urName);
|
|
|
|
// find child, get name and id
|
|
temp = FindSubElem(elem, "child", true);
|
|
ReadAttrTxt(temp, "link", text, true);
|
|
id_child = FindName(text, urName);
|
|
|
|
// make sure parent and child exist
|
|
if (id_parent<0 || id_child<0) {
|
|
throw mjXError(elem, "URDF joint parent or child missing");
|
|
}
|
|
|
|
// check for multiple parents
|
|
if (urParent[id_child]>=0) {
|
|
throw mjXError(elem, "URDF body has multiple parents:", urName[id_child].c_str());
|
|
}
|
|
|
|
// add parent and child info
|
|
urParent[id_child] = id_parent;
|
|
urChildren[id_parent].push_back(id_child);
|
|
}
|
|
|
|
// advance to next element
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
|
|
// find all top-level bodies, call recursive tree constructor
|
|
for (i=0; i<(int)urName.size(); i++) {
|
|
if (urParent[i] < 0) {
|
|
AddToTree(i);
|
|
}
|
|
}
|
|
|
|
// parse bodies
|
|
elem = root->FirstChildElement();
|
|
while (elem) {
|
|
// identify body/link elements
|
|
name = elem->Value();
|
|
if (name=="link") {
|
|
Body(elem);
|
|
}
|
|
|
|
// advance to next element
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
|
|
// parse joints
|
|
elem = root->FirstChildElement();
|
|
while (elem) {
|
|
// identify body/link elements
|
|
name = elem->Value();
|
|
if (name=="joint") {
|
|
Joint(elem);
|
|
}
|
|
|
|
// advance to next element
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// parse body/link
|
|
void mjXURDF::Body(XMLElement* body_elem) {
|
|
std::string name, text;
|
|
XMLElement *elem, *temp, *temp1;
|
|
mjCBody* pbody;
|
|
mjCGeom* pgeom;
|
|
|
|
// get body name and pointer to mjCBody
|
|
ReadAttrTxt(body_elem, "name", name, true);
|
|
pbody = (mjCBody*) model->GetWorld()->FindObject(mjOBJ_BODY, name);
|
|
if (!pbody) {
|
|
throw mjXError(body_elem, "URDF body not found"); // SHOULD NOT OCCUR
|
|
}
|
|
|
|
// inertial element: copy into alternative body frame
|
|
if ((elem = FindSubElem(body_elem, "inertial"))) {
|
|
pbody->explicitinertial = true;
|
|
// origin- relative to joint frame for now
|
|
Origin(elem, pbody->ipos, pbody->iquat);
|
|
|
|
// mass
|
|
temp = FindSubElem(elem, "mass", true);
|
|
ReadAttr(temp, "value", 1, &pbody->mass, text, true);
|
|
|
|
// inertia
|
|
temp = FindSubElem(elem, "inertia", true);
|
|
mjCAlternative alt;
|
|
ReadAttr(temp, "ixx", 1, alt.fullinertia+0, text, true);
|
|
ReadAttr(temp, "iyy", 1, alt.fullinertia+1, text, true);
|
|
ReadAttr(temp, "izz", 1, alt.fullinertia+2, text, true);
|
|
ReadAttr(temp, "ixy", 1, alt.fullinertia+3, text, true);
|
|
ReadAttr(temp, "ixz", 1, alt.fullinertia+4, text, true);
|
|
ReadAttr(temp, "iyz", 1, alt.fullinertia+5, text, true);
|
|
|
|
// process inertia
|
|
// lquat = rotation from specified to default (joint/body) inertial frame
|
|
double lquat[4], tmpquat[4];
|
|
const char* altres =
|
|
alt.Set(lquat, pbody->inertia, model->degree, model->euler);
|
|
|
|
// inertia are sometimes 0 in URDF files: ignore error in altres, fix later
|
|
(void) altres;
|
|
|
|
// correct for alignment of full inertia matrix
|
|
mjuu_mulquat(tmpquat, pbody->iquat, lquat);
|
|
mjuu_copyvec(pbody->iquat, tmpquat, 4);
|
|
}
|
|
|
|
// clear body frame; set by joint later
|
|
mjuu_setvec(pbody->pos, 0, 0, 0);
|
|
mjuu_setvec(pbody->quat, 1, 0, 0, 0);
|
|
|
|
// process all visual and geometry elements in order
|
|
float rgba[4] = {-1, 0, 0, 0};
|
|
elem = body_elem->FirstChildElement();
|
|
while (elem) {
|
|
name = elem->Value();
|
|
|
|
// visual element
|
|
if (name=="visual") {
|
|
// parse material
|
|
if ((temp = FindSubElem(elem, "material"))) {
|
|
// if color specified - use directly
|
|
if ((temp1 = FindSubElem(temp, "color"))) {
|
|
ReadAttr(temp1, "rgba", 4, rgba, text, /*required=*/true);
|
|
}
|
|
|
|
// otherwise use material table
|
|
else {
|
|
ReadAttrTxt(temp, "name", name, true);
|
|
int imat = FindName(name, urMat);
|
|
if (imat>=0) {
|
|
std::memcpy(rgba, urRGBA[imat].val, 4*sizeof(float));
|
|
}
|
|
}
|
|
}
|
|
|
|
// create geom if not discarded
|
|
if (!model->discardvisual) {
|
|
pgeom = Geom(elem, pbody, false);
|
|
|
|
// save color
|
|
if (rgba[0]>=0) {
|
|
std::memcpy(pgeom->rgba, rgba, 4*sizeof(float));
|
|
}
|
|
}
|
|
}
|
|
|
|
// collision element
|
|
else if (name=="collision") {
|
|
pgeom = Geom(elem, pbody, true);
|
|
|
|
// use color from last visual
|
|
if (rgba[0]>=0) {
|
|
std::memcpy(pgeom->rgba, rgba, 4*sizeof(float));
|
|
}
|
|
}
|
|
|
|
// advance
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// parse joint
|
|
void mjXURDF::Joint(XMLElement* joint_elem) {
|
|
std::string jntname, name, text;
|
|
XMLElement *elem;
|
|
mjCBody *pbody, *parent;
|
|
mjCJoint *pjoint=0, *pjoint1=0, *pjoint2=0;
|
|
int jointtype;
|
|
|
|
// get type and name
|
|
ReadAttrTxt(joint_elem, "type", text, true);
|
|
jointtype = FindKey(urJoint_map, urJoint_sz, text);
|
|
if (jointtype < 0) {
|
|
throw mjXError(joint_elem, "invalid joint type in URDF joint definition");
|
|
}
|
|
ReadAttrTxt(joint_elem, "name", jntname, true);
|
|
|
|
// get parent, check
|
|
elem = FindSubElem(joint_elem, "parent", true);
|
|
ReadAttrTxt(elem, "link", name, true);
|
|
parent = (mjCBody*) model->GetWorld()->FindObject(mjOBJ_BODY, name);
|
|
if (!parent) { // SHOULD NOT OCCUR
|
|
throw mjXError(elem, "invalid parent name in URDF joint definition");
|
|
}
|
|
|
|
// get child=this, check
|
|
elem = FindSubElem(joint_elem, "child", true);
|
|
ReadAttrTxt(elem, "link", name, true);
|
|
pbody = (mjCBody*) model->GetWorld()->FindObject(mjOBJ_BODY, name);
|
|
if (!pbody) { // SHOULD NOT OCCUR
|
|
throw mjXError(elem, "invalid child name in URDF joint definition");
|
|
}
|
|
|
|
// read origin and axis
|
|
double axis[3] = {1, 0, 0};
|
|
Origin(joint_elem, pbody->pos, pbody->quat);
|
|
if ((elem = FindSubElem(joint_elem, "axis"))) {
|
|
ReadAttr(elem, "xyz", 3, axis, text, /*required=*/true);
|
|
}
|
|
|
|
// create joint (unless fixed)
|
|
double mat[9], quat[4], tmpaxis[3];
|
|
switch (jointtype) {
|
|
case 0: // revolute
|
|
case 1: // continuous
|
|
pjoint = pbody->AddJoint();
|
|
pjoint->name = jntname;
|
|
pjoint->type = mjJNT_HINGE;
|
|
mjuu_setvec(pjoint->pos, 0, 0, 0);
|
|
mjuu_copyvec(pjoint->axis, axis, 3);
|
|
break;
|
|
|
|
case 2: // prismatic
|
|
pjoint = pbody->AddJoint();
|
|
pjoint->name = jntname;
|
|
pjoint->type = mjJNT_SLIDE;
|
|
mjuu_setvec(pjoint->pos, 0, 0, 0);
|
|
mjuu_copyvec(pjoint->axis, axis, 3);
|
|
break;
|
|
|
|
case 3: // fixed- no joint, return
|
|
return;
|
|
|
|
case 4: // floating
|
|
pjoint = pbody->AddJoint();
|
|
pjoint->name = jntname;
|
|
pjoint->type = mjJNT_FREE;
|
|
break;
|
|
|
|
case 5: // planar- construct complex joint
|
|
// make frame with axis = z
|
|
mjuu_z2quat(quat, axis);
|
|
mjuu_quat2mat(mat, quat);
|
|
|
|
// construct slider along x
|
|
pjoint = pbody->AddJoint();
|
|
pjoint->name = jntname + "_TX";
|
|
pjoint->type = mjJNT_SLIDE;
|
|
tmpaxis[0] = mat[0];
|
|
tmpaxis[1] = mat[3];
|
|
tmpaxis[2] = mat[6];
|
|
mjuu_setvec(pjoint->pos, 0, 0, 0);
|
|
mjuu_copyvec(pjoint->axis, tmpaxis, 3);
|
|
|
|
// construct slider along y
|
|
pjoint1 = pbody->AddJoint();
|
|
pjoint1->name = jntname + "_TY";
|
|
pjoint1->type = mjJNT_SLIDE;
|
|
tmpaxis[0] = mat[1];
|
|
tmpaxis[1] = mat[4];
|
|
tmpaxis[2] = mat[7];
|
|
mjuu_setvec(pjoint1->pos, 0, 0, 0);
|
|
mjuu_copyvec(pjoint1->axis, tmpaxis, 3);
|
|
|
|
// construct hinge around z = locaxis
|
|
pjoint2 = pbody->AddJoint();
|
|
pjoint2->name = jntname + "_RZ";
|
|
pjoint2->type = mjJNT_HINGE;
|
|
mjuu_setvec(pjoint2->pos, 0, 0, 0);
|
|
mjuu_copyvec(pjoint2->axis, axis, 3);
|
|
}
|
|
|
|
// dynamics element
|
|
if ((elem = FindSubElem(joint_elem, "dynamics"))) {
|
|
ReadAttr(elem, "damping", 1, &pjoint->damping, text);
|
|
ReadAttr(elem, "friction", 1, &pjoint->frictionloss, text);
|
|
|
|
// copy parameters to all elements of planar joint
|
|
if (pjoint1) {
|
|
pjoint1->damping = pjoint2->damping = pjoint->damping;
|
|
pjoint1->frictionloss = pjoint2->frictionloss = pjoint->frictionloss;
|
|
}
|
|
}
|
|
|
|
// limit element
|
|
if ((elem = FindSubElem(joint_elem, "limit"))) {
|
|
ReadAttr(elem, "lower", 1, pjoint->range, text);
|
|
ReadAttr(elem, "upper", 1, pjoint->range+1, text);
|
|
pjoint->limited = (mjuu_defined(pjoint->range[0]) &&
|
|
mjuu_defined(pjoint->range[1]) &&
|
|
pjoint->range[0] < pjoint->range[1]);
|
|
|
|
// ReadAttr(elem, "velocity", 1, &pjoint->maxvel, text); // no maxvel in MuJoCo
|
|
ReadAttr(elem, "effort", 1, &pjoint->urdfeffort, text);
|
|
} else {
|
|
pjoint->limited = 0;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// parse origin and geometry elements of visual or collision
|
|
mjCGeom* mjXURDF::Geom(XMLElement* geom_elem, mjCBody* pbody, bool collision) {
|
|
XMLElement *elem, *temp;
|
|
std::string text, meshfile;
|
|
|
|
// get geometry element
|
|
elem = FindSubElem(geom_elem, "geometry", true);
|
|
|
|
// add BOX geom, modify type later
|
|
mjCGeom* pgeom = pbody->AddGeom();
|
|
pgeom->name = "";
|
|
pgeom->type = mjGEOM_BOX;
|
|
if (collision) {
|
|
pgeom->contype = 1;
|
|
pgeom->conaffinity = 1;
|
|
} else {
|
|
pgeom->contype = 0;
|
|
pgeom->conaffinity = 0;
|
|
pgeom->group = 1;
|
|
pgeom->density = 0;
|
|
}
|
|
|
|
// box
|
|
if ((temp = FindSubElem(elem, "box"))) {
|
|
ReadAttr(temp, "size", 3, pgeom->size, text, true, true);
|
|
for (int i=0; i<3; i++) {
|
|
pgeom->size[i] /= 2; // MuJoCo uses half-length
|
|
}
|
|
}
|
|
|
|
// cylinder
|
|
else if ((temp = FindSubElem(elem, "cylinder"))) {
|
|
pgeom->type = mjGEOM_CYLINDER;
|
|
ReadAttr(temp, "radius", 1, pgeom->size, text, true, true);
|
|
ReadAttr(temp, "length", 1, pgeom->size+1, text, true, true);
|
|
pgeom->size[1] /= 2; // MuJoCo uses half-length
|
|
}
|
|
|
|
// sphere
|
|
else if ((temp = FindSubElem(elem, "sphere"))) {
|
|
pgeom->type = mjGEOM_SPHERE;
|
|
ReadAttr(temp, "radius", 1, pgeom->size, text, true, true);
|
|
}
|
|
|
|
// capsule
|
|
else if ((temp = FindSubElem(elem, "capsule"))) {
|
|
pgeom->type = mjGEOM_CAPSULE;
|
|
ReadAttr(temp, "radius", 1, pgeom->size, text, true, true);
|
|
ReadAttr(temp, "length", 1, pgeom->size+1, text, true, true);
|
|
pgeom->size[1] /= 2; // MuJoCo uses half-length
|
|
}
|
|
|
|
// mesh
|
|
else if ((temp = FindSubElem(elem, "mesh"))) {
|
|
// set geom type and read mesh attributes
|
|
double meshscale[3] = {1, 1, 1};
|
|
pgeom->type = mjGEOM_MESH;
|
|
ReadAttrTxt(temp, "filename", meshfile, true);
|
|
ReadAttr(temp, "scale", 3, meshscale, text);
|
|
|
|
// strip file name if necessary
|
|
if (model->strippath) {
|
|
meshfile = mjuu_strippath(meshfile);
|
|
}
|
|
|
|
// construct mesh name: always stripped
|
|
std::string meshname = mjuu_strippath(meshfile);
|
|
meshname = mjuu_stripext(meshname);
|
|
|
|
// look for existing mesh
|
|
mjCMesh* pmesh = (mjCMesh*)model->FindObject(mjOBJ_MESH, meshname);
|
|
|
|
// does not exist: create
|
|
if (!pmesh) {
|
|
pmesh = model->AddMesh();
|
|
}
|
|
|
|
// exists with different scale: append name with '1', create
|
|
else if (pmesh->scale[0]!=meshscale[0] ||
|
|
pmesh->scale[1]!=meshscale[1] ||
|
|
pmesh->scale[2]!=meshscale[2]) {
|
|
pmesh = model->AddMesh();
|
|
meshname = meshname + "1";
|
|
}
|
|
|
|
// set fields
|
|
pmesh->file = meshfile;
|
|
pmesh->name = meshname;
|
|
pgeom->mesh = meshname;
|
|
mjuu_copyvec(pmesh->scale, meshscale, 3);
|
|
}
|
|
|
|
else {
|
|
throw mjXError(elem, "visual geometry specification not found");
|
|
}
|
|
|
|
// origin element
|
|
Origin(geom_elem, pgeom->pos, pgeom->quat);
|
|
|
|
return pgeom;
|
|
}
|
|
|
|
|
|
|
|
// parse origin element
|
|
void mjXURDF::Origin(XMLElement* origin_elem, double* pos, double* quat) {
|
|
XMLElement* temp;
|
|
std::string text;
|
|
|
|
// set defaults
|
|
mjuu_setvec(pos, 0, 0, 0);
|
|
mjuu_setvec(quat, 1, 0, 0, 0);
|
|
|
|
// read origin element if present
|
|
if ((temp = FindSubElem(origin_elem, "origin"))) {
|
|
// position
|
|
ReadAttr(temp, "xyz", 3, pos, text);
|
|
|
|
// orientation
|
|
mjCAlternative alt;
|
|
if (ReadAttr(temp, "rpy", 3, alt.euler, text)) {
|
|
alt.Set(quat, 0, 0, "XYZ");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// find body with given name in list, return -1 if not found
|
|
int mjXURDF::FindName(std::string name, std::vector<std::string>& list) {
|
|
for (unsigned int i=0; i<list.size(); i++)
|
|
if (list[i] == name) {
|
|
return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
|
|
|
|
// add name to list, error if name already exists
|
|
void mjXURDF::AddName(std::string name, std::vector<std::string>& list) {
|
|
// make sure name is unique
|
|
if (FindName(name, list)>=0) {
|
|
throw mjXError(0, "repeated URDF name: ", name.c_str());
|
|
}
|
|
|
|
list.push_back(name);
|
|
}
|
|
|
|
|
|
|
|
// add body name to list of URDF bodies, error if name already exists
|
|
void mjXURDF::AddBody(std::string name) {
|
|
// add body name, make sure it is unique
|
|
AddName(name, urName);
|
|
|
|
// add parent and child elements
|
|
urParent.push_back(-1);
|
|
std::vector<int> children;
|
|
children.clear();
|
|
urChildren.push_back(children);
|
|
}
|
|
|
|
|
|
|
|
// add body with given number to the mjCModel tree, process children
|
|
void mjXURDF::AddToTree(int n) {
|
|
// get pointer to parent in mjCModel tree
|
|
mjCBody *parent = 0, *child = 0;
|
|
if (urParent[n]>=0) {
|
|
parent = (mjCBody*) model->GetWorld()->FindObject(mjOBJ_BODY, urName[urParent[n]]);
|
|
|
|
if (!parent)
|
|
throw mjXError(0, "URDF body parent should already be in tree: %s",
|
|
urName[urParent[n]].c_str()); // SHOULD NOT OCCUR
|
|
} else {
|
|
parent = model->GetWorld();
|
|
}
|
|
|
|
// add this body
|
|
if (urName[n] != "world") {
|
|
child = parent->AddBody();
|
|
child->name = urName[n];
|
|
}
|
|
|
|
// add children recursively
|
|
for (int i=0; i<(int)urChildren[n].size(); i++) {
|
|
AddToTree(urChildren[n][i]);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// find all materials recursively
|
|
void mjXURDF::MakeMaterials(XMLElement* elem) {
|
|
std::string name, text;
|
|
XMLElement* color = 0;
|
|
mjRGBA rgba;
|
|
|
|
// process this element
|
|
if (!std::strcmp(elem->Value(), "material")) {
|
|
// make sure material is named
|
|
if (ReadAttrTxt(elem, "name", name)) {
|
|
// make sure name is not already registered
|
|
if (FindName(name, urMat) < 0) {
|
|
// add rgba value if available
|
|
if ((color = FindSubElem(elem, "color"))) {
|
|
ReadAttr(color, "rgba", 4, rgba.val, text, /*required=*/true);
|
|
AddName(name, urMat);
|
|
urRGBA.push_back(rgba);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// process children recursively
|
|
elem = elem->FirstChildElement();
|
|
while (elem) {
|
|
MakeMaterials(elem);
|
|
elem = elem->NextSiblingElement();
|
|
}
|
|
}
|