TriWild¶
triwild triangulates the plane around curve networks as they come: polylines that cross,
overlap, or miss each other by a little. It simplifies the curves inside an envelope, inserts them
exactly into a triangulation of their bounding box, so that every input segment becomes a chain of
triangle edges and every crossing a vertex, and then improves the triangles' shape while the
inserted curves stay within a distance \(\varepsilon\) of the input
(Hu et al. 2019). A filter can remove what lies
outside the curves at the end.
Use it to mesh a 2D domain bounded by drawn or traced curves, or several overlapping regions at once: each triangle records which inputs it lies inside. For the same in 3D, from triangle surfaces, use TetWild; for regions in a mesh you already have, SimWild.
A first run¶
Five closed curves that overlap, each its own input file, named after its shape:
{
"application": "triwild",
"input": [
"triwild_blob1.obj",
"triwild_blob2.obj",
"triwild_circle.obj",
"triwild_ellipse.obj",
"triwild_rectangle.obj"
],
"input_names": ["blob1", "blob2", "circle", "ellipse", "rectangle"],
"output": "shapes",
"filter": "input",
"stop_energy": 10,
"num_threads": 4,
"report": "shapes_report.json"
}
The input paths are relative to the JSON file; the outputs, the triangle mesh shapes.msh
among them (see outputs), are written to the working directory.
curves = {name: wm.read_mesh(str(DATA / f"triwild_{name}.obj")) for name in NAMES}
outputs, report = wm.triwild(
{"input": NAMES, "input_names": NAMES, "filter": "input", "stop_energy": 10,
"num_threads": 4},
curves,
)
mesh = outputs["mesh"]
triangles = mesh["cells"]["triangle"]
print(len(mesh["V"]), "vertices,", len(triangles["connectivity"]), "triangles,",
f"max energy {report['max_energy']:.2f}")
# One column per input: 1 where the triangle lies inside that curve.
tags = triangles["attributes"]["tags"]
for label, count in zip(tags["labels"], np.asarray(tags["values"]).sum(axis=0)):
print(f" {label}: {count} triangles")
On files instead, with the same options as the JSON file:
const std::vector<std::string> names = {"blob1", "blob2", "circle", "ellipse", "rectangle"};
wmtk::MeshMap curves;
for (const std::string& name : names) {
curves[name] = wmtk::io::read_mesh(data / ("triwild_" + name + ".obj"));
}
auto [outputs, report] = wmtk::components::triwild::triwild(
{{"input", names},
{"input_names", names},
{"filter", "input"},
{"stop_energy", 10},
{"num_threads", 4}},
curves);
const wmtk::MeshData& mesh = outputs.at("mesh");
const wmtk::CellData& triangles = mesh.cell(wmtk::CellType::Triangle);
std::cout << mesh.V.rows() << " vertices, " << triangles.connectivity.rows()
<< " triangles, max energy " << report["max_energy"].get<double>() << "\n";
// One column per input: 1 where the triangle lies inside that curve.
const wmtk::MeshAttribute& tags = triangles.attributes.at("tags");
const wmtk::DataMatrix<int64_t> inside = tags.integers();
for (size_t j = 0; j < tags.labels.size(); ++j) {
std::cout << " " << tags.labels[j] << ": " << inside.col(j).sum() << " triangles\n";
}
wmtk::io::write_mesh("shapes.msh", mesh);
Link wmtk::triwild.
The 719 input vertices (716 segments) are simplified to 297, the arrangement inserts them into a triangulation of the bounding box, and the optimization ends with 265 to 289 vertices and 407 to 455 triangles over seven runs (see threads), the largest energy between 6.1 and 8.8 for a target of 10. A run takes a fraction of a second. The Python and C++ examples then print, for each name, the number of triangles inside that curve:
blob1: 157 triangles
blob2: 143 triangles
circle: 62 triangles
ellipse: 19 triangles
rectangle: 110 triangles
The counts add up to more than the total: where the curves overlap, a triangle lies inside several of them.
The input on the left, the result on the right: drag to rotate, scroll to zoom. 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 (with num_threads 0, so that they repeat exactly):
# One option at a time against the first run (filter input, stop_energy 10).
base = {"filter": "input", "stop_energy": 10}
for change in [{}, {"stop_energy": 100}, {"stop_energy": 5}, {"stop_energy": 3},
{"eps_rel": 0.0002}, {"eps_rel": 0.005},
{"length_rel": 0.02}, {"length_rel": 0.01},
{"coarsen_pass": True}]:
outputs, report = triangulate({**base, **change, "DEBUG_hausdorff": True})
print(f"{change}: {report['#t']} triangles, max energy {report['max_energy']:.2f}, "
f"avg {report['avg_energy']:.2f}, iterations {report['#iterations']}, "
f"curves within {report['hausdorff']:.3f} of the input (eps {report['eps']:.3f})")
The inputs: input, input_names, output¶
input is a list of OBJ files of polylines (v and l lines),
up to 64. Each file is one input: its curves are kept apart from the others' in the output tags,
and each tracked edge is held to the envelope of the inputs it lies on. Vertices that coincide
exactly are merged when a file is read; a vertex of a curve file that no segment uses is kept as a
feature point.
input_names gives the inputs names, in the same order:
they label the tag columns of the output (tag_0, tag_1, ... without them).
output is the file name without extension.
In memory, input names entries of the meshes dictionary, and the input names can be the same
strings.
The envelope: eps_rel¶
eps_rel (default 0.001) is the distance the output's curves
may be from the input, relative to the bounding box diagonal. The input is simplified within half
of it, so detail smaller than \(\varepsilon\) may disappear, and the optimization keeps every
tracked edge within \(\varepsilon\) of the input. A thinner envelope keeps more of the input's vertices
and constrains the optimization more:
eps_rel |
\(\varepsilon\) | triangles | largest distance to the input | iterations |
|---|---|---|---|---|
| 0.0002 | 0.025 | 977 | 0.018 | 10 |
| 0.001 (default) | 0.127 | 428 | 0.090 | 2 |
| 0.005 | 0.637 | 297 | 0.447 | 1 |
The distance is the containment that DEBUG_hausdorff measures: the largest distance from a
point of the output's curves to the input. It stays below \(\varepsilon\) in every run.
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. Away from the curves it sets
the size of the triangles; along them, the curves' own detail and the envelope do:
length_rel |
triangles | max energy |
|---|---|---|
| 0.05 (default) | 428 | 6.85 |
| 0.02 | 1,549 | 7.18 |
| 0.01 | 6,185 | 5.00 |
The quality target: stop_energy¶
The shape of a triangle is measured by its AMIPS energy: 2 for an equilateral triangle, growing
without bound as the triangle flattens (see Quality). The optimization
stops when the largest energy is below stop_energy, or
after max_iterations (80). The default, 100, is a loose
target in 2D, 50 times the minimum; the example asks for 10:
stop_energy |
triangles | max energy | average | iterations |
|---|---|---|---|---|
| 100 (default) | 423 | 21.47 | 3.74 | 1 |
| 10 | 428 | 6.85 | 2.91 | 2 |
| 5 | 498 | 4.36 | 2.58 | 8 |
| 3 | 680 | 2.95 | 2.26 | 6 |
The average barely moves until the target gets close to the minimum: what the target buys is the
worst triangles. When the largest energy stops improving, the triangles above the target are
refined; with a target close to 2 that is nearly all of them, so set
stuck_refine_num_worst to a positive number
there.
The outside: filter¶
The triangulation covers a box around the input.
filter decides what is kept (see Embedding for how each works):
filter |
Keeps | On the five shapes |
|---|---|---|
none (default) |
everything | 924 triangles |
flood |
all but the region the curves separate from the box's border | 457 triangles |
input |
the triangles inside at least one input, by each input's winding number | 428 triangles |
tracked |
the triangles inside the tracked curves, oriented as the input was | 79 triangles |
flood keeps 29 triangles more than input: they lie in pockets enclosed by the union of the
curves but inside none of them. tracked counts all the curves together, with their orientation:
four of the shapes run clockwise and the rectangle counterclockwise, so the winding number is −1
inside a clockwise shape and 0 where one overlaps the rectangle, and only the part of the
rectangle that no other curve covers is kept. input orients each input on its own. Use it for
several inputs. All three need closed curves (see the pitfalls).
Feature points: input_points, preserve_feature_points¶
input_points lists files whose vertices are inserted as
vertices of the triangulation (their edges are ignored). The endpoints of open polylines are kept
the same way. preserve_feature_points (on by
default) anchors them: some vertex stays within \(\varepsilon\) of each, and a collapse that would
remove it is refused. Here a closed curve and twelve points, nine inside it and three outside:
# A closed curve and twelve points to keep as vertices: nine inside, three outside.
star = {"curve": wm.read_mesh(str(DATA / "triwild_free_points_curve.obj")),
"points": wm.read_mesh(str(DATA / "triwild_free_points_points.obj"))}
for preserve in [True, False]:
outputs, report = triangulate(
{"input_points": ["points"], "preserve_feature_points": preserve, "stop_energy": 10,
"DEBUG_feature_retention": True},
star, ["curve"])
V = outputs["mesh"]["V"]
# How far each input point is from the nearest vertex of the output.
worst = max(np.linalg.norm(V - p, axis=1).min() for p in star["points"]["V"])
print(f"preserve_feature_points {preserve}: {report['features_retained']} of "
f"{report['features_total']} points kept, the worst {worst:.3f} from a vertex "
f"(eps {report['eps']:.3f})")
preserve_feature_points |
points with a vertex within \(\varepsilon\) | farthest point from a vertex |
|---|---|---|
| true (default) | 12 of 12 | 0 |
| false | 0 of 12 | 1.50, 44 \(\varepsilon\) |
Without the anchors, the collapses take the points' vertices like any other. The points outside the curve's bounding box grow it, and with it \(\varepsilon\) and the edge length, which are relative to its diagonal. A point in a region the filter discards leaves the mesh with that region, but stays in the feature output.
Topology: preserve_topology¶
By default the simplification and the optimization may change the curves' topology: close a gap
narrower than \(\varepsilon\), merge two junctions, remove a loop smaller than \(\varepsilon\).
DEBUG_euler logs the Euler characteristic of each
connected curve at each stage, which shows such changes.
preserve_topology forbids it: only exactly
coincident vertices are merged, the simplification does not merge junctions or endpoints, and the
operations check the link condition. Set it when the curves' connectivity matters, after making
sure the input has no gaps far below \(\varepsilon\) (see the pitfalls).
Threads and reproducibility¶
num_threads 0 runs sequentially and gives the same mesh
every time (428 triangles here). With threads, the order of the operations depends on the
schedule, and the result changes slightly from run to run: seven runs of the first run gave 407
to 455 triangles. On inputs of this size a run takes a fraction of a second either way.
Outputs¶
On files, output names:
shapes.msh: the triangles, with the vertices in 3D (z = 0). Each triangle has itsquality(its energy) andtags: one column per input, labelled byinput_names, 1 where the triangle is inside that input. When the filter keeps triangles that are inside no input, anambientcolumn marks them.shapes.vtu: the same triangles for ParaView, with a fieldtag_<i>per input,quality, andflood_fill(the regions the curves separate) when the flood fill ran (compute_flood_fill).shapes_surf.vtu: the tracked curves, the edges of the mesh that lie on the input.shapes_features.obj: with feature points only, the tracked curves (l) and the anchored points (p), collected before the filter.- With
report, a JSON file with the vertex and triangle counts (#v,#t),max_energy,avg_energy,#iterations,eps, the coarsening statistics, and, withDEBUG_hausdorff, the distanceshausdorff(output to input, the containment) andcoverage(input to output); withDEBUG_feature_retention,features_retainedandfeatures_total. A value of -1 means not measured.
In memory, the outputs are mesh (as in the .msh), surface (the tracked curves, as edges),
features (with feature points) and envelope (the input curves the envelope is built around).
The report holds the same keys as the file.
history_output records every change to the tracked
curves, from the input files to the output, and tracking maps points between the
two from it. Recording anchors the junctions too, which changes the output, unless
allow_junction_cleanup is given.
Pitfalls¶
-
Open curves have no inside. The filters decide what is inside from the curves, and an open polyline does not bound anything. On
triwild_polylines.obj, a drawing of 46 polylines with 94 loose ends:# Open curves: 46 polylines with 94 loose ends. The filters do not agree on what is inside. drawing = {"drawing": wm.read_mesh(str(DATA / "triwild_polylines.obj"))} for filter in ["none", "flood", "input", "tracked"]: outputs, report = triangulate({"filter": filter}, drawing, ["drawing"]) V, F = outputs["mesh"]["V"], outputs["mesh"]["cells"]["triangle"]["connectivity"] e1, e2 = V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]] area = 0.5 * np.abs(e1[:, 0] * e2[:, 1] - e1[:, 1] * e2[:, 0]).sum() print(f"filter {filter}: {len(F)} triangles, area {area:.0f}")floodkeeps the inside of the drawing's frame (1.35 million square units of the box's 2.76), whileinputandtrackedkeep 1.60 million, a band of triangles above the frame included. On open curves, usefilternoneand select the triangles from the output yourself. - Several inputs withfiltertracked. It sums all curves with their orientation; inputs that run the other way cancel (79 triangles of 428 above). Useinput. -preserve_topologyand tiny gaps. The rectangle of the first run is two polylines whose corners miss each other by \(3 \cdot 10^{-15}\). By default a collapse closes the gap during the optimization. Withpreserve_topologyit stays, and the optimization cannot get rid of the near-degenerate triangles there: on the rectangle alone, the largest energy is \(7.8 \cdot 10^{8}\) after 30 iterations, and on the five shapes it does not converge in 80. With the coordinates rounded to 9 digits, the same run converges in one iteration. Weld such vertices in the input before asking for the topology to be kept: underpreserve_topology,remove_duplicate_epsis ignored. - A misspelled option is ignored.wmtk_appand the Python bindings log a warning (triwild has no option ...) and run with the defaults. Editors catch it beforehand with the JSON Schemas.
See also¶
- Reference: every option of
triwild - Embedding: the simplification, the arrangement and the filters; Quality: the energy and the optimization loop.
- TetWild, the same in 3D; SimWild for regions in a mesh; tracking to map points through the history.
- Hu et al. 2019, TriWild: Robust Triangulation with Curve Constraints