mirror of
https://github.com/plexusorg/Plex-FAWE.git
synced 2025-07-12 10:18:36 +00:00
Refactor heightmap classes to math where it makes more sense
(not required by heightmap processor, nor are they processors, used for heightmap brushes etc.)
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
package com.fastasyncworldedit.core.extent.clipboard;
|
||||
|
||||
import com.fastasyncworldedit.core.extent.processor.heightmap.HeightMapType;
|
||||
import com.fastasyncworldedit.core.math.heightmap.HeightMapType;
|
||||
import com.sk89q.jnbt.CompoundTag;
|
||||
import com.sk89q.worldedit.WorldEditException;
|
||||
import com.sk89q.worldedit.entity.Entity;
|
||||
|
@ -1,7 +1,7 @@
|
||||
package com.fastasyncworldedit.core.extent.processor;
|
||||
|
||||
import com.fastasyncworldedit.core.FaweCache;
|
||||
import com.fastasyncworldedit.core.extent.processor.heightmap.HeightMapType;
|
||||
import com.fastasyncworldedit.core.math.heightmap.HeightMapType;
|
||||
import com.fastasyncworldedit.core.queue.IBatchProcessor;
|
||||
import com.fastasyncworldedit.core.queue.IChunk;
|
||||
import com.fastasyncworldedit.core.queue.IChunkGet;
|
||||
|
@ -1,21 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
public class AbstractDelegateHeightMap implements HeightMap {
|
||||
|
||||
private final HeightMap parent;
|
||||
|
||||
public AbstractDelegateHeightMap(HeightMap parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(int x, int z) {
|
||||
return parent.getHeight(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSize(int size) {
|
||||
parent.setSize(size);
|
||||
}
|
||||
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
public class ArrayHeightMap extends ScalableHeightMap {
|
||||
|
||||
// The heights
|
||||
private final byte[][] height;
|
||||
// The height map width/length
|
||||
private final int width;
|
||||
private final int length;
|
||||
// The size to width/length ratio
|
||||
private double rx;
|
||||
private double rz;
|
||||
|
||||
/**
|
||||
* New height map represented by byte array[][] of values x*z to be scaled given a set size
|
||||
*
|
||||
* @param height array of height values
|
||||
* @param minY min y value allowed to be set. Inclusive.
|
||||
* @param maxY max y value allowed to be set. Inclusive.
|
||||
*/
|
||||
public ArrayHeightMap(byte[][] height, int minY, int maxY) {
|
||||
super(minY, maxY);
|
||||
setSize(5);
|
||||
this.height = height;
|
||||
this.width = height.length;
|
||||
this.length = height[0].length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSize(int size) {
|
||||
super.setSize(size);
|
||||
this.rx = (double) width / (size << 1);
|
||||
this.rz = (double) length / (size << 1);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(int x, int z) {
|
||||
x = (int) Math.max(0, Math.min(width - 1, (x + size) * rx));
|
||||
z = (int) Math.max(0, Math.min(length - 1, (z + size) * rz));
|
||||
return ((height[x][z] & 0xFF) * size) / 256d;
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
public class AverageHeightMapFilter {
|
||||
|
||||
private int[] inData;
|
||||
private int[] buffer;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final int minY;
|
||||
private final int maxY;
|
||||
|
||||
public AverageHeightMapFilter(int[] inData, int width, int height, int minY, int maxY) {
|
||||
this.inData = inData;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.minY = minY;
|
||||
this.maxY = maxY;
|
||||
this.buffer = new int[inData.length];
|
||||
}
|
||||
|
||||
public int[] filter(int iterations) {
|
||||
for (int j = 0; j < iterations; j++) {
|
||||
int a = -width;
|
||||
int b = width;
|
||||
int c = 1;
|
||||
int d = -1;
|
||||
for (int i = 0; i < inData.length; i++, a++, b++, c++, d++) {
|
||||
int height = inData[i];
|
||||
if (height < minY || height > maxY) {
|
||||
buffer[i] = height;
|
||||
continue;
|
||||
}
|
||||
int average = (2 + get(a, height) + get(b, height) + get(c, height) + get(d, height)) >> 2;
|
||||
buffer[i] = average;
|
||||
}
|
||||
int[] tmp = inData;
|
||||
inData = buffer;
|
||||
buffer = tmp;
|
||||
}
|
||||
return inData;
|
||||
}
|
||||
|
||||
private int get(int index, int def) {
|
||||
int val = inData[Math.max(0, Math.min(inData.length - 1, index))];
|
||||
if (val < minY || val > maxY) {
|
||||
return def;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
public class FlatScalableHeightMap extends ScalableHeightMap {
|
||||
|
||||
/**
|
||||
* New height map where the returned height is the minmum height value if outside the size, otherwise returns height equal
|
||||
* to size.
|
||||
*
|
||||
* @param minY min y value allowed to be set. Inclusive.
|
||||
* @param maxY max y value allowed to be set. Inclusive.
|
||||
*/
|
||||
public FlatScalableHeightMap(int minY, int maxY) {
|
||||
super(minY, maxY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(int x, int z) {
|
||||
int dx = Math.abs(x);
|
||||
int dz = Math.abs(z);
|
||||
int d2 = dx * dx + dz * dz;
|
||||
if (d2 > size2) {
|
||||
return minY;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
}
|
@ -1,198 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
import com.sk89q.worldedit.EditSession;
|
||||
import com.sk89q.worldedit.MaxChangedBlocksException;
|
||||
import com.sk89q.worldedit.function.mask.Mask;
|
||||
import com.sk89q.worldedit.math.BlockVector3;
|
||||
import com.sk89q.worldedit.math.convolution.GaussianKernel;
|
||||
import com.sk89q.worldedit.math.convolution.HeightMapFilter;
|
||||
import com.sk89q.worldedit.regions.CuboidRegion;
|
||||
import com.sk89q.worldedit.regions.Region;
|
||||
import com.sk89q.worldedit.util.Location;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public interface HeightMap {
|
||||
|
||||
double getHeight(int x, int z);
|
||||
|
||||
void setSize(int size);
|
||||
|
||||
default void perform(
|
||||
EditSession session,
|
||||
Mask mask,
|
||||
BlockVector3 pos,
|
||||
int size,
|
||||
int rotationMode,
|
||||
double yscale,
|
||||
boolean smooth,
|
||||
boolean towards,
|
||||
boolean layers
|
||||
) throws MaxChangedBlocksException {
|
||||
int[][] data = generateHeightData(session, mask, pos, size, rotationMode, yscale, smooth, towards, layers);
|
||||
applyHeightMapData(data, session, pos, size, yscale, smooth, towards, layers);
|
||||
}
|
||||
|
||||
default void applyHeightMapData(
|
||||
int[][] data,
|
||||
EditSession session,
|
||||
BlockVector3 pos,
|
||||
int size,
|
||||
double yscale,
|
||||
boolean smooth,
|
||||
boolean towards,
|
||||
boolean layers
|
||||
) throws MaxChangedBlocksException {
|
||||
BlockVector3 top = session.getMaximumPoint();
|
||||
int maxY = top.getBlockY();
|
||||
Location min = new Location(session.getWorld(), pos.subtract(size, maxY, size).toVector3());
|
||||
BlockVector3 max = pos.add(size, maxY, size);
|
||||
Region region = new CuboidRegion(session.getWorld(), min.toBlockPoint(), max);
|
||||
com.sk89q.worldedit.math.convolution.HeightMap heightMap = new com.sk89q.worldedit.math.convolution.HeightMap(
|
||||
session,
|
||||
region,
|
||||
data[0],
|
||||
layers
|
||||
);
|
||||
if (smooth) {
|
||||
try {
|
||||
HeightMapFilter filter = (HeightMapFilter) HeightMapFilter.class.getConstructors()[0].newInstance(GaussianKernel.class
|
||||
.getConstructors()[0].newInstance(5, 1));
|
||||
int diameter = 2 * size + 1;
|
||||
data[1] = filter.filter(data[1], diameter, diameter);
|
||||
} catch (IllegalAccessException | InvocationTargetException | InstantiationException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (layers) {
|
||||
heightMap.applyLayers(data[1]);
|
||||
} else {
|
||||
heightMap.apply(data[1]);
|
||||
}
|
||||
}
|
||||
|
||||
default int[][] generateHeightData(
|
||||
EditSession session,
|
||||
Mask mask,
|
||||
BlockVector3 pos,
|
||||
int size,
|
||||
final int rotationMode,
|
||||
double yscale,
|
||||
boolean smooth,
|
||||
boolean towards,
|
||||
final boolean layers
|
||||
) {
|
||||
int maxY = session.getMaxY();
|
||||
int minY = session.getMinY();
|
||||
int diameter = 2 * size + 1;
|
||||
int centerX = pos.getBlockX();
|
||||
int centerZ = pos.getBlockZ();
|
||||
int centerY = pos.getBlockY();
|
||||
int[] oldData = new int[diameter * diameter];
|
||||
int[] newData = new int[oldData.length];
|
||||
if (layers) { // Pixel accuracy
|
||||
centerY <<= 3;
|
||||
maxY <<= 3;
|
||||
}
|
||||
if (towards) {
|
||||
double sizePowInv = 1d / Math.pow(size, yscale);
|
||||
int targetY = pos.getBlockY();
|
||||
int tmpY = targetY;
|
||||
for (int x = -size; x <= size; x++) {
|
||||
int xx = centerX + x;
|
||||
for (int z = -size; z <= size; z++) {
|
||||
int index = (z + size) * diameter + (x + size);
|
||||
int zz = centerZ + z;
|
||||
double raise;
|
||||
switch (rotationMode) {
|
||||
default:
|
||||
raise = getHeight(x, z);
|
||||
break;
|
||||
case 1:
|
||||
raise = getHeight(z, x);
|
||||
break;
|
||||
case 2:
|
||||
raise = getHeight(-x, -z);
|
||||
break;
|
||||
case 3:
|
||||
raise = getHeight(-z, -x);
|
||||
break;
|
||||
}
|
||||
int height;
|
||||
if (layers) {
|
||||
height = tmpY = session.getNearestSurfaceLayer(xx, zz, tmpY, minY, maxY);
|
||||
} else {
|
||||
height = tmpY = session.getNearestSurfaceTerrainBlock(xx, zz, tmpY, minY, maxY);
|
||||
if (height == -1) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
oldData[index] = height;
|
||||
if (height == minY) {
|
||||
newData[index] = centerY;
|
||||
continue;
|
||||
}
|
||||
double raisePow = Math.pow(raise, yscale);
|
||||
int diff = targetY - height;
|
||||
double raiseScaled = diff * (raisePow * sizePowInv);
|
||||
double raiseScaledAbs = Math.abs(raiseScaled);
|
||||
int random =
|
||||
ThreadLocalRandom
|
||||
.current()
|
||||
.nextInt(maxY + 1 - minY) - minY < (int) ((Math.ceil(raiseScaledAbs) - Math.floor(
|
||||
raiseScaledAbs)) * (maxY + 1 - minY)) ? (diff > 0 ? 1 : -1) : 0;
|
||||
int raiseScaledInt = (int) raiseScaled + random;
|
||||
newData[index] = height + raiseScaledInt;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int height = pos.getBlockY();
|
||||
for (int x = -size; x <= size; x++) {
|
||||
int xx = centerX + x;
|
||||
for (int z = -size; z <= size; z++) {
|
||||
int index = (z + size) * diameter + (x + size);
|
||||
int zz = centerZ + z;
|
||||
double raise;
|
||||
switch (rotationMode) {
|
||||
default:
|
||||
raise = getHeight(x, z);
|
||||
break;
|
||||
case 1:
|
||||
raise = getHeight(z, x);
|
||||
break;
|
||||
case 2:
|
||||
raise = getHeight(-x, -z);
|
||||
break;
|
||||
case 3:
|
||||
raise = getHeight(-z, -x);
|
||||
break;
|
||||
}
|
||||
if (layers) {
|
||||
height = session.getNearestSurfaceLayer(xx, zz, height, minY, maxY);
|
||||
} else {
|
||||
height = session.getNearestSurfaceTerrainBlock(xx, zz, height, minY, maxY);
|
||||
if (height == minY - 1) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
oldData[index] = height;
|
||||
if (height == minY) {
|
||||
newData[index] = centerY;
|
||||
continue;
|
||||
}
|
||||
raise = (yscale * raise);
|
||||
int random =
|
||||
ThreadLocalRandom
|
||||
.current()
|
||||
.nextInt(maxY + 1 - minY) - minY < (int) ((raise - (int) raise) * (maxY - minY + 1))
|
||||
? 1 : 0;
|
||||
int newHeight = height + (int) raise + random;
|
||||
newData[index] = newHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new int[][]{oldData, newData};
|
||||
}
|
||||
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
import com.fastasyncworldedit.core.registry.state.PropertyKey;
|
||||
import com.sk89q.worldedit.registry.state.Property;
|
||||
import com.sk89q.worldedit.world.block.BlockCategories;
|
||||
import com.sk89q.worldedit.world.block.BlockState;
|
||||
|
||||
/**
|
||||
* This enum represents the different types of height maps available in minecraft.
|
||||
* <p>
|
||||
* Heightmaps are used to describe the highest position for given {@code (x, z)} coordinates.
|
||||
* What's considered as highest position depends on the height map type and the blocks at that column.
|
||||
* The highest position is a {@code max(y + 1)} such that the block at {@code (x, y, z)} is
|
||||
* {@link #includes(BlockState) included} by the height map type.
|
||||
*/
|
||||
public enum HeightMapType {
|
||||
MOTION_BLOCKING {
|
||||
@Override
|
||||
public boolean includes(BlockState state) {
|
||||
return state.getMaterial().isSolid() || HeightMapType.hasFluid(state);
|
||||
}
|
||||
},
|
||||
MOTION_BLOCKING_NO_LEAVES {
|
||||
@Override
|
||||
public boolean includes(BlockState state) {
|
||||
return (state.getMaterial().isSolid() || HeightMapType.hasFluid(state)) && !HeightMapType.isLeaf(state);
|
||||
}
|
||||
},
|
||||
OCEAN_FLOOR {
|
||||
@Override
|
||||
public boolean includes(BlockState state) {
|
||||
return state.getMaterial().isSolid();
|
||||
}
|
||||
},
|
||||
WORLD_SURFACE {
|
||||
@Override
|
||||
public boolean includes(BlockState state) {
|
||||
return !state.isAir();
|
||||
}
|
||||
};
|
||||
|
||||
static {
|
||||
BlockCategories.LEAVES.getAll(); // make sure this category is initialized, otherwise isLeaf might fail
|
||||
}
|
||||
|
||||
private static boolean isLeaf(BlockState state) {
|
||||
return BlockCategories.LEAVES.contains(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the block state is a fluid or has an attribute that indicates the presence
|
||||
* of fluid.
|
||||
*
|
||||
* @param state the block state to check.
|
||||
* @return {@code true} if the block state has any fluid present.
|
||||
*/
|
||||
private static boolean hasFluid(BlockState state) {
|
||||
if (state.getMaterial().isLiquid()) {
|
||||
return true;
|
||||
}
|
||||
if (!state.getBlockType().hasProperty(PropertyKey.WATERLOGGED)) {
|
||||
return false;
|
||||
}
|
||||
Property<Boolean> waterlogged = state.getBlockType().getProperty(PropertyKey.WATERLOGGED);
|
||||
if (waterlogged == null) {
|
||||
return false;
|
||||
}
|
||||
return state.getState(waterlogged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given block state is included by this height map.
|
||||
*
|
||||
* @param state the block state to check.
|
||||
* @return {@code true} if the block is included.
|
||||
*/
|
||||
public abstract boolean includes(BlockState state);
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
import com.fastasyncworldedit.core.math.MutableVector3;
|
||||
import com.sk89q.worldedit.math.BlockVector3;
|
||||
import com.sk89q.worldedit.math.transform.AffineTransform;
|
||||
|
||||
public class RotatableHeightMap extends AbstractDelegateHeightMap {
|
||||
|
||||
private AffineTransform transform;
|
||||
private final MutableVector3 mutable;
|
||||
|
||||
public RotatableHeightMap(HeightMap parent) {
|
||||
super(parent);
|
||||
mutable = new MutableVector3();
|
||||
this.transform = new AffineTransform();
|
||||
}
|
||||
|
||||
public void rotate(double angle) {
|
||||
this.transform = transform.rotateY(angle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(int x, int z) {
|
||||
mutable.mutX(x);
|
||||
mutable.mutZ(z);
|
||||
BlockVector3 pos = transform.apply(mutable.setComponents(x, 0, z)).toBlockPoint();
|
||||
return super.getHeight(pos.getBlockX(), pos.getBlockZ());
|
||||
}
|
||||
|
||||
}
|
@ -1,125 +0,0 @@
|
||||
package com.fastasyncworldedit.core.extent.processor.heightmap;
|
||||
|
||||
import com.fastasyncworldedit.core.math.IntPair;
|
||||
import com.fastasyncworldedit.core.math.MutableBlockVector3;
|
||||
import com.fastasyncworldedit.core.util.MainUtil;
|
||||
import com.fastasyncworldedit.core.util.MathMan;
|
||||
import com.sk89q.worldedit.extent.clipboard.Clipboard;
|
||||
import com.sk89q.worldedit.math.BlockVector3;
|
||||
import com.sk89q.worldedit.world.block.BlockState;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.Raster;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashSet;
|
||||
|
||||
public class ScalableHeightMap implements HeightMap {
|
||||
|
||||
public int size2;
|
||||
public int size;
|
||||
protected int minY;
|
||||
protected int maxY;
|
||||
|
||||
public enum Shape {
|
||||
CONE,
|
||||
CYLINDER,
|
||||
}
|
||||
|
||||
/**
|
||||
* New height map.
|
||||
*
|
||||
* @param minY min y value allowed to be set. Inclusive.
|
||||
* @param maxY max y value allowed to be set. Inclusive.
|
||||
*/
|
||||
public ScalableHeightMap(final int minY, final int maxY) {
|
||||
this.minY = minY;
|
||||
this.maxY = maxY;
|
||||
setSize(5);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSize(int size) {
|
||||
this.size = size;
|
||||
this.size2 = size * size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(int x, int z) {
|
||||
int dx = Math.abs(x);
|
||||
int dz = Math.abs(z);
|
||||
int d2 = dx * dx + dz * dz;
|
||||
if (d2 > size2) {
|
||||
return minY;
|
||||
}
|
||||
return Math.max(minY, size - MathMan.sqrtApprox(d2));
|
||||
}
|
||||
|
||||
public static ScalableHeightMap fromShape(Shape shape, int minY, int maxY) {
|
||||
switch (shape) {
|
||||
default:
|
||||
case CONE:
|
||||
return new ScalableHeightMap(minY, maxY);
|
||||
case CYLINDER:
|
||||
return new FlatScalableHeightMap(minY, maxY);
|
||||
}
|
||||
}
|
||||
|
||||
public static ScalableHeightMap fromClipboard(Clipboard clipboard, int minY, int maxY) {
|
||||
BlockVector3 dim = clipboard.getDimensions();
|
||||
byte[][] heightArray = new byte[dim.getBlockX()][dim.getBlockZ()];
|
||||
int clipMinX = clipboard.getMinimumPoint().getBlockX();
|
||||
int clipMinZ = clipboard.getMinimumPoint().getBlockZ();
|
||||
int clipMinY = clipboard.getMinimumPoint().getBlockY();
|
||||
int clipMaxY = clipboard.getMaximumPoint().getBlockY();
|
||||
int clipHeight = clipMaxY - clipMinY + 1;
|
||||
HashSet<IntPair> visited = new HashSet<>();
|
||||
MutableBlockVector3 bv = new MutableBlockVector3();
|
||||
for (BlockVector3 pos : clipboard.getRegion()) {
|
||||
IntPair pair = new IntPair(pos.getBlockX(), pos.getBlockZ());
|
||||
if (visited.contains(pair)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(pair);
|
||||
int xx = pos.getBlockX();
|
||||
int zz = pos.getBlockZ();
|
||||
int highestY = clipMinY;
|
||||
bv.setComponents(pos);
|
||||
for (int y = clipMinY; y <= clipMaxY; y++) {
|
||||
bv.mutY(y);
|
||||
BlockState block = clipboard.getBlock(bv);
|
||||
if (!block.getBlockType().getMaterial().isAir()) {
|
||||
highestY = y + 1;
|
||||
}
|
||||
}
|
||||
int pointHeight = Math.min(clipMaxY, ((maxY - minY + 1) * (highestY - clipMinY)) / clipHeight);
|
||||
int x = xx - clipMinX;
|
||||
int z = zz - clipMinZ;
|
||||
heightArray[x][z] = (byte) pointHeight;
|
||||
}
|
||||
return new ArrayHeightMap(heightArray, minY, maxY);
|
||||
}
|
||||
|
||||
public static ScalableHeightMap fromPNG(InputStream stream, int minY, int maxY) throws IOException {
|
||||
BufferedImage heightFile = MainUtil.readImage(stream);
|
||||
int width = heightFile.getWidth();
|
||||
int length = heightFile.getHeight();
|
||||
Raster data = heightFile.getData();
|
||||
byte[][] array = new byte[width][length];
|
||||
double third = 1 / 3.0;
|
||||
double alphaInverse = 1 / 255.0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int z = 0; z < length; z++) {
|
||||
int pixel = heightFile.getRGB(x, z);
|
||||
int red = pixel >> 16 & 0xFF;
|
||||
int green = pixel >> 8 & 0xFF;
|
||||
int blue = pixel >> 0 & 0xFF;
|
||||
int alpha = pixel >> 24 & 0xFF;
|
||||
int intensity = (int) (alpha * ((red + green + blue) * third) * alphaInverse);
|
||||
array[x][z] = (byte) intensity;
|
||||
}
|
||||
}
|
||||
return new ArrayHeightMap(array, minY, maxY);
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user