ADD: BVH Selection ongoing
This commit is contained in:
@@ -78,6 +78,8 @@ add_executable (vulkan_engine
|
|||||||
scene/vk_scene.h
|
scene/vk_scene.h
|
||||||
scene/vk_scene.cpp
|
scene/vk_scene.cpp
|
||||||
scene/vk_scene_picking.cpp
|
scene/vk_scene_picking.cpp
|
||||||
|
scene/mesh_bvh.h
|
||||||
|
scene/mesh_bvh.cpp
|
||||||
scene/vk_loader.h
|
scene/vk_loader.h
|
||||||
scene/vk_loader.cpp
|
scene/vk_loader.cpp
|
||||||
scene/tangent_space.h
|
scene/tangent_space.h
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ void VulkanEngine::draw()
|
|||||||
_lastPick.mesh = picked.sourceMesh;
|
_lastPick.mesh = picked.sourceMesh;
|
||||||
_lastPick.scene = picked.sourceScene;
|
_lastPick.scene = picked.sourceScene;
|
||||||
_lastPick.worldPos = fallbackPos;
|
_lastPick.worldPos = fallbackPos;
|
||||||
|
_lastPick.worldTransform = picked.transform;
|
||||||
_lastPick.firstIndex = picked.firstIndex;
|
_lastPick.firstIndex = picked.firstIndex;
|
||||||
_lastPick.indexCount = picked.indexCount;
|
_lastPick.indexCount = picked.indexCount;
|
||||||
_lastPick.surfaceIndex = picked.surfaceIndex;
|
_lastPick.surfaceIndex = picked.surfaceIndex;
|
||||||
@@ -496,6 +497,7 @@ void VulkanEngine::draw()
|
|||||||
_hoverPick.mesh = hoverObj.sourceMesh;
|
_hoverPick.mesh = hoverObj.sourceMesh;
|
||||||
_hoverPick.scene = hoverObj.sourceScene;
|
_hoverPick.scene = hoverObj.sourceScene;
|
||||||
_hoverPick.worldPos = hoverPos;
|
_hoverPick.worldPos = hoverPos;
|
||||||
|
_hoverPick.worldTransform = hoverObj.transform;
|
||||||
_hoverPick.firstIndex = hoverObj.firstIndex;
|
_hoverPick.firstIndex = hoverObj.firstIndex;
|
||||||
_hoverPick.indexCount = hoverObj.indexCount;
|
_hoverPick.indexCount = hoverObj.indexCount;
|
||||||
_hoverPick.surfaceIndex = hoverObj.surfaceIndex;
|
_hoverPick.surfaceIndex = hoverObj.surfaceIndex;
|
||||||
@@ -816,6 +818,7 @@ void VulkanEngine::run()
|
|||||||
_lastPick.mesh = hitObject.sourceMesh;
|
_lastPick.mesh = hitObject.sourceMesh;
|
||||||
_lastPick.scene = hitObject.sourceScene;
|
_lastPick.scene = hitObject.sourceScene;
|
||||||
_lastPick.worldPos = hitPos;
|
_lastPick.worldPos = hitPos;
|
||||||
|
_lastPick.worldTransform = hitObject.transform;
|
||||||
_lastPick.firstIndex = hitObject.firstIndex;
|
_lastPick.firstIndex = hitObject.firstIndex;
|
||||||
_lastPick.indexCount = hitObject.indexCount;
|
_lastPick.indexCount = hitObject.indexCount;
|
||||||
_lastPick.surfaceIndex = hitObject.surfaceIndex;
|
_lastPick.surfaceIndex = hitObject.surfaceIndex;
|
||||||
@@ -851,6 +854,7 @@ void VulkanEngine::run()
|
|||||||
// Use bounds origin transformed to world as a representative point.
|
// Use bounds origin transformed to world as a representative point.
|
||||||
glm::vec3 centerWorld = glm::vec3(obj.transform * glm::vec4(obj.bounds.origin, 1.0f));
|
glm::vec3 centerWorld = glm::vec3(obj.transform * glm::vec4(obj.bounds.origin, 1.0f));
|
||||||
info.worldPos = centerWorld;
|
info.worldPos = centerWorld;
|
||||||
|
info.worldTransform = obj.transform;
|
||||||
info.firstIndex = obj.firstIndex;
|
info.firstIndex = obj.firstIndex;
|
||||||
info.indexCount = obj.indexCount;
|
info.indexCount = obj.indexCount;
|
||||||
info.surfaceIndex = obj.surfaceIndex;
|
info.surfaceIndex = obj.surfaceIndex;
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ public:
|
|||||||
MeshAsset *mesh = nullptr;
|
MeshAsset *mesh = nullptr;
|
||||||
LoadedGLTF *scene = nullptr;
|
LoadedGLTF *scene = nullptr;
|
||||||
glm::vec3 worldPos{0.0f};
|
glm::vec3 worldPos{0.0f};
|
||||||
|
glm::mat4 worldTransform{1.0f};
|
||||||
uint32_t indexCount = 0;
|
uint32_t indexCount = 0;
|
||||||
uint32_t firstIndex = 0;
|
uint32_t firstIndex = 0;
|
||||||
uint32_t surfaceIndex = 0;
|
uint32_t surfaceIndex = 0;
|
||||||
@@ -151,6 +152,8 @@ public:
|
|||||||
|
|
||||||
// Toggle to enable/disable ID-buffer picking in addition to raycast
|
// Toggle to enable/disable ID-buffer picking in addition to raycast
|
||||||
bool _useIdBufferPicking = false;
|
bool _useIdBufferPicking = false;
|
||||||
|
// Debug: draw mesh BVH boxes for last pick
|
||||||
|
bool _debugDrawBVH = false;
|
||||||
|
|
||||||
// Debug: persistent pass enable overrides (by pass name)
|
// Debug: persistent pass enable overrides (by pass name)
|
||||||
std::unordered_map<std::string, bool> _rgPassToggles;
|
std::unordered_map<std::string, bool> _rgPassToggles;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
#include "engine_context.h"
|
#include "engine_context.h"
|
||||||
#include <vk_types.h>
|
#include <vk_types.h>
|
||||||
|
|
||||||
|
#include "mesh_bvh.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Background / compute playground
|
// Background / compute playground
|
||||||
@@ -538,7 +540,9 @@ static void ui_scene(VulkanEngine *eng)
|
|||||||
ImGui::Text("Opaque draws: %zu", dc.OpaqueSurfaces.size());
|
ImGui::Text("Opaque draws: %zu", dc.OpaqueSurfaces.size());
|
||||||
ImGui::Text("Transp draws: %zu", dc.TransparentSurfaces.size());
|
ImGui::Text("Transp draws: %zu", dc.TransparentSurfaces.size());
|
||||||
ImGui::Checkbox("Use ID-buffer picking", &eng->_useIdBufferPicking);
|
ImGui::Checkbox("Use ID-buffer picking", &eng->_useIdBufferPicking);
|
||||||
|
ImGui::Checkbox("Debug draw mesh BVH (last pick)", &eng->_debugDrawBVH);
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
if (eng->_lastPick.valid)
|
if (eng->_lastPick.valid)
|
||||||
{
|
{
|
||||||
const char *meshName = eng->_lastPick.mesh ? eng->_lastPick.mesh->name.c_str() : "<unknown>";
|
const char *meshName = eng->_lastPick.mesh ? eng->_lastPick.mesh->name.c_str() : "<unknown>";
|
||||||
@@ -556,6 +560,21 @@ static void ui_scene(VulkanEngine *eng)
|
|||||||
ImGui::Text("Indices: first=%u count=%u",
|
ImGui::Text("Indices: first=%u count=%u",
|
||||||
eng->_lastPick.firstIndex,
|
eng->_lastPick.firstIndex,
|
||||||
eng->_lastPick.indexCount);
|
eng->_lastPick.indexCount);
|
||||||
|
|
||||||
|
if (eng->_sceneManager)
|
||||||
|
{
|
||||||
|
const SceneManager::PickingDebug &dbg = eng->_sceneManager->getPickingDebug();
|
||||||
|
ImGui::Text("Mesh BVH used: %s, hit: %s, fallback box: %s",
|
||||||
|
dbg.usedMeshBVH ? "yes" : "no",
|
||||||
|
dbg.meshBVHHit ? "yes" : "no",
|
||||||
|
dbg.meshBVHFallbackBox ? "yes" : "no");
|
||||||
|
if (dbg.meshBVHPrimCount > 0)
|
||||||
|
{
|
||||||
|
ImGui::Text("Mesh BVH stats: prims=%u, nodes=%u",
|
||||||
|
dbg.meshBVHPrimCount,
|
||||||
|
dbg.meshBVHNodeCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include <fastgltf/util.hpp>
|
#include <fastgltf/util.hpp>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include "tangent_space.h"
|
#include "tangent_space.h"
|
||||||
|
#include "mesh_bvh.h"
|
||||||
//> loadimg
|
//> loadimg
|
||||||
std::optional<AllocatedImage> load_image(VulkanEngine *engine, fastgltf::Asset &asset, fastgltf::Image &image, bool srgb)
|
std::optional<AllocatedImage> load_image(VulkanEngine *engine, fastgltf::Asset &asset, fastgltf::Image &image, bool srgb)
|
||||||
{
|
{
|
||||||
@@ -592,18 +593,25 @@ std::optional<std::shared_ptr<LoadedGLTF> > loadGltf(VulkanEngine *engine, std::
|
|||||||
newSurface.bounds.origin = (maxpos + minpos) / 2.f;
|
newSurface.bounds.origin = (maxpos + minpos) / 2.f;
|
||||||
newSurface.bounds.extents = (maxpos - minpos) / 2.f;
|
newSurface.bounds.extents = (maxpos - minpos) / 2.f;
|
||||||
newSurface.bounds.sphereRadius = glm::length(newSurface.bounds.extents);
|
newSurface.bounds.sphereRadius = glm::length(newSurface.bounds.extents);
|
||||||
newSurface.bounds.type = BoundsType::Box;
|
newSurface.bounds.type = BoundsType::Mesh;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
newSurface.bounds.origin = glm::vec3(0.0f);
|
newSurface.bounds.origin = glm::vec3(0.0f);
|
||||||
newSurface.bounds.extents = glm::vec3(0.5f);
|
newSurface.bounds.extents = glm::vec3(0.5f);
|
||||||
newSurface.bounds.sphereRadius = glm::length(newSurface.bounds.extents);
|
newSurface.bounds.sphereRadius = glm::length(newSurface.bounds.extents);
|
||||||
newSurface.bounds.type = BoundsType::Box;
|
newSurface.bounds.type = BoundsType::Mesh;
|
||||||
}
|
}
|
||||||
newmesh->surfaces.push_back(newSurface);
|
newmesh->surfaces.push_back(newSurface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build CPU BVH for precise picking over this mesh (triangle-level).
|
||||||
|
{
|
||||||
|
std::span<const Vertex> vSpan(vertices.data(), vertices.size());
|
||||||
|
std::span<const uint32_t> iSpan(indices.data(), indices.size());
|
||||||
|
newmesh->bvh = build_mesh_bvh(*newmesh, vSpan, iSpan);
|
||||||
|
}
|
||||||
|
|
||||||
newmesh->meshBuffers = engine->_resourceManager->uploadMesh(indices, vertices);
|
newmesh->meshBuffers = engine->_resourceManager->uploadMesh(indices, vertices);
|
||||||
// If CPU vectors ballooned for this mesh, release capacity back to the OS
|
// If CPU vectors ballooned for this mesh, release capacity back to the OS
|
||||||
auto shrink_if_huge = [](auto &vec, size_t elemSizeBytes) {
|
auto shrink_if_huge = [](auto &vec, size_t elemSizeBytes) {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ struct GeoSurface
|
|||||||
std::shared_ptr<GLTFMaterial> material;
|
std::shared_ptr<GLTFMaterial> material;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct MeshBVH;
|
||||||
|
|
||||||
struct MeshAsset
|
struct MeshAsset
|
||||||
{
|
{
|
||||||
std::string name;
|
std::string name;
|
||||||
@@ -52,6 +54,9 @@ struct MeshAsset
|
|||||||
|
|
||||||
std::vector<GeoSurface> surfaces;
|
std::vector<GeoSurface> surfaces;
|
||||||
GPUMeshBuffers meshBuffers;
|
GPUMeshBuffers meshBuffers;
|
||||||
|
|
||||||
|
// Optional CPU BVH for precise picking / queries.
|
||||||
|
std::shared_ptr<MeshBVH> bvh;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LoadedGLTF : public IRenderable
|
struct LoadedGLTF : public IRenderable
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ public:
|
|||||||
float scene_update_time = 0.f;
|
float scene_update_time = 0.f;
|
||||||
} stats;
|
} stats;
|
||||||
|
|
||||||
|
struct PickingDebug
|
||||||
|
{
|
||||||
|
bool usedMeshBVH = false;
|
||||||
|
bool meshBVHHit = false;
|
||||||
|
bool meshBVHFallbackBox = false;
|
||||||
|
uint32_t meshBVHPrimCount = 0;
|
||||||
|
uint32_t meshBVHNodeCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PickingDebug &getPickingDebug() const { return pickingDebug; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
EngineContext *_context = nullptr;
|
EngineContext *_context = nullptr;
|
||||||
|
|
||||||
@@ -126,4 +137,6 @@ private:
|
|||||||
std::unordered_map<std::string, std::shared_ptr<Node> > loadedNodes;
|
std::unordered_map<std::string, std::shared_ptr<Node> > loadedNodes;
|
||||||
std::unordered_map<std::string, MeshInstance> dynamicMeshInstances;
|
std::unordered_map<std::string, MeshInstance> dynamicMeshInstances;
|
||||||
std::unordered_map<std::string, GLTFInstance> dynamicGLTFInstances;
|
std::unordered_map<std::string, GLTFInstance> dynamicGLTFInstances;
|
||||||
|
|
||||||
|
PickingDebug pickingDebug{};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "vk_swapchain.h"
|
#include "vk_swapchain.h"
|
||||||
#include "core/engine_context.h"
|
#include "core/engine_context.h"
|
||||||
|
#include "mesh_bvh.h"
|
||||||
|
|
||||||
#include "glm/gtx/transform.hpp"
|
#include "glm/gtx/transform.hpp"
|
||||||
#include <glm/gtc/matrix_transform.hpp>
|
#include <glm/gtc/matrix_transform.hpp>
|
||||||
@@ -14,6 +15,13 @@
|
|||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
struct BoundsHitDebug
|
||||||
|
{
|
||||||
|
bool usedBVH = false;
|
||||||
|
bool bvhHit = false;
|
||||||
|
bool fallbackBox = false;
|
||||||
|
};
|
||||||
|
|
||||||
// Ray / oriented-box intersection in world space using object-local AABB.
|
// Ray / oriented-box intersection in world space using object-local AABB.
|
||||||
// Returns true when hit; outWorldHit is the closest hit point in world space.
|
// Returns true when hit; outWorldHit is the closest hit point in world space.
|
||||||
bool intersect_ray_box(const glm::vec3 &rayOrigin,
|
bool intersect_ray_box(const glm::vec3 &rayOrigin,
|
||||||
@@ -262,14 +270,18 @@ namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ray / oriented-bounds intersection in world space using object-local shape.
|
// Ray / oriented-bounds intersection in world space using object-local shape.
|
||||||
// Uses a quick sphere test first; on success refines based on BoundsType.
|
// For non-mesh shapes we use a quick world-space bounding-sphere pretest;
|
||||||
|
// for mesh bounds we go directly to the mesh BVH (which already has a root AABB).
|
||||||
// Returns true when hit; outWorldHit is the closest hit point in world space.
|
// Returns true when hit; outWorldHit is the closest hit point in world space.
|
||||||
bool intersect_ray_bounds(const glm::vec3 &rayOrigin,
|
bool intersect_ray_bounds(const glm::vec3 &rayOrigin,
|
||||||
const glm::vec3 &rayDir,
|
const glm::vec3 &rayDir,
|
||||||
const Bounds &bounds,
|
const RenderObject &obj,
|
||||||
const glm::mat4 &worldTransform,
|
glm::vec3 &outWorldHit,
|
||||||
glm::vec3 &outWorldHit)
|
BoundsHitDebug *debug)
|
||||||
{
|
{
|
||||||
|
const Bounds &bounds = obj.bounds;
|
||||||
|
const glm::mat4 &worldTransform = obj.transform;
|
||||||
|
|
||||||
// Non-pickable object.
|
// Non-pickable object.
|
||||||
if (bounds.type == BoundsType::None)
|
if (bounds.type == BoundsType::None)
|
||||||
{
|
{
|
||||||
@@ -281,32 +293,63 @@ namespace
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (bounds.type)
|
||||||
|
{
|
||||||
|
case BoundsType::Sphere:
|
||||||
|
{
|
||||||
// Early reject using bounding sphere in world space.
|
// Early reject using bounding sphere in world space.
|
||||||
float sphereT = 0.0f;
|
float sphereT = 0.0f;
|
||||||
if (!intersect_ray_sphere(rayOrigin, rayDir, bounds, worldTransform, sphereT))
|
if (!intersect_ray_sphere(rayOrigin, rayDir, bounds, worldTransform, sphereT))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape-specific refinement after the conservative sphere test.
|
|
||||||
switch (bounds.type)
|
|
||||||
{
|
|
||||||
case BoundsType::Sphere:
|
|
||||||
{
|
|
||||||
// We already have the hit distance along the ray from the sphere test.
|
// We already have the hit distance along the ray from the sphere test.
|
||||||
outWorldHit = rayOrigin + rayDir * sphereT;
|
outWorldHit = rayOrigin + rayDir * sphereT;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case BoundsType::Capsule:
|
case BoundsType::Capsule:
|
||||||
{
|
{
|
||||||
|
float sphereT = 0.0f;
|
||||||
|
if (!intersect_ray_sphere(rayOrigin, rayDir, bounds, worldTransform, sphereT))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return intersect_ray_capsule(rayOrigin, rayDir, bounds, worldTransform, outWorldHit);
|
return intersect_ray_capsule(rayOrigin, rayDir, bounds, worldTransform, outWorldHit);
|
||||||
}
|
}
|
||||||
|
case BoundsType::Mesh:
|
||||||
|
{
|
||||||
|
// Try high-precision mesh BVH first when available, then fall back to box.
|
||||||
|
if (obj.sourceMesh && obj.sourceMesh->bvh)
|
||||||
|
{
|
||||||
|
if (debug)
|
||||||
|
{
|
||||||
|
debug->usedBVH = true;
|
||||||
|
}
|
||||||
|
MeshBVHPickHit meshHit{};
|
||||||
|
if (intersect_ray_mesh_bvh(*obj.sourceMesh->bvh, worldTransform, rayOrigin, rayDir, meshHit))
|
||||||
|
{
|
||||||
|
if (debug)
|
||||||
|
{
|
||||||
|
debug->bvhHit = true;
|
||||||
|
}
|
||||||
|
outWorldHit = meshHit.worldPos;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (debug)
|
||||||
|
{
|
||||||
|
debug->fallbackBox = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// return intersect_ray_box(rayOrigin, rayDir, bounds, worldTransform, outWorldHit);
|
||||||
|
}
|
||||||
case BoundsType::Box:
|
case BoundsType::Box:
|
||||||
case BoundsType::Mesh: // TODO: replace with BVH/mesh query; box is a safe fallback.
|
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
// For Capsule and Mesh we currently fall back to the oriented box;
|
float sphereT = 0.0f;
|
||||||
// this still benefits from tighter AABBs if you author them.
|
if (!intersect_ray_sphere(rayOrigin, rayDir, bounds, worldTransform, sphereT))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return intersect_ray_box(rayOrigin, rayDir, bounds, worldTransform, outWorldHit);
|
return intersect_ray_box(rayOrigin, rayDir, bounds, worldTransform, outWorldHit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,12 +440,16 @@ bool SceneManager::pick(const glm::vec2 &mousePosPixels, RenderObject &outObject
|
|||||||
float bestDist2 = std::numeric_limits<float>::max();
|
float bestDist2 = std::numeric_limits<float>::max();
|
||||||
glm::vec3 bestHitPos{};
|
glm::vec3 bestHitPos{};
|
||||||
|
|
||||||
|
// Reset debug info for this pick.
|
||||||
|
pickingDebug = {};
|
||||||
|
|
||||||
auto testList = [&](const std::vector<RenderObject> &list)
|
auto testList = [&](const std::vector<RenderObject> &list)
|
||||||
{
|
{
|
||||||
for (const RenderObject &obj: list)
|
for (const RenderObject &obj: list)
|
||||||
{
|
{
|
||||||
glm::vec3 hitPos{};
|
glm::vec3 hitPos{};
|
||||||
if (!intersect_ray_bounds(rayOrigin, rayDir, obj.bounds, obj.transform, hitPos))
|
BoundsHitDebug localDebug{};
|
||||||
|
if (!intersect_ray_bounds(rayOrigin, rayDir, obj, hitPos, &localDebug))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -414,6 +461,23 @@ bool SceneManager::pick(const glm::vec2 &mousePosPixels, RenderObject &outObject
|
|||||||
bestHitPos = hitPos;
|
bestHitPos = hitPos;
|
||||||
outObject = obj;
|
outObject = obj;
|
||||||
anyHit = true;
|
anyHit = true;
|
||||||
|
|
||||||
|
// Capture debug info for the best hit so far.
|
||||||
|
pickingDebug.usedMeshBVH = localDebug.usedBVH;
|
||||||
|
pickingDebug.meshBVHHit = localDebug.bvhHit;
|
||||||
|
pickingDebug.meshBVHFallbackBox = localDebug.fallbackBox;
|
||||||
|
if (obj.sourceMesh && obj.sourceMesh->bvh)
|
||||||
|
{
|
||||||
|
pickingDebug.meshBVHPrimCount =
|
||||||
|
static_cast<uint32_t>(obj.sourceMesh->bvh->primitives.size());
|
||||||
|
pickingDebug.meshBVHNodeCount =
|
||||||
|
static_cast<uint32_t>(obj.sourceMesh->bvh->nodes.size());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pickingDebug.meshBVHPrimCount = 0;
|
||||||
|
pickingDebug.meshBVHNodeCount = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user