dt

Mesh Data Structures

Implementation Tutorial #2 — Designing a clean, extensible in memory representation for triangle meshes in the context of btm framework.

1. Why Mesh Data Structures Matter

In the previous tutorial, we established the rendering window and the platform layer. Now we move to the core of any geometry processing system: how a mesh is represented in memory. This choice determines:

  • how efficiently we can traverse neighborhoods
  • how robustly we can compute curvature
  • how easily we can detect feature lines
  • how cleanly we can segment or refine the mesh
  • how straightforward it is to upload data to the GPU
  • how extensible the system becomes as algorithms grow

In TheMeshProject, the mesh structure must be:

  • explicit about connectivity
  • friendly to numerical algorithms
  • compatible with btm framework’s rendering layer
  • simple enough to understand and extend
  • stable across tutorials and future modules
This tutorial introduces a minimal but extensible mesh representation that will serve as the foundation for curvature estimation, segmentation, and refinement.

2. Basic Entities: Vertices, Faces, and Connectivity

At the lowest level, a triangle mesh consists of:

  • a list of vertex positions
  • a list of triangular faces, each referencing three vertex indices
  • optional adjacency information used to accelerate neighborhood queries

A minimal starting point looks like this:

template <typename T>
struct VertexExplicit {
    basevec<T, 3> position;
};

struct FaceExplicit {
    std::uint32_t v0, v1, v2;
};

struct VertexAdjacency {
    std::vector<std::uint32_t> incident_faces;
    std::vector<std::uint32_t> neighbor_vertices;
};

template <typename T>
struct MeshAttributes {
    std::vector<basevec<T, 3>> vertex_normals;  // 3D vectors for each vertex
    std::vector<basevec<T, 3>> face_normals;    // 3D vectors for each face

    // Curvature fields
    std::vector<T> k1;  // principal curvature 1
    std::vector<T> k2;  // principal curvature 2

    // Segmentation labels
    std::vector<int> segment_id;
};

template <typename T>
class MeshExplicit {
public:
    std::vector<VertexExplicit<T>> vertices;
    std::vector<FaceExplicit>      faces;
    std::vector<VertexAdjacency>   adjacency;
    MeshAttributes<T>              attributes;
    // methods for loading, saving, and manipulating the mesh would go here
}; 

This representation is intentionally simple.

It is enough to:

  • load a mesh from file
  • iterate over faces and vertices
  • upload vertex/index buffers to the GPU
  • implement basic visualization

We will extend it gradually as algorithms require more structure.

3. Integrating with btm-framework

In btm-framework, the mesh structure typically lives in a geometry module, separate from the rendering code.

This separation is intentional:

  • geometry algorithms (curvature, segmentation, refinement) should not depend on OpenGL
  • rendering code should not need to know about adjacency or curvature
  • both modules should communicate through a clean, minimal interface

A common pattern is:

  • btm::MeshExplicit — owns vertices, faces, adjacency, attributes
  • btm::gl_prim — knows how to upload a mesh to GPU buffers and draw it

These two should be decoupled enough that we can change the mesh structure (e.g., add half-edge connectivity) without affecting the rendering code, while still allowing efficient data transfer when needed.

Conceptually:

// in the geometry module
template <typename T>
class MeshExplicit {
public:
    std::vector<VertexExplicit<T>>       vertices;
    std::vector<FaceExplicit>    faces;
    std::vector<VertexAdjacency> adjacency;
    MeshAttributes<T>            attributes;
};

// in the rendering module
class gl_prim
{
public:
    gl_prim() { 
        // intialize class internals
    }
    void render([drawing parameters]) {
        // actual OpenGL calls to draw the mesh using vertex/index buffers
    }
}; 

template <typename T>
gl_prim* create_prim(btm::MeshExplicit<T>* ms, GLenum drmode = GL_LINE, bool dr_el = true) {
    // creates a gl_prim from the mesh, which uploads the vertex and index buffers to the GPU
    // returns a pointer to the gl_prim object that can be used for rendering
}

This separation keeps the geometry logic independent from the rendering backend, while still allowing tight integration where needed.

4. Adding Adjacency (Preparing for Curvature and Refinement)

Most algorithms in TheMeshProject rely on local neighborhoods:

  • curvature estimation
  • feature line detection
  • segmentation
  • metric based refinement
  • smoothing and filtering

To support these operations, we need adjacency information.

A simple and effective approach is to store, for each vertex:

  • the list of incident faces
  • the list of neighboring vertices

Example Adjacency structure

struct VertexAdjacency
{
    std::vector<std::uint32_t> incident_faces;   // indices into triangles[]
    std::vector<std::uint32_t> neighbor_vertices; // indices into vertices[]
};

struct FaceAdjacency {
    std::vector<std::uint32_t> neighbor_faces;
};

class MeshExplicit
{
    std::vector<VertexExplicit<T>>          vertices;
    std::vector<FaceExplicit>    faces;
    std::vector<VertexAdjacency> adjacency;
    std::vector<FaceAdjacency>   face_adjacency;
};

The adjacency can be built once after loading the mesh:

void MeshExplicit::build_adjacency() {
    adjacency.clear();
    adjacency.resize(vertices.size());

    face_adjacency.clear();
    face_adjacency.resize(faces.size());


    for (std::uint32_t f = 0; f < faces.size(); ++f) {
        const auto& tri = faces[f];

        std::uint32_t vs[3] = { tri.v0, tri.v1, tri.v2 };

        for (int i = 0; i < 3; ++i) {
            auto v = vs[i];
            adjacency[v].incident_faces.push_back(f);

            auto vn = vs[(i + 1) % 3];
            adjacency[v].neighbor_vertices.push_back(vn);
        }
        adjacent_faces(f, face_adjacency[f].neighbor_faces);
    }
}

// Given a face index, find all adjacent faces (sharing an edge) and return them in out_faces.
void MeshExplicit::adjacent_faces(std::uint32_t face, std::vector<std::uint32_t>& out_faces) const {
    EdgeKey e0(faces[face].v0, faces[face].v1);
    EdgeKey e1(faces[face].v1, faces[face].v2);
    EdgeKey e2(faces[face].v2, faces[face].v0);

    std::set<std::uint32_t> adj_faces;

    auto& fc1 = edges.find(e0)->second.incident_faces; // adjacent faces across edge e0
    auto& fc2 = edges.find(e1)->second.incident_faces; // adjacent faces across edge e1
    auto& fc3 = edges.find(e2)->second.incident_faces; // adjacent faces across edge e2
    adj_faces.insert(fc1.begin(), fc1.end());
    adj_faces.insert(fc2.begin(), fc2.end());
    adj_faces.insert(fc3.begin(), fc3.end());

    auto excludeCondition = [face](std::uint32_t value) {
        return value != face; // keep only odd numbers
        };

    std::copy_if(adj_faces.begin(), adj_faces.end(),
        std::back_inserter(out_faces),
        excludeCondition);
}

This is not the only possible representation, but it is:

  • easy to understand
  • good enough for curvature estimation and many local operators
  • compatible with later refinements (half edge, edge maps, etc.)

5. Attributes and Extensibility

As we progress through the implementation series, we will need to attach additional data to the mesh:

  • per vertex normals
  • principal curvatures \(k_1, k_2\)
  • mean and Gaussian curvature
  • feature intensities
  • segmentation labels
  • refinement metrics

A clean approach is to store these in parallel arrays, grouped under a MeshAttributes structure.

template <typename T> struct MeshAttributes {
    std::vector vertex_normals;
    std::vector face_normals;

    // Curvature fields
    std::vector<T> k1; // principal curvature 1
    std::vector<T> k2; // principal curvature 2

    // Segmentation labels
    std::vector<int> segment_id;
};

template <typename T> 
class MeshExplicit {
public:
    std::vector<VertexExplicit<T>> vertices;
    std::vector<FaceExplicit> faces;
    std::vector<VertexAdjacency> adjacency;
    MeshAttributes<T> attributes;
}; 

This keeps the core connectivity structure stable while allowing attributes to evolve as algorithms become more sophisticated.

6. Connecting to the Rendering Window

From the perspective of the rendering window created in Tutorial #1, the mesh structure provides:

  • a vertex buffer (positions, normals, etc.)
  • an index buffer (triangles)

A typical flow in btm-framework might look like:

// in our main function, after setting up the platform and window
// here we create/load our mesh
MeshExplicit<float> mesh;
create_mesh(mesh);
// The mesh_renderer/gl_prim will generate the vertex buffer and other necessary data for rendering 
// based on the mesh topology and attributes.
// the mesh_renderer is a global unique_ptr to a gl_prim object that handles the OpenGL rendering of the mesh.
mesh_renderer.reset(create_prim<float>(&mesh));

while (pollEvents()) {
    // in this example, the camera and the shader programs are global variables, 
    and the render_resources struct contains pointers to them, along with any other resources needed for rendering.
    // which the renderer can use to set up the rendering state.
    render();
}

The renderer would be responsible for uploading the vertex and index buffers to the GPU, while the main loop handles events and issues draw calls.

static bool create_mesh(MeshExplicit<float>& r_mesh) {
    // vertices of a cube
    static std::vector<basevec3<float>> cube_vertices = {
        {0.5f, 0.5f, -0.5f},   {0.5f, -0.5f, -0.5f},  {0.5f, 0.5f, 0.5f},
        {0.5f, -0.5f, 0.5f},   {-0.5f, 0.5f, -0.5f},  {-0.5f, -0.5f, -0.5f},
        {-0.5f, 0.5f, 0.5f},   {-0.5f, -0.5f, 0.5f},
    };
    // triangles of the cube (12 triangles, 2 per face)
    static std::vector<FaceExplicit> cube_triangles = {
        {4, 2, 0}, {2, 7, 3}, {6, 5, 7}, {1, 7, 5}, {0, 3, 1}, {4, 1, 5},
        {4, 6, 2}, {2, 6, 7}, {6, 4, 5}, {1, 3, 7}, {0, 2, 3}, {4, 0, 1}
    };

    // Add vertices and triangles to the mesh. The MeshExplicit class will store them in its internal data structures.
    for (const auto &v : cube_vertices) {
        r_mesh.add_vertex(v);
    }
    for (const auto &tri : cube_triangles) {
        r_mesh.add_triangle(tri);
    }

    // build mesh adjacency and attributes. The adjacency will be used to compute the vertex normals and other attributes for rendering.
    r_mesh.build_adjacency();
    r_mesh.build_attributes();

    return true;
}

void render() {
    // first we check OpenGL state
    btm::GLContext* context = btm::get_current_gl_context();
    if (!context)
        return;

    begin_render();

    glClearColor(0.2f, 0.4f, 0.6f, 1.f);

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // gets the window dimensions from the GL context and sets the camera aspect ratio and viewport accordingly. 
    // This ensures that the rendered scene is displayed correctly within the window.
    int width = context->width();
    int height = context->height();
    g_cam->set_aspect(width, height);
    g_cam->set_viewport();

    // bring the shader into use 
    g_shader->use();
    // call the camera's apply method to set the view and projection matrices in the shader.
    // The shader will use these matrices to transform the vertex positions from world space to clip space for rendering.
    g_cam->apply(g_shader.get());

    // now call the renderer to draw the mesh. The MeshRenderer will use the shader and the mesh data to issue 
    // OpenGL draw calls to render the triangles of the mesh on the screen.
    mesh_renderer->force_black = false;  // set to true to render the mesh in black (e.g., for wireframe)
    mesh_renderer->render(g_shader.get());

    g_shader->end();

    end_render();
}

In later tutorials, we will enhance the gl_prim to implement more advanced visualization techniques such as displaying: curvature fields, feature lines, segmentation overlays, and refinement diagnostics.

7. Summary and Next Steps

In this tutorial, we:

  • defined a minimal mesh structure (vertices + triangles)
  • introduced adjacency as a foundation for local operators
  • outlined how attributes can be attached and extended
  • sketched how this structure connects to the rendering window

This data structure is the backbone for many algorithms in TheMeshProject. In future tutorials, we will use it to implement:

  • Curvature Estimation in C++ — computing curvature on triangle meshes and preparing the results for visualization.