TetWild¶
tetwild fills the inside of triangle surfaces with tetrahedra, taking the surfaces as they come:
open, self-intersecting, non-manifold, inconsistently oriented. It simplifies the input inside an
envelope, inserts it exactly into a Delaunay tetrahedralization of its bounding box, so that every
input triangle becomes a union of tet faces, and then improves the tets' shape while the inserted
surface stays within a distance \(\varepsilon\) of the input
(Hu et al. 2018,
2020). A filter can remove the tets outside at the end.
Use it to get a tet mesh for simulation from a surface that is not clean enough for a constrained Delaunay mesher, or several surfaces at once. The surface of the result approximates the input within \(\varepsilon\); it does not reproduce its triangles. For curves in the plane, use TriWild; to keep curves and points inside the volume, see TetWild with features.
A first run¶
A snowman from Thingi10K (thingi_366725.stl, 1,617 triangles), as found in the wild: a body with
holes, the eyes and buttons as five separate open patches over them, eleven edges where
neighbouring triangles disagree on their orientation, and a non-manifold edge.
{
"application": "tetwild",
"input": ["thingi_366725.stl"],
"output": "snowman",
"filter": "tracked",
"stop_energy": 10,
"num_threads": 4,
"report": "snowman_report.json"
}
The input path is relative to the JSON file; the outputs, the tet mesh snowman_final.msh
among them (see outputs), are written to the working directory.
snowman = wm.read_mesh(str(DATA / "thingi_366725.stl"))
outputs, report = wm.tetwild(
{"input": ["snowman"], "filter": "tracked", "stop_energy": 10, "num_threads": 4},
{"snowman": snowman},
)
mesh = outputs["mesh"]
tets = mesh["cells"]["tetrahedron"]["connectivity"]
surface = outputs["surface"]["cells"]["triangle"]["connectivity"]
print(len(mesh["V"]), "vertices,", len(tets), "tets,", len(surface), "surface triangles,",
f"max energy {report['max_energy']:.1f}, avg {report['avg_energy']:.2f}")
On files instead, with the same options as the JSON file:
wmtk::MeshMap meshes{{"snowman", wmtk::io::read_mesh(data / "thingi_366725.stl")}};
auto [outputs, report] = wmtk::components::tetwild::tetwild(
{{"input", nlohmann::json::array({"snowman"})},
{"filter", "tracked"},
{"stop_energy", 10},
{"num_threads", 4}},
meshes);
const wmtk::MeshData& mesh = outputs.at("mesh");
const wmtk::MeshData& surface = outputs.at("surface");
std::cout << mesh.V.rows() << " vertices, "
<< mesh.cell(wmtk::CellType::Tetrahedron).connectivity.rows() << " tets, "
<< surface.cell(wmtk::CellType::Triangle).connectivity.rows()
<< " surface triangles, max energy " << report["max_energy"].get<double>() << "\n";
wmtk::io::write_mesh("snowman.msh", mesh);
Link wmtk::tetwild.
The 971 input vertices (after welding the STL's corners) are simplified to about 670. The surface is inserted into a Delaunay mesh of its bounding box (about 19,000 tets), which the insertion cuts into about 58,000, and the optimization runs until every tet's energy is below 10. Of the result, the filter keeps the inside: over twelve runs, 8,000 to 9,500 tets on 1,900 to 2,230 vertices, the largest energy between 9.5 and 10, the average 3.65 to 3.69 (see threads). A run takes seconds.
The input on the left, the result on the right: drag to rotate, scroll to zoom; on the tet mesh, move the cut with the slider. Every example is in the gallery.
Key settings¶
The numbers below come from the sweep section of the example, which changes one option at a
time against the first run, and from runs with the same options and other values:
# One option at a time against the first run. With 4 threads the numbers vary a little from run
# to run; num_threads 0 makes them repeat exactly, in about twice the time.
for change in [{}, {"stop_energy": 100}, {"coarsen_pass": True}, {"length_rel": 0.1},
{"eps_rel": 0.005}]:
options = {"input": ["snowman"], "filter": "tracked", "stop_energy": 10, "num_threads": 4,
"DEBUG_hausdorff": True, **change}
outputs, report = wm.tetwild(options, {"snowman": snowman})
print(f"{change}: {report['#v']} vertices, {report['#t']} tets, "
f"max energy {report['max_energy']:.1f}, avg {report['avg_energy']:.2f}, "
f"iterations {report['#iterations']}, "
f"surface within {report['hausdorff']:.3f} of the input (eps {report['eps']:.3f})")
They run with 4 threads, so counts vary by several percent from run to run; a range is over several runs, a single number from one.
The inputs: input, input_names, output¶
input is a list of triangle meshes (OBJ, STL, MSH, ...), up to
64. They are read, welded (vertices that coincide exactly are merged, see
remove_duplicate_eps) and meshed together: their
intersections are resolved exactly. With more than one input, each tracked face is held to the
envelope of its own input. Each input gets a winding-number field on the tets: wn_0, wn_1,
..., or, with input_names, wn_<name> in the VTU file
and the memory outputs. output
is the file name without extension (default out).
In memory, input names entries of the meshes dictionary.
The envelope: eps_rel¶
eps_rel (default 0.001) is the distance the output's surface
may be from the input, relative to the bounding box diagonal. The input is first simplified within
half of it, so detail smaller than \(\varepsilon\) may disappear; the optimization then keeps
every tracked face within \(\varepsilon\) of the input. A thinner envelope keeps the surface
closer and leaves the optimizer less room, so it takes more tets to reach the same quality:
eps_rel |
\(\varepsilon\) | tets | largest distance to the input |
|---|---|---|---|
| 0.0005 | 0.059 | 14,183 | 0.033 |
| 0.001 (default) | 0.117 | 8,000–9,500 | 0.065–0.067 |
| 0.002 | 0.235 | 6,817 | 0.140 |
| 0.005 | 0.586 | 6,400–6,600 | 0.33–0.36 |
The distance is what DEBUG_hausdorff measures and the
report stores as hausdorff: the largest distance from a point of the output's surface to the
input, sampled. It stays below \(\varepsilon\) in every run. The other direction, coverage, is
not bounded: the simplification may remove a feature thinner than \(\varepsilon\).
The edge length: length_rel¶
length_rel (default 0.05) is the target edge length,
relative to the bounding box diagonal. Edges longer than 4/3 of it are split, and edges shorter
than 4/5 of it are collapsed when the envelope and the quality allow. It sets the size of the tets
away from the surface; near the surface, the surface's own detail and the envelope do:
length_rel |
tets |
|---|---|
| 0.1 | 4,300–6,500 |
| 0.05 (default) | 8,000–9,500 |
| 0.025 | 47,315 |
Halving the length multiplies the count by about five; doubling it removes only a third to a half of the tets, because most of the snowman's tets are near its surface. A finer mesh takes proportionally longer: the run at 0.025 took about five times as long as one at the default.
The quality target: stop_energy¶
The shape of a tet is measured by its AMIPS energy: 3 for a regular tetrahedron, growing without
bound as the tet flattens (see Quality). The optimization stops when the
largest energy is below stop_energy, or after
max_iterations (80):
stop_energy |
tets | max energy | average | iterations |
|---|---|---|---|---|
| 100 (default) | 6,100–6,500 | 22.3–58.2 | 3.83–4.02 | 1–3 |
| 20 | 6,518 | 18.1 | 3.72 | 6 |
| 10 | 8,000–9,500 | 9.5–10 | 3.65–3.69 | 7–10 |
| 5 | 53,353 | 4.91 | 3.50 | 32 |
The average hardly moves; the target decides the worst tets, which are what a simulation suffers
from. At the default the result is whatever the first iterations leave below 100: the largest
energy ranged from 22 to 89 over nine runs. The examples ask for 10. Below that the cost climbs
quickly: the run at 5 took six to eight times as long as runs at 10, for six times the tets. A
run that ends at max_iterations without reaching the target still writes its mesh;
throw_on_fail makes it an error instead.
The outside: filter¶
The tet mesh covers the bounding box of the input, grown by a margin.
filter decides what is kept (see
Embedding for how each works). The default, none, keeps everything.
The three filters agree on a closed surface and differ on the others (these runs are coarse,
length_rel 0.1, and sequential, so they repeat exactly):
# A sphere of radius 0.7, closed, and the same sphere without its top cap.
sphere = wm.read_mesh(str(DATA / "sphere.obj"))
V, F = sphere["V"], sphere["cells"]["triangle"]["connectivity"]
cap = V[F].mean(axis=1)[:, 2] > 0.49
opened = {"V": V, "cells": {"triangle": {"connectivity": F[~cap]}}}
print(f"removed {cap.sum()} of {len(F)} triangles")
for name, surface in [("closed", sphere), ("open", opened)]:
for filter in ["none", "flood", "input", "tracked"]:
print(f"{name} sphere, filter {filter}: {keep(surface, filter)}")
| Surface | none |
flood |
input |
tracked |
|---|---|---|---|---|
| sphere, closed | 16,383 tets, volume 5.118 | 4,434 tets, volume 1.404 | 4,434, 1.404 | 4,434, 1.404 |
| sphere without its top cap (80 of 760 triangles) | 15,111, 4.637 | error: Empty Output after Filter! |
3,864, 1.330 | 3,864, 1.330 |
The closed sphere's volume is 1.404, and all three filters keep exactly the tets inside it. On the
open sphere, flood finds a single region, the box, and removes it all; the winding numbers still
tell inside from outside away from the hole, and both keep the tets whose winding number is above
1/2. tracked is documented to need closed components; it worked on this hole, which is not a
guarantee.
They also differ in what they read. flood uses only the surface, not its orientation; input
and tracked use the orientation. On double_sphere.obj, two intersecting spheres whose triangles
are oriented at random:
# Two intersecting spheres whose triangles are oriented at random.
spheres = wm.read_mesh(str(DATA / "double_sphere.obj"))
for filter in ["flood", "input", "tracked"]:
print(f"double sphere, filter {filter}: {keep(spheres, filter)}")
flood keeps 4,192 tets (volume 1.824, the union of the two balls), tracked 3,956 (1.720), and
input 143 (0.040). tracked repairs triangles flipped against their neighbours, which recovers
most of the volume here; input evaluates the input as it is. And where both work, input
judges each tet by the input surface rather than by the surface the mesh conforms to, so the
boundary of what it keeps can stray from the tracked surface: on the Octocat (default options),
31 faces of the kept tets' boundary are not tracked faces with input, none with tracked or
flood. flood, for its part, removes only the region outside, so it keeps cavities that a
winding number would call outside.
In short: tracked for consistently oriented inputs, closed or nearly so; flood for closed
inputs whose orientation cannot be trusted; input for open inputs, as in the
tracking tutorial. The winding numbers are written to the output in any case (see
outputs), so a none run can be filtered afterwards.
Feature curves and points: input_edges, input_points¶
input_edges lists edge meshes whose edges are inserted as
chains of tet edges, and input_points files whose
vertices are inserted as tet vertices; the optimization keeps both in place. The
TetWild with features workflow shows them on a cube, and how to
check what was kept.
Topology: preserve_topology¶
By default the simplification and the optimization may change the surface's topology: close holes
and slits narrower than the envelope, and make the surface non-manifold.
preserve_topology forbids it: only exactly
coincident vertices are merged, the simplification and the operations check the link condition,
and the run compares the Euler characteristics of the input surface and the output's surface at
the end, logging a warning when they differ (an error with throw_on_fail). With a filter, the
output's surface is the boundary of the kept tets, which is closed, so the check only makes sense
for closed inputs.
Use it on clean inputs whose topology matters. On the Octocat (one closed surface, Euler
characteristic 2), with filter tracked and stop_energy 10, it takes 13 iterations and
10,095 tets where the same run without it takes 7 and 8,122; the Euler characteristic is 2 in
both. On the snowman it does not converge: after 15 iterations the largest energy is still 4,135,
with 75,001 tets.
Coarsening: coarsen_pass¶
The collapses of the optimization judge each candidate on the mesh right after it, before smoothing
has relaxed it, so a converged mesh keeps vertices it does not need.
coarsen_pass runs a last pass that takes such collapses
and keeps each one if the worst tet around it is no worse than before. The largest energy cannot
rise; the average does:
| tets | max energy | average | |
|---|---|---|---|
| first run | 8,000–9,500 | 9.5–10 | 3.65–3.69 |
with coarsen_pass |
2,330–2,530 | 9.97 | 4.70–4.92 |
About a quarter of the tets for the same worst case, at about a tenth more time. The report's
coarsen_max_energy_before and coarsen_max_energy_after were equal in every run that recorded
them (9.97); coarsen_t_before and coarsen_t_after count the whole box's tets, before the
filter (37,932 and 5,483 in one run).
Correspondences: history_output¶
history_output records every change to the tracked
surface, from the input files to the output, and tracking maps points between the
input surface and the tet mesh's surface from it. With
volume_history_output the tets around each
change are recorded too, for a map of the whole volume. The tracking tutorial chains isotropic
remeshing and tetwild this way, and lists the options that keep the map a bijection.
Threads and reproducibility¶
num_threads 0 runs sequentially and gives the same mesh
every time (8,358 tets for the first run's options). With threads, the order of the operations
depends on the schedule, and the result changes from run to run: 8,000 to 9,500 tets. Run back to
back, the sequential run took about 2.3 times as long as one with 4 threads.
Outputs¶
On files, output names:
snowman_final.msh: the tet mesh. Each tet has its energy (energy),winding_number_input(the winding number of all inputs together at its centroid), onewn_<i>per input, and, when they were computed,winding_number_trackedand the flood-fill regionpart. The vertex fieldsizingholds the sizing scalar of the optimization.snowman.vtu: the same tets for ParaView, witht_energy, the winding numbers (wn_<name>withinput_names) and the vertex fieldssizing_field,is_rounded,on_surface,on_open_boundaryandorder.snowman_surf.vtu: the tracked surface, the tet faces that lie on the input.snowman_surface.obj: with a filter, the boundary of the tets kept; withfilternone, the tracked surface.snowman_simplified_input.obj: the input after the simplification, as inserted.snowman_features.obj: with feature curves or points, the tracked curves (l) and the anchored points (p).- With
report, a JSON file with the vertex and tet counts (#v,#t),max_energy,avg_energy,#iterations,eps, the coarsening statistics, andhausdorffandcoveragewithDEBUG_hausdorff,input_euler_characteristicandoutput_euler_characteristicwithDEBUG_eulerorpreserve_topology(-1 or empty when not measured), andtime, the seconds from the insertion to the filter.
The winding-number fields let a filter none run be split afterwards: a tet is inside an input
where its wn_<i> is above 1/2.
compute_tracked_winding_number and compute_flood_fill add the other two fields
without filtering; skip_winding_number leaves the
input's out when no filter needs them.
In memory, the outputs are mesh (the tets, with the fields of the VTU file), surface (the
tracked triangles, on the same vertices as mesh), envelope (the welded input the envelope is
built around), and features with feature curves or points. The report holds the same keys as the
file, and the history's bytes under history when history_output is set.
Pitfalls¶
- The default filter keeps the box.
filternonereturns the whole bounding box, inside and outside; set a filter to get the inside. floodon an open input removes everything and fails withEmpty Output after Filter!.- Everything is relative to the bounding box.
eps_relandlength_relmultiply the diagonal of the box around all the inputs, feature curves and points included. A stray triangle or point far from the rest grows the box, and with it \(\varepsilon\) and the edge length. Remove it, or compensate with smaller values. preserve_topologyon a dirty input keeps every tiny handle, gap and sliver of the input, and the optimization may not converge around them. Repair the input first (see manifold extraction).- A misspelled option is ignored.
wmtk_appand the Python bindings log a warning (tetwild has no option ...) and run with the defaults. Editors catch it beforehand with the JSON Schemas.
See also¶
- Reference: every option of
tetwild - Embedding: the simplification, the insertion, the tracked surface and the filters; Quality: the energy, the optimization loop and the coarsening pass; Robust geometry: the envelope.
- TetWild with features, TriWild, tracking.
- Hu et al. 2018, Tetrahedral Meshing in the Wild; Hu et al. 2020, Fast Tetrahedral Meshing in the Wild