[orx-math] Add demo and readme texts.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# orx-math
|
# orx-math
|
||||||
|
|
||||||
Mathematical utilities
|
Mathematical utilities, including complex numbers,
|
||||||
|
linear ranges, simplex ranges, matrices and radial basis functions (RBF).
|
||||||
|
|
||||||
|
|
||||||
<!-- __demos__ -->
|
<!-- __demos__ -->
|
||||||
## Demos
|
## Demos
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import org.openrndr.shape.Rectangle
|
|||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrate how to create a 1D linear range between two instances of a `LinearType`, in this case,
|
||||||
|
* a horizontal `Rectangle` and a vertical one.
|
||||||
|
*
|
||||||
|
* Notice how the `..` operator is used to construct the `LinearRange1D`.
|
||||||
|
*
|
||||||
|
* The resulting `LinearRange1D` provides a `value()` method that takes a normalized
|
||||||
|
* input and returns an interpolated value between the two input elements.
|
||||||
|
*
|
||||||
|
* This example draws a grid of rectangles interpolated between the horizontal and the vertical
|
||||||
|
* triangles. The x and y coordinates and the `seconds` variable are used to specify the
|
||||||
|
* interpolation value for each grid cell.
|
||||||
|
*
|
||||||
|
* One can use the `LinearRange` class to construct
|
||||||
|
* - a `LinearRange2D` out of two `LinearRange1D`
|
||||||
|
* - a `LinearRange3D` out of two `LinearRange2D`
|
||||||
|
* - a `LinearRange4D` out of two `LinearRange3D`
|
||||||
|
*
|
||||||
|
* (not demonstrated here)
|
||||||
|
*
|
||||||
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
configure {
|
configure {
|
||||||
@@ -24,7 +45,7 @@ fun main() {
|
|||||||
for (y in 0 until height step 72) {
|
for (y in 0 until height step 72) {
|
||||||
for (x in 0 until width step 72) {
|
for (x in 0 until width step 72) {
|
||||||
val u = cos(seconds + x * 0.007) * 0.5 + 0.5
|
val u = cos(seconds + x * 0.007) * 0.5 + 0.5
|
||||||
val s = sin(seconds*1.03 + y * 0.0075) * 0.5 + 0.5
|
val s = sin(seconds * 1.03 + y * 0.0075) * 0.5 + 0.5
|
||||||
drawer.isolated {
|
drawer.isolated {
|
||||||
drawer.translate(x.toDouble(), y.toDouble())
|
drawer.translate(x.toDouble(), y.toDouble())
|
||||||
drawer.rectangle(range.value(u * s))
|
drawer.rectangle(range.value(u * s))
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import org.openrndr.shape.Rectangle
|
|||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrates how to create a `LinearRange2D` out of two `LinearRange1D` instances.
|
||||||
|
* The first range interpolates a horizontal rectangle into a vertical one.
|
||||||
|
* The second range interpolates two smaller squares of equal size, one placed
|
||||||
|
* higher along the y-axis and another one lower.
|
||||||
|
*
|
||||||
|
* A grid of such rectangles is displayed, animating the `u` and `v` parameters based on
|
||||||
|
* `seconds`, `x` and `y` indices. The second range results in a vertical wave effect.
|
||||||
|
*
|
||||||
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
configure {
|
configure {
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import kotlin.math.cos
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demonstrate least squares method to find a regression line through noisy points
|
* Demonstrate least squares method to find a regression line through noisy points.
|
||||||
* Line drawn in red is the estimated line, in green is the ground-truth line
|
* The line drawn in red is the estimated line. The green one is the ground-truth.
|
||||||
*
|
*
|
||||||
* Ax = b => x = A⁻¹b
|
* `Ax = b => x = A⁻¹b`
|
||||||
* because A is likely inconsistent, we look for an approximate x based on AᵀA, which is consistent.
|
* because `A` is likely inconsistent, we look for an approximate `x` based on `AᵀA`, which is consistent.
|
||||||
* x̂ = (AᵀA)⁻¹ Aᵀb
|
* `x̂ = (AᵀA)⁻¹ Aᵀb`
|
||||||
*/
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
@@ -26,23 +26,23 @@ fun main() {
|
|||||||
}
|
}
|
||||||
program {
|
program {
|
||||||
extend {
|
extend {
|
||||||
val ls = drawer.bounds.horizontal(0.5).rotateBy(cos(seconds)*45.0)
|
val groundTruth = drawer.bounds.horizontal(0.5).rotateBy(cos(seconds) * 45.0)
|
||||||
|
|
||||||
val r = Random((seconds*10).toInt())
|
val r = Random((seconds * 10).toInt())
|
||||||
|
|
||||||
val pointCount = 100
|
val pointCount = 100
|
||||||
val A = Matrix(pointCount, 2)
|
val A = Matrix(pointCount, 2)
|
||||||
val b = Matrix(pointCount, 1)
|
val b = Matrix(pointCount, 1)
|
||||||
for (i in 0 until pointCount) {
|
for (i in 0 until pointCount) {
|
||||||
|
|
||||||
val p = ls.position(Double.uniform(0.0, 1.0, r))
|
val point = groundTruth.position(Double.uniform(0.0, 1.0, r))
|
||||||
val pr = p + Vector2.uniformRing(0.0, 130.0, r)
|
val pointRandomized = point + Vector2.uniformRing(0.0, 130.0, r)
|
||||||
|
|
||||||
A[i, 0] = 1.0
|
A[i, 0] = 1.0
|
||||||
A[i, 1] = pr.x
|
A[i, 1] = pointRandomized.x
|
||||||
b[i, 0] = pr.y
|
b[i, 0] = pointRandomized.y
|
||||||
|
|
||||||
drawer.circle(pr, 5.0)
|
drawer.circle(pointRandomized, 5.0)
|
||||||
}
|
}
|
||||||
val At = A.transposed()
|
val At = A.transposed()
|
||||||
val AtA = At * A
|
val AtA = At * A
|
||||||
@@ -58,7 +58,7 @@ fun main() {
|
|||||||
drawer.lineSegment(p0, p1)
|
drawer.lineSegment(p0, p1)
|
||||||
|
|
||||||
drawer.stroke = ColorRGBa.GREEN
|
drawer.stroke = ColorRGBa.GREEN
|
||||||
drawer.lineSegment(ls)
|
drawer.lineSegment(groundTruth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ import kotlin.math.pow
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demonstrate least squares method to fit a cubic bezier to noisy points
|
* Demonstrate how to use the `least squares` method to fit a cubic bezier to noisy points.
|
||||||
|
*
|
||||||
|
* On every animation frame, 10 concentric circles are created centered on the window and converted to contours.
|
||||||
|
* In OPENRNDR, circular contours are made ouf of 4 cubic-Bezier curves. Each of those curves is considered
|
||||||
|
* one by one as the ground truth, then 5 points are sampled near those curves.
|
||||||
|
* Finally, two matrices are constructed using those points and math operations are applied to
|
||||||
|
* revert the randomization attempting to reconstruct the original curves.
|
||||||
|
*
|
||||||
|
* The result is drawn on every animation frame, revealing concentric circles that are more or less similar
|
||||||
|
* to the ground truth depending on the random values used.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
@@ -34,8 +44,8 @@ fun main() {
|
|||||||
}
|
}
|
||||||
extend {
|
extend {
|
||||||
for (z in 0 until 10) {
|
for (z in 0 until 10) {
|
||||||
val c = Circle(drawer.bounds.center, 300.0- z*30.0).contour
|
val c = Circle(drawer.bounds.center, 300.0 - z * 30.0).contour
|
||||||
for (ls in c.segments) {
|
for (groundTruth in c.segments) {
|
||||||
|
|
||||||
val pointCount = 5
|
val pointCount = 5
|
||||||
val A = Matrix(pointCount, 4)
|
val A = Matrix(pointCount, 4)
|
||||||
@@ -46,15 +56,15 @@ fun main() {
|
|||||||
pointCount - 1 -> 1.0
|
pointCount - 1 -> 1.0
|
||||||
else -> Double.uniform(0.0, 1.0, r)
|
else -> Double.uniform(0.0, 1.0, r)
|
||||||
}
|
}
|
||||||
val p = ls.position(t)
|
val point = groundTruth.position(t)
|
||||||
val pr = p + Vector2.uniformRing(0.0, 0.5, r)
|
val pointRandomized = point + Vector2.uniformRing(0.0, 0.5, r)
|
||||||
|
|
||||||
A[i, 0] = bernstein(3, 0, t)
|
A[i, 0] = bernstein(3, 0, t)
|
||||||
A[i, 1] = bernstein(3, 1, t)
|
A[i, 1] = bernstein(3, 1, t)
|
||||||
A[i, 2] = bernstein(3, 2, t)
|
A[i, 2] = bernstein(3, 2, t)
|
||||||
A[i, 3] = bernstein(3, 3, t)
|
A[i, 3] = bernstein(3, 3, t)
|
||||||
b[i, 0] = pr.x
|
b[i, 0] = pointRandomized.x
|
||||||
b[i, 1] = pr.y
|
b[i, 1] = pointRandomized.y
|
||||||
}
|
}
|
||||||
val At = A.transposed()
|
val At = A.transposed()
|
||||||
val AtA = At * A
|
val AtA = At * A
|
||||||
@@ -64,11 +74,9 @@ fun main() {
|
|||||||
val x = AtAI * Atb
|
val x = AtAI * Atb
|
||||||
|
|
||||||
val segment = Segment2D(
|
val segment = Segment2D(
|
||||||
//ls.start,
|
|
||||||
Vector2(x[0, 0], x[0, 1]),
|
Vector2(x[0, 0], x[0, 1]),
|
||||||
Vector2(x[1, 0], x[1, 1]),
|
Vector2(x[1, 0], x[1, 1]),
|
||||||
Vector2(x[2, 0], x[2, 1]),
|
Vector2(x[2, 0], x[2, 1]),
|
||||||
//ls.end
|
|
||||||
Vector2(x[3, 0], x[3, 1])
|
Vector2(x[3, 0], x[3, 1])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ import kotlin.ranges.until
|
|||||||
import kotlin.text.trimIndent
|
import kotlin.text.trimIndent
|
||||||
import kotlin.text.trimMargin
|
import kotlin.text.trimMargin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrates using a two-dimensional Radial Basis Function (RBF) interpolator
|
||||||
|
* with the user provided 2D input points, their corresponding values (colors in this demo),
|
||||||
|
* a smoothing factor, and a radial basis function kernel.
|
||||||
|
*
|
||||||
|
* The program chooses 14 random points in the window area leaving a 100 pixels
|
||||||
|
* margin around the borders and assigns a randomized color to each point.
|
||||||
|
*
|
||||||
|
* Next it creates the interpolator using those points and colors, a smoothing factor
|
||||||
|
* and the RBF function used for interpolation. This function takes a squared distance
|
||||||
|
* as input and returns a scalar value representing the influence of points at that distance.
|
||||||
|
*
|
||||||
|
* A ShadeStyle implementing the RBF interpolation is created next, used to render
|
||||||
|
* the background gradient interpolating all points and their colors.
|
||||||
|
*
|
||||||
|
* After rendering the background, the original points and their colors are
|
||||||
|
* drawn as circles for reference.
|
||||||
|
*
|
||||||
|
* Finally, the current mouse position is used for sampling a color
|
||||||
|
* from the interpolator and displayed for comparison. Notice that even if
|
||||||
|
* the fill color is flat, it may look like a gradient due to the changing
|
||||||
|
* colors in the surrounding pixels.
|
||||||
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
configure {
|
configure {
|
||||||
@@ -32,7 +55,7 @@ fun main() {
|
|||||||
val r = Random(0)
|
val r = Random(0)
|
||||||
val points = drawer.bounds.offsetEdges(-100.0).uniform(14, r)
|
val points = drawer.bounds.offsetEdges(-100.0).uniform(14, r)
|
||||||
|
|
||||||
val colors = (0 until points.size).map {
|
val colors = points.map {
|
||||||
ColorRGBa.PINK
|
ColorRGBa.PINK
|
||||||
.shiftHue<OKHSV>(Double.uniform(-180.0, 180.0, r))
|
.shiftHue<OKHSV>(Double.uniform(-180.0, 180.0, r))
|
||||||
.shadeLuminosity<OKLab>(Double.uniform(0.4, 1.0, r))
|
.shadeLuminosity<OKLab>(Double.uniform(0.4, 1.0, r))
|
||||||
@@ -50,12 +73,13 @@ fun main() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shader style that implements RBF interpolation in the fragment shader.
|
* Shader style that implements RBF interpolation in the fragment shader.
|
||||||
* Uses Gaussian RBF function to interpolate colors between given points.
|
* Uses a Gaussian RBF function to interpolate colors between given points.
|
||||||
* Includes custom distance calculation and color interpolation functions.
|
* Includes custom distance calculation and color interpolation functions.
|
||||||
*/
|
*/
|
||||||
val ss = shadeStyle {
|
val ss = shadeStyle {
|
||||||
fragmentPreamble = """${fhash12Phrase}
|
fragmentPreamble = """
|
||||||
|${rbfGaussianPhrase}
|
|$fhash12Phrase
|
||||||
|
|$rbfGaussianPhrase
|
||||||
|float squaredDistance(vec2 p, vec2 q) {
|
|float squaredDistance(vec2 p, vec2 q) {
|
||||||
| vec2 d = p - q;
|
| vec2 d = p - q;
|
||||||
| return dot(d, d);
|
| return dot(d, d);
|
||||||
@@ -64,9 +88,7 @@ fun main() {
|
|||||||
| vec3 c = p_mean;
|
| vec3 c = p_mean;
|
||||||
| for (int i = 0; i < p_weights_SIZE; ++i) {
|
| for (int i = 0; i < p_weights_SIZE; ++i) {
|
||||||
| float r = rbfGaussian(squaredDistance(p_points[i], p), $scale);
|
| float r = rbfGaussian(squaredDistance(p_points[i], p), $scale);
|
||||||
| c.r += p_weights[i].r * r;
|
| c += p_weights[i].rgb * r;
|
||||||
| c.g += p_weights[i].g * r;
|
|
||||||
| c.b += p_weights[i].b * r;
|
|
||||||
| }
|
| }
|
||||||
| return c;
|
| return c;
|
||||||
|}
|
|}
|
||||||
@@ -74,8 +96,8 @@ fun main() {
|
|||||||
|
|
||||||
fragmentTransform = """
|
fragmentTransform = """
|
||||||
x_fill.rgb = rbfInterpolate(c_boundsPosition.xy * vec2(720.0, 720.0));
|
x_fill.rgb = rbfInterpolate(c_boundsPosition.xy * vec2(720.0, 720.0));
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val weights = (0 until points.size).map {
|
val weights = (0 until points.size).map {
|
||||||
Vector3(interpolator.weights[it][0], interpolator.weights[it][1], interpolator.weights[it][2])
|
Vector3(interpolator.weights[it][0], interpolator.weights[it][1], interpolator.weights[it][2])
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|||||||
@@ -9,23 +9,36 @@ import org.openrndr.extra.color.spaces.OKLab
|
|||||||
import org.openrndr.extra.color.tools.shadeLuminosity
|
import org.openrndr.extra.color.tools.shadeLuminosity
|
||||||
import org.openrndr.extra.color.tools.shiftHue
|
import org.openrndr.extra.color.tools.shiftHue
|
||||||
import org.openrndr.extra.math.rbf.Rbf2DInterpolator
|
import org.openrndr.extra.math.rbf.Rbf2DInterpolator
|
||||||
import org.openrndr.extra.math.rbf.rbfGaussian
|
|
||||||
import org.openrndr.extra.math.rbf.rbfInverseMultiQuadratic
|
import org.openrndr.extra.math.rbf.rbfInverseMultiQuadratic
|
||||||
import org.openrndr.extra.math.rbf.rbfInverseQuadratic
|
|
||||||
import org.openrndr.extra.noise.uniform
|
import org.openrndr.extra.noise.uniform
|
||||||
import org.openrndr.extra.shaderphrases.noise.fhash12Phrase
|
import org.openrndr.extra.shaderphrases.noise.fhash12Phrase
|
||||||
import org.openrndr.extra.shaderphrases.rbf.rbfGaussianPhrase
|
|
||||||
import org.openrndr.extra.shaderphrases.rbf.rbfInverseMultiQuadraticPhrase
|
import org.openrndr.extra.shaderphrases.rbf.rbfInverseMultiQuadraticPhrase
|
||||||
import org.openrndr.extra.shaderphrases.rbf.rbfInverseQuadraticPhrase
|
|
||||||
import org.openrndr.math.Vector3
|
import org.openrndr.math.Vector3
|
||||||
import kotlin.collections.indices
|
|
||||||
import kotlin.collections.map
|
|
||||||
import kotlin.collections.toTypedArray
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.ranges.until
|
|
||||||
import kotlin.text.trimIndent
|
|
||||||
import kotlin.text.trimMargin
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrates using a two-dimensional Radial Basis Function (RBF) interpolator
|
||||||
|
* with the user provided 2D input points, their corresponding values (colors in this demo),
|
||||||
|
* a smoothing factor, and a radial basis function kernel.
|
||||||
|
*
|
||||||
|
* The program chooses 20 random points in the window area leaving a 100 pixels
|
||||||
|
* margin around the borders and assigns a randomized color to each point.
|
||||||
|
*
|
||||||
|
* Next it creates the interpolator using those points and colors, a smoothing factor
|
||||||
|
* and the RBF function used for interpolation. This function takes a squared distance
|
||||||
|
* as input and returns a scalar value representing the influence of points at that distance.
|
||||||
|
*
|
||||||
|
* A ShadeStyle implementing the same RBF interpolation is created next, used to render
|
||||||
|
* the background gradient interpolating all points and their colors.
|
||||||
|
*
|
||||||
|
* After rendering the background, the original points and their colors are
|
||||||
|
* drawn as circles for reference.
|
||||||
|
*
|
||||||
|
* Finally, the current mouse position is used for sampling a color
|
||||||
|
* from the interpolator and displayed for comparison. Notice that even if
|
||||||
|
* the fill color is flat, it may look like a gradient due to the changing
|
||||||
|
* colors in the surrounding pixels.
|
||||||
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
configure {
|
configure {
|
||||||
@@ -36,7 +49,7 @@ fun main() {
|
|||||||
val r = Random(0)
|
val r = Random(0)
|
||||||
val points = drawer.bounds.offsetEdges(-100.0).uniform(20, r)
|
val points = drawer.bounds.offsetEdges(-100.0).uniform(20, r)
|
||||||
|
|
||||||
val colors = (0 until points.size).map {
|
val colors = points.map {
|
||||||
ColorRGBa.PINK
|
ColorRGBa.PINK
|
||||||
.shiftHue<OKHSV>(Double.uniform(-180.0, 180.0, r))
|
.shiftHue<OKHSV>(Double.uniform(-180.0, 180.0, r))
|
||||||
.shadeLuminosity<OKLab>(Double.uniform(0.4, 1.0, r))
|
.shadeLuminosity<OKLab>(Double.uniform(0.4, 1.0, r))
|
||||||
@@ -54,12 +67,13 @@ fun main() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shader style that implements RBF interpolation in the fragment shader.
|
* Shader style that implements RBF interpolation in the fragment shader.
|
||||||
* Uses Gaussian RBF function to interpolate colors between given points.
|
* Uses an Inverse MultiQuadratic RBF function to interpolate colors between given points.
|
||||||
* Includes custom distance calculation and color interpolation functions.
|
* Includes custom distance calculation and color interpolation functions.
|
||||||
*/
|
*/
|
||||||
val ss = shadeStyle {
|
val ss = shadeStyle {
|
||||||
fragmentPreamble = """${fhash12Phrase}
|
fragmentPreamble = """
|
||||||
|${rbfInverseMultiQuadraticPhrase}
|
|$fhash12Phrase
|
||||||
|
|$rbfInverseMultiQuadraticPhrase
|
||||||
|float squaredDistance(vec2 p, vec2 q) {
|
|float squaredDistance(vec2 p, vec2 q) {
|
||||||
| vec2 d = p - q;
|
| vec2 d = p - q;
|
||||||
| return dot(d, d);
|
| return dot(d, d);
|
||||||
@@ -68,9 +82,7 @@ fun main() {
|
|||||||
| vec3 c = p_mean;
|
| vec3 c = p_mean;
|
||||||
| for (int i = 0; i < p_weights_SIZE; ++i) {
|
| for (int i = 0; i < p_weights_SIZE; ++i) {
|
||||||
| float r = rbfInverseMultiQuadratic(squaredDistance(p_points[i], p), $scale);
|
| float r = rbfInverseMultiQuadratic(squaredDistance(p_points[i], p), $scale);
|
||||||
| c.r += p_weights[i].r * r;
|
| c += p_weights[i].rgb * r;
|
||||||
| c.g += p_weights[i].g * r;
|
|
||||||
| c.b += p_weights[i].b * r;
|
|
||||||
| }
|
| }
|
||||||
| return c;
|
| return c;
|
||||||
|}
|
|}
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ import org.openrndr.extra.meshgenerators.boxMesh
|
|||||||
import org.openrndr.extra.math.simplexrange.SimplexRange3D
|
import org.openrndr.extra.math.simplexrange.SimplexRange3D
|
||||||
import org.openrndr.math.Vector3
|
import org.openrndr.math.Vector3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrates the use of the `SimplexRange3D` class. Its constructor takes 4 instances of a `LinearType`
|
||||||
|
* (something that can be interpolated linearly, like `ColorRGBa`). The `SimplexRange3D` instance provides
|
||||||
|
* a `value()` method that returns a `LinearType` interpolated across the 4 constructor arguments using
|
||||||
|
* a normalized 3D coordinate.
|
||||||
|
*
|
||||||
|
* This demo program creates a 3D grid of 20x20x20 unit 3D cubes. Their color is set by interpolating
|
||||||
|
* their XYZ index across the 4 input colors.
|
||||||
|
*
|
||||||
|
* 2D, 4D and ND varieties are also provided by `SimplexRange`.
|
||||||
|
*
|
||||||
|
* *Simplex Range* is not to be confused with *Simplex Noise*.
|
||||||
|
*/
|
||||||
fun main() {
|
fun main() {
|
||||||
application {
|
application {
|
||||||
configure {
|
configure {
|
||||||
|
|||||||
Reference in New Issue
Block a user