## Solid Geometry Tutorial## Teapot Construction 101## Contents## Introduction
## Getting Started
The first step we will take on our journey is the creation of a class encapsulating our teapot construction
logic, aptly named
TeapotBuilder.
The constructor will accept only a single argument: an instance of
Precision.DoubleEquivalence from the
Commons Numbers library.
This is a ubiquitous type in public class TeapotBuilder { private final Precision.DoubleEquivalence precision; public TeapotBuilder(final Precision.DoubleEquivalence precision) { this.precision = precision; } } Next, we will create a stub method for our teapot-building code. We will fill in this method as we work through the tutorial. public RegionBSPTree3D buildTeapot() { // TODO: actually build a teapot here return RegionBSPTree3D.empty(); }
We have chosen the
RegionBSPTree3D
type for both the construction of the teapot geometry as well as the return value. This is the primary type
in A major benefit of using BSP trees to represent regions is that it allows us to perform boolean operations such as union, intersection, difference, and xor on arbitrary regions. We will use these operations to combine simple shapes to construct our teapot.
Before constructing our first geometry, we need to handle one more bit of housekeeping, namely, how to view our
work. The easiest way to do this is to export our geometries using a common 3D file format, such as
STL, and view the
model in a 3D modeling program. I enjoy working with Blender
so that is the modeling program I have chosen to use for this tutorial. However any program able to load and display
3D models should work. To create our geometry files, we will use the 3D file writing capabilities of TeapotBuilder builder = new TeapotBuilder(precision); RegionBSPTree3D teapot = builder.buildTeapot(); IO3D.write(teapot, Paths.get("teapot.stl")); With this bit of code place, we are ready to start creating geometry! ## Building the Parts
Our teapot will contain four main parts: the body, the lid, the handle, and the spout. We will create
private methods for each of theses parts in our ## The Body
The first part we will construct is the teapot body. We will start by creating a private method named
public RegionBSPTree3D buildTeapot() { // build parts RegionBSPTree3D body = buildBody(); // TODO: combine into the final region return body; // return for debugging } private RegionBSPTree3D buildBody() { // TODO return RegionBSPTree3D.empty(); }
Unlike some fancier types you might see, our teapot is going to have a simple, rounded body. So, we will start
by creating a sphere and go from there. Luckily, Sphere sphere = Sphere.from(Vector3D.ZERO, 1, precision);
We now have a Sphere sphere = Sphere.from(Vector3D.ZERO, 1, precision); RegionBSPTree3D body = sphere.toTree(4); This finally gives us something we can look at in our 3D modeling program. Our next step is to tweak the sphere to make it more teapot-like. First, we will squash it vertically a little to make it less of a perfect sphere. Our tool of choice for this squashing exercise is the AffineTransformMatrix3D class, which we will be using quite frequently in the remainder of this tutorial. This class represents a 4x4 transform matrix that can be used to perform affine transformations in 3D space. In short, it lets us perform operations like translate, scale, and rotate on geometries. Here, we will use it to scale down our sphere approximation along the z-axis, while keeping the x and y axes the same. AffineTransformMatrix3D t = AffineTransformMatrix3D.createScale(1, 1, 0.75); body.transform(t); Our sphere now looks flattened a bit. Note that our choice to scale along the z-axis (as opposed to the x or y axes) was completely arbitrary; our sphere was entirely symmetrical and we had no definition of what was "up" and what was "down" as it relates to our teapot. Now that we have performed our first non-symmetrical operation, we should officially declare the orientation of our teapot: henceforth the positive z-axis will be "up" and the the positive x-axis will be "forward" (the direction that the spout is pointing) in "teapot-space". Keeping this orientation in mind will help us when working through later transformations. Our teapot is now pleasantly flattened but it is still very likely to roll off tables and create all manner of messes. To address this shortcoming, we will chop off part of the bottom to make a flat base for the teapot to sit on. In code, we will define this plane, construct a region of infinite size with that plane as the "top", and compute the difference between our flattened sphere and the infinite region. Plane bottomPlane = Planes.fromPointAndNormal( Vector3D.of(0, 0, -0.6), Vector3D.Unit.PLUS_Z, precision); PlaneConvexSubset bottom = bottomPlane.span(); body.difference(RegionBSPTree3D.from(Arrays.asList(bottom))); There's a lot going on in just a few lines here so let's go through it step by step. Plane bottomPlane = Planes.fromPointAndNormal( Vector3D.of(0, 0, -0.6), Vector3D.Unit.PLUS_Z, precision); Here we construct a plane from an arbitrary point lying in the plane, the plane normal, and our good friend Precision.DoubleEquivalence. We choose a point that is directly "below" the origin (per our previous definition of teapot-space) and that will cause the plane to intersect the bottom of the teapot body. The plane normal points "up", along the positive z axis. PlaneConvexSubset bottom = bottomPlane.span();
This may well be the most confusing line in this section. This line constructs a
PlaneConvexSubset
that represents all of the points in the plane we just created. These seem like equivalent concepts —
a plane and another object that represents the exact same set of points —
but they're slightly different. The plane body.difference(RegionBSPTree3D.from(Arrays.asList(bottom)));
This line constructs a BSP tree from our bottom plane convex subset and then computes the difference between
## The LidNext, we will make the lid of our teapot. As before, we will begin by creating a helper method and referencing it in the main build method. public RegionBSPTree3D buildTeapot() { // build parts RegionBSPTree3D body = buildBody(); RegionBSPTree3D lid = buildLid(body); // TODO: combine into the final region return lid; // return for debugging } private RegionBSPTree3D buildLid(RegionBSPTree3D body) { // TODO return RegionBSPTree3D.empty(); }
You may be wondering why we passed the - translate a copy of the body "up" a small amount,
- trim this translated portion to the correct size, and
- add a small, flattened sphere on top as a handle.
RegionBSPTree3D lid = body.copy(); AffineTransformMatrix3D t = AffineTransformMatrix3D.createTranslation(0, 0, 0.03); lid.transform(t);
Our lid is now slightly raised above the body and matches its curve exactly. Unfortunately, the lid is also the
same There are many ways we could go about creating our cylinder helper method. We could, for example, have it return a BSP tree like all of our other methods so far. This would work in the case of the lid. However, for other parts of the teapot, we are going to want fine-grain control over the position of the cylinder vertices. Therefore, we will design our helper method to return a TriangleMesh, which will give us the precise vertex placement that we need. The method will accept 3 arguments: - the number of vertical segments in the cylinder,
- the number of vertices forming the cylinder circle, and
- a function that callers can use to place each vertex into its final location.
0
to 1. However, the final orientation of the mesh will be determined by the supplied transform function.
private TriangleMesh buildUnitCylinderMesh(int segments, int circleVertexCount, UnaryOperator<Vector3D> vertexTransform) { // TODO return null; } We will use the SimpleTriangleMesh class to build our mesh. This class has a builder type that allows us to easily define vertices and faces. SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(precision);
Next, we will define the vertices. As mentioned above, the cylinder will initially be constructed along the
positive z-axis with z values in the range double zDelta = 1.0 / segments; double zValue; double azDelta = Angle.TWO_PI / circleVertexCount; double az; Vector3D vertex; for (int i = 0; i <= segments; ++i) { zValue = (i * zDelta); for (int v = 0; v < circleVertexCount; ++v) { az = v * azDelta; vertex = Vector3D.of( Math.cos(az), Math.sin(az), zValue); builder.addVertex(vertexTransform.apply(vertex)); } } Now come the face definitions. We need to define the faces on the bottom, sides, and top of the cylinder, making sure at each step that the triangle normals point outward. // add the bottom faces using a triangle fan, making sure // that the triangles are oriented so that the face normal // points down for (int i = 1; i < circleVertexCount - 1; ++i) { builder.addFace(0, i + 1, i); } // add the side faces int circleStart; int v1; int v2; int v3; int v4; for (int s = 0; s < segments; ++s) { circleStart = s * circleVertexCount; for (int i = 0; i < circleVertexCount; ++i) { v1 = i + circleStart; v2 = ((i + 1) % circleVertexCount) + circleStart; v3 = v2 + circleVertexCount; v4 = v1 + circleVertexCount; builder .addFace(v1, v2, v3) .addFace(v1, v3, v4); } } // add the top faces using a triangle fan int lastCircleStart = circleVertexCount * segments; for (int i = 1 + lastCircleStart; i < builder.getVertexCount() - 1; ++i) { builder.addFace(lastCircleStart, i, i + 1); } return builder.build();
That should do it! Now we can use this in our lid construction, making sure to pass a transform that will
give us the radius that we want with enough height to make sure we don't miss any part of the top. We'll
use the TriangleMesh cylinder = buildUnitCylinderMesh(1, 20, AffineTransformMatrix3D.createScale(0.5, 0.5, 10)); lid.intersection(cylinder.toTree()); The fact that the lid is extremely thick does not matter for our purposes. We will be merging it with the teapot body soon and the lower portion will simply become part of the body. All that's left now is the small rounded handle on top of the lid. We've already done something similar for the body so this should be simple. The only difference from before is that we will be using the axis-aligned bounding box of the lid to help place the handle in the correct location. Sphere sphere = Sphere.from(Vector3D.of(0, 0, 0), 0.15, precision); RegionBSPTree3D sphereTree = sphere.toTree(2); Bounds3D lidBounds = lid.getBounds(); double sphereZ = lidBounds.getMax().getZ() + 0.075; sphereTree.transform(AffineTransformMatrix3D.createScale(1, 1, 0.75) .translate(0, 0, sphereZ)); lid.union(sphereTree); This completes our teapot lid. ## The HandleConstructing the handle of our teapot is going to be something of an adventure; not only will we need to apply scaling and translations to our starting geometry, we will need to interpolate between a range of 3D rotations. In this case, perhaps it would be best to see the end product first before we jump into the details. That will give us a frame of reference for what we're working toward. Below is our goal for the handle. As you can see, the handle is a long, straight cylinder that we've curved back on itself, leaving straight sections at the beginning and end. This means that we can use our new cylinder mesh helper method to construct the shape. Let's start by adding the private builder method to our class, leaving a placeholder for the portion where we manipulate the position of the cylinder. public RegionBSPTree3D buildTeapot() { // build parts RegionBSPTree3D body = buildBody(); RegionBSPTree3D lid = buildLid(body); RegionBSPTree3D handle = buildHandle(); // TODO: combine into the final region return handle; // return for debugging } private RegionBSPTree3D buildHandle() { UnaryOperator<Vector3D> vertexTransform = v -> { // TODO return v; }; return buildUnitCylinderMesh(10, 14, vertexTransform).toTree(); }
We now have the start of our handle in place. Note that we've used a larger number of cylinder segments
(
The handle is a cylinder with a radius of double handleRadius = 0.1; double height = 1; AffineTransformMatrix3D scale = AffineTransformMatrix3D.createScale(handleRadius, handleRadius, height); UnaryOperator<Vector3D> vertexTransform = v -> { return scale.apply(v); }; return buildUnitCylinderMesh(10, 14, vertexTransform).toTree(); This gives our handle the thickness that we want. Now on to the tricky bit: the rotation. The
QuaternionRotation
class is the go-to class in QuaternionRotation startRotation = QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, -Angle.PI_OVER_TWO); QuaternionRotation endRotation = QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Angle.PI_OVER_TWO); DoubleFunction<QuaternionRotation> slerp = startRotation.slerp(endRotation);
We've defined our rotation sequence as starting at
Let's apply our new slerp function to the cylinder. Since the cylinder z-values range from - We want to make sure to rotate around a point
*outside*of the cylinder instead of around the origin, which is where rotations occur by default. AffineTransformMatrix3D has a method to create this type of transformation. - In order to keep the curve smooth, we will start rotating each point from its position projected on the xy plane (with z = 0). This prevents the rotation from being affected by the changing distance of the vertex from the curve center. You can picture this as taking a hula hoop lying in the xy plane and swinging it back and forth to define a tube in 3D space.
double t = v.getZ(); Vector3D scaled = scale.apply(v); QuaternionRotation rot = slerp.apply(t); AffineTransformMatrix3D mat = AffineTransformMatrix3D.createRotation(curveCenter, rot); return mat.apply(Vector3D.of(scaled.getX(), scaled.getY(), 0)); Looks good! We only need a few more tweaks: - We want the beginning and end segments of the curve to extend straight out along the x-axis. We'll add
an additional x-axis offset when
`t`is at`0`(the start) or`1`(the end). - The handle is a bit taller than our goal of one unit since we're placing the handle
*center*on the curve with radius of`0.5`. We'll adjust by removing twice the handle thickness from the curve radius. - The handle needs to be translated back along the x-axis to be in the correct position relative to the rest of the body.
double handleRadius = 0.1; double height = 1 - (2 * handleRadius); AffineTransformMatrix3D scale = AffineTransformMatrix3D.createScale(handleRadius, handleRadius, height); QuaternionRotation startRotation = QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, -Angle.PI_OVER_TWO); QuaternionRotation endRotation = QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Angle.PI_OVER_TWO); DoubleFunction<QuaternionRotation> slerp = startRotation.slerp(endRotation); Vector3D curveCenter = Vector3D.of(0.5 * height, 0, 0); AffineTransformMatrix3D translation = AffineTransformMatrix3D.createTranslation(Vector3D.of(-1.38, 0, 0)); UnaryOperator<Vector3D> vertexTransform = v -> { double t = v.getZ(); Vector3D scaled = scale.apply(v); QuaternionRotation rot = slerp.apply(t); AffineTransformMatrix3D mat = AffineTransformMatrix3D.createRotation(curveCenter, rot); Vector3D rotated = mat.apply(Vector3D.of(scaled.getX(), scaled.getY(), 0)); Vector3D result = (t > 0 && t < 1) ? rotated : rotated.add(Vector3D.Unit.PLUS_X); return translation.apply(result); }; return buildUnitCylinderMesh(10, 14, vertexTransform).toTree(); Voilà. ## The SpoutNow that we have the handle under our belt, the spout will be a piece of cake. Our general approach will be the same as the handle, where we begin with a cylinder and transform the mesh vertices into their final positions. Instead of a curve, however, we will be creating a taper and a shear. Let's create our method stub to start. public RegionBSPTree3D buildTeapot() { // build parts RegionBSPTree3D body = buildBody(); RegionBSPTree3D lid = buildLid(body); RegionBSPTree3D handle = buildHandle(); RegionBSPTree3D spout = buildSpout(); // TODO: combine into the final region return spout; // return for debugging } private RegionBSPTree3D buildSpout() { UnaryOperator<Vector3D> vertexTransform = v -> { // TODO return v; }; return buildUnitCylinderMesh(10, 14, vertexTransform).toTree(); } We'll start with the taper. We want the spout to be an oval that is wider at the base and narrower near the top. We can represent this as a two different scalings in the horizontal plane and store the scale factors in 2D vectors. We'll define the factors for the base and then simply compute the factors for the top as a multiple of that. The scaling for each vertex is then a linear interpolation between the two, with the vertex z value as the interpolation parameter. We'll use the vector lerp method to perform the interpolation. Vector2D baseScale = Vector2D.of(0.4, 0.2); Vector2D topScale = baseScale.multiply(0.6); UnaryOperator<Vector3D> vertexTransform = v -> { Vector2D scale = baseScale.lerp(topScale, v.getZ()); Vector3D tv = Vector3D.of( v.getX() * scale.getX(), v.getY() * scale.getY(), v.getZ() ); return tv; }; return buildUnitCylinderMesh(1, 14, vertexTransform).toTree(); Now for the shear, or slant, in the positive x direction. This is simply a multiple of the vertex z value that we add to the x value. While we're at it, we'll also translate the spout to its final position in the teapot. Vector2D baseScale = Vector2D.of(0.4, 0.2); Vector2D topScale = baseScale.multiply(0.6); double shearZ = 0.9; AffineTransformMatrix3D translation = AffineTransformMatrix3D.createTranslation(Vector3D.of(0.25, 0, -0.4)); UnaryOperator<Vector3D> vertexTransform = v -< { Vector2D scale = baseScale.lerp(topScale, v.getZ()); Vector3D tv = Vector3D.of( (v.getX() * scale.getX()) + (v.getZ() * shearZ), v.getY() * scale.getY(), v.getZ() ); return translation.apply(tv); }; return buildUnitCylinderMesh(1, 14, vertexTransform).toTree(); ## Putting it all togetherNow that we have all of the parts of our teapot in place we can begin combining them to construct the full teapot. We simply need to compute the union of all of the parts using the BSP tree union method. public RegionBSPTree3D buildTeapot(Map<String, RegionBSPTree3D> debugOutputs) { // build the parts RegionBSPTree3D body = buildBody(1); RegionBSPTree3D lid = buildLid(body); RegionBSPTree3D handle = buildHandle(); RegionBSPTree3D spout = buildSpout(1); // combine into the final region RegionBSPTree3D teapot = RegionBSPTree3D.empty(); teapot.union(body, lid); teapot.union(handle); teapot.union(spout); return teapot; }
Note that we've used two different forms of the Now that all of the parts are combined, we can finally view our teapot. It looks pretty good! One thing is off, though: our teapot is completely solid. In order to make it hollow like a real teapot, we'd need to hollow out the body and the interior of the spout. Luckily, we can do this with just a few small tweaks to our code: we can add parameters to our body and spout construction methods that control the overall size of the produced region. We can then subtract these smaller regions from the teapot to hollow it out. public RegionBSPTree3D buildTeapot() { // build the parts RegionBSPTree3D body = buildBody(1); RegionBSPTree3D lid = buildLid(body); RegionBSPTree3D handle = buildHandle(); RegionBSPTree3D spout = buildSpout(1); // combine into the final region RegionBSPTree3D teapot = RegionBSPTree3D.empty(); teapot.union(body, lid); teapot.union(handle); teapot.union(spout); // subtract scaled-down versions of the body and spout to // create the hollow interior teapot.difference(buildBody(0.9)); teapot.difference(buildSpout(0.8)); return teapot; } private RegionBSPTree3D buildBody(double initialRadius) { Sphere sphere = Sphere.from(Vector3D.ZERO, initialRadius, precision); // ... Plane bottomPlane = Planes.fromPointAndNormal( Vector3D.of(0, 0, -0.6 * initialRadius), Vector3D.Unit.PLUS_Z, precision); // ... } private RegionBSPTree3D buildSpout(double initialRadius) { Vector2D baseScale = Vector2D.of(0.4, 0.2).multiply(initialRadius); // ... } This gives us our final result. ## Extra CreditLet's say that we want to take this even further. We're not satisfied with our single-piece teapot and want the lid as a separate piece that we can remove. Well, today is our lucky day because we can use what we've learned about BSP trees and boolean operations to accomplish this easily. Our approach will be to create an "extractor" region consisting of an outer cylinder with a smaller inner cylinder poking out of the bottom. We will scale and position this extractor so that it just fits over the teapot lid. Our removable teapot lid then becomes the intersection of the teapot and the extractor while the body becomes the difference. Let's put this into code. Our method will simply return a map containing the name of the part and the associated region. public Map<String, RegionBSPTree3D> buildSeparatedTeapot() { // construct the single-piece teapot RegionBSPTree3D teapot = buildTeapot(); // create a region to extract the lid AffineTransformMatrix3D innerCylinderTransform = AffineTransformMatrix3D.createScale(0.4, 0.4, 1) .translate(0, 0, 0.5); RegionBSPTree3D innerCylinder = buildUnitCylinderMesh(1, 20, innerCylinderTransform).toTree(); AffineTransformMatrix3D outerCylinderTransform = AffineTransformMatrix3D.createScale(0.5, 0.5, 10); RegionBSPTree3D outerCylinder = buildUnitCylinderMesh(1, 20, outerCylinderTransform).toTree(); Plane step = Planes.fromPointAndNormal(Vector3D.of(0, 0, 0.645), Vector3D.Unit.MINUS_Z, precision); RegionBSPTree3D extractor = RegionBSPTree3D.from(Arrays.asList(step.span())); extractor.union(innerCylinder); extractor.intersection(outerCylinder); // extract the lid RegionBSPTree3D lid = RegionBSPTree3D.empty(); lid.intersection(teapot, extractor); // remove the lid from the body RegionBSPTree3D body = RegionBSPTree3D.empty(); body.difference(teapot, extractor); // build the output Map<String, RegionBSPTree3D> result = new LinkedHashMap<>(); result.put("lid", lid); result.put("body", body); return result; } While we can easily write these parts out into separate geometry files, it would be very convenient to keep them together. The OBJ file format supports multiple named geometries in a single file so let's use that to create our output file. We will use the low-level ObjWriter class instead of the IO3D convenience class in order to gain access to OBJ-specific features. Map<String, RegionBSPTree3D> partMap = builder.buildSeparatedTeapot(); try (ObjWriter writer = new ObjWriter(Files.newBufferedWriter(Paths.get("separated-teapot.obj")))) { for (Map.Entry<String, RegionBSPTree3D> entry : partMap.entrySet()) { writer.writeObjectName(entry.getKey()); writer.writeBoundaries(entry.getValue()); } } Loading this into our 3D modeling program gives us two separate geometries that we can manipulate independently. The image below shows the teapot with the lid lifted up to reveal the interior. ## Conclusion
In this tutorial, we've explored creating and combining solid geometries with |