The Wild driver¶
tetwild, triwild, simwild and topological_offset improve their meshes with the same loop of
splits, collapses, swaps and smoothing, written once per dimension:
TetOptimizerMesh
for tet meshes and
TriOptimizerMesh
for triangle meshes. Each component derives its mesh from one of them, inherits the passes and
the loop, and changes what it needs through virtual hooks. This page describes the loop as the
code runs it; the measurements behind the defaults are in the comments of
OptimizerParameters.h.
Who uses it¶
| Component | Mesh class | Base | What runs the loop |
|---|---|---|---|
| tetwild | TetWildMesh |
TetOptimizerMesh |
mesh_improvement(max_iterations), after the input is simplified and embedded; use_legacy_code runs the older mesh_improvement_legacy instead |
| triwild | TriWildMesh |
TriOptimizerMesh |
mesh_improvement(max_iterations) |
| simwild | SimWildMesh (3D), SimWildMeshTri (2D) |
TetOptimizerMesh, TriOptimizerMesh |
mesh_improvement(max_iterations), for the operation remeshing |
| topological_offset | TopoOffsetTetMesh, TopoOffsetTriMesh |
TetOptimizerMesh, TriOptimizerMesh |
its own turns of local_operations() while it places the offset front, then mesh_improvement() for the final quality pass |
The two bases are deliberately separate: the same algorithms on different primitives, with a different quality convention (a tet stores AMIPS cubed and the 3D loop reports its cube root; a triangle stores the 2D AMIPS energy). The header comment of each base says why it was seeded by moving tetwild's (3D) and triwild's (2D) implementation into it.
The loop: mesh_improvement()¶
TetOptimizerMesh::mesh_improvement in
TetOptimizerMesh.cpp,
with logging and debug output left out; the 2D loop in TriOptimizerMesh.cpp is the same,
without optimization_iteration_done():
compute_vertex_partition_morton();
if (optimization_bare_coarsen_passes()) {
// Performing swaps after the initial collapse improves quality and convergence.
local_operations({{0, 1, 1, 0}}, false);
}
double pre_max_metric = std::get<0>(optimization_quality_stats());
int refine_cooldown = m_params.stuck_refine_cooldown;
for (int it = 0; it < max_its; ++it) {
if (!m_params.interleaved_smoothing) {
std::tie(max_metric, avg_metric) =
local_operations({{1, 1, 1, m_params.num_smoothing_passes}});
} else {
const int k = m_params.interleaved_smoothing_passes;
const std::array<std::array<int, 4>, 3> passes = {
{{{1, 0, 0, k}}, {{0, 1, 0, k}}, {{0, 0, 1, k}}}};
for (const auto& ops : passes) {
std::tie(max_metric, avg_metric) = local_operations(ops);
if (max_metric < optimization_stop_metric()) break;
}
}
optimization_iteration_done(it);
// count the rounded vertices: cnt_round of cnt_verts
if (max_metric < optimization_stop_metric()) {
if (cnt_round == cnt_verts) break;
}
consolidate_mesh();
if (optimization_stop_at_float() && round_and_check_all_rounded()) {
break;
}
if (refine_cooldown > 0) {
--refine_cooldown;
} else if (
it > 0 && max_metric > optimization_stop_metric() &&
optimization_stalled(pre_max_metric, max_metric)) {
refine_sizing_around_worst(max_metric);
refine_cooldown = m_params.stuck_refine_cooldown;
}
pre_max_metric = max_metric;
}
if (optimization_bare_coarsen_passes()) {
local_operations({{0, 1, 1, 0}});
}
coarsen_mesh();
The partition. At the start, the vertices are cut into NUM_THREADS parts along a Morton
order of their positions; a parallel pass queues every operation in the part of its tuple's
vertex (see scheduling). With num_threads 0 there is no
partition, and every pass is serial.
The bare passes. Before the loop, a collapse pass without the length gate
(collapse_limit_length false), then a swap pass: they remove what the embedding left. After
the loop, a collapse pass with the gate, then a swap pass. When
optimization_bare_coarsen_passes() returns false, neither runs. topological_offset's 2D mesh
returns false; its 3D mesh returns true only under TetWild's rules, in its final pass. The offset
surface is what its loop places, no envelope holds it, and an ungated collapse pass would
decimate it (TetOptimizerMesh.h has the measurement).
An iteration. With interleaved_smoothing
(on by default in all four components), three groups: splits, collapses, swaps, each followed by
interleaved_smoothing_passes smoothing passes, and the iteration ends early after a group that
brings the metric under the stop. Without it, one group: splits, collapses and swaps, then
num_smoothing_passes smoothing passes.
The stop. The loop ends when the max metric is under optimization_stop_metric() and every
vertex is rounded to doubles, or after max_iterations. For tetwild and triwild the metric is the
max AMIPS energy and the stop is stop_energy. simwild
overrides both: its metric is each cell's quality relative to its region's target, and the stop
is 1. With simwild's stop_at_float, optimization_stop_at_float() also ends the loop as soon as
every vertex is rounded, and skips it when every vertex already is.
Consolidation. After every iteration that does not stop, consolidate_mesh() compacts the
storage: ids change, and every tuple held across it is invalid.
Stuck refinement. When an iteration improved the max metric by at most
stuck_refine_stall_eps times the distance still to cover, the mesh is stalled:
refine_sizing_around_worst(), which each application implements, then lowers the sizing field
around the worst cells. tetwild's (TetWildMesh.cpp)
picks the cells above a filter energy, marks the longest edge of each for a forced split in the
next split pass (stuck_refine_force_split), multiplies the sizing scalar of their vertices
(grown by stuck_refine_rings rings) by stuck_refine_factor, down to stuck_refine_min_scalar,
and grades the result into its surroundings (stuck_refine_gradation). After a refinement, the
next stuck_refine_cooldown iterations do not refine.
Coarsening. coarsen_mesh() returns at once unless
coarsen_pass is set; it is not gated by the bare passes.
Then, for at most coarsen_max_rounds rounds, it runs a collapse pass in coarsening mode, where a
collapse is kept only if the worst cell in the region it touched is no worse than before, then
coarsen_global_smoothing_passes smoothing passes; it stops after a round that collapses nothing.
A group: local_operations()¶
local_operations(ops) takes four counts, {split, collapse, swap, smooth}, and runs, in this
order:
| Count | What runs |
|---|---|
ops[0] |
split_all_edges(), that many times |
ops[1] |
collapse_all_edges(collapse_limit_length), that many times |
ops[2] |
the swaps, at most that many times, stopping after a pass that swaps nothing: swap_all_edges() in 2D, swap_all_edges_all() then swap_all_faces() in 3D |
ops[3] |
smooth_all_vertices(ops[3]), then round_all_vertices() |
After each kind that ran it logs the max and average energy, runs the sanity checks when
DEBUG_sanity_checks is set, and calls update_attributes() (in 3D,
update_attributes_and_history()). If a pass ran out of preallocated
slots (slots_exhausted()), it consolidates, which reclaims the slots of removed simplices and
re-derives the storage from the live count, and runs the same kind again.
The passes¶
The split, collapse and swap passes collect their candidates in parallel
(parallel_collect_edge_ops, parallel_collect_face_ops). Every pass runs them through an
ExecutePass built by run_pass
(src/wmtk/utils/RunPass.hpp):
kPartition with NUM_THREADS > 0, kSeq otherwise, with the lock of its kind. All but
smoothing run through run_localized_to_convergence
(LocalizedRetry.hpp):
the executor runs again until a round succeeds nowhere, and a failed operation is retried only
if one of its vertices was in the region a successful operation renewed during that round.
| Pass | Candidates | Popped first | Dropped from the queue when | Lock |
|---|---|---|---|---|
| split | every edge, edge_split |
the longest | its length changed since it was queued, or it is not due: shorter than 4/3 of the target length times the sizing (splitting_l2), and not a forced split |
edge two-ring |
| collapse | every edge, both ways, edge_collapse |
the shortest | its length changed, or, with the length gate, it is longer than 4/5 of the target length times the sizing (collapsing_l2) |
edge two-ring |
| swap, 2D | every edge, edge_swap |
the largest drop in max energy (swap_weight) |
its drop changed, or is under 1e-5 | edge two-ring |
| swap, 3D | every edge, as edge_swap, edge_swap_44 and edge_swap_56; then every face, face_swap |
the longest edge | edge two-ring; face two-ring for face swaps | |
| smoothing | every vertex; with skip_good_regions, only the vertices of cells whose energy is at least skip_good_regions_margin times the stop energy, and every tracked-surface vertex |
vertex one-ring, or none (colored) |
The target length is length_rel times the bounding box
diagonal (OptimizerParameters::init_lengths_from_diagonal); the sizing is the mean of the two
endpoints' sizing scalars, which stuck refinement lowers.
Three details matter when changing a pass:
- Longest-edge order. In a parallel split pass an edge that loses a lock race is set aside,
and the thread moves on to shorter edges.
must_waitmakes a split wait while a longer due edge of one of its cells is still queued, so that the parallel pass splits in the order the serial one does (the comment inTriOptimizerMeshSplit.cpphas the measurements). - Screening. A parallel pass first dry-runs every candidate without locks, and only the
survivors take their ring locks (writing an operation).
The 2D collapse pass turns it off (
executor.screen_before_commit = false), where it costs more than it saves. - Colored smoothing. With
colored_smoothing(the default, except in topological_offset) and more than one thread, smoothing does not lock: the vertices are split into classes of pairwise non-adjacent vertices (utils::greedy_vertex_coloring), and each class runs in parallel. A smoothing hook may then write only its vertex and its own cells, and must read everything outside the vertex's star through const access; every smooth checks it and throws on the first offence. In 3D,check_surface_foldsturns colored smoothing off.
What an application overrides¶
The bases implement the operation hooks (split_edge_before, collapse_edge_after, ...) and
call narrower virtual hooks from them; tetwild, triwild and simwild override only those.
topological_offset also overrides the operation hooks themselves.
| Hook | Default | Overridden by |
|---|---|---|
refine_sizing_around_worst(max) |
none (pure virtual) | every application |
optimization_quality_stats(), optimization_stop_metric() |
max and average AMIPS; stop_energy |
simwild (relative quality, stop at 1) |
optimization_stop_at_float() |
false | simwild (stop_at_float) |
optimization_bare_coarsen_passes() |
true | topological_offset |
optimization_stalled(prev, cur) |
the stall test above | none |
collapse_quality_allowed |
allowed when the removed vertex is not rounded, or the new cells are no worse than the ring they replace (in 2D also: under stop_energy) |
simwild, topological_offset |
swap_quality_allowed |
strict improvement of the max energy | topological_offset |
collapse_before_vertex, collapse_after_vertex, split_after_vertex, ... |
accept, do nothing | tetwild (features, open boundaries), simwild, topological_offset |
smoothing_energy_envelope(vid) |
2D: the mesh's envelope for a surface vertex; 3D: none (pure virtual) | tetwild, triwild, simwild-3D, topological_offset |
cell_quality(tid), set_cell_quality(tid, q) (3D) |
none (pure virtual): the cell attributes are the application's | tetwild, simwild, topological_offset |
allow_surface_swap(), check_surface_topology() (3D) |
none (pure virtual) | tetwild and simwild (their options of the same names), topological_offset |
The headers list the rest, each with what it may and may not do.
Changing the shared code¶
A change to TriOptimizerMesh or TetOptimizerMesh changes four components at once, and
tetwild and simwild also simplify their input with
ShortestEdgeCollapse. Run the unit tests of all four
(wmtk_test_tetwild, wmtk_test_triwild, wmtk_test_simwild, wmtk_test_topological_offset)
and the integration configs in a Release build (testing). When simwild moved onto
the bases, the rule was that tetwild's and triwild's output must not move where the copies
disagreed, and simwild's could; simwild: behaviours adopted from tetwild /
triwild records each place where it did.