-
Notifications
You must be signed in to change notification settings - Fork 0
Using Shapes
The shapes library is written to not only provide all of the tools needed in order to generate basic and complex shapes, but also to be used in any java application. That means that the shapes library is mostly independent from any Minecraft code (except for a couple methods that convert types), and could easily be used for application in any voxel based purpose.
That being said, this makes the shapes library not as simple as just calling a method and passing it a world and a position to generate a shape; however, with a little explanation for what each piece of the shapes library does, I'm sure you'll see the benefit of how it's structured.
In order to generate a shape using the shapes library the first thing you have to do is simply call a method in the Shapes class. The shapes class contains various primitive shapes for you to choose from, but if you don't see a shape that you like you can also write your own Shape (see Custom Shapes).
For the purposes of this example we'll be using the method Shapes.ellipse
to illustrate all of the features of the shapes library.
Calling one of the methods in the Shapes class will generate a Shape object which contains the following three things
- A mathematical formula that determines whether or not a given position in space is inside the shape or not (In the form of a Predicate)
- A position representing the maximum point in the shapes bounding box
- A position representing the minimum point in the shapes bounding box
In this case the ellipse method specifically takes two double values representing and magnitude of the semi-major and the semi-minor axes of the ellipse respectively. Pass whatever values you like, I'll be using 5 and 5 to make it a circle.
At this point to fill the shape in with blocks simply call the Shape.fill method and provide a Filler object. The default filler object for Minecraft is the SimpleFiller
class which takes a world and a block state in order to fill the blocks. To make a SimpleFiller you can either use the constructor or the of
method and pass the two parameters.
So, to recap this is the code that we've written so far
Shapes.ellipse(5, 5).fill(SimpleFiller.of(world, Blocks.DIRT.getDefaultState()));
Now, if you were to go and test this code in game you'll probably be very frustrated because it would appear that nothing is happening. Well in fact, something is happening it's just probably not what you want to be happening.
By default all shapes are written to generate centered around the position 0, 0, 0
. This is because, once again, in their most basic form shapes are just mathematical equations and so those equations are written near to the origin of a cartesian graph.
If you want you shape to generate in the right position you'll have to translate it before filling it. Luckily the shapes library was built specifically so that shapes could be easily translated, rotated, and dilated many times to get the desired effect.
In order to transform the shape we'll need to use a transformation layer. Transformation layers come in many forms (see Transformation Layers), but for our purposes we only need the most simple form, the translate layer.
To apply a transformation layer to our shape we simply need to call Shape.applyLayer
and pass it a layer object. In this case we want a translate layer, which we can construct either through the constructor or by calling TranslateLayer.of
.
The method takes a Position object which is the shapes library's own internal system for passing around information about points in space. A position object can be constructed either manually by calling Position.of
and passing it three double values, or by calling Position.of
and passing it a BlockPos
value.
So, just to recap again we've now created a transformation layer that will move our shape to the desired position before filling it, so our code should now look something like this
Shapes.ellipse(5, 5)
.applyLayer(TranslateLayer.of(Position.of(pos)))
.fill(SimpleFiller.of(world, Blocks.DIRT.getDefaultState()));
Tada! you've now created your first shape, but if you're used to creating features for world generation you're probably wondering if there's a way to check the blocks before you fill them, or to manage the way the shape generates in the world. For that we'll need Validation Layers and Custom Fillers.
Validation layers are a simply way to check if a shape meets a certain criteria. Custom validation layers can be made to check any number of requirements for the shape, like whether it takes up enough volume, or is below a certain height, but the most basic and most common use of a validation layer is to determine if it's actually safe to fill the shape in the world.
To do this we can use the simple validation layer AirValidator
which will check whether or not all of the blocks within the shapes area are air, and run code accordingly. To use a validation layer simply call Shape.validate
and provide the validation layer, and a callback for what code to run if the shape is in fact valid. To construct the basic validation layer use the method AirValidator.of
and pass it a world object to check the positions inside of. From there we also need a simple callback that will fill the shape if the shape is valid.
At this point the code should look something like this
Shapes.ellipse(10, 10)
.applyLayer(TranslateLayer.of(Position.of(pos)))
.validate(AirValidator.of(world), (shape) -> shape.fill(SimpleFiller.of(world, Blocks.DIRT.getDefaultState())));
This way the code will generate the shape, apply the transformation, check if the shape is valid, and if it is, then fill it with dirt.
For more use of validation layers check the Custom Validation Layers section.
In the introductory section we explored the use of a basic translate layer in order to move our shape to a desired location; however, the shapes library also includes both dilations and rotations as additional transformation types.
In addition, you can add as many of these layers as you want, in whatever order you want to achieve the desired shape. So, let's look into the two other types of transformation layers.
The rotate layer is used just like the translate layer by calling Shape.applyLayer
and passing a constructed object. To construct a rotate layer you similarly call RotateLayer.of()
and then pass it a quaternion value representing the rotation in 3d space.
Once again, the shapes library is written to be as separate as possible from Minecraft meaning this is a custom Quaternion class and not the Minecraft Quaternion class. That being said, however, calling Quaternion.of() and passing a Minecraft quaternion will convert between the two.
All rotations occur relative to the origin (0, 0, 0) meaning it's recommended that you do all of your rotating before you translate otherwise things might get very messy. If you need to rotate around a certain point that is not the center of the shape, it is possible to get your desired effect, it's just all about order of operations. Just translate it by the offset from that point to the origin, rotate, and then translate the rest of the way.
Similarly to the rotate and translate layers the dilate layer is used by calling Shape.applyLayer
and passing a layer object that you can construct by calling DilateLayer.of
and passing a Position value representing the multiplier in each direction. Just like the rotate layer the dilate layer works about the origin so be wise with your order of operations if you're doing really complex transformations.
In addition to Transformation layers the shapes library has Pathfinder layers which allows multiple shapes to be joined, subtracted, etc. For people who have used Adobe Illustrator this is basically a version of the pathfinder tool in that software.
Just like transformation layers, pathfinder layers can be applied multiple times, in any order, and even mixed in with transformation layers. To use a pathfinder layer you call Shape.applyLayer
and pass it a constructed layer object. All pathfinder layers require you to pass it another Shape object in order to apply to the other shape.
For example the AddLayer
adds the given shape to the shape that it's applied to. Once again pathfinder layers can be used in any order so you can rotate a shape and then add another shape onto it, and then rotate that merged shape. Or rotate a shape and then create a new shape and add the first shape onto the second shape and then translate that shape. The options are endless.
The following is a list of all four pathfinder layers and their effect
-
AddLayer
: Adds two shapes together (think A or B) -
ExcludeLayer
: Adds two shapes together but subtracts the overlap (think A xor B) -
IntersectLayer
: Returns the intersection of two shapes (think A and B) -
SubtractLayer
: Subtracts the given shape from the shape it is applied to (think A and not B)
While the shapes library comes by default with most of the basic shapes you'll ever need like ellipses, cones, and prisms, sometimes there's a want to create a custom shape. To create a custom shape simply call Shape.of
and pass it three things.
- A Predicate, basically just a lambda function that accepts a Position value and returns a boolean corresponding to whether or not that position is within the shape
- A Position value that represents the maximum x, y, and z values that the shape fills
- A Position value that represents the minimum x, y, and z values that the shape fills
With those three things you can make whatever shape you want without limitation.
In case you want to create a custom transformation layer, here's how. Let's start with a basic understanding of how transformation layers work. Imagine a simple x, y graph with a circle on it. Better yet, draw it. Center that circle around the origin. It's pretty easy to determine whether or not a point is inside of that circle or not, you just use the basic equation for a circle which happens to be (x^2)/(a^2) + (y^2)/(a^2) = 1. You can easily turn that into a boolean value by making that operator a less than or equal to sign. The point is, finding whether or not a point is in a shape is pretty easy without transformations.
So now you have a different cartesian graph with a new circle, but this time centered around the point (5, 5) how do you determine if the point is within the circle or not. One way is to find the point relative to the circle. Take for example the point (6, 4) if we find that point relative to the center it becomes (1, -1). What we've done is subtracted the absolute position of the point by the relative position of the circle.
Transformation layers basically do this but stacked on top of one another and multiple times. Transformation layers accept a position value and then do the opposite of the desired transformation (just like how we subtracted instead of added) to test the position value around a basic shape.
So, to make a Transformation layer you simply have to write a class that extends the interface Layer
. The interface requires three things, how the maximum value will change when the transformation is applied, how the minimum value will change when the transformation is applied, and what transformation we should apply to each point being fed into the test equation.
Lets say you wanted to write a version of the TranslateLayer you would modify the max and min values by simply adding the translation to them (this is because in absolute space the max and min values are moving to where you'd imagine the shape would be), but you would modify the equation in the inverse way, by subtracting the translation. What this does is it tells the generator to look at a different point in the world (the max and min values) to test the blocks, so when the blocks enter the test function we have to undo that movement to another part of the world.
Hopefully that made some sense, if you've taken geometry it should be pretty easy to understand, we basically just sequentially do the inverse of each transformation going downwards in order to test the shape based on a simpler model.
In case you want to create a custom validation layer, here's how. Just simply write your own class that extends the Validator
interface. The interface requires one method validate
which takes a Shape and returns a boolean value. You can check whatever fields you want within the Shape based in whatever context you like, and determine if the Shape is valid or not.
Another thing that should be noted is that you can call Shape.validate
multiple times in line meaning you can have multiple validation layers that do different things based on the result.
Here are some common implementations of the Shapes library
This implementation will make an ellipse shape, rotate it, and then translate it to the given position. After that it will check if all of the blocks within the shape are clear, and if they are then it'll fill the shape with blue concrete.
Shapes.ellipse(10, 10)
.applyLayer(RotateLayer.of(Quaternion.of(0.854, 0.146, 0.354, 0.354)))
.applyLayer(TranslateLayer.of(Position.of(pos)))
.validate(AirValidator.of(world), (shape) -> shape.fill(SimpleFiller.of(world, Blocks.BLUE_CONCRETE.getDefaultState())));
This implementation will make an ellipse shape, translate it to the given position, and then filter the blocks within the shape to only those that currently have air. After that it'll fill those blocks with blue concrete.
Shapes.ellipse(10, 10)
.applyLayer(TranslateLayer.of(Position.of(pos)))
.stream()
.filter(AirValidator.of(world))
.forEach(SimpleFiller.of(world, Blocks.BLUE_CONCRETE.getDefaultState()));
The difference between the two is that the former is all or nothing, while the latter fills what it can without replacing existing blocks. You could do without either filter type and simply fill all of the blocks as well.