Manifold extraction¶
manifold_extraction makes the boundary of a selection of regions manifold. In a tagged tet mesh
(3D) or triangle mesh (2D), a selection is a set of cells given by a tag expression, and its
boundary is the surface (3D) or curve (2D) between the selected cells and the others. Where two
parts of the selection touch only along an edge or at a vertex, that boundary is non-manifold
there. The component finds those edges and vertices, carves a thin region around them, and gives
the carved cells the tags in fill_tags: the background separates the parts, a selected tag joins
them.
Use it on a tagged mesh whose regions touch, before a step that needs a manifold boundary, such as a surface extracted for a solver or a surface remesher. The carving is a topological offset of the non-manifold edges and vertices, construction only: it is fast, but the cells it cuts are of lower quality, and simwild's remeshing is the step after it.
What is non-manifold¶
The test is local, at each vertex and edge on the selection's boundary:
- An edge (3D) is non-manifold when the selected tets around it do not form one fan: going from tet to tet across their faces around the edge does not reach all of them. Around an edge of the circle where two balls touch, the tets go ball, outside, ball, outside: the selected ones form two fans.
- A vertex (3D) is non-manifold when the selected tets around it, or the other tets around it, are not one face-connected set each. The outside of the mesh counts as one cell of the others.
- A vertex (2D) is non-manifold when the selected triangles around it do not form one fan across their edges: two triangles that share a vertex and no edge.
An edge or vertex that is not on the boundary is not tested. A boundary that passes the test everywhere is a closed manifold surface (in 2D, closed curves).
A first run¶
Two spheres, tag_0 and tag_1, overlap; the overlap was removed, so they touch along the
circle where their surfaces cross. The union tag_0 | tag_1 has a non-manifold boundary along
that circle. With the default fill_tags, the background:
{
"application": "manifold_extraction",
"input": "double_sphere_hole.msh",
"output": "double_sphere_manifold",
"tag_selection": "tag_0 | tag_1",
"fill_tags": [],
"write_surface": true,
"report": "double_sphere_manifold.json"
}
The input must be a .msh. The repaired mesh is double_sphere_manifold.msh, and with
write_surface the boundary of the selection is double_sphere_manifold.obj.
spheres = wm.read_mesh(str(DATA / "double_sphere_hole.msh"))
outputs, report = wm.manifold_extraction(
{"input": "spheres", "tag_selection": "tag_0 | tag_1", "fill_tags": []},
{"spheres": spheres},
)
repaired = outputs["mesh"]
print(report["before #t"], "->", report["after #t"], "tets")
On files instead, with the same options as the JSON file:
wmtk::MeshMap meshes{{"spheres", wmtk::io::read_mesh(data / "double_sphere_hole.msh")}};
auto [outputs, report] = wmtk::components::manifold_extraction::manifold_extraction(
{{"input", "spheres"},
{"tag_selection", "tag_0 | tag_1"},
{"fill_tags", nlohmann::json::array()}},
meshes);
std::cout << report["before #t"] << " -> " << report["after #t"] << " tets\n";
wmtk::io::write_mesh("double_sphere_manifold.msh", outputs.at("mesh"));
// The boundary of the selection after the repair, a triangle mesh of its own.
wmtk::io::write_mesh("double_sphere_manifold_surface.msh", outputs.at("surface"));
Link wmtk::manifold_extraction.
The log reports 28 non-manifold edges and 28 non-manifold vertices (the count of vertices includes the edges' ends), and the mesh goes from 15,131 to 17,093 tets in a fraction of a second. The union's boundary had 970 triangles and 28 non-manifold edges; it now has 1,284 triangles, no non-manifold edge or vertex, and is two closed surfaces: the spheres no longer touch. To check a result, count the edges of the boundary that are not on exactly two triangles:
def selected(mesh, *tags):
"""The tets that carry any of `tags`."""
t = mesh["cells"]["tetrahedron"]["attributes"]["tags"]
columns = [t["labels"].index(tag) for tag in tags]
return np.asarray(t["values"])[:, columns].any(axis=1)
def boundary(mesh, inside):
"""The triangles between the tets in `inside` and the others (or the outside)."""
T = np.asarray(mesh["cells"]["tetrahedron"]["connectivity"])[inside]
F = np.sort(np.concatenate([T[:, [0, 1, 2]], T[:, [0, 1, 3]], T[:, [0, 2, 3]],
T[:, [1, 2, 3]]]), axis=1)
faces, count = np.unique(F, axis=0, return_counts=True)
return faces[count == 1]
def nonmanifold_edges(F):
"""Edges of the triangles F that are not on exactly two of them."""
E = np.sort(np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]]), axis=1)
_, count = np.unique(E, axis=0, return_counts=True)
return int((count != 2).sum())
before = boundary(spheres, selected(spheres, "tag_0", "tag_1"))
after = boundary(repaired, selected(repaired, "tag_0", "tag_1"))
print("non-manifold edges:", nonmanifold_edges(before), "->", nonmanifold_edges(after))
print("extracted surface:", len(outputs["surface"]["cells"]["triangle"]["connectivity"]),
"triangles, non-manifold edges:",
nonmanifold_edges(outputs["surface"]["cells"]["triangle"]["connectivity"]))
It prints 28 -> 0, and 0 for the extracted surface.
In 2D¶
Two triangles of tag_0 share one vertex and no edge (vertex_adj_tris.msh, 50 triangles):
{
"application": "manifold_extraction",
"input": "vertex_adj_tris.msh",
"output": "vertex_adj_tris_manifold",
"tag_selection": "tag_0",
"write_surface": true
}
With write_surface in 2D, vertex_adj_tris_manifold.obj holds the boundary as polylines
(l records).
# Two triangles of tag_0 that share a vertex and nothing else.
triangles = wm.read_mesh(str(DATA / "vertex_adj_tris.msh"))
out, report = wm.manifold_extraction(
{"input": "triangles", "tag_selection": "tag_0"}, {"triangles": triangles})
curve = out["surface"] # the boundary of tag_0 after the repair: edges
print(report["before #f"], "->", report["after #f"], "triangles;",
len(curve["cells"]["edge"]["connectivity"]), "boundary edges")
The log reports one non-manifold vertex. The mesh goes from 50 to 62 triangles; tag_0 is four
triangles in two pieces, which no longer touch, with a manifold boundary of 8 edges.
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 selection: tag_selection¶
tag_selection (default tag_0) is the
selection whose boundary is made manifold: an expression over the tag names, with & (and), |
(or), ! (not) and parentheses, as in simwild. ambient names the background. A cell is
selected when its tags satisfy the expression. On the two spheres, tag_0 | tag_1 and
!ambient select the same cells and give the same mesh.
The selection decides what counts as touching. tag_0 alone is a ball with a dent where the
overlap was; its boundary is manifold, and so is tag_1's. Only their union is non-manifold.
What fills the cut: fill_tags¶
fill_tags are the tags given to every carved
cell, replacing the tags it had. The default, [], is the background: ambient cannot be named
here (the component ignores it, with a warning). On the two spheres, the carved tube is 886 tets:
# Fill the carved tube with tag_0 instead of ambient: the two spheres are joined, not separated.
for fill in [[], ["tag_0"]]:
out, _ = wm.manifold_extraction(
{"input": "spheres", "tag_selection": "tag_0 | tag_1", "fill_tags": fill},
{"spheres": spheres})
m = out["mesh"]
print(f"fill_tags {fill}: tag_0 {selected(m, 'tag_0').sum()} tets,"
f" tag_1 {selected(m, 'tag_1').sum()} tets,"
f" non-manifold edges {nonmanifold_edges(boundary(m, selected(m, 'tag_0', 'tag_1')))}")
fill_tags |
tag_0 tets |
tag_1 tets |
boundary of the union |
|---|---|---|---|
[] |
1,637 | 1,574 | 1,284 triangles, two closed surfaces |
["tag_0"] |
2,523 | 1,574 | 1,568 triangles, an outer surface and the cavity |
With the background, the parts come apart. With a selected tag, the tube joins them, and the
removed overlap becomes a closed cavity. A tag the mesh does not have is added as a new region
(the log says Tag 'bridge' does not exist. Adding to mesh.), which marks the carved cells; for
the selection it is background unless the expression names it. In 2D, filling with tag_0 gives
tag_0 10 triangles in one piece, with a manifold boundary of 10 edges.
The size of the cut: radius_rel, radius¶
radius_rel (default 0.01) is the radius of the
carved region, relative to the bounding box diagonal;
radius, when positive, replaces it with an absolute
length. The carved region is always one ring of cells around the non-manifold edges and vertices;
the radius places the cut inside that ring. Each edge that leaves a non-manifold simplex is cut at
the radius when the far end of every such edge is farther than the radius from the non-manifold
simplices; otherwise every one is cut at half the smallest of those distances (the log calls it the
maximum marchable distance). The log says which:
[construction] construction_mode max_marchable_fallback: maximum marchable distance 0.0587032 over 356 marched edges; target_distance 0.0674414 is not below it, so the march traces to half of it, 0.0293516 (0.4352x target_distance)
On the two spheres (diagonal 3.37), the default radius, 0.0337, is below that limit, 0.0587:
radius_rel |
where the cut is | max AMIPS |
|---|---|---|
| (input) | 7.26 | |
| 0.005 | at the radius, 0.0169 | 112.9 |
| 0.01 | at the radius, 0.0337 | 42.06 |
| 0.02 | at half the limit, 0.0294 | 51.43 |
| 0.05 | at half the limit, 0.0294 | 51.43 |
So a small radius cuts thin cells next to the non-manifold edges, and a radius above the limit changes nothing: the cut never reaches beyond the cells that touch the contact.
Output files: write_surface¶
write_surface writes the boundary of the
selection after the repair as <output>.obj: triangles in 3D, polylines in 2D. The component checks
it before writing and logs Vertex manifoldness check passed. and Edge manifoldness check
passed. (in 2D, Curve manifoldness check passed.), or a warning.
Outputs¶
- The repaired mesh:
outputon the command line (defaultout, written asout.msh),outputs["mesh"]in memory. It has the input's tags, the fill tags on the carved cells, and diagnostic fields of the offset (labels,offset_tag,amipsand others). outputs["surface"]in memory: the boundary of the selection after the repair, a triangle mesh in 3D and an edge mesh in 2D, with vertices of its own. On files,write_surfacewrites it.- With
report, a JSON file with the vertex, edge, face (and in 3D tet) counts before and after, and the run time. In memory, the report is the second return value. - With
save_vtu, the mesh as.vtufiles for ParaView.
Pitfalls¶
- A selection that is already manifold is left as it is. When no edge or vertex is
non-manifold (on the two spheres,
tag_0alone, ortag_0 & tag_1, which selects nothing), there is nothing to offset: the run logs it and returns the mesh unchanged. - The quality drops. The cut is not optimized: on the two spheres the max AMIPS goes from 7.26 to 42.06 (avg 3.69 to 4.29). Run simwild's remeshing on the result; it keeps the regions' topology and so their manifold boundary.
- The radius is not a distance guarantee. See the size of the cut: the cut stays within one ring of cells.
- A selection that reaches the mesh boundary. Vertices on the mesh boundary are judged with the
outside of the mesh as one more cell; the log warns
Vertex of offset input complex lies on mesh boundary, and the final check may report a non-manifold surface, which it attributes to that. Keep the selection inside the domain, with background cells around it. - Face tags are refused. A mesh with tagged faces (simwild's
input_typesurface) stops withmanifold_extraction does not take tagged faces yet.
See also¶
- Reference: every option of
manifold_extraction - Topological offset: the carving is its construction
- Regions: the tags and the expressions that select them
- Reference: every option of
simwild: the remeshing that follows