mirror of
https://github.com/id-Software/DOOM-3-BFG.git
synced 2025-03-14 22:50:45 +00:00
- idRenderModelGLTF now set dynamicModel_t to DM_STATIC when no anims are found, or to DM_CONTINUOUS
- idRenderModelGLTF now does only transform the model verts on read for DM_STATIC. Vertices for dynamicmodels are transformed on instantiation [wip] - A-Style update
This commit is contained in:
parent
8f80816183
commit
8030a5b1de
4 changed files with 331 additions and 88 deletions
|
@ -841,43 +841,26 @@ public:
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
gltfNode* GetNode( gltfScene* scene , gltfMesh* mesh )
|
||||
gltfNode* GetNode( gltfScene* scene , gltfMesh* mesh , int* id = nullptr )
|
||||
{
|
||||
assert( scene );
|
||||
assert( mesh );
|
||||
|
||||
auto& nodeList = scene->nodes;
|
||||
int nodeCnt = 0;
|
||||
for( auto& nodeId : nodeList )
|
||||
{
|
||||
|
||||
if( nodes[nodeId]->mesh != -1 &&*& meshes[nodes[nodeId]->mesh] == mesh )
|
||||
{
|
||||
if( id != nullptr )
|
||||
{
|
||||
*id = nodeCnt;
|
||||
}
|
||||
|
||||
return nodes[nodeId];
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
gltfNode* GetNode( idStr sceneName, idStr name )
|
||||
{
|
||||
int sceneId = GetSceneId( sceneName );
|
||||
if( sceneId < 0 || sceneId > scenes.Num() )
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
gltfScene* scene = scenes[sceneId];
|
||||
|
||||
assert( scene );
|
||||
assert( name[0] );
|
||||
|
||||
auto& nodeList = scene->nodes;
|
||||
for( auto& nodeId : nodeList )
|
||||
{
|
||||
if( nodes[nodeId]->name == name )
|
||||
{
|
||||
return nodes[nodeId];
|
||||
}
|
||||
nodeCnt++;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
|
@ -900,6 +883,81 @@ public:
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
gltfNode* GetNode( idStr sceneName, int id, idStr* name = nullptr )
|
||||
{
|
||||
int sceneId = GetSceneId( sceneName );
|
||||
if( sceneId < 0 || sceneId > scenes.Num( ) )
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
gltfScene* scene = scenes[sceneId];
|
||||
|
||||
assert( scene );
|
||||
assert( id >= 0 );
|
||||
|
||||
auto& nodeList = scene->nodes;
|
||||
for( auto& nodeId : nodeList )
|
||||
{
|
||||
if( nodeId == id )
|
||||
{
|
||||
if( name != nullptr )
|
||||
{
|
||||
*name = nodes[nodeId]->name;
|
||||
}
|
||||
|
||||
return nodes[nodeId];
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
gltfNode* GetNode( idStr sceneName, idStr name , int* id = nullptr )
|
||||
{
|
||||
int sceneId = GetSceneId( sceneName );
|
||||
if( sceneId < 0 || sceneId > scenes.Num() )
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
gltfScene* scene = scenes[sceneId];
|
||||
|
||||
assert( scene );
|
||||
assert( name[0] );
|
||||
|
||||
auto& nodeList = scene->nodes;
|
||||
for( auto& nodeId : nodeList )
|
||||
{
|
||||
if( nodes[nodeId]->name.Icmp( name ) == 0 )
|
||||
{
|
||||
if( id != nullptr )
|
||||
{
|
||||
*id = nodeId;
|
||||
}
|
||||
|
||||
return nodes[nodeId];
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool HasAnimation( int nodeID )
|
||||
{
|
||||
for( auto anim : animations )
|
||||
{
|
||||
for( auto channel : anim->channels )
|
||||
{
|
||||
if( channel->target.node == nodeID )
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int GetSceneId( idStr sceneName ) const
|
||||
{
|
||||
for( int i = 0; i < scenes.Num(); i++ )
|
||||
|
@ -911,6 +969,7 @@ public:
|
|||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
idMat4 GetViewMatrix( int camId ) const
|
||||
{
|
||||
//if (cameraManager->HasOverideID(camId) )
|
||||
|
@ -946,6 +1005,7 @@ public:
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
//Please note : assumes all nodes are _not_ dirty!
|
||||
idMat4 GetLightMatrix( int lightId ) const
|
||||
{
|
||||
|
|
|
@ -41,6 +41,7 @@ idCVar gltf_ModelSceneName( "gltf_ModelSceneName", "models", CVAR_SYSTEM , "Scen
|
|||
|
||||
static const byte GLMB_VERSION = 100;
|
||||
static const unsigned int GLMB_MAGIC = ( 'M' << 24 ) | ( 'L' << 16 ) | ( 'G' << 8 ) | GLMB_VERSION;
|
||||
static const char* GLTF_SnapshotName = "_GLTF_Snapshot_";
|
||||
|
||||
bool idRenderModelStatic::ConvertGltfMeshToModelsurfaces( const gltfMesh* mesh )
|
||||
{
|
||||
|
@ -49,62 +50,87 @@ bool idRenderModelStatic::ConvertGltfMeshToModelsurfaces( const gltfMesh* mesh )
|
|||
|
||||
void idRenderModelGLTF::ProcessNode( gltfNode* modelNode, idMat4 trans, gltfData* data )
|
||||
{
|
||||
auto& meshList = data->MeshList();
|
||||
auto& nodeList = data->NodeList();
|
||||
auto& meshList = data->MeshList( );
|
||||
auto& nodeList = data->NodeList( );
|
||||
|
||||
//find all animations
|
||||
//!!check all nodes --> test with scale_rotate_anim
|
||||
for( auto anim : data->AnimationList( ) )
|
||||
{
|
||||
for( auto channel : anim->channels )
|
||||
{
|
||||
if( channel->target.node >= 0 && nodeList[channel->target.node] == modelNode )
|
||||
{
|
||||
animIds.Alloc( ) = channel->target.node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gltfData::ResolveNodeMatrix( modelNode );
|
||||
|
||||
idMat4 curTrans = trans * modelNode->matrix;
|
||||
|
||||
gltfMesh* targetMesh = meshList[modelNode->mesh];
|
||||
|
||||
for( auto prim : targetMesh->primitives )
|
||||
if( modelNode->mesh >= 0 )
|
||||
{
|
||||
auto* newMesh = MapPolygonMesh::ConvertFromMeshGltf( prim, data, curTrans );
|
||||
modelSurface_t surf;
|
||||
gltfMesh* targetMesh = meshList[modelNode->mesh];
|
||||
|
||||
gltfMaterial* mat = NULL;
|
||||
if( prim->material != -1 )
|
||||
{
|
||||
mat = data->MaterialList()[prim->material];
|
||||
}
|
||||
if( mat != NULL && !gltf_ForceBspMeshTexture.GetBool() )
|
||||
{
|
||||
surf.shader = declManager->FindMaterial( mat->name );
|
||||
}
|
||||
else
|
||||
{
|
||||
surf.shader = declManager->FindMaterial( "textures/base_wall/snpanel2rust" );
|
||||
}
|
||||
surf.id = this->NumSurfaces();
|
||||
idMat4 newTrans = mat4_identity;
|
||||
|
||||
srfTriangles_t* tri = R_AllocStaticTriSurf();
|
||||
tri->numIndexes = newMesh->GetNumPolygons() * 3;
|
||||
tri->numVerts = newMesh->GetNumVertices();
|
||||
|
||||
R_AllocStaticTriSurfIndexes( tri, tri->numIndexes );
|
||||
R_AllocStaticTriSurfVerts( tri, tri->numVerts );
|
||||
|
||||
int indx = 0;
|
||||
for( int i = 0; i < newMesh->GetNumPolygons(); i++ )
|
||||
if( !animIds.Num() )
|
||||
{
|
||||
auto& face = newMesh->GetFace( i );
|
||||
auto& faceIdxs = face.GetIndexes();
|
||||
tri->indexes[indx] = faceIdxs[0];
|
||||
tri->indexes[indx + 1] = faceIdxs[1];
|
||||
tri->indexes[indx + 2] = faceIdxs[2];
|
||||
indx += 3;
|
||||
newTrans = curTrans;
|
||||
}
|
||||
|
||||
for( int i = 0; i < tri->numVerts; ++i )
|
||||
for( auto prim : targetMesh->primitives )
|
||||
{
|
||||
tri->verts[i] = newMesh->GetDrawVerts()[i];
|
||||
tri->bounds.AddPoint( tri->verts[i].xyz );
|
||||
//ConvertFromMeshGltf should only be used for the map, ConvertGltfMeshToModelsurfaces should be used.
|
||||
auto* newMesh = MapPolygonMesh::ConvertFromMeshGltf( prim, data, newTrans );
|
||||
modelSurface_t surf;
|
||||
|
||||
gltfMaterial* mat = NULL;
|
||||
if( prim->material != -1 )
|
||||
{
|
||||
mat = data->MaterialList( )[prim->material];
|
||||
}
|
||||
if( mat != NULL && !gltf_ForceBspMeshTexture.GetBool( ) )
|
||||
{
|
||||
surf.shader = declManager->FindMaterial( mat->name );
|
||||
}
|
||||
else
|
||||
{
|
||||
surf.shader = declManager->FindMaterial( "textures/base_wall/snpanel2rust" );
|
||||
}
|
||||
surf.id = this->NumSurfaces( );
|
||||
|
||||
srfTriangles_t* tri = R_AllocStaticTriSurf( );
|
||||
tri->numIndexes = newMesh->GetNumPolygons( ) * 3;
|
||||
tri->numVerts = newMesh->GetNumVertices( );
|
||||
|
||||
R_AllocStaticTriSurfIndexes( tri, tri->numIndexes );
|
||||
R_AllocStaticTriSurfVerts( tri, tri->numVerts );
|
||||
|
||||
int indx = 0;
|
||||
for( int i = 0; i < newMesh->GetNumPolygons( ); i++ )
|
||||
{
|
||||
auto& face = newMesh->GetFace( i );
|
||||
auto& faceIdxs = face.GetIndexes( );
|
||||
tri->indexes[indx] = faceIdxs[0];
|
||||
tri->indexes[indx + 1] = faceIdxs[1];
|
||||
tri->indexes[indx + 2] = faceIdxs[2];
|
||||
indx += 3;
|
||||
}
|
||||
|
||||
for( int i = 0; i < tri->numVerts; ++i )
|
||||
{
|
||||
tri->verts[i] = newMesh->GetDrawVerts( )[i];
|
||||
tri->bounds.AddPoint( tri->verts[i].xyz );
|
||||
}
|
||||
|
||||
bounds.AddBounds( tri->bounds );
|
||||
|
||||
surf.geometry = tri;
|
||||
AddSurface( surf );
|
||||
}
|
||||
|
||||
bounds.AddBounds( tri->bounds );
|
||||
|
||||
surf.geometry = tri;
|
||||
AddSurface( surf );
|
||||
}
|
||||
|
||||
for( auto& child : modelNode->children )
|
||||
|
@ -113,10 +139,11 @@ void idRenderModelGLTF::ProcessNode( gltfNode* modelNode, idMat4 trans, gltfData
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
void idRenderModelGLTF::MakeMD5Mesh( )
|
||||
{
|
||||
|
||||
meshes.SetGranularity( 1 );
|
||||
//meshes.SetNum( num );
|
||||
}
|
||||
|
||||
//constructs a renderModel from a gltfScene node found in the "models" scene of the given gltfFile.
|
||||
|
@ -126,12 +153,13 @@ void idRenderModelGLTF::MakeMD5Mesh( )
|
|||
//If no nodeName/nodeId is given, all primitives active in default scene will be added as surfaces.
|
||||
void idRenderModelGLTF::InitFromFile( const char* fileName )
|
||||
{
|
||||
hasAnimations = false;
|
||||
fileExclusive = false;
|
||||
root = nullptr;
|
||||
int meshID = -1;
|
||||
idStr meshName;
|
||||
idStr gltfFileName = idStr( fileName );
|
||||
|
||||
model_state = DM_STATIC;
|
||||
|
||||
gltfManager::ExtractMeshIdentifier( gltfFileName, meshID, meshName );
|
||||
|
||||
|
@ -158,6 +186,10 @@ void idRenderModelGLTF::InitFromFile( const char* fileName )
|
|||
|
||||
if( !meshName[0] )
|
||||
{
|
||||
//this needs to be fixed to correctly support multiple meshes.
|
||||
// atm this will only correctlty with static models.
|
||||
// we could do gltfMeshes a la md5
|
||||
// or re-use this class
|
||||
auto& nodeList = data->NodeList();
|
||||
for( auto& nodeID : data->SceneList()[sceneId]->nodes )
|
||||
{
|
||||
|
@ -169,10 +201,10 @@ void idRenderModelGLTF::InitFromFile( const char* fileName )
|
|||
}
|
||||
else
|
||||
{
|
||||
gltfNode* modelNode = data->GetNode( gltf_ModelSceneName.GetString(), meshName );
|
||||
gltfNode* modelNode = data->GetNode( gltf_ModelSceneName.GetString(), meshName, &rootID );
|
||||
if( modelNode )
|
||||
{
|
||||
root = modelNode;
|
||||
root = modelNode;
|
||||
ProcessNode( modelNode, mat4_identity, data );
|
||||
}
|
||||
|
||||
|
@ -184,6 +216,7 @@ void idRenderModelGLTF::InitFromFile( const char* fileName )
|
|||
MakeDefaultModel( );
|
||||
return;
|
||||
}
|
||||
model_state = animIds.Num() ? DM_CONTINUOUS : DM_STATIC;
|
||||
|
||||
// derive mikktspace tangents from normals
|
||||
FinishSurfaces( true );
|
||||
|
@ -198,11 +231,56 @@ void idRenderModelGLTF::InitFromFile( const char* fileName )
|
|||
|
||||
bool idRenderModelGLTF::LoadBinaryModel( idFile* file, const ID_TIME_T sourceTimeStamp )
|
||||
{
|
||||
hasAnimations = false;
|
||||
fileExclusive = false; // not written.
|
||||
root = nullptr;
|
||||
prevTrans = mat4_identity;
|
||||
|
||||
if ( !idRenderModelStatic::LoadBinaryModel( file, sourceTimeStamp ) ) {
|
||||
//we should still load the scene information ?
|
||||
if( !idRenderModelStatic::LoadBinaryModel( file, sourceTimeStamp ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned int magic = 0;
|
||||
file->ReadBig( magic );
|
||||
|
||||
if( magic != GLMB_MAGIC )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
file->ReadBig( model_state );
|
||||
file->ReadBig( rootID );
|
||||
|
||||
idStr dataFilename;
|
||||
file->ReadString( dataFilename );
|
||||
|
||||
if( gltfParser->currentFile.Length( ) )
|
||||
{
|
||||
if( gltfParser->currentAsset && gltfParser->currentFile != dataFilename )
|
||||
{
|
||||
common->FatalError( "multiple GLTF file loading not supported" );
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
gltfParser->Load( dataFilename );
|
||||
}
|
||||
|
||||
data = gltfParser->currentAsset;
|
||||
root = data->GetNode( gltf_ModelSceneName.GetString(), rootID );
|
||||
assert( root );
|
||||
|
||||
int animCnt;
|
||||
file->ReadBig( animCnt );
|
||||
if( animCnt > 0 )
|
||||
{
|
||||
animIds.Resize( animCnt, 1 );
|
||||
file->ReadBigArray( animIds.Ptr( ), animCnt );
|
||||
animIds.SetNum( animCnt );
|
||||
}
|
||||
hasAnimations = animCnt > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -211,12 +289,21 @@ void idRenderModelGLTF::WriteBinaryModel( idFile* file, ID_TIME_T* _timeStamp /*
|
|||
|
||||
idRenderModelStatic::WriteBinaryModel( file );
|
||||
|
||||
if ( file == NULL || root == nullptr) {
|
||||
if( file == NULL || root == nullptr )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//file->WriteBig( GLMB_MAGIC );
|
||||
file->WriteBig( GLMB_MAGIC );
|
||||
file->WriteBig( model_state );
|
||||
file->WriteBig( rootID );
|
||||
file->WriteString( data->FileName() );
|
||||
|
||||
file->WriteBig( animIds.Num( ) );
|
||||
if( animIds.Num( ) )
|
||||
{
|
||||
file->WriteBigArray( animIds.Ptr(), animIds.Size() );
|
||||
}
|
||||
////check if this model has a skeleton
|
||||
//if ( root->skin >= 0 )
|
||||
//{
|
||||
|
@ -304,10 +391,6 @@ void idRenderModelGLTF::WriteBinaryModel( idFile* file, ID_TIME_T* _timeStamp /*
|
|||
|
||||
// file->WriteBig( meshes[i].surfaceNum );
|
||||
//}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
void idRenderModelGLTF::PurgeModel()
|
||||
|
@ -350,13 +433,92 @@ int idRenderModelGLTF::Memory() const
|
|||
|
||||
dynamicModel_t idRenderModelGLTF::IsDynamicModel() const
|
||||
{
|
||||
return DM_STATIC;
|
||||
return model_state;
|
||||
}
|
||||
|
||||
void TransformVertsAndTangents( idDrawVert* targetVerts, const int numVerts, idMat4 trans )
|
||||
{
|
||||
for( int i = 0; i < numVerts; i++ )
|
||||
{
|
||||
|
||||
targetVerts[i].xyz *= trans;// * idVec3( base.xyz.x, base.xyz.y, base.xyz.z);
|
||||
targetVerts[i].SetNormal( trans.ToMat3() * targetVerts[i].GetNormal( ) );
|
||||
targetVerts[i].SetTangent( trans.ToMat3() * targetVerts[i].GetTangent( ) );
|
||||
}
|
||||
}
|
||||
|
||||
void idRenderModelGLTF::UpdateSurface( const struct renderEntity_s* ent, idMat4 trans, modelSurface_t* surf )
|
||||
{
|
||||
|
||||
if( surf->geometry != NULL )
|
||||
{
|
||||
R_FreeStaticTriSurfVertexCaches( surf->geometry );
|
||||
}
|
||||
else
|
||||
{
|
||||
surf->geometry = R_AllocStaticTriSurf( );
|
||||
}
|
||||
|
||||
srfTriangles_t* tri = surf->geometry;
|
||||
TransformVertsAndTangents( tri->verts, tri->numVerts, trans );
|
||||
|
||||
}
|
||||
|
||||
idRenderModel* idRenderModelGLTF::InstantiateDynamicModel( const struct renderEntity_s* ent, const viewDef_t* view, idRenderModel* cachedModel )
|
||||
{
|
||||
common->Warning( "The method or operation is not implemented." );
|
||||
return nullptr;
|
||||
if( cachedModel != NULL && !r_useCachedDynamicModels.GetBool() )
|
||||
{
|
||||
delete cachedModel;
|
||||
cachedModel = NULL;
|
||||
}
|
||||
|
||||
if( purged )
|
||||
{
|
||||
common->DWarning( "model %s instantiated while purged", Name() );
|
||||
LoadModel();
|
||||
}
|
||||
|
||||
idRenderModelStatic* staticModel;
|
||||
if( cachedModel != NULL )
|
||||
{
|
||||
assert( dynamic_cast< idRenderModelStatic* >( cachedModel ) != NULL );
|
||||
assert( idStr::Icmp( cachedModel->Name( ), GLTF_SnapshotName ) == 0 );
|
||||
staticModel = static_cast< idRenderModelStatic* >( cachedModel );
|
||||
}
|
||||
else
|
||||
{
|
||||
staticModel = new( TAG_MODEL ) idRenderModelStatic;
|
||||
staticModel->InitEmpty( GLTF_SnapshotName );
|
||||
}
|
||||
|
||||
idStr prevName = name;
|
||||
name = GLTF_SnapshotName;
|
||||
*staticModel = *this;
|
||||
name = prevName;
|
||||
|
||||
idMat3 rotation = idAngles( 0.0f, 0.0f, 90.0f ).ToMat3( );
|
||||
idMat4 axisTransform( rotation, vec3_origin );
|
||||
idMat4 trans = prevTrans;
|
||||
bool wasDirty = root->dirty;
|
||||
|
||||
gltfData::ResolveNodeMatrix( root, &trans );
|
||||
|
||||
if( wasDirty )
|
||||
{
|
||||
trans *= axisTransform;
|
||||
}
|
||||
|
||||
for( modelSurface_t& surf : staticModel->surfaces )
|
||||
{
|
||||
UpdateSurface( ent, trans, &surf );
|
||||
}
|
||||
|
||||
prevTrans = root->matrix.Inverse();
|
||||
|
||||
//staticModel->bounds *= trans.ToMat3( );
|
||||
//bounds.Translate( idVec3( trans[0][3], trans[1][3], trans[2][3] ) );
|
||||
|
||||
return staticModel;
|
||||
}
|
||||
|
||||
int idRenderModelGLTF::NumJoints() const
|
||||
|
@ -398,4 +560,9 @@ int idRenderModelGLTF::NearestJoint( int surfaceNum, int a, int b, int c ) const
|
|||
idBounds idRenderModelGLTF::Bounds( const struct renderEntity_s* ent ) const
|
||||
{
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
idGltfMesh::idGltfMesh( gltfMesh* _mesh, gltfData* _data ) : mesh( _mesh ), data( _data )
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
@ -31,6 +31,16 @@ If you have questions concerning this license or the applicable additional terms
|
|||
|
||||
|
||||
|
||||
class idGltfMesh
|
||||
{
|
||||
public:
|
||||
idGltfMesh( ) {};
|
||||
idGltfMesh( gltfMesh* _mesh, gltfData* _data );
|
||||
private:
|
||||
gltfMesh* mesh;
|
||||
gltfData* data;
|
||||
idMD5Mesh md5Mesh;
|
||||
};
|
||||
|
||||
class idRenderModelGLTF : public idRenderModelStatic
|
||||
{
|
||||
|
@ -58,13 +68,18 @@ public:
|
|||
return true;
|
||||
}
|
||||
|
||||
void MakeMD5Mesh () ;
|
||||
void MakeMD5Mesh() ;
|
||||
private:
|
||||
void ProcessNode( gltfNode* modelNode, idMat4 trans, gltfData* data );
|
||||
void UpdateSurface( const struct renderEntity_s* ent, idMat4 trans, modelSurface_t* surf );
|
||||
int rootID;
|
||||
|
||||
gltfData* data;
|
||||
gltfNode* root;
|
||||
bool fileExclusive;
|
||||
|
||||
idList<idMD5Mesh, TAG_MODEL> meshes;
|
||||
bool hasAnimations;
|
||||
idList<int> animIds;
|
||||
idList<idGltfMesh, TAG_MODEL> meshes;
|
||||
dynamicModel_t model_state;
|
||||
idMat4 prevTrans;
|
||||
};
|
|
@ -179,6 +179,7 @@ protected:
|
|||
class idMD5Mesh
|
||||
{
|
||||
friend class idRenderModelMD5;
|
||||
friend class idRenderModelGLTF;
|
||||
|
||||
public:
|
||||
idMD5Mesh();
|
||||
|
|
Loading…
Reference in a new issue