天天看点

【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

1、简介

官网地址:

https://www.khronos.org/gltf/

glTF格式本质上是一个JSON文件。. 这一文件描述了整个3D场景的内容。. 它包含了对场景结构进行描述的场景图。. 场景中的3D对象通过场景结点引用网格进行定义。. 材质定义了3D对象的外观,动画定义了3D对象的变换操作 (比如选择、平移操作)。

2、libgltf (v2.0, C++)

https://github.com/code4game/libgltf

libgltf:glTF 2.0 parser/loader for C++11, supports many extensions

likes

KHR_draco_mesh_compression

,

KHR_lights_punctual

,

KHR_materials_clearcoat

, and more.

<font color=blue size=5> 注意:目前该库仅支持glTF 2.0格式。

它的编译依赖库不不不需要额外下载。

【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

2.1 下载和编译

cd C:\Users\tomcat\Desktop\test
git clone https://github.com/code4game/libgltf.git
cd libgltf
git submodule update --init
mkdir bin
cd bin
cmake ..
## or 
cmake -G "Visual Studio 15 2017" .. -A x64
           
  • 从github上下载libgltf的源代码,如下图所示:
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
  • 下载libgltf的源代码的文件夹,如下所示:
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
  • 通过cmake生成vs2017的工程文件。
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
  • vs2017打开上面生成的工程文件,编译生成libgltf的库文件,如下图所示:
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

2.2 nlohmann/json库

https://github.com/nlohmann/json

JSON for Modern C++
【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

代码示例如下:

// write a JSON file

// create an empty structure (null)
json j;

// add a number that is stored as double (note the implicit conversion of j to an object)
j["pi"] = 3.141;

// add a Boolean that is stored as bool
j["happy"] = true;

// add a string that is stored as std::string
j["name"] = "Niels";

// add another null object by passing nullptr
j["nothing"] = nullptr;

// add an object inside the object
j["answer"]["everything"] = 42;

// add an array that is stored as std::vector (using an initializer list)
j["list"] = { 1, 0, 2 };

// add another object (using an initializer list of pairs)
j["object"] = { {"currency", "USD"}, {"value", 42.99} };

// instead, you could also write (which looks very similar to the JSON above)
json j2 = {
  {"pi", 3.141},
  {"happy", true},
  {"name", "Niels"},
  {"nothing", nullptr},
  {"answer", {
    {"everything", 42}
  }},
  {"list", {1, 0, 2}},
  {"object", {
    {"currency", "USD"},
    {"value", 42.99}
  }}
};
           
// read a JSON file
std::ifstream i("file.json");
json j;
i >> j;

// write prettified JSON to another file
std::ofstream o("pretty.json");
o << std::setw(4) << j << std::endl;
           

2.3 官网代码示例1

std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(/*your gltf file*/);
std::shared_ptr<libgltf::SGlTF> loaded_gltf = gltf_loader->glTF().lock();
if (!loaded_gltf)
{
    printf("failed to load your gltf file");
}
           

2.4 官网代码示例2

#include "runtest.h"
#include <libgltf/libgltf_ext.h>

#include <string>
#include <sstream>
#include <fstream>

#if defined(LIBGLTF_PLATFORM_WINDOWS)
#include <tchar.h>
#include <crtdbg.h>
#endif

#if defined(LIBGLTF_CHARACTOR_ENCODING_IS_UNICODE) && defined(LIBGLTF_PLATFORM_WINDOWS)
int _tmain(int _iArgc, wchar_t* _pcArgv[])
#else
int main(int _iArgc, char* _pcArgv[])
#endif
{
#if defined(LIBGLTF_PLATFORM_WINDOWS) && defined(_DEBUG)
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
#endif

#if defined(LIBGLTF_BUILD_COVERAGE)
    int error_code = 0;
#else
    int error_code = 1;
#endif

#if defined(LIBGLTF_CHARACTOR_ENCODING_IS_UNICODE) && defined(LIBGLTF_PLATFORM_WINDOWS)
    std::wstring input_file_path;
#else
    std::string input_file_path;
#endif

    {
#if defined(LIBGLTF_CHARACTOR_ENCODING_IS_UNICODE) && defined(LIBGLTF_PLATFORM_WINDOWS)
        std::wstringstream argument;
#else
        std::stringstream argument;
#endif
        argument << _pcArgv[1];
        input_file_path = argument.str();
    }

    if (input_file_path.length() == 0)
    {
        printf("Command line format: runtest input_file_path\n");
        return error_code;
    }

#if defined(LIBGLTF_CHARACTOR_ENCODING_IS_UTF16)
    std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(libgltf::UTF8ToUTF16(input_file_path));
#elif defined(LIBGLTF_CHARACTOR_ENCODING_IS_UTF32)
    std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(libgltf::UTF8ToUTF32(input_file_path));
#elif defined(LIBGLTF_CHARACTOR_ENCODING_IS_UNICODE)
#if defined(LIBGLTF_PLATFORM_WINDOWS)
    std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(input_file_path);
#else
    std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(libgltf::UTF8ToUNICODE(input_file_path));
#endif
#else
    std::shared_ptr<libgltf::IglTFLoader> gltf_loader = libgltf::IglTFLoader::Create(input_file_path);
#endif
    
    std::shared_ptr<libgltf::SGlTF> loaded_gltf = gltf_loader->glTF().lock();
    if (loaded_gltf)
    {
        printf("operator << Success\n");
    }
    else
    {
        printf("operator << Failed\n");
        return error_code;
    }

	int num_node = loaded_gltf->nodes.size();
	int num_mesh = loaded_gltf->meshes.size();
	const std::shared_ptr<libgltf::SMesh>& mesh = loaded_gltf->meshes[0];
	int num_pri = mesh->primitives.size();

    libgltf::TDimensionVector<1, size_t> triangle_data;
    std::shared_ptr<libgltf::TAccessorStream<libgltf::TDimensionVector<1, size_t> > > triangle_stream = std::make_shared<libgltf::TAccessorStream<libgltf::TDimensionVector<1, size_t> > >(triangle_data);
    gltf_loader->GetOrLoadMeshPrimitiveIndicesData(0, 0, triangle_stream);

    libgltf::TDimensionVector<3, float> position_data;
    std::shared_ptr<libgltf::TAccessorStream<libgltf::TDimensionVector<3, float> > > position_stream = std::make_shared<libgltf::TAccessorStream<libgltf::TDimensionVector<3, float> > >(position_data);
    gltf_loader->GetOrLoadMeshPrimitiveAttributeData(0, 0, GLTFTEXT("position"), position_stream);

    libgltf::TDimensionVector<3, float> normal_data;
    std::shared_ptr<libgltf::TAccessorStream<libgltf::TDimensionVector<3, float> > > normal_stream = std::make_shared<libgltf::TAccessorStream<libgltf::TDimensionVector<3, float> > >(normal_data);
    gltf_loader->GetOrLoadMeshPrimitiveAttributeData(0, 0, GLTFTEXT("normal"), normal_stream);

    libgltf::TDimensionVector<2, float> texcoord_0_data;
    std::shared_ptr<libgltf::TAccessorStream<libgltf::TDimensionVector<2, float> > > texcoord_0_stream = std::make_shared<libgltf::TAccessorStream<libgltf::TDimensionVector<2, float> > >(texcoord_0_data);
    gltf_loader->GetOrLoadMeshPrimitiveAttributeData(0, 0, GLTFTEXT("texcoord_0"), texcoord_0_stream);

    std::vector<uint8_t> image0_data;
    libgltf::string_t image0_data_type;
    gltf_loader->GetOrLoadImageData(0, image0_data, image0_data_type);

    //TODO: just convert to json, save the mesh or image data to file in future
    libgltf::string_t output_content;
    if (loaded_gltf >> output_content)
    {
        printf("nodes: %d\n", num_node);
		printf("meshes: %d\n", num_mesh);
		printf("meshes[0]'s primitives: %d\n", num_pri);

		printf("triangle_data: %d\n", triangle_data.size());
		printf("position_data: %d\n", position_data.size());
		printf("normal_data: %d\n", normal_data.size());
		printf("texcoord_0_data: %d\n", texcoord_0_data.size());
		printf("image0_data: %d\n", image0_data.size());
        printf("operator >> Success\n");
    }
    else
    {
        printf("operator >> Failed\n");
        return error_code;
    }

    return 0;
}
           

加载三维模型文件:C:\Users\tomcat\Desktop\dtiles_3\BlockBAB\BlockBAB.glb

【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

运行结果如下:

【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

2.5 自己测试代码

//***********************************************************************
//   Purpose:   在libgltf代码中增加读取b3dm文件格式的接口
//   Author:    爱看书的小沐
//   Date:      2022-4-19
//   Languages: C++
//   Platform:  Visual Studio 2017
//   OS:        Win10 win64
// ***********************************************************************

int CFileLoader::ReadB3dmHeader(const string_t& _sFilePath) const
{
	float RTC_CENTER[3] = { 0,0,0 };
	bool RTC_CENTER_enable = false;

	const std::vector<uint8_t>& file_data = (*this)[_sFilePath];
	if (file_data.empty()) return 0;
	int header[7];
	::memcpy(header, file_data.data(), 7*sizeof(int));

	int len_featureTable = header[3];
	if (len_featureTable > 20) {
		char *buffer = new char[len_featureTable + 1];
		::memcpy(buffer, file_data.data() + 7 * sizeof(int), len_featureTable * sizeof(char));
		buffer[len_featureTable] = '\0';
		auto j = json::parse(buffer);
		
		bool ret = j.contains(json::json_pointer("/RTC_CENTER"));
		if (ret) {
			auto center = j["RTC_CENTER"];
			RTC_CENTER[0] = center[0];
			RTC_CENTER[1] = center[1];
			RTC_CENTER[2] = center[2];
			RTC_CENTER_enable = true;
		}
		else {
			RTC_CENTER[0] = 0;
			RTC_CENTER[1] = 0;
			RTC_CENTER[2] = 0;
			RTC_CENTER_enable = false;
		}

		delete[] buffer;
	}
	return 7*sizeof(int)+ header[3] + header[4] + header[5] + header[6];
}
           

3、assimp (v1.0, v2.0, C++)

https://github.com/assimp/assimp

https://assimp-docs.readthedocs.io/en/v5.1.0/

  • 目前assimp库支持如下模型格式的导入:
3D Manufacturing Format (.3mf)
Collada (.dae, .xml)
Blender (.blend)
Biovision BVH (.bvh)
3D Studio Max 3DS (.3ds)
3D Studio Max ASE (.ase)
glTF (.glTF)
glTF2.0 (.glTF)
FBX-Format, as ASCII and binary (.fbx)
Stanford Polygon Library (.ply)
AutoCAD DXF (.dxf)
IFC-STEP (.ifc)
Neutral File Format (.nff)
Sense8 WorldToolkit (.nff)
Valve Model (.smd, .vta)
Quake I (.mdl)
Quake II (.md2)
Quake III (.md3)
Quake 3 BSP (.pk3)
RtCW (.mdc)
Doom 3 (.md5mesh, .md5anim, .md5camera)
DirectX X (.x)
Quick3D (.q3o, .q3s)
Raw Triangles (.raw)
AC3D (.ac, .ac3d)
Stereolithography (.stl)
Autodesk DXF (.dxf)
Irrlicht Mesh (.irrmesh, .xml)
Irrlicht Scene (.irr, .xml)
Object File Format ( .off )
Wavefront Object (.obj)
Terragen Terrain ( .ter )
3D GameStudio Model ( .mdl )
3D GameStudio Terrain ( .hmp )
Ogre ( .mesh.xml, .skeleton.xml, .material )
OpenGEX-Fomat (.ogex)
Milkshape 3D ( .ms3d )
LightWave Model ( .lwo )
LightWave Scene ( .lws )
Modo Model ( .lxo )
CharacterStudio Motion ( .csm )
Stanford Ply ( .ply )
TrueSpace (.cob, .scn)
XGL-3D-Format (.xgl)
           

3.1 下载和编译

3.2 官网代码示例

  • 代码示例:这是一段加载gltf模型文件的代码。
#include <stdlib.h>
#include <stdio.h>

/* assimp include files. These three are usually needed. */
#include <assimp/cimport.h>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

#pragma comment(lib, "..\\..\\lib\\Debug\\assimp-vc141-mtd.lib")
#pragma comment(lib, "..\\..\\contrib\\zlib\\Debug\\zlibstaticd.lib")

/* ---------------------------------------------------------------------------- */
inline static void print_error(const char* msg) {
	printf("ERROR: %s\n", msg);
}

#define NEW_LINE "\n"
#define DOUBLE_NEW_LINE NEW_LINE NEW_LINE

/* the global Assimp scene object */
const C_STRUCT aiScene* scene = NULL;
C_STRUCT aiVector3D scene_min, scene_max, scene_center;

/* current rotation angle */
static float angle = 0.f;

#define aisgl_min(x,y) (x<y?x:y)
#define aisgl_max(x,y) (y>x?y:x)

/* ---------------------------------------------------------------------------- */
void get_bounding_box_for_node (const C_STRUCT aiNode* nd,
	C_STRUCT aiVector3D* min,
	C_STRUCT aiVector3D* max,
	C_STRUCT aiMatrix4x4* trafo
){
	C_STRUCT aiMatrix4x4 prev;
	unsigned int n = 0, t;

	prev = *trafo;
	aiMultiplyMatrix4(trafo,&nd->mTransformation);

	for (; n < nd->mNumMeshes; ++n) {
		const C_STRUCT aiMesh* mesh = scene->mMeshes[nd->mMeshes[n]];
		for (t = 0; t < mesh->mNumVertices; ++t) {

			C_STRUCT aiVector3D tmp = mesh->mVertices[t];
			aiTransformVecByMatrix4(&tmp,trafo);

			min->x = aisgl_min(min->x,tmp.x);
			min->y = aisgl_min(min->y,tmp.y);
			min->z = aisgl_min(min->z,tmp.z);

			max->x = aisgl_max(max->x,tmp.x);
			max->y = aisgl_max(max->y,tmp.y);
			max->z = aisgl_max(max->z,tmp.z);
		}
	}

	for (n = 0; n < nd->mNumChildren; ++n) {
		get_bounding_box_for_node(nd->mChildren[n],min,max,trafo);
	}
	*trafo = prev;
}

/* ---------------------------------------------------------------------------- */
void get_bounding_box(C_STRUCT aiVector3D* min, C_STRUCT aiVector3D* max)
{
	C_STRUCT aiMatrix4x4 trafo;
	aiIdentityMatrix4(&trafo);

	min->x = min->y = min->z =  1e10f;
	max->x = max->y = max->z = -1e10f;
	get_bounding_box_for_node(scene->mRootNode,min,max,&trafo);
}

/* ---------------------------------------------------------------------------- */
void color4_to_float4(const C_STRUCT aiColor4D *c, float f[4])
{
	f[0] = c->r;f[1] = c->g;f[2] = c->b;f[3] = c->a;
}

/* ---------------------------------------------------------------------------- */
void set_float4(float f[4], float a, float b, float c, float d)
{
	f[0] = a;f[1] = b;f[2] = c;f[3] = d;
}

/* ---------------------------------------------------------------------------- */
void apply_material(const C_STRUCT aiMaterial *mtl)
{
	float c[4];

	int fill_mode;
	int ret1, ret2;
	C_STRUCT aiColor4D diffuse;
	C_STRUCT aiColor4D specular;
	C_STRUCT aiColor4D ambient;
	C_STRUCT aiColor4D emission;
	ai_real shininess, strength;
	int two_sided;
	int wireframe;
	unsigned int max;

	set_float4(c, 0.8f, 0.8f, 0.8f, 1.0f);
	if (AI_SUCCESS == aiGetMaterialColor(mtl, AI_MATKEY_COLOR_DIFFUSE, &diffuse))
		color4_to_float4(&diffuse, c);
	printf("	GL_DIFFUSE: %.3f, %.3f, %.3f, %.3f\n", c[0], c[1], c[2], c[3]);

	set_float4(c, 0.0f, 0.0f, 0.0f, 1.0f);
	if (AI_SUCCESS == aiGetMaterialColor(mtl, AI_MATKEY_COLOR_SPECULAR, &specular))
		color4_to_float4(&specular, c);
	printf("	GL_SPECULAR: %.3f, %.3f, %.3f, %.3f\n", c[0], c[1], c[2], c[3]);

	set_float4(c, 0.2f, 0.2f, 0.2f, 1.0f);
	if (AI_SUCCESS == aiGetMaterialColor(mtl, AI_MATKEY_COLOR_AMBIENT, &ambient))
		color4_to_float4(&ambient, c);
	printf("	GL_AMBIENT: %.3f, %.3f, %.3f, %.3f\n", c[0], c[1], c[2], c[3]);

	set_float4(c, 0.0f, 0.0f, 0.0f, 1.0f);
	if (AI_SUCCESS == aiGetMaterialColor(mtl, AI_MATKEY_COLOR_EMISSIVE, &emission))
		color4_to_float4(&emission, c);
	printf("	GL_EMISSION:%.3f, %.3f, %.3f, %.3f\n", c[0], c[1], c[2], c[3]);

	max = 1;
	ret1 = aiGetMaterialFloatArray(mtl, AI_MATKEY_SHININESS, &shininess, &max);
	if (ret1 == AI_SUCCESS) {
		max = 1;
		ret2 = aiGetMaterialFloatArray(mtl, AI_MATKEY_SHININESS_STRENGTH, &strength, &max);
		if (ret2 == AI_SUCCESS)
			printf("	GL_SHININESS: %.3f\n", shininess * strength);
		else
			printf("	GL_SHININESS: %.3f\n", shininess);
	}

	max = 1;
	if (AI_SUCCESS == aiGetMaterialIntegerArray(mtl, AI_MATKEY_ENABLE_WIREFRAME, &wireframe, &max))
		printf("	fill_mode: %s\n", wireframe ? "GL_LINE" : "GL_FILL");

	max = 1;
	if ((AI_SUCCESS == aiGetMaterialIntegerArray(mtl, AI_MATKEY_TWOSIDED, &two_sided, &max)) && two_sided)
		printf("	two_sided: true\n");
}

/* ---------------------------------------------------------------------------- */
void recursive_render (const C_STRUCT aiScene *sc, const C_STRUCT aiNode* nd)
{
	unsigned int i;
	unsigned int n = 0, t;
	C_STRUCT aiMatrix4x4 m = nd->mTransformation;

	/* update transform */
	aiTransposeMatrix4(&m);

	/* draw all meshes assigned to this node */
	printf("Node(name: %s, mNumMeshes: %d)\n", nd->mName.data, nd->mNumMeshes);
	for (; n < nd->mNumMeshes; ++n) {
		const C_STRUCT aiMesh* mesh = scene->mMeshes[nd->mMeshes[n]];

		apply_material(sc->mMaterials[mesh->mMaterialIndex]);

		printf("	mNumFaces(%d): %d\n", n, mesh->mNumFaces);
		for (t = 0; t < mesh->mNumFaces; ++t) {
			const C_STRUCT aiFace* face = &mesh->mFaces[t];

			//1:GL_POINTS, 2:GL_LINES, 3:GL_TRIANGLES, 0:GL_POLYGON
			int face_mode = face->mNumIndices;

			for(i = 0; i < face->mNumIndices; i++) {
				int index = face->mIndices[i];

				//if(mesh->mColors[0] != NULL)
				//	glColor4fv((GLfloat*)&mesh->mColors[0][index]);
				//if(mesh->mNormals != NULL)
				//	glNormal3fv(&mesh->mNormals[index].x);
				//glVertex3fv(&mesh->mVertices[index].x);
			}
		}
	}

	/* draw all children */
	for (n = 0; n < nd->mNumChildren; ++n) {
		recursive_render(sc, nd->mChildren[n]);
	}
}

/* ---------------------------------------------------------------------------- */
int loadasset (const char* path)
{
	/* we are taking one of the postprocessing presets to avoid
	   spelling out 20+ single postprocessing flags here. */
	scene = aiImportFile(path,aiProcessPreset_TargetRealtime_MaxQuality);

	if (scene) {
		get_bounding_box(&scene_min,&scene_max);
		scene_center.x = (scene_min.x + scene_max.x) / 2.0f;
		scene_center.y = (scene_min.y + scene_max.y) / 2.0f;
		scene_center.z = (scene_min.z + scene_max.z) / 2.0f;

		printf("scene_center: (%.3f, %.3f, %.3f)\n"
			, scene_center.x, scene_center.y, scene_center.z);
		return 0;
	}
	return 1;
}

/* ---------------------------------------------------------------------------- */
int main(int argc, char **argv)
{
	const char* model_file = NULL;
	C_STRUCT aiLogStream stream;

	// Check and validate the specified model file extension.
	model_file = "D:\\test_3dxml\\Duck1.glb";
	const char* extension = strrchr(model_file, '.');
	if (!extension) {
		print_error("Please provide a file with a valid extension.");
		return EXIT_FAILURE;
	}

	if (AI_FALSE == aiIsExtensionSupported(extension)) {
		print_error("The specified model file extension is currently "
			"unsupported in Assimp " ASSIMP_VERSION ".");
		return EXIT_FAILURE;
	}

	stream = aiGetPredefinedLogStream(aiDefaultLogStream_STDOUT,NULL);
	aiAttachLogStream(&stream);
	stream = aiGetPredefinedLogStream(aiDefaultLogStream_FILE,"assimp_log.txt");
	aiAttachLogStream(&stream);

	// Load the model file.
	if(0 != loadasset(model_file)) {
		print_error("Failed to load model. Please ensure that the specified file exists.");
		aiDetachAllLogStreams();
		return EXIT_FAILURE;
	}

	// Print the model data.
	recursive_render(scene, scene->mRootNode);

	// Relese
	aiReleaseImport(scene);
	aiDetachAllLogStreams();
	return EXIT_SUCCESS;
}
           
  • 运行结果如下:

    (1)加载gltf1.0的模型文件:Duck1.glb

    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语
    (2)加载gltf2.0的模型文件:Avocado2.glb
    【CAD开发】glTF和b3dm文件格式读取二(C++,libgltf )1、简介2、libgltf (v2.0, C++)3、assimp (v1.0, v2.0, C++)结语

结语