Primitives

In GeometryBasics.jl, a GeometryPrimitive is an object from which a mesh can be constructed.

Existing GeometryPrimitives

GeometryBasics comes with a few predefined primitives:

HyperRectangle

A Rect{D, T} = HyperRectangle{D, T} is a D-dimensional axis-aligned hyperrectangle defined by an origin and a size.

julia> using GeometryBasics
julia> r1 = HyperRectangle{4, Float64}(Point{4, Float64}(0), Vec{4, Float64}(1))Rectd{4}([0.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0])
julia> r2 = Rect3f(Point3f(-1), Vec3f(2))HyperRectangle{3, Float32}(Float32[-1.0, -1.0, -1.0], Float32[2.0, 2.0, 2.0])
julia> r3 = Rect2i(0, 0, 1, 1)HyperRectangle{2, Int64}([0, 0], [1, 1])

Rect2 supports normal and texture coordinate generation as well as tessellation. Without tessellation, the coordinates of 2D Rects are defined in anti-clockwise order. Rect3 supports normals and texture coordinates, but not tessellation.

Shorthands:

T(eltype)Float64Float32IntUInt
N(dim)Rect{N,T}Rectd{N}Rectf{N}Recti{N}Rectui{N}
2Rect2{T}Rect2dRect2fRect2iRect2ui
3Rect3{T}Rect3dRect3fRect3iRect3ui

Sphere and Circle

Circle and Sphere are the 2 and 3 dimensional variants of HyperSphere. They are defined by an origin and a radius. While you can technically create a HyperSphere of any dimension, decomposition is only defined in 2D and 3D.

julia> s1 = HyperSphere{4, Int}(Point{4, Int}(0), 5)ERROR: UndefVarError: `HyperSphere` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Hint: a global variable of this name also exists in GeometryBasics.
julia> s2 = Sphere(Point3f(0, 0, 1), 1)ERROR: UndefVarError: `Sphere` not defined in `Main` Suggestion: check for spelling errors or missing imports. Hint: a global variable of this name also exists in GeometryBasics. Hint: a global variable of this name may be made accessible by importing Makie in the current active module Main Hint: a global variable of this name may be made accessible by importing GLMakie in the current active module Main
julia> s3 = Circle(Point2d(0), 2.0)ERROR: UndefVarError: `Circle` not defined in `Main` Suggestion: check for spelling errors or missing imports. Hint: a global variable of this name also exists in GeometryBasics. Hint: a global variable of this name may be made accessible by importing Makie in the current active module Main Hint: a global variable of this name may be made accessible by importing GLMakie in the current active module Main

Circle and Sphere support normal and texture coordinate generation as well as tessellation. The coordinates of Circle are defined in anti-clockwise order.

Cylinder

A Cylinder is a 3D shape defined by two points and a radius.

julia> c = Cylinder(Point3f(-1, 0, 0), Point3f(0, 0, 1), 0.3f0) # start point, end point, radiusERROR: UndefVarError: `Cylinder` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Hint: a global variable of this name also exists in GeometryBasics.

Cylinder supports normals an Tessellation, but currently no texture coordinates.

Pyramid

Pyramid corresponds to a pyramid shape with a square base and four triangles coming together into a sharp point. It is defined by by the center point of the base, its height and its width.

julia> p = Pyramid(Point3f(0), 1f0, 0.3f0) # center, height, widthERROR: UndefVarError: `Pyramid` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Hint: a global variable of this name also exists in GeometryBasics.

Pyramid supports normals, but currently no texture coordinates or tessellation

Tessellation

In GeometryBasics Tessellation is a wrapper type for primitives which communicates how dense the mesh generated from one should be.

julia> t = Tessellation(Cylinder(Point3f(0), Point3f(0,0,1), 0.2), 32) # 32 vertices for each circleERROR: UndefVarError: `Tessellation` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Hint: a global variable of this name also exists in GeometryBasics.
julia> normal_mesh(t)ERROR: UndefVarError: `normal_mesh` not defined in `Main` Suggestion: check for spelling errors or missing imports. Hint: a global variable of this name also exists in GeometryBasics.
julia> t = Tessellation(Rect2(Point2f(0), Vec2f(1)), (8, 6)) # 8 vertices in x direction by 6 in y directionERROR: UndefVarError: `Tessellation` not defined in `Main` Suggestion: check for spelling errors or missing imports. Hint: a global variable of this name also exists in GeometryBasics.
julia> triangle_mesh(t)ERROR: UndefVarError: `triangle_mesh` not defined in `Main` Suggestion: check for spelling errors or missing imports. Hint: a global variable of this name also exists in GeometryBasics.

Primitive Interface / Implementing a new GeometryPrimitive

Every primitive should inherit from GeometryPrimitive{Dim, eltype} and implement at least coordinates(primitive) and faces(primitive) so that a mesh can be build from it. This will also be enough to automatically generate normals for a 3D primitive and texture coordinates for a 2D primitive. You can also implement functions to generate them directly with normals(primitive) and texturecoordinates(primitive). Depending on your primitive this might be necessary to get the normals and uvs you want.

To be compatible with Tessellation all of the functions mentioned above should implement a second tessellation argument. This will be the second argument passed to the Tessellation constructor. It's up to you to decide what makes sense here, though typically it's just an integer that more or less corresponds to the number of generated vertices.

Example

As an example, let's implement a parallelepiped, i.e. a 3D version or a parallelogram. In this case we need an origin and 3 vectors telling us how far and in which directions the object extends.

struct Parallelepiped{T} <: GeometryPrimitive{3, T}
    origin::Point{3, T}
    v1::Vec{3, T}
    v2::Vec{3, T}
    v3::Vec{3, T}
end

Like the Rect{3}, this object comes with 8 unique positions which we want to return as its coordinates.

function GeometryBasics.coordinates(primitive::Parallelepiped{T}) where {T}
    o = primitive.origin
    v1 = primitive.v1; v2 = primitive.v2; v3 = primitive.v3
    return Point{3, T}[o, o+v2, o+v1+v2, o+v1,  o+v3, o+v2+v3, o+v1+v2+v3, o+v1+v3]
end

To connect these points into a mesh, we need to generate a set of faces. The faces of a prallelepiped are parallelograms, which we can describe with QuadFace. Here we should be concious of the winding direction of faces. They are often used to determine the front vs the backside of a (2D) face. For example GeometryBasics normal generation and OpenGL's backface culling assume a counter-clockwise windig direction to correspond to a front-facing face. This means that if we look at a face from outside the shape, the positions referred to by that face should be ordered counter-clockwise. With that in mind the faces of our primitive become:

function GeometryBasics.faces(::Parallelepiped)
    return QuadFace{Int}[
        (1, 2, 3, 4), (5, 8, 7, 6), # facing -n3, +n3 (n3 being the normal of v1 x v2)
        (1, 5, 6, 2), (4, 3, 7, 8), # facing -n2, +n2
        (2, 6, 7, 3), (1, 4, 8, 5), # facing -n1, +n1
    ]
end

Note that you can check the correct winding direction fairly easily with Makie and the default generated normals. After implementing faces and coordinates, you can create a mesh plot of your primitive with Makie.mesh(primitive). If the mesh reacts to light in a reasonable way, i.e. gets brighter when light shines on it, then your faces have the correct winding direction. (It maybe useful to compare to other primitives like Sphere(Point3f(0), 1f0) here.)

Next on our TODO list are normals. The default normals produced by GeometryBasics.normal(primitive) are vertex normals, which assume that a primitive to be smooth. Since this is not the case for our primitive, we need to implement custom normals. Here we could rely on GeometryBasics.face_normal() which returns a normal per face, but for this example we will implement them ourselves.

For our shape we want one normal per face, pointing in the normal direction of the corresponding 2D plane. We can calculate the normal vector as n = normalize(cross(v, w)) where v and w correspond to combinations of v1, v2 and v3. To get them to act per face rather than per vertex, we need to overwrite the faces generated by faces(). We can do that by creating a FaceView with a new set of faces which only act on normals. Each of these new faces needs to refer to one normal by index to get what we want.

using LinearAlgebra
function GeometryBasics.normals(primitive::Parallelepiped)
    n1 = normalize(cross(primitive.v2, primitive.v3))
    n2 = normalize(cross(primitive.v3, primitive.v1))
    n3 = normalize(cross(primitive.v1, primitive.v2))
    ns = [-n3, n3, -n2, n2, -n1, n1]
    fs = QuadFace{Int}[1, 2, 3, 4, 5, 6] # = [QuadFace{Int}(1), QuadFace{Int}(2), ...]
    return FaceView(ns, fs)
end

As the last piece of the interface we can implement texture coordinates. They generally refer to a 2D image with normalized 2D coordinates on a per-vertex basis. There are many ways to define these coordinates. Here we will partition the image in 2x3 even sized rectangular sections, split by the sign of the normal directions defined above.

function GeometryBasics.texturecoordinates(::Parallelepiped{T}) where {T}
    uvs = [Vec2f(x, y) for x in range(0, 1, length=4) for y in range(0, 1, 3)]
    fs = QuadFace{Int}[
        (1, 2, 5, 4),   (2, 3, 6, 5),
        (4, 5, 8, 7),   (5, 6, 9, 8), 
        (7, 8, 11, 10), (8, 9, 12, 11)
    ]
    return FaceView(uvs, fs)
end