Created
August 12, 2012 17:11
-
-
Save iKlsR/3333055 to your computer and use it in GitHub Desktop.
hrrmm code from irc
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#include "model.hpp" | |
#include "global.hpp" | |
#include <sys/stat.h> | |
#include <glm/gtx/integer.hpp> | |
#include <glm/gtc/quaternion.hpp> | |
#include <glm/gtx/quaternion.hpp> | |
#include <assimp/postprocess.h> | |
#include <assimp/scene.h> | |
#include <sstream> | |
#include <vector> | |
#include <map> | |
Model::Model(): | |
normal_offset(0), | |
texture_offset(0), | |
tangent_offset(0) | |
{} | |
Model::Model(Model&& m) : | |
vao(std::move(m.vao)), | |
vbo(std::move(m.vbo)), | |
ibo(std::move(m.ibo)), | |
normal_offset(m.normal_offset), | |
texture_offset(m.texture_offset), | |
tangent_offset(m.tangent_offset), | |
meshes(std::move(m.meshes)), | |
animations(std::move(m.animations)), | |
materials(std::move(m.materials)), | |
scenes(std::move(m.scenes)), | |
bounding_boxes(std::move(m.bounding_boxes)), | |
bounding_box_vbo(std::move(m.bounding_box_vbo)), | |
bounding_box_vao(std::move(m.bounding_box_vao)) | |
{ | |
} | |
Model& Model::operator=(Model &&m) | |
{ | |
vao = std::move(m.vao); | |
vbo = std::move(m.vbo); | |
ibo = std::move(m.ibo); | |
normal_offset = m.normal_offset; | |
texture_offset = m.texture_offset; | |
tangent_offset = m.tangent_offset; | |
meshes = std::move(m.meshes); | |
animations = std::move(m.animations); | |
materials = std::move(m.materials); | |
scenes = std::move(m.scenes); | |
bounding_boxes = std::move(m.bounding_boxes); | |
bounding_box_vbo = std::move(m.bounding_box_vbo); | |
bounding_box_vao = std::move(m.bounding_box_vao); | |
hit_volume = std::move(m.hit_volume); | |
return *this; | |
} | |
//returns textures that are used by the model | |
std::vector<std::string> Model::initialize(const std::string& filename) | |
{ | |
Assimp::Importer importer; | |
std::ifstream file(filename); | |
if(!file.is_open()){ | |
std::cerr << "Model not found " << filename << std::endl; | |
assert(file.is_open()); | |
return std::vector<std::string>(); | |
} | |
file.close(); | |
auto scene = importer.ReadFile(filename, aiProcessPreset_TargetRealtime_Quality); | |
if(scene == nullptr){ | |
std::cerr << "Failed to load model " << filename << std::endl | |
<< importer.GetErrorString() << std::endl; | |
return std::vector<std::string>(); | |
} | |
//if reloading fails after this, the model will break and the rest is UB | |
meshes.clear(); | |
animations.clear(); | |
materials.clear(); | |
scenes.clear(); | |
bounding_boxes.clear(); | |
recursive_extract_scene_formation(scene->mRootNode,aiMatrix4x4(),std::vector<glm::mat4>()); | |
load_meshes(*scene); | |
load_animations(*scene); | |
auto required_textures = load_materials(*scene); | |
remove_boundingboxes_from_scene_data(); | |
vao.initialize(); | |
vao.bind(); | |
ibo.bind(); | |
vbo.bind(); | |
glEnableVertexAttribArray(0); | |
glEnableVertexAttribArray(1); | |
glEnableVertexAttribArray(2); | |
glEnableVertexAttribArray(3); | |
glEnableVertexAttribArray(4); | |
glVertexAttribPointer(0,3,GL_FLOAT,false,0,nullptr); //Vertex | |
glVertexAttribPointer(1,3,GL_FLOAT,false,0,normal_offset); //Normal | |
glVertexAttribPointer(2,3,GL_FLOAT,false,0,texture_offset); //Texture | |
glVertexAttribPointer(3,3,GL_FLOAT,false,0,tangent_offset); //Tangent | |
glVertexAttribPointer(4,3,GL_FLOAT,false,0,bitangent_offset); //Tangent | |
//vao.unbind(); | |
bounding_box_vao.initialize(); | |
bounding_box_vao.bind(); | |
bounding_box_vbo.bind(); | |
glEnableVertexAttribArray(0); | |
glVertexAttribPointer(0,3,GL_FLOAT,false,0,nullptr); | |
bounding_box_vao.unbind(); | |
return required_textures; | |
} | |
std::vector<Path> Model::load_path_only(const std::string& filename) | |
{ | |
assert(filename.size() > 4); | |
std::string file_type = filename.substr(filename.size()-3); | |
assert(file_type == "obj"); | |
auto pathes = fast_obj_path_load(filename); | |
return pathes; | |
} | |
Heightmap Model::load_heightmap_only(const std::string& filename) | |
{ | |
assert(filename.size() > 4); | |
std::string file_type = filename.substr(filename.size()-3); | |
assert(file_type == "obj"); | |
auto vertices = fast_obj_heightmap_load("models/"+filename); | |
std::sort(vertices.begin(),vertices.end(),[] | |
(const glm::vec3& a, const glm::vec3& b) | |
{ | |
return int(a.z+0.5) < int(b.z+0.5); | |
}); | |
std::stable_sort(vertices.begin(),vertices.end(),[] | |
(const glm::vec3& a, const glm::vec3& b) | |
{ | |
return int(a.x+0.5) < int(b.x+0.5); | |
}); | |
std::vector<float> vertices2; | |
vertices2.reserve(vertices.size()); | |
for(auto iter = vertices.begin();iter != vertices.end();++iter) | |
vertices2.push_back(iter->y); | |
unsigned int height_samples_width = glm::sqrt(vertices2.size()); | |
unsigned int height_samples_depth = height_samples_width; | |
assert(height_samples_width*height_samples_depth == vertices2.size()); | |
BoundingBox bb = calculate_bounding_box(vertices.begin(),vertices.end(),glm::mat4()); | |
float real_width = bb.max.x-bb.min.x; | |
float real_depth = bb.max.z-bb.min.z; | |
float width_scale = real_width/height_samples_width; | |
float depth_scale = real_depth/height_samples_depth; | |
return Heightmap(vertices2, height_samples_width, height_samples_depth, width_scale, depth_scale); | |
} | |
void Model::update_animations() | |
{ | |
for(auto iter = animations.begin();iter != animations.end();++iter) | |
iter->update(); | |
} | |
std::vector<BoundingBox> Model::get_bounding_boxes()const | |
{ | |
return bounding_boxes; | |
} | |
HitVolume Model::get_hit_volume()const | |
{ | |
return hit_volume; | |
} | |
void Model::render_bounding_boxes(const Shader& shader)const | |
{ | |
shader["transformation"] = glm::mat4(); | |
bounding_box_vao.bind(); | |
int index=0; | |
for(auto iter = bounding_boxes.begin();iter != bounding_boxes.end();++iter){ | |
//shader["transformation"] = iter->transformation; | |
glDrawArrays(GL_LINE_LOOP,index*24,19); | |
++index; | |
} | |
bounding_box_vao.unbind(); | |
} | |
void Model::render(const Shader& shader, const TextureManager& texture_manager, const glm::mat4& transformation)const | |
{ | |
vao.bind(); | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
const Animation* animation = get_possible_animation(iter->name); | |
if(animation != nullptr){ | |
glm::mat4 animation_transformation = animation->get_transformation(); | |
glm::mat4 user_transformation = transformation; | |
shader["transformation"] = user_transformation * animation_transformation * iter->root_transformation; | |
shader["inverse_transpose_transformation"] = glm::transpose(glm::inverse(user_transformation * animation_transformation * iter->root_transformation)); | |
} | |
else{ | |
shader["transformation"] = transformation * iter->global_transformation; | |
shader["inverse_transpose_transformation"] = glm::transpose(glm::inverse(transformation * iter->global_transformation)); | |
} | |
for(auto iter2 = iter->meshes.begin();iter2 != iter->meshes.end();++iter2){ | |
const Mesh& mesh = meshes[*iter2]; | |
const Material& material = materials[mesh.material_index]; | |
texture_manager.get_texture(material.diffuse_texture_name).bind(); | |
glDrawElements(GL_TRIANGLES,mesh.count,GL_UNSIGNED_INT,(void*)(mesh.begin*sizeof(unsigned int))); | |
} | |
} | |
vao.unbind(); | |
} | |
void Model::fix_scene_mesh_indexes_after_changing_scene(std::vector<Scene>::iterator after, std::size_t fix) | |
{ | |
for(auto iter = after;iter != scenes.end();++iter) | |
for(auto iter2 = iter->meshes.begin();iter2 != iter->meshes.end();++iter2) | |
*iter2 -= fix; | |
} | |
void Model::remove_boundingboxes_from_scene_data() | |
{ | |
for(auto iter=scenes.begin();iter != scenes.end();){ | |
if(is_bounding_box_mesh_name(iter->name) || is_hit_volume_mesh_name(iter->name)){ | |
std::size_t deleted_meshes = iter->meshes.size(); | |
fix_scene_mesh_indexes_after_changing_scene(iter+1, deleted_meshes); | |
iter = scenes.erase(iter); | |
} | |
else | |
++iter; | |
} | |
} | |
bool Model::operator ==(const Model& m)const | |
{ | |
if(m.scenes.size() == scenes.size()){ | |
for(std::size_t i=0;i<scenes.size();++i) | |
if(m.scenes[i].name != m.scenes[i].name) | |
return false; | |
return true; | |
} | |
return false; | |
} | |
void Model::recursive_extract_scene_formation(const aiNode* node, aiMatrix4x4 transformation, std::vector<glm::mat4> transformations) | |
{ | |
Scene scene; | |
scene.root_transformation = glm::transpose(glm::mat4(transformation.a1, transformation.a2, transformation.a3, transformation.a4, | |
transformation.b1, transformation.b2, transformation.b3, transformation.b4, | |
transformation.c1, transformation.c2, transformation.c3, transformation.c4, | |
transformation.d1, transformation.d2, transformation.d3, transformation.d4)); | |
transformation = transformation*node->mTransformation; | |
scene.name = std::string(node->mName.data); | |
scene.global_transformation = glm::transpose(glm::mat4(transformation.a1, transformation.a2, transformation.a3, transformation.a4, | |
transformation.b1, transformation.b2, transformation.b3, transformation.b4, | |
transformation.c1, transformation.c2, transformation.c3, transformation.c4, | |
transformation.d1, transformation.d2, transformation.d3, transformation.d4)); | |
scene.local_transformation = glm::transpose(glm::mat4(node->mTransformation.a1, node->mTransformation.a2, node->mTransformation.a3, node->mTransformation.a4, | |
node->mTransformation.b1, node->mTransformation.b2, node->mTransformation.b3, node->mTransformation.b4, | |
node->mTransformation.c1, node->mTransformation.c2, node->mTransformation.c3, node->mTransformation.c4, | |
node->mTransformation.d1, node->mTransformation.d2, node->mTransformation.d3, node->mTransformation.d4)); | |
transformations.push_back(scene.local_transformation); | |
scene.transformations = transformations; | |
for(unsigned int i=0;i<node->mNumMeshes;++i) | |
scene.meshes.push_back(node->mMeshes[i]); | |
if(!scene.meshes.empty()) | |
scenes.push_back(scene); | |
for(unsigned int i=0;i<node->mNumChildren;++i) | |
recursive_extract_scene_formation(node->mChildren[i], transformation, transformations); | |
} | |
void Model::load_animations(const aiScene& scene) | |
{ | |
for(unsigned int i=0;i<scene.mNumAnimations;++i){ | |
aiAnimation& animation = *scene.mAnimations[i]; | |
for(unsigned int j=0;j<animation.mNumChannels;++j){ | |
Animation animation_; | |
aiNodeAnim& channel = *animation.mChannels[j]; | |
animation_.initialize(channel, static_cast<float>(animation.mTicksPerSecond)); | |
animations.push_back(animation_); | |
} | |
} | |
} | |
std::vector<std::string> Model::load_materials(const aiScene& scene) | |
{ | |
std::vector<std::string> required_textures; | |
for(unsigned int i=0;i<scene.mNumMaterials;++i){ | |
auto& material = *scene.mMaterials[i]; | |
aiString texture_name; | |
material.GetTexture(aiTextureType_DIFFUSE,0,&texture_name); | |
std::string diffuse_texture_name = std::string(texture_name.data); | |
if(!diffuse_texture_name.empty()){ | |
required_textures.push_back("textures/"+remove_absolute_address(Global::to_lower(diffuse_texture_name))); | |
materials.push_back(Material("textures/"+remove_absolute_address(Global::to_lower(diffuse_texture_name)))); | |
} | |
else{ | |
required_textures.push_back("textures/default.png"); | |
materials.push_back(Material("textures/default.png")); | |
} | |
} | |
return required_textures; | |
} | |
std::string Model::remove_absolute_address(const std::string &file_name) | |
{ | |
std::size_t last_slash = file_name.find_last_of('/'); | |
if(last_slash == std::string::npos) | |
last_slash = file_name.find_last_of('\\'); | |
if(last_slash != std::string::npos) | |
return file_name.substr(last_slash+1); | |
return file_name; | |
} | |
void Model::load_meshes(const aiScene& scene) | |
{ | |
int indice_count = count_total_indice_count(scene); | |
int vertice_count = count_total_vertice_count(scene); | |
std::vector<unsigned int> indices; | |
std::vector<aiVector3D> vertices; | |
indices.reserve(indice_count); | |
vertices.resize(vertice_count); | |
int current_vertice_offset = 0; | |
int current_normal_offset = vertice_count/5; | |
int current_texture_offset = vertice_count/5*2; | |
int current_tangent_offset = vertice_count/5*3; | |
int current_bitangent_offset = vertice_count/5*4; | |
int current_indice_offset = 0; | |
for(unsigned int i=0;i<scene.mNumMeshes;++i){ | |
auto& mesh = *scene.mMeshes[i]; | |
if(is_bounding_box_mesh(i)){ | |
bounding_boxes.push_back(calculate_bounding_box(mesh.mVertices, mesh.mVertices+mesh.mNumVertices, get_mesh_transformation(i))); | |
} | |
else if(is_hit_volume_mesh(i)){ | |
if(is_hit_box_mesh(i)) | |
hit_volume.hit_boxes.push_back(calculate_bounding_box(mesh.mVertices, mesh.mVertices+mesh.mNumVertices, get_mesh_transformation(i))); | |
else if(is_hit_sphere_mesh(i)) | |
hit_volume.hit_spheres.push_back(calculate_bounding_sphere(mesh.mVertices, mesh.mVertices+mesh.mNumVertices, get_mesh_transformation(i))); | |
} | |
else{ | |
int indices_last_size=indices.size(); | |
for(auto iter = mesh.mFaces; iter != mesh.mFaces+mesh.mNumFaces;++iter){ | |
for(auto iter2 = iter->mIndices; iter2 != iter->mIndices+iter->mNumIndices;++iter2) | |
indices.push_back(*iter2+current_indice_offset); | |
} | |
meshes.push_back(Mesh(indices_last_size,indices.size()-indices_last_size,mesh.mMaterialIndex)); | |
current_indice_offset += mesh.mNumVertices; | |
std::copy(mesh.mVertices,mesh.mVertices+mesh.mNumVertices,vertices.begin()+current_vertice_offset); | |
current_vertice_offset += mesh.mNumVertices; | |
std::copy(mesh.mNormals,mesh.mNormals+mesh.mNumVertices,vertices.begin()+current_normal_offset); | |
current_normal_offset += mesh.mNumVertices; | |
if(mesh.mTextureCoords[0] != nullptr) | |
std::copy(mesh.mTextureCoords[0],mesh.mTextureCoords[0]+mesh.mNumVertices,vertices.begin()+current_texture_offset); | |
current_texture_offset += mesh.mNumVertices; | |
if(mesh.mTangents != nullptr) | |
std::copy(mesh.mTangents, mesh.mTangents+mesh.mNumVertices, vertices.begin()+current_tangent_offset); | |
current_tangent_offset += mesh.mNumVertices; | |
if(mesh.mBitangents!= nullptr) | |
std::copy(mesh.mBitangents, mesh.mBitangents+mesh.mNumVertices, vertices.begin()+current_bitangent_offset); | |
current_bitangent_offset += mesh.mNumVertices; | |
} | |
} | |
if(ibo.getId() == 0) | |
ibo.initialize(VertexBuffer::ELEMENT_ARRAY_BUFFER); | |
ibo.bufferData(indices,VertexBuffer::STATIC_DRAW); | |
if(vbo.getId() == 0) | |
vbo.initialize(VertexBuffer::ARRAY_BUFFER); | |
vbo.bufferData(vertices,VertexBuffer::STATIC_DRAW); | |
normal_offset = (void*)(vertice_count/5*sizeof(float)*3); //3 vertex | |
texture_offset = (void*)(vertice_count/5*sizeof(float)*6); //3 vertex + 3 normal | |
tangent_offset = (void*)(vertice_count/5*sizeof(float)*9); //3 vertex + 3 normal + 3 texcoords | |
bitangent_offset = (void*)(vertice_count/5*sizeof(float)*12); //3 vertex + 3 normal + 3 texcoords + 3 tangents | |
auto bb_geometry = create_bounding_box_geometry(); | |
if(bounding_box_vbo.getId() == 0) | |
bounding_box_vbo.initialize(VertexBuffer::ARRAY_BUFFER); | |
bounding_box_vbo.bufferData(bb_geometry,VertexBuffer::STATIC_DRAW); | |
} | |
bool Model::Scene::contains_mesh(unsigned int mesh_index)const | |
{ | |
for(auto iter = meshes.begin();iter != meshes.end();++iter) | |
if(*iter == mesh_index) | |
return true; | |
return false; | |
} | |
glm::mat4 Model::get_mesh_transformation(unsigned int mesh_index)const | |
{ | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
if(iter->contains_mesh(mesh_index)) | |
return iter->global_transformation; | |
} | |
return glm::mat4(); | |
} | |
bool Model::is_bounding_box_mesh(unsigned int mesh_index)const | |
{ | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
if(is_bounding_box_mesh_name(iter->name)) | |
if(iter->contains_mesh(mesh_index)) | |
return true; | |
} | |
return false; | |
} | |
bool Model::is_bounding_box_mesh_name(const std::string& mesh_name)const | |
{ | |
return Global::begins_with(mesh_name, "bb_"); | |
} | |
bool Model::is_hit_volume_mesh(unsigned int mesh_index)const | |
{ | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
if(is_hit_volume_mesh_name(iter->name)) | |
if(iter->contains_mesh(mesh_index)) | |
return true; | |
} | |
return false; | |
} | |
bool Model::is_hit_sphere_mesh(unsigned int mesh_index)const | |
{ | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
if(is_hit_sphere_mesh_name(iter->name)) | |
if(iter->contains_mesh(mesh_index)) | |
return true; | |
} | |
return false; | |
} | |
bool Model::is_hit_box_mesh(unsigned int mesh_index)const | |
{ | |
for(auto iter = scenes.begin();iter != scenes.end();++iter){ | |
if(is_hit_box_mesh_name(iter->name)) | |
if(iter->contains_mesh(mesh_index)) | |
return true; | |
} | |
return false; | |
} | |
bool Model::is_hit_volume_mesh_name(const std::string& mesh_name)const | |
{ | |
return is_hit_box_mesh_name(mesh_name) || is_hit_sphere_mesh_name(mesh_name); | |
} | |
bool Model::is_hit_sphere_mesh_name(const std::string& mesh_name)const | |
{ | |
return Global::begins_with(mesh_name, "hs_"); | |
} | |
bool Model::is_hit_box_mesh_name(const std::string& mesh_name)const | |
{ | |
return Global::begins_with(mesh_name, "hb_"); | |
} | |
BoundingSphere Model::calculate_bounding_sphere(const aiVector3D* begin, const aiVector3D* end, const glm::mat4 &transformation) | |
{ | |
BoundingSphere bs; | |
BoundingBox bb = calculate_bounding_box(begin, end, transformation); | |
bs.position = bb.get_middle_position(); | |
glm::vec3 side = bb.min; | |
side.y = (bb.min.y+bb.max.y)/2.0f; | |
side.z = (bb.min.z+bb.max.z)/2.0f; | |
bs.radius = Radius(glm::distance(bs.position, side)); | |
return bs; | |
} | |
BoundingBox Model::calculate_bounding_box(const aiVector3D* begin, const aiVector3D* end, const glm::mat4 &transformation) | |
{ | |
//glm::quat rotation = glm::quat_cast(transformation); | |
//auto real = glm::angle(rotation); | |
//glm::vec3 angles = glm::axis(rotation); | |
BoundingBox bb; | |
bb.min = glm::vec3(std::numeric_limits<float>::max()); | |
bb.max = glm::vec3(std::numeric_limits<float>::lowest()); | |
for(auto iter = begin;iter != end;++iter){ | |
glm::vec4 vertex(iter->x,iter->y,iter->z,1); | |
vertex = transformation*vertex; | |
//bb.rotation = rotation; | |
//bb.transformation = transformation; | |
if(vertex.x < bb.min.x) | |
bb.min.x = vertex.x; | |
if(vertex.y < bb.min.y) | |
bb.min.y = vertex.y; | |
if(vertex.z < bb.min.z) | |
bb.min.z = vertex.z; | |
if(vertex.x > bb.max.x) | |
bb.max.x = vertex.x; | |
if(vertex.y > bb.max.y) | |
bb.max.y = vertex.y; | |
if(vertex.z > bb.max.z) | |
bb.max.z = vertex.z; | |
} | |
return bb; | |
} | |
BoundingBox Model::calculate_bounding_box(std::vector<glm::vec3>::iterator begin, std::vector<glm::vec3>::iterator end, const glm::mat4 &transformation) | |
{ | |
BoundingBox bb; | |
bb.min = glm::vec3(std::numeric_limits<float>::max()); | |
bb.max = glm::vec3(std::numeric_limits<float>::lowest()); | |
for(auto iter = begin;iter != end;++iter){ | |
glm::vec4 vertex(iter->x,iter->y,iter->z,1); | |
vertex = transformation*vertex; | |
if(vertex.x < bb.min.x) | |
bb.min.x = vertex.x; | |
if(vertex.y < bb.min.y) | |
bb.min.y = vertex.y; | |
if(vertex.z < bb.min.z) | |
bb.min.z = vertex.z; | |
if(vertex.x > bb.max.x) | |
bb.max.x = vertex.x; | |
if(vertex.y > bb.max.y) | |
bb.max.y = vertex.y; | |
if(vertex.z > bb.max.z) | |
bb.max.z = vertex.z; | |
} | |
return bb; | |
} | |
std::vector<glm::vec3> Model::create_bounding_box_geometry() | |
{ | |
std::vector<glm::vec3> vertices; | |
for(auto iter = bounding_boxes.begin();iter != bounding_boxes.end();++iter){ | |
auto cube = create_line_loop_cube(iter->min, iter->max); | |
vertices.insert(vertices.end(),cube.begin(),cube.end()); | |
} | |
return vertices; | |
} | |
int Model::count_total_vertice_count(const aiScene& scene)const | |
{ | |
int vertices=0; | |
for(unsigned int i=0;i<scene.mNumMeshes;++i) | |
if(!is_bounding_box_mesh(i) || !is_hit_volume_mesh(i)) | |
vertices += scene.mMeshes[i]->mNumVertices*5; //assume we always have vertex,normal, tangent, bitangent and texture coordinates | |
return vertices; | |
} | |
int Model::count_total_indice_count(const aiScene& scene)const | |
{ | |
int indices=0; | |
for(unsigned int i=0;i<scene.mNumMeshes;++i) | |
if(!is_bounding_box_mesh(i) || !is_hit_volume_mesh(i)) | |
for(auto iter=scene.mMeshes[i]->mFaces;iter != scene.mMeshes[i]->mFaces+scene.mMeshes[i]->mNumFaces;++iter) | |
indices += iter->mNumIndices; //assume we always have vertex,normal,tangent and texture coordinates | |
return indices; | |
} | |
const Animation* Model::get_possible_animation(const std::string& mesh_name)const | |
{ | |
for(auto iter = animations.begin();iter != animations.end();++iter) | |
if(iter->affected_mesh == mesh_name) | |
return &*iter; | |
return nullptr; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment