General Utility Functions
Logging
Lagrange uses spdlog for logging purposes. Spdlog provides feature-rich formatting via the
excellent fmt library. This provides several advantages over a
traditional std::cout messaging:
- Python-like formatting syntax improves code readability
- 6 levels of logging: trace, debug, info, warn, error, critical
- Fast, thread-safe formatting
- Multiple sinks (file, stdout, etc.)
The default Lagrange logger can be accessed as follows:
#include <lagrange/Logger.h>
lagrange::logger().debug("This mesh has {} vertices and {} facets", v, f);
To control the logging level, or set additional sinks (e.g. to write logs to a file), please read the spdlog documentation.
A common pattern we use in example applications is to set the logging level/sink via command-line arguments. E.g. using CLI11 to parse cli args:
struct
{
std::string log_file;
int log_level = 2;
bool quiet = false;
} args;
// Parse command-line args with CLI11
CLI::App app{argv[0]};
app.option_defaults()->always_capture_default();
app.add_flag("-q,--quiet", args.quiet, "Hide logger on stdout.");
app.add_option("-l,--level", args.log_level, "Log level (0 = most verbose, 6 = off).");
app.add_option("-f,--log-file", args.log_file, "Log file.");
CLI11_PARSE(app, argc, argv)
if (args.quiet) {
// Hide stdout default sink
lagrange::logger().sinks().clear();
}
if (!args.log_file.empty()) {
// Add a new file sink
lagrange::logger().sinks().emplace_back(
std::make_shared<spdlog::sinks::basic_file_sink_mt>(args.log_file, true));
}
// Set log level
args.log_level = std::max(0, std::min(6, args.log_level));
spdlog::set_level(static_cast<spdlog::level::level_enum>(args.log_level));
Timing
Lagrange provides handy timing functions based on modern C++'s chrono package. Here is an example use case:
#include <lagrange/utils/timing.h>
auto start_time = lagrange::get_timestamp();
...
auto finish_time = lagrange::get_timestamp();
auto duration = lagrange::timestamp_diff_in_seconds(start_time, finish_time);
lagrange::logger().info("Duration: {}s", duration);
Assertion
Lagrange provide two types of assertion macros:
-
Runtime assertions, using
la_runtime_assert().Those are used to check the validity of user inputs as a pre-condition to a function. For example, providing a function with an empty, or a mesh with incorrect dimension, could result in such an assertion being raised. A runtime assert is executed even in Release configuration, and indicates a problem with the function input. -
Debug assertions using
la_debug_assert(). Those are only checked in debug code (macroNDEBUGis undefined). A debug assert is used to check internal code validity, and should not be triggered under any circumstance by client code. A failed debug assert indicates an error in the programmer's code, and should be fixed accordingly.
Usage In Expressions
Our assert macros behave as functions, meaning they expand to a void expression and can be
used in an expression such as y = (la_debug_assert(true), x).
Our assertion macros can take either 1 or 2 arguments, the second argument being an optional error
message. To conveniently format assertion messages with a printf-like syntax, use fmt::format:
#include <lagrange/utils/assert.h>
#include <spdlog/fmt/fmt.h>
la_debug_assert(x == 3);
la_debug_assert(x == 3, "Error message");
la_debug_assert(x == 3, fmt::format("Incorrect value of x: {}", x));
Break Into Debugger
It is possible to have assert macros break into the debugger upon failure. To do that, enable
the CMake option LAGRANGE_ASSERT_DEBUG_BREAK when compiling Lagrange into your project.
Type Cast
It is often necessary to cast a variable from one type to another. To ensure such cast is safe, Lagrange provides an explicit casting function:
#include <lagrange/utils/safe_cast.h>
// Cast variable y into variable x of type `type1`
type1 x = lagrange::safe_cast<type1>(y);
Implementation Detail
safe_cast checks for type compatibility, sign consistency and truncation error. We
recommended to use code that avoids casting in the first place.
Pointer Type Conversion
It is often necessary to convert a std::unique_ptr to std::shared_ptr.
Lagrange provides a handy function that avoids repeated type specification:
#include <lagrange/common.h>
std::unique_ptr<T> unique_val = ...;
auto shared_val = lagrange::to_shared_ptr(std::move(unique_val));
assert(unique_val == nullptr);
assert(shared_val.use_count() == 1);
Range
Lagrange provides a handy range() function to enable a python-like range-based
for loop:
#include <lagrange/range.h>
for (auto i : lagrange::range(num_vertices)) {
// i goes from 0 to num_vertices-1
}
The advantage of using range() is that the type of the index variable i is
automatically inferred from the type of num_vertices. This reduces
the amount of unnecessary implicit casts and compiler warnings.
Lagrange also provide range_sparse() to loop through an active subset of the
range:
#include <lagrange/range.h>
std::vector<Index> active_set;
// Populate active_set... (optional)
for (auto i : lagrange::range_sparse(N, active_set)) {
// i will loop through only the active entries.
}
The iterator behaves as follows:
- If
active_setis empty,range_sparse(N, active_set)will loop from 0 toN(same behavior asrange(N)). - However, if
active_setis non-empty,range_sparse(N, active_set)will loop through the entries ofactive_set.
Disjoint-Set / Union-Find
Lagrange provide a simple Disjoint-Set data-structure that can be used to implement algorithms such as Kruskal's minimum spanning tree.
#include <lagrange/utils/DisjointSets.h>
using Edge = std::array<Index, 2>;
std::vector<Edge> min_spanning_tree(
Index num_vertices,
const std::vector<Edge>& sorted_edges)
{
// Disjoint-sets for efficient union-find operations
DisjointSets<Index> union_find(num_vertices);
std::vector<Edge> output_edges;
// Iterate over all input edges in ascending order
for (auto [x, y] : sorted_edges) {
// If vertices belong to disconnected unrooted trees,
// merge them and add edge (x, y) to the solution
if (union_find.find(x) != union_find.find(y)) {
output_edges.emplace_back(x, y);
union_find.merge(x, y);
}
}
return output_edges;
}
Implementation Detail
For simplicity, our Disjoint-Set data structure currently only implements path compression, but not union by rank.
BitField
The BitField class can be used to implement convenient bitwise boolean operations over an enum type.
#include <lagrange/utils/BitField.h>
#include <lagrange/Logger.h>
enum Operation : int {
Translation = (1 << 0),
Rotation = (1 << 1),
Scaling = (1 << 2),
}
BitField<Operation> op;
op.set(Operation::Translation);
op.set(Operation::Scaling);
if (op.test(Operation::Scaling)) {
lagrange::logger().info("Scaling bit is set");
}
Floating Point Exceptions
On certain platforms, it is possible to trap floating point exceptions using compiler-specific functions. This is useful for debugging purposes, e.g. to detect when a NaN is emitted during numerical computation.
Lagrange provide a convenient platform-agnostic function called enable_fpe() / disable_fpe() to enable/disable trapping floating point exceptions. Our implementation currently supports Linux and macOS. On non-supported platform, calling the function is a no-op.
#include <lagrange/utils/fpe.h>
void my_main() {
lagrange::enable_fpe();
// call problematic operation [...]
lagrange::disable_fpe();
}
Compilation Issues
If our implementation of enable_fpe() causes compilation issues on your target platform (e.g.
macOS M1, Emscripten, etc.), it is possible to disable the feature explicitly by setting the
CMake option LAGRANGE_DISABLE_FPE=ON. In this case, calling enable_fpe() will do nothing.
Scope Guard
Sometimes it is necessary to ensure that certain resources are properly deallocated when leaving the scope of a function. This typically happens when dealing with C API that provide abstract object handling functions such as:
struct MyObject;
MyObject * create_object();
void delete_object(MyObject *);
To make object deletion exception-safe in C++, one must ensure that the delete_object() function
is called even if an exception is raised. One possibility is to use a
RAII class. Lagrange provides a simple
make_scope_guard() function that wraps an arbitrary callable using RAII:
#include <lagrange/utils/scope_guard.h>
void my_function() {
MyObject *obj = create_object();
// Deferred called to `delete_object()` destructor.
// Called when `guard` goes out of scope.
auto guard = lagrange::make_scope_guard([&]() { delete_object(obj); });
// Use `obj` ...
}
This paradigm is also useful when using imperative programming, e.g. when implementing an "editable
state" in an object, and you want to ensure the begin_edit() and end_edit() methods are always
called together. It can also be use to wrap calls to glBegin() and glEnd() in OpenGL, ensuring
the state machine stays consistent.
Stack-Allocated Containers
Lagrange provides different implementations of stack-allocated containers. Such containers are extremely useful when dealing with many small buffers with a known upper-limit size, or at least a good initial guess. For example if the nodes of a graph have at most 6 neighbors, you can use a stack-allocated containers to store the neighbor indices. Another example is storing a quad-dominant mesh, where facets can have a size 3 or 4.
Lagrange currently provide the following implementations:
- SmallVector. An implementation of a
std::vector<>with a pre-allocated stack buffer that grow on the heap beyond the initial upper limit. - StackVector. An implementation of a
std::vector<>with a fixed upper size provided at compile-time. Inserting new elements beyond the limit will throw an exception. - StackSet. An implementation of a
std::set<>with a fixed upper size provided at compile-time. Inserting new elements beyond the limit will throw an exception.
Shared Span
Lagrange's SurfaceMesh class relies heavily on a span<> implementation to provide bounds-safe view over raw pointers. Since our mesh attribute supports copy-on-write and wrapping of external buffers, it can be desirable to wrap external buffers as attribute while managing their lifetime.
To this end, Lagrange implements a SharedSpan object that offers a bounds-safe view
over a raw pointer, while tracking the actual owner object via a different std::shared_ptr<>.
Example 1: Buffer Created via a C-style API
struct MyMesh {
std::vector<float> vertices;
std::vector<uint32_t> facets;
};
// Create a new mesh instance
MyMesh * mesh_new();
// Free previously allocated mesh instance
void mesh_free(MyMesh* obj);
void wrap_mesh(lagrange::SurfaceMesh<float, uint32_t> &mesh)
{
// Create vertex buffer via abstract C API
auto owner_obj = std::shared_ptr<MyObject>(mesh_new(), &mesh_free);
// Number of vertices is number of coordinates divided by 3
uint32_t num_vertices = owner_obj->vertices.size() / 3;
// Create a "view" of the raw vertices pointer while tracking ownership information
auto vertices_view = make_shared_span(
owner_obj,
owner_obj->vertices.data(),
owner_obj->vertices.size());
mesh.wrap_as_vertices(vertices_view, num_vertices);
// Wrap facet buffer sharing the same owner object
uint32_t num_facets = owner_obj->facets.size() / 3;
auto facets_view = make_shared_span(
owner_obj,
owner_obj->facets.data(),
owner_obj->facets.size());
mesh.wrap_as_facets(facets_view, num_facets, 3);
}
Example 2: Buffer Stored as an Eigen Matrix
using RowMatrixXd = Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, Eigen::RowMajor>;
// Moves a N x 3 matrix of vertex positions into a Lagrange SurfaceMesh
// without copying the underlying buffer
void wrap_vertices(lagrange::SurfaceMesh32d& mesh, RowMatrixXd&& V)
{
// Take ownership of the input matrix V
auto eigen_matrix = std::make_shared<RowMatrixXd>(std::move(V));
// Create a safe view of the raw position data
auto vertices_view =
lagrange::make_shared_span(eigen_matrix, eigen_matrix->data(), eigen_matrix->size());
// Wrap raw position data as vertices without any copy
mesh.wrap_as_vertices(vertices_view, eigen_matrix->rows());
}