Topological offset¶
topological_offset adds a region at a given distance around a selection of a tagged tet mesh: a
shell around a body, inside it or both, or a band around an expression of tags (an overlap, the
curve where three regions meet) or around a surface group. It grows a band of cells from the
selection, gives them new tags, and then optimizes the mesh until the band's outer boundary, the
front, lies at the target distance. The band's topology is fixed when it is built and kept
throughout (Topological Offsets, ACM TOG 2025).
Use it to make a coating, a gap, or a contact layer of a given thickness around regions that already exist in a tagged mesh, such as one from simwild. To separate or join regions that touch only along an edge or at a vertex, use manifold extraction, which carves the same kind of band without the optimization.
What runs in this version
The component runs on tet meshes only: a triangle mesh stops with 2D input refused: the 2D
offset is not up to date. The optimization is under active development; many of its options
are marked EXPERIMENTAL_ in the reference and change often. This page covers the stable
options.
A first run¶
127891.msh is a bar (127891, 342 tets) in a box of background, 1,556 tets in all. A band of
distance 0.4 around it, outside, built but not optimized (optimize_offset false), which takes a
fraction of a second:
{
"application": "topological_offset",
"input": "127891.msh",
"output": "127891_offset",
"offset_selection": "127891",
"offset_output_tags": ["offset"],
"target_distance": 0.4,
"optimize_offset": false,
"report": "127891_offset.json"
}
The input must be a .msh. The result is 127891_offset.msh, with the band tagged offset.
bar = wm.read_mesh(str(DATA / "127891.msh"))
outputs, report = wm.topological_offset(
{"input": "bar", "offset_selection": "127891", "offset_output_tags": ["offset"],
"target_distance": 0.4, "optimize_offset": False},
{"bar": bar},
)
offset = outputs["mesh"]
print(report["before #t"], "->", report["after #t"], "tets,", count(offset, "offset"), "in the band")
On files instead, with the same options as the JSON file:
wmtk::MeshMap meshes{{"bar", wmtk::io::read_mesh(data / "127891.msh")}};
auto [outputs, report] = wmtk::components::topological_offset::topological_offset(
{{"input", "bar"},
{"offset_selection", "127891"},
{"offset_output_tags", nlohmann::json::array({"offset"})},
{"target_distance", 0.4},
{"optimize_offset", false}},
meshes);
std::cout << report["before #t"] << " -> " << report["after #t"] << " tets\n";
wmtk::io::write_mesh("127891_offset.msh", outputs.at("mesh"));
Link wmtk::topological_offset.
It prints 1556 -> 4802 tets, 2094 in the band. The log says how the band was placed and that it
passed its checks:
[construction] construction_mode max_marchable_fallback: maximum marchable distance 0.49254 over 592 marched edges; target_distance 0.4 is below it, so the march traces to the target
Offset region manifold check passed.
connected components check passed. (# components=1)
The front is a closed surface of 1,180 triangles. Its 592 vertices lie at the target: their distance to the bar is between 0.990 and 1.000 times 0.4. Its triangles do not: they are chords between those vertices, and their centroids are between 0.54 and 1.02 times the target (0.93 on average). The band's cells are thin (max AMIPS 179, against 19.2 in the input). Moving the whole front onto the target and restoring the quality is the job of the optimization.
How the band is built¶
- The complex.
offset_selectionpicks the input complex: cells, and the faces, edges and vertices between them. - The embedding. Cells are split so that no edge, face or tet outside the complex has all
its vertices on it (the log says
Input complex/offset simplicially embedded: TRUE). - The march. Every edge with one end on the complex is split at the target distance from the complex, found by sphere tracing. That needs the edge's far end to be farther than the target. The smallest such distance over all the edges is the maximum marchable distance; when the target is not below it, every edge is split at half of it instead, and the front starts short of the target.
- The band. The cells around the new vertices are the band, one cell thick, and get the output tags.
- The optimization (unless
optimize_offsetis false): turns of splits, collapses, swaps and smoothing that move the front onto the target distance and refine where it is not, with TetWild passes for quality before and after them.
The band's topology is the one the march gives it, and every later operation keeps it.
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¶
Which side: offset_selection, offset_in, offset_out¶
offset_selection names what to offset. A
single tag name, as in the first run, is single body mode: the body is that region, and
offset_out (default true) and
offset_in (default false) say on which side of
its boundary the band grows. At least one must be true.
# Outward (the default), inward, and both: the band is the cells tagged "offset".
for side in [{"offset_out": True}, {"offset_in": True, "offset_out": False},
{"offset_in": True, "offset_out": True}]:
out, _ = wm.topological_offset(
{"input": "bar", "offset_selection": "127891", "offset_output_tags": ["offset"],
"target_distance": 0.4, "optimize_offset": False, **side},
{"bar": bar})
print(side, count(out["mesh"], "offset"), "band tets")
| side | band tets | the march traces to |
|---|---|---|
offset_out |
2,094 | 0.4, the target |
offset_in |
2,876 | 0.0973, half of the inside's maximum marchable distance, 0.195 |
| both | 4,970 | 0.0973 |
Inside the bar the maximum marchable distance is 0.195, below the target, so the inward band
starts at a quarter of the target and the optimization has to carry it the rest of the way. The
default offset_selection, !_, left the example mesh unchanged: set it.
Expressions¶
Any other offset_selection is an
expression over tag names, with &, |, ! and parentheses, as in simwild (ambient is the
background). The complex is every tet whose tags satisfy it, and every face, edge and vertex
whose surrounding tets' tags, together, satisfy it. The band grows on every side of the complex;
offset_in and offset_out are not read. On double_sphere.msh, two overlapping spheres
tag_0 and tag_1, with a target of 0.05:
# Two overlapping spheres, tag_0 and tag_1. An expression selects the complex to offset.
spheres = wm.read_mesh(str(DATA / "double_sphere.msh"))
for selection in ["tag_0 & tag_1", # the overlap, a lens
"ambient & tag_0 & tag_1"]: # the circle where the two surfaces cross
out, _ = wm.topological_offset(
{"input": "spheres", "offset_selection": selection, "offset_output_tags": ["offset"],
"target_distance": 0.05, "optimize_offset": False},
{"spheres": spheres})
print(f"{selection!r}: {count(out['mesh'], 'offset')} band tets")
offset_selection |
the complex | band tets |
|---|---|---|
tag_0 & tag_1 |
the overlap, a lens of tets | 2,716 |
ambient & tag_0 & tag_1 |
the circle where the two surfaces cross: 28 edges | 886 |
A complex of edges alone, like the circle, is built but not optimized: with the optimization the
run stops with the energy's band term needs an input complex of triangles only. Set
optimize_offset false for it.
Surfaces: EnvelopeSurface and face tags¶
A .msh with an EnvelopeSurface triangle group (simwild writes one) has that group as a tag of
its own name, and offset_selection can name it alone: the complex is the faces of the mesh that
lie within envelope_size_rel of the
group, and the band grows on both sides of them. This is how to offset an open sheet. The log
says how many faces carry it:
Surface group 'EnvelopeSurface' (tag 3): 330 triangles; 310 of the mesh's faces lie inside its envelope (eps 0.0836779) and carry the tag
In 127891.msh the group is the bar's own surface, so "offset_selection": "EnvelopeSurface"
gives the same 4,970 band tets as offset_in and offset_out together. A face tag (simwild's
input_type surface) is selected by its name in the same way, with optimize_offset false
only.
Tags of the band: offset_output_tags, protected_tags¶
offset_output_tags (default
["newtag"]) are the tags every band cell gets, replacing the tags it had; a name the mesh does
not have is added as a new region. [] makes the band background, which carves a gap.
protected_tags are kept on the band cells
that have them, alongside the output tags. ambient can be neither (it is ignored, with a
warning). On the two spheres, the band around tag_0 runs through tag_1:
# The band around tag_0 runs through tag_1. Protected, tag_1 stays on the band's cells.
for protected in [[], ["tag_1"]]:
out, _ = wm.topological_offset(
{"input": "spheres", "offset_selection": "tag_0", "offset_output_tags": ["offset"],
"protected_tags": protected, "target_distance": 0.05, "optimize_offset": False},
{"spheres": spheres})
m = out["mesh"]
print(f"protected {protected}: offset {count(m, 'offset')}, tag_1 {count(m, 'tag_1')} tets")
The band is 3,998 tets either way; tag_1 keeps 3,539 tets without protection and 4,729 with it:
1,190 band cells stay in tag_1 as well.
The distance: target_distance_rel, target_distance¶
target_distance_rel (default 0.01) is
the target relative to the diagonal of the whole mesh's bounding box, background included;
target_distance, when positive, replaces it
with an absolute length. The box of 127891.msh has a diagonal of 83.7, so the default target is
0.837, above the maximum marchable distance, 0.493: the march puts the front at 0.246, 0.29 of the
target, and the optimization has to grow it. On this mesh, with the other options at their
defaults (and four threads), that optimization had not converged after 22 turns, when the run was
stopped.
A target below the maximum marchable distance is traced exactly, as in the first run. That distance is the one from the complex to the nearest far end of an edge that leaves it, so it is set by the cells around the selection, and the construction does not change them beforehand: the input's resolution decides what the construction can reach.
The field: offset_field¶
offset_field is the field the front is placed
on. euclidean, the default, is the distance to the complex, and the only one available: smooth
stops with offset_field "smooth" is not available in 3D.
The optimization: optimize_offset, front_conv_rel¶
optimize_offset (default true) runs the
optimization after the construction: up to
max_rounds (default 40) turns of splits,
collapses, swaps and smoothing that place the front, a TetWild pass before them on the
constructed mesh, and a final one if the max AMIPS is over
stop_energy (100). After each turn, each
front vertex is measured on the front triangles around it: the root mean square of
(distance − target) / target over sample points on them. The loop stops when every vertex is
within the bar, front_conv_rel times the
target (default 0.025); around a vertex over the bar, the target edge length is halved. Each
turn logs the measure in units of the bar:
The optimization is most of the cost. The first run's offset, optimized for five turns with a bar of 20% of the target and four threads:
# The optimization, cut short: five turns, a bar of 20% of the target, four threads.
outputs, report = wm.topological_offset(
{"input": "bar", "offset_selection": "127891", "offset_output_tags": ["offset"],
"target_distance": 0.4, "front_conv_rel": 0.2, "max_rounds": 5, "num_threads": 4},
{"bar": bar},
)
metrics = report["optimization_metrics"]
print("converged:", report["converged"], "|", report["after #t"], "tets |",
f"front distance error: avg {metrics['avg_l2_dist_err'][-1]:.3g},"
f" max {metrics['max_l2_dist_err'][-1]:.3g}")
It prints converged: False, 21,683 to 24,423 tets in four runs (the threads change the order of
the operations), and a front distance error of 0.016 to 0.024 on average and 0.4 at most.
Measured back to back, these five turns take about 400 times as long as the construction, and the
time per turn grows with the mesh. On one result (23,148 tets), 84% of the front's vertices are
within 2.5% of the target and 83% of its triangles' centroids within 5% (55% after the
construction); the max AMIPS is 117.9. A few front vertices are still off by the whole target, and the log warns
that some 170 to 200 of the front's 2,300 to 2,500 triangles are folded ([foldover]). Ten turns
with the same bar end at 100,131 tets, still not converged (worst vertex 4.9 times the bar). None
of the optimized runs made for this page converged.
With optimize_offset false, the run stops after the march: the front's vertices are at the
target (or at half the maximum marchable distance), the report has no convergence fields, and the
mesh's diagnostic fields (input_distance and others) are NaN.
The checks: check_manifoldness¶
With check_manifoldness (default true),
the region made of the complex and the band must be manifold after the construction, or the run
stops with OFFSET REGION IS NOT MANIFOLD; after the optimization it is checked again, and a
failure is logged as an error. Whatever this option says, the mesh's connected components are
counted before and after the construction, and a change stops the run
(connected components check passed in the log).
Outputs¶
- The mesh:
outputon the command line (defaultout, written asout.msh),outputs["mesh"]in memory. Its tags are the input's, with the output tags on the band (and an emptyEnvelopeSurfacecolumn when the input has that group). The vertex fieldlabelsis 1 on the complex, 2 on the vertices the march added, 0 elsewhere: in the first run, 157, 592 and 195. The other fields are diagnostics of the optimization. - In memory,
outputs["offset"]: the front, as triangles on the mesh's vertices, after the optimization (2,462 triangles in one five-turn run); a construction-only run returns it without triangles.outputs["envelope"]is the input'sEnvelopeSurfacegroup, passed on. - With
report, a JSON file with the vertex, edge, face and tet counts before and after, the threads and the run time; after the optimization alsoconvergedandoptimization_metrics, whoseavg_l2_dist_errandmax_l2_dist_errare the front's distance error. In memory, the report is the second return value. - With
save_vtu,.vtufiles for ParaView.
Recipes and pitfalls¶
- A misspelled or outdated option is an error. Unlike most components,
topological_offsetrefuses an option its spec does not have:No rule matched for "/front_conv_criterion"(wmtk_applogstopological_offset has no option ...first). Configurations written for earlier versions, withfront_conv_criterion,offset_gradient_reloroffset_residual_rel, fail this way. Start from the reference. - The run does not converge. The result is written anyway, with a warning
Optimization did not convergeandconvergedfalse in the report; check it before using the offset.throw_on_nonconvergencemakes it an error instead. - The target is beyond the cells. When the log says the march traces to half of the maximum
marchable distance, the constructed front is short of the target, and only the optimization
carries it out, which is slow. Pick a target below that distance where the offset allows it;
growth_modesets how the optimization grows the front. - A surface group that does not lie on mesh faces. The group's faces are found
geometrically. On
double_sphere.msh, only 129 of the mesh's faces lie within the group's envelope, in 36 pieces, so the complex is a scatter of patches; the log line above says so. - Parallel runs differ. With
num_threadsabove 0 the optimization's result changes slightly from run to run (the tet counts above); 0 runs sequentially. - For prismatic meshes,
save_offset_correspondencerecords which input vertex each marched vertex came from; it needsoptimize_offsetfalse.
See also¶
- Reference: every option of
topological_offset - Manifold extraction: the same construction around non-manifold edges and vertices
- Regions: tagged meshes, and the tag expressions
- Topological Offsets, ACM TOG 2025