Remove dependency on IWorldObject by introducing callbacks, and change terminology from "world object" to "(world) entity"
This commit is contained in:
parent
3bea3f5a9b
commit
83141aeb14
@ -1,13 +0,0 @@
|
|||||||
using System.Numerics;
|
|
||||||
|
|
||||||
namespace SpatialCollections
|
|
||||||
{
|
|
||||||
public interface IWorldObject
|
|
||||||
{
|
|
||||||
Vector2 Position { get; }
|
|
||||||
|
|
||||||
float BoundingRadius { get; }
|
|
||||||
|
|
||||||
Rectangle BoundingBox { get; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,15 +3,16 @@
|
|||||||
namespace SpatialCollections
|
namespace SpatialCollections
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <para>Quadtree for fast add, remove, and query of world objects. The world size does not have to be known in
|
/// <para>Quadtree for fast add, remove, and query of world entities. The world size does not have to be known in
|
||||||
/// advance, instead the boundaries will be determined dynamically as objects are added.</para>
|
/// advance, instead the boundaries will be determined dynamically as entities are added.</para>
|
||||||
/// <para>Objects are expected to have similar size, and to be somewhat small in relation to the distances in
|
/// <para>Entities are expected to have similar size, and to be somewhat small in relation to the distances in
|
||||||
/// between them. This allows simple queries. If very large objects would have to be stored, it could become
|
/// between them. This allows simple queries. If very large entities would have to be stored, it could become
|
||||||
/// necessary to store objects not in a single leaf, but in each leaf they intersect with.</para>
|
/// necessary to store entities not in a single leaf, but in each leaf they intersect with.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// TODO: Support for moving objects without having to remove and re-add them.
|
/// <typeparam name="T">Type of the managed entities.</typeparam>
|
||||||
// TODO: Add method to prune empty leaves. An empty leaf is not removed automatically when its last object is removed.
|
// TODO: Support for moving entites without having to remove and re-add them.
|
||||||
public class Quadtree
|
// TODO: Add method to prune empty leaves. An empty leaf is not removed automatically when its last entity is removed.
|
||||||
|
public class Quadtree<T>
|
||||||
{
|
{
|
||||||
// TODO: It should be possible to calculate whether average query time will be better with or without split, without resorting to max leaf size and max tree depth.
|
// TODO: It should be possible to calculate whether average query time will be better with or without split, without resorting to max leaf size and max tree depth.
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -26,6 +27,16 @@ namespace SpatialCollections
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly int _maxTreeDepth;
|
private readonly int _maxTreeDepth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback to determine the position of an entity.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<T, Vector2> _getPositionCallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback to determine the bounding box of an entity.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<T, Rectangle> _getBoundingBoxCallback;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of quadtree vertices. The root vertex is always the first item. The four child vertices of a branch
|
/// The list of quadtree vertices. The root vertex is always the first item. The four child vertices of a branch
|
||||||
/// are always created together and stored contiguously.
|
/// are always created together and stored contiguously.
|
||||||
@ -35,14 +46,14 @@ namespace SpatialCollections
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of all items stored in the quadtree.
|
/// The list of all items stored in the quadtree.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly List<QuadtreeItem<IWorldObject>> _items;
|
private readonly List<QuadtreeItem<T>> _items;
|
||||||
|
|
||||||
private Rectangle _rootBoundingBox;
|
private Rectangle _rootBoundingBox;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The maximum bounding radius of all stored objects, used to inflate the bounding box for queries.
|
/// The maximum bounding radius of all stored entities, used to inflate the bounding box for queries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private float _maxObjectRadius;
|
private float _maxEntityBoundingRadius;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stack of vertex indices with their bounding boxes. It is reused for each query.
|
/// Stack of vertex indices with their bounding boxes. It is reused for each query.
|
||||||
@ -54,27 +65,30 @@ namespace SpatialCollections
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Stack<int> _vertexStack;
|
private readonly Stack<int> _vertexStack;
|
||||||
|
|
||||||
public Quadtree(int maxLeafSize, int maxTreeDepth)
|
public Quadtree(int maxLeafSize, int maxTreeDepth, Func<T, Vector2> getPositionCallback,
|
||||||
|
Func<T, Rectangle> getBoundingBoxCallback)
|
||||||
{
|
{
|
||||||
_maxLeafSize = maxLeafSize;
|
_maxLeafSize = maxLeafSize;
|
||||||
_maxTreeDepth = maxTreeDepth;
|
_maxTreeDepth = maxTreeDepth;
|
||||||
|
_getPositionCallback = getPositionCallback;
|
||||||
|
_getBoundingBoxCallback = getBoundingBoxCallback;
|
||||||
_vertices = new List<QuadtreeVertex>() { new(-1, 0) };
|
_vertices = new List<QuadtreeVertex>() { new(-1, 0) };
|
||||||
_items = new List<QuadtreeItem<IWorldObject>>();
|
_items = new List<QuadtreeItem<T>>();
|
||||||
_rootBoundingBox = new Rectangle(Vector2.Zero, Vector2.Zero);
|
_rootBoundingBox = new Rectangle(Vector2.Zero, Vector2.Zero);
|
||||||
_maxObjectRadius = 0f;
|
_maxEntityBoundingRadius = 0f;
|
||||||
_queryStack = new Stack<QuadtreeQuery>();
|
_queryStack = new Stack<QuadtreeQuery>();
|
||||||
_vertexStack = new Stack<int>();
|
_vertexStack = new Stack<int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Count => _items.Count;
|
public int Count => _items.Count;
|
||||||
|
|
||||||
public void Add(IWorldObject obj)
|
public void Add(T entity, float boundingRadius)
|
||||||
{
|
{
|
||||||
var itemIndex = _items.Count;
|
var itemIndex = _items.Count;
|
||||||
_items.Add(new QuadtreeItem<IWorldObject>(obj));
|
_items.Add(new QuadtreeItem<T>(entity));
|
||||||
if (_maxObjectRadius < obj.BoundingRadius)
|
if (_maxEntityBoundingRadius < boundingRadius)
|
||||||
{
|
{
|
||||||
_maxObjectRadius = obj.BoundingRadius;
|
_maxEntityBoundingRadius = boundingRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_vertices.Count == 1 && _vertices[0].ChildCount < _maxLeafSize)
|
if (_vertices.Count == 1 && _vertices[0].ChildCount < _maxLeafSize)
|
||||||
@ -86,42 +100,49 @@ namespace SpatialCollections
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Expands the root until the new world object fits.
|
var position = _getPositionCallback(entity);
|
||||||
while (!_rootBoundingBox.Contains(obj.Position))
|
|
||||||
|
// Expands the root until the new entity fits.
|
||||||
|
while (!_rootBoundingBox.Contains(position))
|
||||||
{
|
{
|
||||||
ExpandRoot(obj.Position);
|
ExpandRoot(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
(int leafIndex, int depth, Rectangle bounds) = FindLeaf(0, _rootBoundingBox, 0, obj.Position);
|
(int leafIndex, int depth, Rectangle bounds) = FindLeaf(0, _rootBoundingBox, 0, position);
|
||||||
while (_vertices[leafIndex].ChildCount >= _maxLeafSize && depth < _maxTreeDepth)
|
while (_vertices[leafIndex].ChildCount >= _maxLeafSize && depth < _maxTreeDepth)
|
||||||
{
|
{
|
||||||
// Splits the vertex and decends into one of the new leaves.
|
// Splits the vertex and decends into one of the new leaves.
|
||||||
SplitLeaf(leafIndex, bounds);
|
SplitLeaf(leafIndex, bounds);
|
||||||
(leafIndex, depth, bounds) = FindLeaf(leafIndex, bounds, depth, obj.Position);
|
(leafIndex, depth, bounds) = FindLeaf(leafIndex, bounds, depth, position);
|
||||||
}
|
}
|
||||||
AddToLeaf(leafIndex, itemIndex);
|
AddToLeaf(leafIndex, itemIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Remove(IWorldObject obj)
|
public bool Remove(T entity)
|
||||||
{
|
{
|
||||||
// Finds the leaf that should contain the world object.
|
if (entity == null)
|
||||||
(int leafIndex, _, _) = FindLeaf(0, _rootBoundingBox, 0, obj.Position);
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Tries to find the item of the world object.
|
// Finds the leaf that should contain the entity.
|
||||||
|
(int leafIndex, _, _) = FindLeaf(0, _rootBoundingBox, 0, _getPositionCallback(entity));
|
||||||
|
|
||||||
|
// Tries to find the item referencing the entity.
|
||||||
int previous = -1;
|
int previous = -1;
|
||||||
int current = _vertices[leafIndex].FirstChildIndex;
|
int current = _vertices[leafIndex].FirstChildIndex;
|
||||||
while (current != -1)
|
while (current != -1)
|
||||||
{
|
{
|
||||||
QuadtreeItem<IWorldObject> item = _items[current];
|
QuadtreeItem<T> item = _items[current];
|
||||||
if (item.WorldObject == obj)
|
if (entity.Equals(item.Entity))
|
||||||
{
|
{
|
||||||
// Removes the found item from the leaf vertex.
|
// Removes the found item from the leaf vertex.
|
||||||
QuadtreeVertex leaf = _vertices[leafIndex];
|
QuadtreeVertex leaf = _vertices[leafIndex];
|
||||||
leaf.ChildCount--;
|
leaf.ChildCount--;
|
||||||
if (previous != -1)
|
if (previous != -1)
|
||||||
{
|
{
|
||||||
_items[previous] = new QuadtreeItem<IWorldObject>(_items[previous].WorldObject, item.Next);
|
_items[previous] = new QuadtreeItem<T>(_items[previous].Entity, item.Next);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -138,14 +159,14 @@ namespace SpatialCollections
|
|||||||
current = item.Next;
|
current = item.Next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// World object was not found.
|
// Entity was not found.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Query(Rectangle box, List<IWorldObject> resultList)
|
public void Query(Rectangle box, List<T> resultList)
|
||||||
{
|
{
|
||||||
Rectangle inflatedBox = box;
|
Rectangle inflatedBox = box;
|
||||||
inflatedBox.Expand(_maxObjectRadius);
|
inflatedBox.Expand(_maxEntityBoundingRadius);
|
||||||
|
|
||||||
QueryProcessChildVertex(inflatedBox, box, 0, _rootBoundingBox, resultList, _queryStack);
|
QueryProcessChildVertex(inflatedBox, box, 0, _rootBoundingBox, resultList, _queryStack);
|
||||||
|
|
||||||
@ -175,7 +196,7 @@ namespace SpatialCollections
|
|||||||
_vertices.Add(new QuadtreeVertex(-1, 0));
|
_vertices.Add(new QuadtreeVertex(-1, 0));
|
||||||
_items.Clear();
|
_items.Clear();
|
||||||
_rootBoundingBox = new Rectangle(Vector2.Zero, Vector2.Zero);
|
_rootBoundingBox = new Rectangle(Vector2.Zero, Vector2.Zero);
|
||||||
_maxObjectRadius = 0f;
|
_maxEntityBoundingRadius = 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -223,7 +244,7 @@ namespace SpatialCollections
|
|||||||
|
|
||||||
private void AddToRootLeaf(int itemIndex)
|
private void AddToRootLeaf(int itemIndex)
|
||||||
{
|
{
|
||||||
Vector2 position = _items[itemIndex].WorldObject.Position;
|
Vector2 position = _getPositionCallback(_items[itemIndex].Entity);
|
||||||
if (_vertices[0].ChildCount == 0)
|
if (_vertices[0].ChildCount == 0)
|
||||||
{
|
{
|
||||||
_vertices[0] = new QuadtreeVertex(itemIndex, 1);
|
_vertices[0] = new QuadtreeVertex(itemIndex, 1);
|
||||||
@ -252,7 +273,7 @@ namespace SpatialCollections
|
|||||||
next = _vertices[leafIndex].FirstChildIndex;
|
next = _vertices[leafIndex].FirstChildIndex;
|
||||||
_vertices[leafIndex] = new QuadtreeVertex(itemIndex, _vertices[leafIndex].ChildCount + 1);
|
_vertices[leafIndex] = new QuadtreeVertex(itemIndex, _vertices[leafIndex].ChildCount + 1);
|
||||||
}
|
}
|
||||||
_items[itemIndex] = new QuadtreeItem<IWorldObject>(_items[itemIndex].WorldObject, next);
|
_items[itemIndex] = new QuadtreeItem<T>(_items[itemIndex].Entity, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SplitLeaf(int leafIndex, Rectangle leafBounds)
|
private void SplitLeaf(int leafIndex, Rectangle leafBounds)
|
||||||
@ -271,7 +292,7 @@ namespace SpatialCollections
|
|||||||
while (next != -1)
|
while (next != -1)
|
||||||
{
|
{
|
||||||
var item = _items[next];
|
var item = _items[next];
|
||||||
int newLeafIndex = FindNextVertex(leafIndex, center, item.WorldObject.Position);
|
int newLeafIndex = FindNextVertex(leafIndex, center, _getPositionCallback(item.Entity));
|
||||||
AddToLeaf(newLeafIndex, next);
|
AddToLeaf(newLeafIndex, next);
|
||||||
next = item.Next;
|
next = item.Next;
|
||||||
}
|
}
|
||||||
@ -322,7 +343,7 @@ namespace SpatialCollections
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void QueryProcessChildVertex(Rectangle inflatedBox, Rectangle box, int vertexIndex,
|
private void QueryProcessChildVertex(Rectangle inflatedBox, Rectangle box, int vertexIndex,
|
||||||
Rectangle vertexBounds, List<IWorldObject> resultList, Stack<QuadtreeQuery> stack)
|
Rectangle vertexBounds, List<T> resultList, Stack<QuadtreeQuery> stack)
|
||||||
{
|
{
|
||||||
switch (inflatedBox.Intersects(vertexBounds))
|
switch (inflatedBox.Intersects(vertexBounds))
|
||||||
{
|
{
|
||||||
@ -353,7 +374,7 @@ namespace SpatialCollections
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueryAppendContainedItems(int vertexIndex, Rectangle box, List<IWorldObject> resultList)
|
private void QueryAppendContainedItems(int vertexIndex, Rectangle box, List<T> resultList)
|
||||||
{
|
{
|
||||||
_vertexStack.Push(vertexIndex);
|
_vertexStack.Push(vertexIndex);
|
||||||
|
|
||||||
@ -377,15 +398,15 @@ namespace SpatialCollections
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueryAppendContainedLeafItems(int vertexIndex, Rectangle box, List<IWorldObject> resultList)
|
private void QueryAppendContainedLeafItems(int vertexIndex, Rectangle box, List<T> resultList)
|
||||||
{
|
{
|
||||||
int index = _vertices[vertexIndex].FirstChildIndex;
|
int index = _vertices[vertexIndex].FirstChildIndex;
|
||||||
while (index != -1)
|
while (index != -1)
|
||||||
{
|
{
|
||||||
IWorldObject obj = _items[index].WorldObject;
|
T entity = _items[index].Entity;
|
||||||
if (box.Intersects(obj.BoundingBox) != IntersectionType.Disjoint)
|
if (box.Intersects(_getBoundingBoxCallback(entity)) != IntersectionType.Disjoint)
|
||||||
{
|
{
|
||||||
resultList.Add(obj);
|
resultList.Add(entity);
|
||||||
}
|
}
|
||||||
index = _items[index].Next;
|
index = _items[index].Next;
|
||||||
}
|
}
|
||||||
@ -401,7 +422,7 @@ namespace SpatialCollections
|
|||||||
_items.RemoveUnorderedAt(index);
|
_items.RemoveUnorderedAt(index);
|
||||||
if (index != _items.Count)
|
if (index != _items.Count)
|
||||||
{
|
{
|
||||||
(int leafIndex, _, _) = FindLeaf(0, _rootBoundingBox, 0, _items[index].WorldObject.Position);
|
(int leafIndex, _, _) = FindLeaf(0, _rootBoundingBox, 0, _getPositionCallback(_items[index].Entity));
|
||||||
int current = _vertices[leafIndex].FirstChildIndex;
|
int current = _vertices[leafIndex].FirstChildIndex;
|
||||||
if (current == _items.Count)
|
if (current == _items.Count)
|
||||||
{
|
{
|
||||||
@ -413,7 +434,7 @@ namespace SpatialCollections
|
|||||||
{
|
{
|
||||||
current = _items[current].Next;
|
current = _items[current].Next;
|
||||||
}
|
}
|
||||||
_items[current] = new QuadtreeItem<IWorldObject>(_items[current].WorldObject, index);
|
_items[current] = new QuadtreeItem<T>(_items[current].Entity, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,16 +7,16 @@
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int Next;
|
public int Next;
|
||||||
|
|
||||||
public T WorldObject;
|
public T Entity;
|
||||||
|
|
||||||
public QuadtreeItem(T worldObject) : this(worldObject, -1)
|
public QuadtreeItem(T entity) : this(entity, -1)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public QuadtreeItem(T worldObject, int next)
|
public QuadtreeItem(T entity, int next)
|
||||||
{
|
{
|
||||||
Next = next;
|
Next = next;
|
||||||
WorldObject = worldObject;
|
Entity = entity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,32 +6,32 @@ namespace QuadtreeTests
|
|||||||
{
|
{
|
||||||
public class Tests
|
public class Tests
|
||||||
{
|
{
|
||||||
private class TestWorldObject(Vector2 position) : IWorldObject
|
private const float BoundingRadius = 1.0f;
|
||||||
|
|
||||||
|
private class TestEntity(Vector2 position)
|
||||||
{
|
{
|
||||||
public Vector2 Position { get; } = position;
|
public Vector2 Position { get; } = position;
|
||||||
|
|
||||||
public float BoundingRadius { get; } = 1.0f;
|
|
||||||
|
|
||||||
public Rectangle BoundingBox { get; } = new Rectangle(position - Vector2.One, position + Vector2.One);
|
public Rectangle BoundingBox { get; } = new Rectangle(position - Vector2.One, position + Vector2.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Vector2> _positions;
|
private List<Vector2> _positions;
|
||||||
|
|
||||||
private List<IWorldObject> _objects;
|
private List<TestEntity> _entities;
|
||||||
|
|
||||||
private Quadtree _quadtree;
|
private Quadtree<TestEntity> _quadtree;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void Setup()
|
public void Setup()
|
||||||
{
|
{
|
||||||
_positions = [new Vector2(1.0f, 1.0f), new Vector2(3.0f, 3.0f), new Vector2(5.0f, 8.0f)];
|
_positions = [new Vector2(1.0f, 1.0f), new Vector2(3.0f, 3.0f), new Vector2(5.0f, 8.0f)];
|
||||||
_objects = new();
|
_entities = new();
|
||||||
_quadtree = new(20, 4);
|
_quadtree = new(20, 4, e => e.Position, e => e.BoundingBox);
|
||||||
|
|
||||||
for (int i = 0; i < _positions.Count; i++)
|
for (int i = 0; i < _positions.Count; i++)
|
||||||
{
|
{
|
||||||
var obj = new TestWorldObject(_positions[i]);
|
var obj = new TestEntity(_positions[i]);
|
||||||
_objects.Add(obj);
|
_entities.Add(obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,10 +46,10 @@ namespace QuadtreeTests
|
|||||||
{
|
{
|
||||||
AddObjectsAndAssertCount();
|
AddObjectsAndAssertCount();
|
||||||
|
|
||||||
for (int i = 0; i < _objects.Count; i++)
|
for (int i = 0; i < _entities.Count; i++)
|
||||||
{
|
{
|
||||||
_quadtree.Remove(_objects[i]);
|
_quadtree.Remove(_entities[i]);
|
||||||
Assert.That(_quadtree.Count, Is.EqualTo(_objects.Count - i - 1));
|
Assert.That(_quadtree.Count, Is.EqualTo(_entities.Count - i - 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,16 +57,16 @@ namespace QuadtreeTests
|
|||||||
public void TestQuery()
|
public void TestQuery()
|
||||||
{
|
{
|
||||||
AddObjectsAndAssertCount();
|
AddObjectsAndAssertCount();
|
||||||
List<IWorldObject> result = new();
|
List<TestEntity> result = new();
|
||||||
_quadtree.Query(new Rectangle(new Vector2(3.5f, 3.5f), new Vector2(10.0f, 10.0f)), result);
|
_quadtree.Query(new Rectangle(new Vector2(3.5f, 3.5f), new Vector2(10.0f, 10.0f)), result);
|
||||||
Assert.That(result.Count, Is.EqualTo(2));
|
Assert.That(result.Count, Is.EqualTo(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddObjectsAndAssertCount()
|
private void AddObjectsAndAssertCount()
|
||||||
{
|
{
|
||||||
for (int i = 0; i < _objects.Count; i++)
|
for (int i = 0; i < _entities.Count; i++)
|
||||||
{
|
{
|
||||||
_quadtree.Add(_objects[i]);
|
_quadtree.Add(_entities[i], BoundingRadius);
|
||||||
Assert.That(_quadtree.Count, Is.EqualTo(i + 1));
|
Assert.That(_quadtree.Count, Is.EqualTo(i + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
# Quadtree
|
# Quadtree
|
||||||
|
|
||||||
Quadtree for fast add, remove, and query of world objects. The world size does not have to be known in advance, instead the boundaries will be determined dynamically as objects are added.
|
Quadtree for fast add, remove, and query of world entities. The world size does not have to be known in advance, instead the boundaries will be determined dynamically as entities are added.
|
Loading…
x
Reference in New Issue
Block a user